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, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
DatePicker,
Card,
@@ -16,6 +16,7 @@ import {
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
interface ScheduleData {
year: number;
@@ -36,7 +37,7 @@ const ClassroomSchedulePage: React.FC = () => {
const [data, setData] = useState<ScheduleData | null>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res: any = await api.get('/classroom-rentals/schedule', {
@@ -47,11 +48,11 @@ const ClassroomSchedulePage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [month]);
useEffect(() => {
fetchData();
}, [month]);
}, [fetchData]);
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
@@ -89,23 +90,15 @@ const ClassroomSchedulePage: React.FC = () => {
}
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
});
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
} catch {
// downloadBlob already shows an error via throw
}
};
return (
@@ -164,8 +157,10 @@ const ClassroomSchedulePage: React.FC = () => {
title="整体占用率"
value={overall.rate}
suffix="%"
valueStyle={{
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
styles={{
value: {
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
},
}}
/>
</Card>
@@ -202,7 +197,7 @@ const ClassroomSchedulePage: React.FC = () => {
size="small"
title={group.name}
style={{ marginBottom: 12 }}
bodyStyle={{ padding: 0 }}
styles={{ body: { padding: 0 } }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>