Files
gongxue-base/apps/admin/src/pages/Rooms/index.tsx
wangziqi 42d3f0e27f 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
2026-07-09 09:11:56 +08:00

508 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;