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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user