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

@@ -18,6 +18,7 @@ import {
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
const ClassroomRentalsPage: React.FC = () => {
@@ -30,6 +31,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const filteredData = useMemo(() => {
if (!searchText) return data;
@@ -73,6 +75,7 @@ const ClassroomRentalsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
const payload = {
classroomId: values.classroomId,
tenantId: values.tenantId,
@@ -103,6 +106,8 @@ const ClassroomRentalsPage: React.FC = () => {
} else {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
@@ -116,27 +121,15 @@ const ClassroomRentalsPage: 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) => {
if (!res.ok) throw new Error('下载失败');
return 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);
})
.catch(() => message.error('下载失败(可能文件已丢失)'));
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
} catch {
message.error('下载失败(可能文件已丢失)');
}
};
const handleDeleteContract = async (id: number) => {
@@ -162,7 +155,7 @@ const ClassroomRentalsPage: React.FC = () => {
setModalOpen(true);
};
const columns = [
const columns = useMemo(() => [
{
title: '教室', width: 120,
dataIndex: 'classroom',
@@ -215,7 +208,7 @@ const ClassroomRentalsPage: React.FC = () => {
</Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
</Popconfirm>
</Space>
) : (
@@ -268,7 +261,7 @@ const ClassroomRentalsPage: React.FC = () => {
</Space>
),
},
];
], []);
return (
<div>
@@ -330,6 +323,7 @@ const ClassroomRentalsPage: React.FC = () => {
setModalOpen(false);
setEditing(null);
}}
confirmLoading={saving}
okText="保存"
width={600}
>