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
508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Modal,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Select,
|
||
Space,
|
||
message,
|
||
Tag,
|
||
Popconfirm,
|
||
Badge,
|
||
Upload,
|
||
} from 'antd';
|
||
import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface';
|
||
import {
|
||
PlusOutlined,
|
||
UploadOutlined,
|
||
DownloadOutlined,
|
||
UndoOutlined,
|
||
InboxOutlined,
|
||
SearchOutlined,
|
||
ExportOutlined,
|
||
DeleteOutlined,
|
||
} from '@ant-design/icons';
|
||
import api from '../../api';
|
||
import { downloadBlob } from '../../utils/download';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
available: { text: '可入住', color: 'green' },
|
||
full: { text: '已满', color: 'red' },
|
||
maintenance: { text: '维修中', color: 'orange' },
|
||
archived: { text: '已归档', color: '#999' },
|
||
};
|
||
|
||
function parseRoomNumber(input: string) {
|
||
const cleaned = input.replace(/[((].*?[))]/g, '').trim();
|
||
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
|
||
if (familyMatch) {
|
||
return {
|
||
building: `${familyMatch[1]}-${familyMatch[2]}栋`,
|
||
floor: parseInt(familyMatch[3].charAt(0), 10) || undefined,
|
||
roomType: '家庭房',
|
||
capacity: 4,
|
||
};
|
||
}
|
||
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
|
||
if (stdMatch) {
|
||
const bldgNum = stdMatch[1];
|
||
const roomPart = stdMatch[2];
|
||
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
|
||
let roomType = '四人间';
|
||
let capacity = 4;
|
||
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
|
||
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
|
||
return { building: `${bldgNum}号楼`, floor, roomType, capacity };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const RoomsPage: React.FC = () => {
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<any>(null);
|
||
const [detailModal, setDetailModal] = useState<any>(null);
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [archivedCount, setArchivedCount] = useState(0);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
|
||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||
const [saving, setSaving] = useState(false);
|
||
const [form] = Form.useForm();
|
||
|
||
const handleBatchDelete = async () => {
|
||
try {
|
||
const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys });
|
||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 间`);
|
||
setSelectedRowKeys([]);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '批量归档失败');
|
||
}
|
||
};
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: any = { includeArchived: 'true' };
|
||
const res: any = await api.get('/rooms/overview', { params });
|
||
const archived = res.filter((r: any) => r.status === 'archived');
|
||
setArchivedCount(archived.length);
|
||
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
|
||
setData(filtered);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [showArchived]);
|
||
|
||
// 获取楼栋列表用于筛选
|
||
const buildings = useMemo(() => {
|
||
const set = new Set(data.map((r: any) => r.building).filter(Boolean));
|
||
return [...set].sort();
|
||
}, [data]);
|
||
|
||
// 前端搜索和楼栋筛选
|
||
const filteredData = useMemo(() => {
|
||
let result = data;
|
||
if (searchText) {
|
||
const keyword = searchText.toLowerCase();
|
||
result = result.filter((r: Record<string, unknown>) => typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword));
|
||
}
|
||
if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
|
||
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
||
return result;
|
||
}, [data, searchText, filterBuilding, filterStatus]);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
if (editing) {
|
||
await api.put(`/rooms/${editing.id}`, values);
|
||
message.success('更新成功');
|
||
} else {
|
||
await api.post('/rooms', values);
|
||
message.success('创建成功');
|
||
}
|
||
setModalOpen(false);
|
||
form.resetFields();
|
||
setEditing(null);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const showDetail = async (id: number) => {
|
||
try {
|
||
const res = await api.get(`/rooms/${id}`);
|
||
setDetailModal(res);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const handleArchive = async (id: number) => {
|
||
try {
|
||
await api.delete(`/rooms/${id}`);
|
||
message.success('已归档');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '归档失败');
|
||
}
|
||
};
|
||
|
||
const handleRestore = async (id: number) => {
|
||
try {
|
||
await api.put(`/rooms/${id}/restore`);
|
||
message.success('已恢复');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '恢复失败');
|
||
}
|
||
};
|
||
|
||
const handleDownloadTemplate = () => {
|
||
downloadBlob('/rooms/template', '房间导入模板.xlsx').catch(() => message.error('下载失败'));
|
||
};
|
||
|
||
const handleExport = () => {
|
||
const params = showArchived ? '?includeArchived=true' : '';
|
||
downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => message.error('导出失败'));
|
||
};
|
||
|
||
const columns = useMemo(() => [
|
||
{
|
||
title: '房间号',
|
||
dataIndex: 'roomNumber',
|
||
width: 100,
|
||
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
|
||
},
|
||
{ title: '楼栋', dataIndex: 'building', width: 80 },
|
||
{ title: '楼层', dataIndex: 'floor', width: 80 },
|
||
{ title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' },
|
||
{
|
||
title: '租赁类型',
|
||
dataIndex: 'rentalCategory',
|
||
width: 100,
|
||
render: (v: string) => {
|
||
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
||
if (v === 'short') return <Tag color="green">短租</Tag>;
|
||
return '-';
|
||
},
|
||
},
|
||
{
|
||
title: '月租金',
|
||
dataIndex: 'monthlyRate',
|
||
width: 100,
|
||
render: (v: number) => (v ? `¥${v}` : '-'),
|
||
},
|
||
{ title: '额定人数', dataIndex: 'capacity', width: 80 },
|
||
{
|
||
title: '当前入住',
|
||
width: 80,
|
||
render: (_: any, r: any) =>
|
||
r.status === 'archived' ? (
|
||
<Tag color="#999">-</Tag>
|
||
) : (
|
||
<Badge
|
||
count={r.currentCount}
|
||
showZero
|
||
overflowCount={99}
|
||
style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '性别',
|
||
dataIndex: 'gender',
|
||
width: 80,
|
||
render: (v: any) => (v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-'),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 220,
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
{record.status === 'archived' ? (
|
||
<Popconfirm
|
||
title="确定恢复此宿舍?恢复后将重新出现在宿舍总览中。"
|
||
onConfirm={() => handleRestore(record.id)}
|
||
okText="恢复"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton permission="room:edit" size="small" icon={<UndoOutlined />} type="link">
|
||
恢复
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
) : (
|
||
<>
|
||
<PermissionButton
|
||
permission="room:view"
|
||
size="small"
|
||
type="link"
|
||
onClick={() => showDetail(record.id)}
|
||
>
|
||
查看住户
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="room:edit"
|
||
size="small"
|
||
onClick={() => {
|
||
setEditing(record);
|
||
form.setFieldsValue(record);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
<Popconfirm
|
||
title="归档后不会删除数据,可随时恢复。有在住人员将无法归档。"
|
||
onConfirm={() => handleArchive(record.id)}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton permission="room:delete" size="small" icon={<InboxOutlined />}>
|
||
归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, showDetail]);
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Space wrap>
|
||
<h3 style={{ margin: 0 }}>宿舍管理</h3>
|
||
<Input.Search
|
||
placeholder="搜索房间号"
|
||
onSearch={setSearchText}
|
||
allowClear
|
||
style={{ width: 160 }}
|
||
prefix={<SearchOutlined />}
|
||
/>
|
||
<Select
|
||
placeholder="筛选楼栋"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
onChange={(v) => setFilterBuilding(v)}
|
||
options={buildings.map((b) => ({ value: b, label: b }))}
|
||
/>
|
||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
|
||
options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} />
|
||
<Button
|
||
type={showArchived ? 'primary' : 'default'}
|
||
onClick={() => setShowArchived(!showArchived)}
|
||
>
|
||
{showArchived
|
||
? '隐藏已归档'
|
||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||
</Button>
|
||
</Space>
|
||
<Space wrap>
|
||
<Popconfirm
|
||
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
||
onConfirm={handleBatchDelete}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
disabled={selectedRowKeys.length === 0}
|
||
>
|
||
<PermissionButton permission="room:delete" danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}>
|
||
批量归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
<PermissionButton
|
||
permission="room:create"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
添加宿舍
|
||
</PermissionButton>
|
||
<Upload
|
||
accept=".xlsx,.xls"
|
||
showUploadList={false}
|
||
customRequest={async (options: UploadRequestOption<{ message?: string }>) => {
|
||
const { file, onSuccess, onError } = options;
|
||
if (typeof file === 'string') {
|
||
message.error('不支持字符串文件');
|
||
return;
|
||
}
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const res = await api.post<{ message?: string }>('/rooms/import', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
message.success(res.message || '导入成功');
|
||
onSuccess?.(res);
|
||
fetchData();
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '导入失败');
|
||
onError?.(e as UploadRequestError);
|
||
}
|
||
}}
|
||
>
|
||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||
</Upload>
|
||
<PermissionButton
|
||
permission="room:view"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleDownloadTemplate}
|
||
>
|
||
下载模板
|
||
</PermissionButton>
|
||
<PermissionButton permission="room:view" icon={<ExportOutlined />} onClick={handleExport}>
|
||
导出列表
|
||
</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={filteredData}
|
||
rowKey="id"
|
||
scroll={{ x: 1200 }}
|
||
loading={loading}
|
||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 间` }}
|
||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||
}}
|
||
/>
|
||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||
|
||
<Modal
|
||
title={editing ? '编辑宿舍' : '添加宿舍'}
|
||
open={modalOpen}
|
||
onOk={handleSave}
|
||
onCancel={() => {
|
||
setModalOpen(false);
|
||
setEditing(null);
|
||
}}
|
||
okText="保存"
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
|
||
<Input
|
||
placeholder="如:4-102(自动解析楼栋楼层)"
|
||
onChange={(e) => {
|
||
const parsed = parseRoomNumber(e.target.value);
|
||
if (parsed) form.setFieldsValue(parsed);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="building" label="楼栋">
|
||
<Input placeholder="如:4号楼(留空自动解析)" />
|
||
</Form.Item>
|
||
<Form.Item name="floor" label="楼层">
|
||
<InputNumber min={1} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="capacity" label="额定人数" rules={[{ required: true }]}>
|
||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="roomType" label="宿舍类型">
|
||
<Select
|
||
allowClear
|
||
options={[
|
||
{ value: '四人间', label: '四人间' },
|
||
{ value: '单人间', label: '单人间' },
|
||
{ value: '家庭房', label: '家庭房' },
|
||
{ value: '爆改房', label: '爆改房' },
|
||
]}
|
||
placeholder="留空自动解析"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="rentalCategory" label="租赁类别">
|
||
<Select
|
||
allowClear
|
||
options={[
|
||
{ value: 'short', label: '短租' },
|
||
{ value: 'long', label: '长租' },
|
||
]}
|
||
placeholder="默认为短租"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="monthlyRate" label="月租金">
|
||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="长租月租金" />
|
||
</Form.Item>
|
||
{editing && (
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'available', label: '可入住' },
|
||
{ value: 'full', label: '已满' },
|
||
{ value: 'maintenance', label: '维修中' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`宿舍 ${detailModal?.roomNumber} 当前住户`}
|
||
open={!!detailModal}
|
||
onCancel={() => setDetailModal(null)}
|
||
footer={null}
|
||
width={600}
|
||
>
|
||
{detailModal?.currentOccupants?.length > 0 ? (
|
||
<Table
|
||
dataSource={detailModal.currentOccupants}
|
||
rowKey="id"
|
||
pagination={false}
|
||
columns={[
|
||
{ title: '学生', render: (_: any, r: any) => r.student?.name },
|
||
{ title: '入住日期', dataIndex: 'checkInDate' },
|
||
{ title: '计费起始', dataIndex: 'billingStartDate' },
|
||
]}
|
||
/>
|
||
) : (
|
||
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}>暂无住户</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RoomsPage;
|