import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, Modal, Form, Input, InputNumber, Select, Space, Tag, Popconfirm, Badge, Upload, Drawer, Tabs, Empty, } from 'antd'; import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface'; import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, SearchOutlined, ExportOutlined, } from '@ant-design/icons'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; const statusMap: Record = { available: { text: '可入住', color: 'green' }, full: { text: '已满', color: 'red' }, maintenance: { text: '维修中', color: 'orange' }, archived: { text: '已归档', color: '#999' }, }; interface BedItem { id: number; bedNumber: string; status: string; notes?: string | null; } interface LockerItem { id: number; lockerNumber: string; status: string; notes?: string | null; } 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([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); const [archivedCount, setArchivedCount] = useState(0); const [searchText, setSearchText] = useState(''); const [filterBuilding, setFilterBuilding] = useState(undefined); const [filterStatus, setFilterStatus] = useState(undefined); const [filterRentalCategory, setFilterRentalCategory] = useState(undefined); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerRoom, setDrawerRoom] = useState(null); const [beds, setBeds] = useState([]); const [lockers, setLockers] = useState([]); const [bedModalOpen, setBedModalOpen] = useState(false); const [bedEditing, setBedEditing] = useState(null); const [lockerModalOpen, setLockerModalOpen] = useState(false); const [lockerEditing, setLockerEditing] = useState(null); const [bedForm] = Form.useForm(); const [lockerForm] = Form.useForm(); const [savingBed, setSavingBed] = useState(false); const [savingLocker, setSavingLocker] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const handleBatchDelete = async () => { setBatchLoading(true); 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 || '批量归档失败'); } finally { setBatchLoading(false); } }; 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: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载失败,请稍后重试'); } 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) => typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword), ); } if (filterBuilding) result = result.filter((r: Record) => r.building === filterBuilding); if (filterStatus) result = result.filter((r: Record) => r.status === filterStatus); if (filterRentalCategory) { result = result.filter( (r: Record) => r.rentalCategory === filterRentalCategory, ); } return result; }, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]); const remainingBedSlots = useMemo(() => { const capacity = Number(drawerRoom?.capacity) || 0; return Math.max(capacity - beds.length, 0); }, [drawerRoom?.capacity, beds.length]); const defaultBatchBedCount = Math.min(4, Math.max(remainingBedSlots, 1)); const handleSave = async () => { const values = await form.validateFields(); const payload = values; setSaving(true); try { if (editing) { await api.put(`/rooms/${editing.id}`, payload); message.success('更新成功'); } else { await api.post('/rooms', payload); message.success(`创建成功,已自动生成 ${values.capacity} 张床位`); } setModalOpen(false); form.resetFields(); setEditing(null); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSaving(false); } }; const saveRoomCell = async (record: any, field: string, value: unknown) => { await api.put(`/rooms/${record.id}`, { [field]: value }); message.success('已保存'); await fetchData(); }; const saveBedCell = async (record: BedItem, field: string, value: unknown) => { if (!drawerRoom) return; await api.put(`/rooms/${drawerRoom.id}/beds/${record.id}`, { [field]: value }); message.success('已保存'); await fetchBeds(drawerRoom.id); }; const saveLockerCell = async (record: LockerItem, field: string, value: unknown) => { if (!drawerRoom) return; await api.put(`/rooms/${drawerRoom.id}/lockers/${record.id}`, { [field]: value }); message.success('已保存'); await fetchLockers(drawerRoom.id); }; const fetchBeds = useCallback(async (roomId: number) => { try { const res = await api.get(`/rooms/${roomId}/beds`); setBeds(res); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载床位失败'); } }, []); const fetchLockers = useCallback(async (roomId: number) => { try { const res = await api.get(`/rooms/${roomId}/lockers`); setLockers(res); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载柜子失败'); } }, []); const handleSaveBed = async () => { const values = await bedForm.validateFields(); setSavingBed(true); try { if (bedEditing) { await api.put(`/rooms/${drawerRoom.id}/beds/${bedEditing.id}`, values); } else { await api.post(`/rooms/${drawerRoom.id}/beds`, values); } message.success(bedEditing ? '更新成功' : '添加成功'); setBedModalOpen(false); bedForm.resetFields(); setBedEditing(null); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSavingBed(false); } }; const handleDeleteBed = async (id: number) => { try { await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`); message.success('已归档'); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '归档失败'); } }; const handleBatchBeds = async (count: number) => { try { await api.post(`/rooms/${drawerRoom.id}/beds/batch`, { count }); message.success(`已生成 ${count} 张床位`); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '批量生成失败'); } }; const handleSaveLocker = async () => { const values = await lockerForm.validateFields(); setSavingLocker(true); try { if (lockerEditing) { await api.put(`/rooms/${drawerRoom.id}/lockers/${lockerEditing.id}`, values); } else { await api.post(`/rooms/${drawerRoom.id}/lockers`, values); } message.success(lockerEditing ? '更新成功' : '添加成功'); setLockerModalOpen(false); lockerForm.resetFields(); setLockerEditing(null); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSavingLocker(false); } }; const handleDeleteLocker = async (id: number) => { try { await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`); message.success('已归档'); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '归档失败'); } }; const handleBatchLockers = async (count: number) => { try { await api.post(`/rooms/${drawerRoom.id}/lockers/batch`, { count }); message.success(`已生成 ${count} 个柜子`); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '批量生成失败'); } }; 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), render: (v: string, r: any) => ( saveRoomCell(r, 'roomNumber', next)} > {v} ), }, { title: '楼栋', dataIndex: 'building', width: 80, render: (v: string, r: any) => ( saveRoomCell(r, 'building', next)} > {v || '-'} ), }, { title: '楼层', dataIndex: 'floor', width: 80, render: (v: number, r: any) => ( saveRoomCell(r, 'floor', next)} > {v ?? '-'} ), }, { title: '类型', dataIndex: 'roomType', width: 90, render: (v: any, r: any) => ( saveRoomCell(r, 'roomType', next)} > {v || '-'} ), }, { title: '租赁类型', dataIndex: 'rentalCategory', width: 100, render: (v: string, r: any) => ( saveRoomCell(r, 'rentalCategory', next)} > {(() => { if (v === 'long') return 长租; if (v === 'short') return 短租; return '-'; })()} ), }, { title: '月租金', dataIndex: 'monthlyRate', width: 100, render: (v: number, r: any) => ( saveRoomCell(r, 'monthlyRate', next)} > {v ? `¥${v}` : '-'} ), }, { title: '额定人数', dataIndex: 'capacity', width: 80, render: (v: number, r: any) => ( saveRoomCell(r, 'capacity', next)} > {v} ), }, { title: '当前入住', width: 80, render: (_: any, r: any) => r.status === 'archived' ? ( - ) : ( = r.capacity ? '#ff4d4f' : '#52c41a' }} /> ), }, { title: '状态', dataIndex: 'status', width: 80, render: (s: string, r: any) => ( saveRoomCell(r, 'status', next)} > {statusMap[s]?.text || s} ), }, { title: '操作', width: 220, render: (_: unknown, record: unknown) => { const r = record as { status?: string; id: number }; return ( {r.status === 'archived' ? ( handleRestore(r.id)}> } type="link" > 恢复 ) : ( <> { const rec = record as { id: number }; setDrawerRoom(record); setDrawerOpen(true); await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]); }} > 查看 { const rec = record as { id: number }; setEditing(rec); form.setFieldsValue(rec); setModalOpen(true); }} > 编辑 handleArchive(r.id)}> } > 归档 )} ); }, }, ], [ showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, fetchBeds, fetchLockers, ], ); return (

宿舍管理

} /> { const parsed = parseRoomNumber(e.target.value); if (parsed) form.setFieldsValue(parsed); }} /> {editing && (