fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)
This commit is contained in:
@@ -14,11 +14,13 @@ import {
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
CalendarOutlined,
|
||||
UnorderedListOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -61,6 +63,17 @@ const SOURCE_OPTIONS = [
|
||||
{ value: 'dingtalk', label: '钉钉导入' },
|
||||
];
|
||||
|
||||
|
||||
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
unmatched: { text: '未处理', color: 'default' },
|
||||
pending: { text: '待匹配', color: 'orange' },
|
||||
matched: { text: '已匹配', color: 'green' },
|
||||
};
|
||||
|
||||
const MATCH_STATUS_OPTIONS = Object.entries(MATCH_STATUS_MAP).map(([value, { text }]) => ({
|
||||
value,
|
||||
label: text,
|
||||
}));
|
||||
const SOURCE_MAP: Record<string, string> = {
|
||||
manual: '手动录入',
|
||||
dingtalk: '钉钉导入',
|
||||
@@ -94,6 +107,15 @@ interface BatchRecordInput {
|
||||
studentName: string;
|
||||
}
|
||||
|
||||
interface DingRecord {
|
||||
id: number;
|
||||
dingUserId: string;
|
||||
checkTime: string;
|
||||
rawStatus: string;
|
||||
matchStatus: string;
|
||||
studentId?: number;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
@@ -119,6 +141,25 @@ const AttendancePage: React.FC = () => {
|
||||
>([]);
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
|
||||
// Tab
|
||||
const [activeTab, setActiveTab] = useState('records');
|
||||
|
||||
// DingTalk raw data
|
||||
const [dingRecords, setDingRecords] = useState<DingRecord[]>([]);
|
||||
const [dingLoading, setDingLoading] = useState(false);
|
||||
const [dingPage, setDingPage] = useState(1);
|
||||
const [dingPageSize, setDingPageSize] = useState(20);
|
||||
const [dingTotal, setDingTotal] = useState(0);
|
||||
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
// Match modal
|
||||
const [matchModalOpen, setMatchModalOpen] = useState(false);
|
||||
const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
|
||||
const [matchStudentSearch, setMatchStudentSearch] = useState('');
|
||||
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>([]);
|
||||
const [matchStudentLoading, setMatchStudentLoading] = useState(false);
|
||||
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||
|
||||
// Batch modal
|
||||
const [batchModalOpen, setBatchModalOpen] = useState(false);
|
||||
const [batchStudents, setBatchStudents] = useState<BatchRecordInput[]>([]);
|
||||
@@ -159,8 +200,8 @@ const AttendancePage: React.FC = () => {
|
||||
// ── Fetch classes ──
|
||||
const fetchClasses = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/attendance-records/classes');
|
||||
setClassOptions(res as ClassOption[]);
|
||||
const res = await api.get<ClassOption[]>('/attendance-records/classes');
|
||||
setClassOptions(res);
|
||||
} catch {
|
||||
// Silently fail — class filter just stays empty
|
||||
}
|
||||
@@ -178,8 +219,7 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSource) params.source = filterSource;
|
||||
|
||||
const res = await api.get('/attendance-records', { params });
|
||||
const data = res as { list: AttendanceRecordItem[]; total: number };
|
||||
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params });
|
||||
setRecords(data.list);
|
||||
setTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -198,10 +238,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setCalendarLoading(true);
|
||||
try {
|
||||
const res = await api.get('/attendance-records/calendar', {
|
||||
const res = await api.get<{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]>('/attendance-records/calendar', {
|
||||
params: { classId: filterClassId },
|
||||
});
|
||||
setCalendarData(res as { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]);
|
||||
setCalendarData(res);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '获取日历数据失败');
|
||||
@@ -210,6 +250,27 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, [filterClassId]);
|
||||
|
||||
// ── Fetch DingTalk raw data ──
|
||||
const fetchDingRecords = useCallback(async () => {
|
||||
setDingLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: dingPage, pageSize: dingPageSize };
|
||||
if (filterClassId) params.classId = filterClassId;
|
||||
if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD');
|
||||
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
|
||||
if (dingMatchStatus) params.matchStatus = dingMatchStatus;
|
||||
|
||||
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { params });
|
||||
setDingRecords(data.list);
|
||||
setDingTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '获取钉钉原始数据失败');
|
||||
} finally {
|
||||
setDingLoading(false);
|
||||
}
|
||||
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
|
||||
|
||||
// ── Effects ──
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
@@ -223,6 +284,12 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, [calendarView, fetchRecords, fetchCalendar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'dingtalk') {
|
||||
fetchDingRecords();
|
||||
}
|
||||
}, [activeTab, fetchDingRecords]);
|
||||
|
||||
// ── Student search ──
|
||||
const handleStudentSearch = useCallback(async (value: string) => {
|
||||
setStudentSearch(value);
|
||||
@@ -232,8 +299,7 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setStudentSearchLoading(true);
|
||||
try {
|
||||
const res = await api.get('/students', { params: { search: value, pageSize: 10 } });
|
||||
const data = res as { list?: { id: number; name: string }[] } | { id: number; name: string }[];
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
setStudentSearchResults(list);
|
||||
} catch {
|
||||
@@ -300,6 +366,126 @@ const AttendancePage: React.FC = () => {
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
// ── Match modal ──
|
||||
const openMatchModal = useCallback((recordId: number) => {
|
||||
setMatchRecordId(recordId);
|
||||
setMatchStudentSearch('');
|
||||
setMatchStudentResults([]);
|
||||
setMatchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMatchStudentSearch = useCallback(async (value: string) => {
|
||||
setMatchStudentSearch(value);
|
||||
if (!value || value.length < 1) {
|
||||
setMatchStudentResults([]);
|
||||
return;
|
||||
}
|
||||
setMatchStudentLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
setMatchStudentResults(list);
|
||||
} catch {
|
||||
setMatchStudentResults([]);
|
||||
} finally {
|
||||
setMatchStudentLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMatchSubmit = useCallback(async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
}, [matchRecordId, fetchDingRecords]);
|
||||
|
||||
// ── Report download ──
|
||||
const handleExportReport = useCallback(() => {
|
||||
const baseURL = '/api';
|
||||
const token = localStorage.getItem('token');
|
||||
const params = new URLSearchParams();
|
||||
if (filterClassId) params.set('classId', String(filterClassId));
|
||||
if (filterDateRange?.[0]) params.set('dateFrom', filterDateRange[0].format('YYYY-MM-DD'));
|
||||
if (filterDateRange?.[1]) params.set('dateTo', filterDateRange[1].format('YYYY-MM-DD'));
|
||||
|
||||
fetch(`${baseURL}/attendance-records/report?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('下载失败');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '考勤报表.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('报表下载成功');
|
||||
})
|
||||
.catch(() => message.error('报表下载失败'));
|
||||
}, [filterClassId, filterDateRange]);
|
||||
|
||||
// ── DingTalk table columns ──
|
||||
const dingColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '钉钉用户ID',
|
||||
dataIndex: 'dingUserId',
|
||||
key: 'dingUserId',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '打卡时间',
|
||||
dataIndex: 'checkTime',
|
||||
key: 'checkTime',
|
||||
width: 160,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '打卡状态',
|
||||
dataIndex: 'rawStatus',
|
||||
key: 'rawStatus',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '匹配状态',
|
||||
dataIndex: 'matchStatus',
|
||||
key: 'matchStatus',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const item = MATCH_STATUS_MAP[v];
|
||||
return item ? <Tag color={item.color}>{item.text}</Tag> : v;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
if (record.matchStatus === 'matched') return <span>-</span>;
|
||||
return (
|
||||
<Button size="small" type="link" onClick={() => openMatchModal(record.id)}>
|
||||
匹配
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[openMatchModal],
|
||||
);
|
||||
|
||||
// ── Table columns ──
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -391,172 +577,264 @@ const AttendancePage: React.FC = () => {
|
||||
// ── Render ──
|
||||
return (
|
||||
<div>
|
||||
{/* ── Filter bar ── */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[12, 12]} align="middle">
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="选择班级"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
value={filterClassId}
|
||||
onChange={(v) => {
|
||||
setFilterClassId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={classOptions.map((c) => ({
|
||||
value: c.classId,
|
||||
label: c.className,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<RangePicker
|
||||
value={filterDateRange}
|
||||
onChange={(dates) => {
|
||||
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="时段"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
value={filterSession}
|
||||
onChange={(v) => {
|
||||
setFilterSession(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SESSION_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => {
|
||||
setFilterStatus(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="来源"
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
value={filterSource}
|
||||
onChange={(v) => {
|
||||
setFilterSource(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SOURCE_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'records',
|
||||
label: '考勤记录',
|
||||
children: (
|
||||
<>
|
||||
{/* ── Filter bar ── */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[12, 12]} align="middle">
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="选择班级"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
value={filterClassId}
|
||||
onChange={(v) => {
|
||||
setFilterClassId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={classOptions.map((c) => ({
|
||||
value: c.classId,
|
||||
label: c.className,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<RangePicker
|
||||
value={filterDateRange}
|
||||
onChange={(dates) => {
|
||||
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="时段"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
value={filterSession}
|
||||
onChange={(v) => {
|
||||
setFilterSession(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SESSION_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => {
|
||||
setFilterStatus(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="来源"
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
value={filterSource}
|
||||
onChange={(v) => {
|
||||
setFilterSource(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SOURCE_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ── Action bar ── */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setBatchModalOpen(true)}
|
||||
>
|
||||
批量录入
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
<Button
|
||||
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||
onClick={() => setCalendarView(!calendarView)}
|
||||
>
|
||||
{calendarView ? '列表视图' : '日历视图'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* ── Action bar ── */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setBatchModalOpen(true)}
|
||||
>
|
||||
批量录入
|
||||
</PermissionButton>
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
onClick={handleExportReport}
|
||||
>
|
||||
导出考勤报表
|
||||
</Button>
|
||||
</Space>
|
||||
<Button
|
||||
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||
onClick={() => setCalendarView(!calendarView)}
|
||||
>
|
||||
{calendarView ? '列表视图' : '日历视图'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Table view ── */}
|
||||
{!calendarView && (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* ── Table view ── */}
|
||||
{!calendarView && (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Calendar view ── */}
|
||||
{calendarView && (
|
||||
<Card loading={calendarLoading} title={calendarView ? '考勤日历' : undefined}>
|
||||
{calendarData.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
暂无数据,请选择班级后查看
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="studentId"
|
||||
dataSource={calendarData}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
|
||||
columns={[
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{/* ── Calendar view ── */}
|
||||
{calendarView && (
|
||||
<Card loading={calendarLoading} title="考勤日历">
|
||||
{calendarData.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
暂无数据,请选择班级后查看
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="studentId"
|
||||
dataSource={calendarData}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
|
||||
columns={[
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dingtalk',
|
||||
label: '钉钉原始数据',
|
||||
children: (
|
||||
<>
|
||||
{/* ── DingTalk filter ── */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[12, 12]} align="middle">
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="选择班级"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
value={filterClassId}
|
||||
onChange={(v) => {
|
||||
setFilterClassId(v);
|
||||
setDingPage(1);
|
||||
}}
|
||||
options={classOptions.map((c) => ({
|
||||
value: c.classId,
|
||||
label: c.className,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<RangePicker
|
||||
value={filterDateRange}
|
||||
onChange={(dates) => {
|
||||
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
|
||||
setDingPage(1);
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select
|
||||
placeholder="匹配状态"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={dingMatchStatus}
|
||||
onChange={(v) => {
|
||||
setDingMatchStatus(v);
|
||||
setDingPage(1);
|
||||
}}
|
||||
options={MATCH_STATUS_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ── DingTalk table ── */}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={dingColumns}
|
||||
dataSource={dingRecords}
|
||||
loading={dingLoading}
|
||||
scroll={{ x: 700 }}
|
||||
pagination={{
|
||||
current: dingPage,
|
||||
pageSize: dingPageSize,
|
||||
total: dingTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setDingPage(p);
|
||||
setDingPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Batch add modal ── */}
|
||||
<Modal
|
||||
@@ -612,7 +890,8 @@ const AttendancePage: React.FC = () => {
|
||||
placeholder="搜索学生姓名或学号"
|
||||
filterOption={false}
|
||||
onSearch={handleStudentSearch}
|
||||
onSelect={(value: number) => {
|
||||
onSelect={(value: number | undefined) => {
|
||||
if (value === undefined) return;
|
||||
const student = studentSearchResults.find((s) => s.id === value);
|
||||
if (student) addStudentToBatch(student.id, student.name);
|
||||
}}
|
||||
@@ -666,6 +945,48 @@ const AttendancePage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Match student modal ── */}
|
||||
<Modal
|
||||
title="匹配学生"
|
||||
open={matchModalOpen}
|
||||
onOk={() => {
|
||||
const selected = matchStudentResults.find((s) => s.name === matchStudentSearch);
|
||||
if (selected) handleMatchSubmit(selected.id);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
setMatchStudentSearch('');
|
||||
setMatchStudentResults([]);
|
||||
}}
|
||||
confirmLoading={matchSubmitting}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="选择学生" required>
|
||||
<Select
|
||||
showSearch
|
||||
value={undefined}
|
||||
placeholder="搜索学生姓名或学号"
|
||||
filterOption={false}
|
||||
onSearch={handleMatchStudentSearch}
|
||||
onSelect={(value: number | undefined) => {
|
||||
if (value === undefined) return;
|
||||
handleMatchSubmit(value);
|
||||
}}
|
||||
loading={matchStudentLoading}
|
||||
options={matchStudentResults.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (ID: ${s.id})`,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
notFoundContent={matchStudentSearch ? '未找到匹配的学生' : '输入关键词搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user