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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user