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 {
Table,
Button,
@@ -28,6 +28,8 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
@@ -47,34 +49,42 @@ const OccupanciesPage: React.FC = () => {
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm();
const [transferForm] = Form.useForm();
const [batchCheckOutForm] = Form.useForm();
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [occ, stu, rm, tn]: any[] = await Promise.all([
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students'),
api.get('/rooms/overview'),
api.get('/tenants'),
]);
setData(occ);
setStudents(stu);
setRooms(rm);
setTenants(tn);
])) as PromiseSettledResult<any>[];
const labels = ['入住数据', '学生列表', '房间列表', '租赁方'];
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
if (res.status === 'rejected') {
message.warning(`${labels[i]}加载失败`);
}
});
setData(occRes.status === 'fulfilled' ? occRes.value : []);
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []);
} catch (e) {
console.error(e);
message.error('数据加载异常');
}
setLoading(false);
};
}, [showActive, dateRange]);
useEffect(() => {
fetchData();
setSelectedRowKeys([]);
}, [showActive, dateRange]);
}, [fetchData]);
const filteredData = useMemo(() => {
if (!searchText) return data;
@@ -88,6 +98,7 @@ const OccupanciesPage: React.FC = () => {
const handleCheckIn = async () => {
const values = await checkInForm.validateFields();
setSaving(true);
try {
await api.post('/occupancies/check-in', {
studentId: values.studentId,
@@ -104,11 +115,14 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleCheckOut = async () => {
const values = await checkOutForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${checkOutModal.id}/check-out`, {
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
@@ -121,11 +135,14 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleTransfer = async () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${transferModal.id}/transfer`, {
newRoomId: values.newRoomId,
@@ -140,6 +157,8 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -173,7 +192,7 @@ const OccupanciesPage: React.FC = () => {
}
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
{ title: '入住日期', dataIndex: 'checkInDate', width: 110 },
@@ -237,19 +256,19 @@ const OccupanciesPage: React.FC = () => {
</Space>
),
},
];
], [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm]);
const rowSelection = {
const rowSelection = useMemo(() => ({
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量删除
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
};
}), [selectedRowKeys, showActive]);
return (
<div>
<Alert
message="一站式导入"
title="一站式导入"
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
@@ -337,23 +356,9 @@ const OccupanciesPage: React.FC = () => {
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/occupancies/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('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
@@ -362,24 +367,11 @@ const OccupanciesPage: React.FC = () => {
permission="occupancy:view"
icon={<ExportOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showActive ? '?active=true' : '';
fetch(`${baseURL}/occupancies/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 = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() =>
message.error('导出失败'),
);
}}
>
@@ -388,21 +380,34 @@ const OccupanciesPage: React.FC = () => {
<Switch size="small" checked={autoDeposit} onChange={setAutoDeposit} />
{autoDeposit && (
<Space.Compact>
<InputNumber
size="small"
min={0}
value={depositAmount}
onChange={(v) => setDepositAmount(v || 500)}
style={{ width: 80 }}
addonAfter="元"
style={{ width: 60 }}
/>
<span
style={{
padding: '0 8px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
fontSize: 12,
}}
>
</span>
</Space.Compact>
)}
</span>
</Space>
</div>
{selectedRowKeys.length > 0 && (
<Alert
message={
title={
<span>
<strong>{selectedRowKeys.length}</strong>
{showActive ? (
@@ -464,6 +469,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleCheckIn}
onCancel={() => setCheckInModal(false)}
okText="确认入住"
confirmLoading={saving}
width={500}
>
<Form form={checkInForm} layout="vertical">
@@ -480,7 +486,7 @@ const OccupanciesPage: React.FC = () => {
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber || s.phone || ''})`,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
}))}
/>
</Form.Item>
@@ -549,6 +555,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleCheckOut}
onCancel={() => setCheckOutModal(null)}
okText="确认退宿"
confirmLoading={saving}
>
<Form form={checkOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
@@ -636,6 +643,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleTransfer}
onCancel={() => setTransferModal(null)}
okText="确认换房"
confirmLoading={saving}
width={500}
>
<Form form={transferForm} layout="vertical">