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, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
@@ -66,6 +67,7 @@ const DepositsPage: React.FC = () => {
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>('all');
const [saving, setSaving] = useState(false);
const fetchData = async () => {
setLoading(true);
@@ -105,7 +107,19 @@ const DepositsPage: React.FC = () => {
});
}, [data, searchText, filterStatus]);
const studentOptions = useMemo(
() =>
students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
})),
[students],
);
const handleCreate = async () => {
setSaving(true);
const values = await createForm.validateFields();
try {
await api.post('/deposits', {
@@ -120,10 +134,13 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleRefund = async () => {
setSaving(true);
const values = await refundForm.validateFields();
try {
await api.put(`/deposits/${refundModal.id}/refund`, {
@@ -138,6 +155,8 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -165,6 +184,7 @@ const DepositsPage: React.FC = () => {
const handleRejectRefund = async () => {
if (!rejectModal) return;
setSaving(true);
try {
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
message.success('已驳回退款申请');
@@ -174,6 +194,8 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '驳回失败');
} finally {
setSaving(false);
}
};
const handleAddInstallment = async () => {
@@ -216,7 +238,7 @@ const DepositsPage: React.FC = () => {
}
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
@@ -304,7 +326,7 @@ const DepositsPage: React.FC = () => {
</Space>
),
},
];
], [handleRequestRefund, fetchData]);
const pendingColumns = [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
@@ -444,6 +466,7 @@ const DepositsPage: React.FC = () => {
onOk={handleCreate}
onCancel={() => setCreateModal(false)}
okText="确认"
confirmLoading={saving}
>
<Form form={createForm} layout="vertical">
<Form.Item
@@ -455,12 +478,7 @@ const DepositsPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber || s.phone || ''})`,
}))}
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
@@ -482,6 +500,7 @@ const DepositsPage: React.FC = () => {
onOk={handleRefund}
onCancel={() => setRefundModal(null)}
okText="确认退还"
confirmLoading={saving}
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
@@ -624,9 +643,10 @@ const DepositsPage: React.FC = () => {
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
okText="确认驳回"
okButtonProps={{ danger: true }}
confirmLoading={saving}
>
<Input.TextArea
placeholder="请输入驳回原因"
aria-label="驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}