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,
Modal,
@@ -12,6 +12,7 @@ import {
Input,
Select,
Tooltip,
Spin,
} from 'antd';
import {
FileTextOutlined,
@@ -22,6 +23,7 @@ import {
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
const { RangePicker } = DatePicker;
@@ -50,8 +52,10 @@ const BillsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterExpenseType, setFilterExpenseType] = useState<string | undefined>(undefined);
const [generateForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | undefined> = {};
@@ -63,11 +67,11 @@ const BillsPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [filterStatus, filterExpenseType]);
useEffect(() => {
fetchData();
}, [filterStatus, filterExpenseType]);
}, [fetchData]);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
@@ -83,6 +87,7 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
@@ -95,15 +100,20 @@ const BillsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
} finally {
setSaving(false);
}
};
const showDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e) {
console.error(e);
} finally {
setDetailLoading(false);
}
};
@@ -155,65 +165,40 @@ const BillsPage: React.FC = () => {
};
const handleExportExcel = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const url = `${baseURL}/bills/export/excel`;
const a = document.createElement('a');
// 使用 fetch 来携带 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
a.click();
URL.revokeObjectURL(blobUrl);
message.success('Excel 导出成功');
})
.catch(() => message.error('导出失败'));
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
() => message.success('Excel 导出成功'),
() => message.error('导出失败'),
);
};
const handleExportPdf = (billId: number) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/bills/export/pdf/${billId}`, {
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 = `账单_${billId}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
message.error('导出失败'),
);
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{
title: '分摊费用',
dataIndex: 'sharedAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
{
@@ -308,7 +293,7 @@ const BillsPage: React.FC = () => {
</Space>
),
},
];
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
return (
<div>
@@ -417,6 +402,7 @@ const BillsPage: React.FC = () => {
onOk={handleGenerate}
onCancel={() => setGenerateModal(false)}
okText="生成"
confirmLoading={saving}
>
<Form form={generateForm} layout="vertical">
<Form.Item
@@ -442,7 +428,7 @@ const BillsPage: React.FC = () => {
width={800}
>
{detailModal && (
<>
<Spin spinning={detailLoading}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
@@ -536,7 +522,7 @@ const BillsPage: React.FC = () => {
},
]}
/>
</>
</Spin>
)}
</Modal>
</div>