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

@@ -26,6 +26,7 @@ import {
DeleteOutlined,
} from '@ant-design/icons';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
@@ -35,6 +36,31 @@ const statusMap: Record<string, { text: string; color: string }> = {
archived: { text: '已归档', color: '#999' },
};
function parseRoomNumber(input: string) {
const cleaned = input.replace(/[(].*?[)]/g, '').trim();
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
if (familyMatch) {
return {
building: `${familyMatch[1]}-${familyMatch[2]}`,
floor: parseInt(familyMatch[3].charAt(0), 10) || undefined,
roomType: '家庭房',
capacity: 4,
};
}
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
return { building: `${bldgNum}号楼`, floor, roomType, capacity };
}
return null;
}
const RoomsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -47,6 +73,7 @@ const RoomsPage: React.FC = () => {
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const handleBatchDelete = async () => {
@@ -99,6 +126,7 @@ const RoomsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/rooms/${editing.id}`, values);
@@ -113,6 +141,8 @@ const RoomsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -146,43 +176,15 @@ const RoomsPage: React.FC = () => {
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/rooms/template`, { 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 = '宿舍导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
downloadBlob('/rooms/template', '房间导入模板.xlsx').catch(() => message.error('下载失败'));
};
const handleExport = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showArchived ? '?includeArchived=true' : '';
fetch(`${baseURL}/rooms/export${params}`, { 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 = '宿舍列表.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => message.error('导出失败'));
};
const columns = [
const columns = useMemo(() => [
{
title: '房间号',
dataIndex: 'roomNumber',
@@ -288,7 +290,7 @@ const RoomsPage: React.FC = () => {
</Space>
),
},
];
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, showDetail]);
return (
<div>
@@ -416,10 +418,17 @@ const RoomsPage: React.FC = () => {
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
<Input placeholder="如4-102自动解析楼栋楼层" />
<Input
placeholder="如4-102自动解析楼栋楼层"
onChange={(e) => {
const parsed = parseRoomNumber(e.target.value);
if (parsed) form.setFieldsValue(parsed);
}}
/>
</Form.Item>
<Form.Item name="building" label="楼栋">
<Input placeholder="如4号楼留空自动解析" />