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

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Tabs,
Card,
@@ -20,6 +20,7 @@ import {
Row,
Col,
Statistic,
Spin,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -33,6 +34,8 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive';
// ---- Types ----
@@ -752,16 +755,87 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
}
}, [studentId]);
if (!aggregateData) return null;
const handleViewSensitive = useViewSensitive(studentId, '学生档案');
const { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
return [
{
key: 'profile',
label: '扩展档案',
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: (
<EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: '出勤记录',
children: <Empty description="暂无出勤记录" />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'result',
label: '录取归档',
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: (
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
];
}, [aggregateData, studentId, fetchData]);
if (!aggregateData) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return null;
}
const { student, profile } = aggregateData;
return (
<div>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name} ({student.studentNo})
</span>
@@ -794,8 +868,26 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
<Descriptions.Item label="电话">{student.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="身份证号">{student.idNumber || '-'}</Descriptions.Item>
<Descriptions.Item label="电话">
{student.phone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
<a onClick={() => handleViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{student.idNumber ? (
<span>
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
<a onClick={() => handleViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag>{student.status || '-'}</Tag>
</Descriptions.Item>
@@ -818,61 +910,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Tabs
defaultActiveKey="profile"
items={[
{
key: 'profile',
label: '扩展档案',
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: (
<EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: '出勤记录',
children: <Empty description="暂无出勤记录" />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'result',
label: '录取归档',
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: (
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
]}
items={tabItems}
/>
</div>
);