feat: DingTalk attendance import + integration config + expense types + UI polish

Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -8,6 +8,7 @@ import {
CalendarOutlined,
UnorderedListOutlined,
ExportOutlined,
CloudDownloadOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -140,6 +141,12 @@ const AttendancePage: React.FC = () => {
const [dingPageSize, setDingPageSize] = useState(20);
const [dingTotal, setDingTotal] = useState(0);
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
// DingTalk import
const [importModalOpen, setImportModalOpen] = useState(false);
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null);
const [importAutoMatch, setImportAutoMatch] = useState(true);
const [importing, setImporting] = useState(false);
const [importProgressMsg, setImportProgressMsg] = useState('');
// Match modal
const [matchModalOpen, setMatchModalOpen] = useState(false);
@@ -261,11 +268,56 @@ const AttendancePage: React.FC = () => {
}
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
// ── DingTalk import handler ──
const handleImportDingTalk = useCallback(async () => {
if (!importDateRange?.[0] || !importDateRange?.[1]) {
message.warning('请选择导入日期范围');
return;
}
setImporting(true);
setImportProgressMsg('正在从钉钉拉取考勤数据...');
try {
const result = await api.post<{
success: boolean; imported: number; skipped: number; matched: number; errors: string[]; duration: number;
}>('/attendance-records/import/dingtalk', {
start: importDateRange[0].format('YYYY-MM-DD'),
end: importDateRange[1].format('YYYY-MM-DD'),
autoMatch: importAutoMatch,
});
setImportProgressMsg('');
if (result.success) {
message.success(
`导入完成:${result.imported} 条新增, ${result.skipped} 条跳过, ${result.matched} 条匹配 (${result.duration}ms)`,
);
if (result.errors?.length) {
message.warning(`警告:${result.errors.join('; ')}`);
}
setImportModalOpen(false);
fetchDingRecords();
} else {
message.error(`导入失败:${result.errors.join('; ')}`);
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
}, [importDateRange, importAutoMatch, fetchDingRecords]);
// ── Effects ──
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
useEffect(() => { api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts).catch(() => {}) }, []);
useEffect(() => {
let cancelled = false;
api.get<AlertItem[]>('/attendance-records/alerts')
.then((data) => { if (!cancelled) setAlerts(data); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (calendarView) {
@@ -564,12 +616,44 @@ const AttendancePage: React.FC = () => {
return Array.from(dates).sort();
}, [calendarData]);
const calendarColumns = useMemo(() => [
{
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>
);
},
})),
], [calendarDates]);
// ── Render ──
return (
<div>
{alerts.length > 0 && (
<Alert type="warning" showIcon closable
message={`考勤预警:${alerts.length} 名学生异常`}
title={`考勤预警:${alerts.length} 名学生异常`}
description={alerts.map(a => `${a.studentName}(${a.className || '-'})${a.type} ${a.count}次,最近${a.lastDate}`).join('')}
style={{ marginBottom: 16 }} />)}
<Tabs
@@ -718,37 +802,7 @@ const AttendancePage: React.FC = () => {
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>
);
},
})),
]}
columns={calendarColumns}
/>
)}
</Card>
@@ -803,7 +857,22 @@ const AttendancePage: React.FC = () => {
options={MATCH_STATUS_OPTIONS}
/>
</Col>
<Col>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={importing}
onClick={() => setImportModalOpen(true)}
>
</Button>
</Col>
</Row>
{importing && (
<div style={{ marginTop: 12 }}>
<span style={{ fontSize: 12, color: '#888' }}>{importProgressMsg}</span>
</div>
)}
</Card>
{/* ── DingTalk table ── */}
@@ -925,6 +994,39 @@ const AttendancePage: React.FC = () => {
</Form>
</Modal>
{/* ── DingTalk import modal ── */}
<Modal
title="从钉钉拉取考勤数据"
open={importModalOpen}
onOk={handleImportDingTalk}
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }}
confirmLoading={importing}
okText="开始拉取"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="日期范围" required>
<RangePicker
value={importDateRange}
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
<Form.Item label="自动匹配">
<Select
value={importAutoMatch ? 'yes' : 'no'}
onChange={(v) => setImportAutoMatch(v === 'yes')}
options={[
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' },
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* ── Edit modal ── */}
<Modal title="编辑考勤" open={editModalOpen} onOk={handleEditSubmit} onCancel={() => setEditModalOpen(false)}>
<Form form={editForm} layout="vertical">