fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)

This commit is contained in:
2026-07-06 01:02:26 +08:00
parent 4a48a65a48
commit 6b0a21d266
22 changed files with 4051 additions and 273 deletions

View File

@@ -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>
);
};