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,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, message } from 'antd';
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
@@ -62,6 +62,17 @@ interface DashboardStats {
incomeTrend: IncomeTrendRow[];
}
const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
@@ -77,7 +88,7 @@ const DashboardPage: React.FC = () => {
dayjs().endOf('month').format('YYYY-MM-DD'),
]);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [s, rr, cr, g] = await Promise.all([
@@ -100,13 +111,14 @@ const DashboardPage: React.FC = () => {
setClassroomUtil(cu);
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
}
setLoading(false);
};
}, [period]);
useEffect(() => {
fetchData();
}, [period]);
}, [fetchData, period]);
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
@@ -118,16 +130,8 @@ const DashboardPage: React.FC = () => {
}).catch(() => {});
}, []);
const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
// 今日出勤状态分布环图
const attendanceRingOption = {
const attendanceRingOption = useMemo(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
@@ -143,11 +147,11 @@ const DashboardPage: React.FC = () => {
},
],
color: COLORS,
};
}), [stats?.attendanceByStatus]);
// 宿舍费用排行
const barOption = {
const barOption = useMemo(() => ({
tooltip: {},
grid: { left: 80, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value' },
@@ -163,7 +167,7 @@ const DashboardPage: React.FC = () => {
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
};
}), [roomRanking]);
// 班级考勤排行 - 前5
const classRankingTopOption = {
@@ -249,7 +253,7 @@ const DashboardPage: React.FC = () => {
};
// 入住时间线(甘特图)
const ganttOption = {
const ganttOption = useMemo(() => ({
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
@@ -300,7 +304,7 @@ const DashboardPage: React.FC = () => {
}))
),
}],
};
}), [ganttData]);
if (loading && !stats)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
@@ -319,6 +323,7 @@ const DashboardPage: React.FC = () => {
>
<h2 style={{ margin: 0 }}></h2>
<RangePicker
aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
@@ -326,7 +331,7 @@ const DashboardPage: React.FC = () => {
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
@@ -363,7 +368,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教室总数" value={stats?.classroomCount || 0} prefix={<BankOutlined />} />
@@ -401,7 +406,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
@@ -439,7 +444,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Card title="教室利用率" style={{ marginBottom: 16 }}>
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[24, 16]}>
<Col xs={12} sm={6}>
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
@@ -453,7 +458,7 @@ const DashboardPage: React.FC = () => {
value={classroomUtil?.utilizationRate ?? '-'}
suffix="%"
prefix={<PercentageOutlined />}
valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }}
/>
</Col>
<Col xs={12} sm={6}>
@@ -462,7 +467,7 @@ const DashboardPage: React.FC = () => {
</Row>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
@@ -483,7 +488,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? (
@@ -504,7 +509,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24}>
<Card title="教室占用热力图">
{classroomOccupancy.length > 0 ? (
@@ -533,7 +538,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="费用类型分布">
{((stats?.expenseByType) ?? []).length > 0 ? (