1287 lines
45 KiB
TypeScript
1287 lines
45 KiB
TypeScript
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';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import { selectArchiveRecords } from '../archive-view';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
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 { hasPermission, permissionsReady } = usePermission();
|
||
const canEditRooms = permissionsReady && hasPermission('room:edit');
|
||
const canCreateRooms = permissionsReady && hasPermission('room:create');
|
||
const canDeleteRooms = permissionsReady && hasPermission('room:delete');
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<any>(null);
|
||
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
|
||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
const [filterRentalCategory, setFilterRentalCategory] = useState<string | undefined>(undefined);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||
const [saving, setSaving] = useState(false);
|
||
const [form] = Form.useForm();
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [drawerRoom, setDrawerRoom] = useState<any>(null);
|
||
const [beds, setBeds] = useState<any[]>([]);
|
||
const [lockers, setLockers] = useState<any[]>([]);
|
||
const [bedModalOpen, setBedModalOpen] = useState(false);
|
||
const [bedEditing, setBedEditing] = useState<any>(null);
|
||
const [lockerModalOpen, setLockerModalOpen] = useState(false);
|
||
const [lockerEditing, setLockerEditing] = useState<any>(null);
|
||
const [bedForm] = Form.useForm();
|
||
const [lockerForm] = Form.useForm();
|
||
const [savingBed, setSavingBed] = useState(false);
|
||
const [savingLocker, setSavingLocker] = useState(false);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
|
||
// Close modals when the required permission is lost
|
||
useEffect(() => {
|
||
if (!canSaveRoom && modalOpen) {
|
||
setModalOpen(false);
|
||
setEditing(null);
|
||
form.resetFields();
|
||
}
|
||
}, [canSaveRoom, modalOpen, form]);
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (batchLoading) return;
|
||
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 handleBatchRestore = async () => {
|
||
if (batchLoading) return;
|
||
setBatchLoading(true);
|
||
try {
|
||
const res = await api.put<{ message?: string; restored: number; skipped: number }>(
|
||
'/rooms/batch-restore',
|
||
{ ids: selectedRowKeys },
|
||
);
|
||
message.success(
|
||
`已批量恢复 ${res.restored} 间${res.skipped ? `,跳过 ${res.skipped} 间` : ''}`,
|
||
);
|
||
setSelectedRowKeys([]);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '批量恢复失败');
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: any = { includeArchived: showArchived ? 'true' : undefined };
|
||
const res: any = await api.get('/rooms/overview', { params });
|
||
const filtered = selectArchiveRecords(res, showArchived ? 'archived' : 'active');
|
||
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<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);
|
||
if (filterRentalCategory) {
|
||
result = result.filter(
|
||
(r: Record<string, unknown>) => 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<BedItem[]>(`/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<LockerItem[]>(`/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) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'roomNumber', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '楼栋',
|
||
dataIndex: 'building',
|
||
width: 80,
|
||
render: (v: string, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'building', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '楼层',
|
||
dataIndex: 'floor',
|
||
width: 80,
|
||
render: (v: number, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'floor', next)}
|
||
>
|
||
{v ?? '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'roomType',
|
||
width: 90,
|
||
render: (v: any, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'roomType', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '租赁类型',
|
||
dataIndex: 'rentalCategory',
|
||
width: 100,
|
||
render: (v: string, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'long', label: '长租' },
|
||
{ value: 'short', label: '短租' },
|
||
]}
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'rentalCategory', next)}
|
||
>
|
||
{(() => {
|
||
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
||
if (v === 'short') return <Tag color="green">短租</Tag>;
|
||
return '-';
|
||
})()}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '月租金',
|
||
dataIndex: 'monthlyRate',
|
||
width: 100,
|
||
render: (v: number, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="money"
|
||
min={0}
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'monthlyRate', next)}
|
||
>
|
||
{v ? `¥${v}` : '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '额定人数',
|
||
dataIndex: 'capacity',
|
||
width: 80,
|
||
render: (v: number, r: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
min={1}
|
||
required
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'capacity', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
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: 'status',
|
||
width: 80,
|
||
render: (s: string, r: any) => (
|
||
<EditableCell
|
||
value={s}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'available', label: '可入住' },
|
||
{ value: 'full', label: '已满' },
|
||
{ value: 'maintenance', label: '维修中' },
|
||
]}
|
||
permission="room:edit"
|
||
disabled={r.status === 'archived'}
|
||
onSave={(next) => saveRoomCell(r, 'status', next)}
|
||
>
|
||
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 220,
|
||
render: (_: unknown, record: unknown) => {
|
||
const r = record as { status?: string; id: number };
|
||
return (
|
||
<Space>
|
||
{r.status === 'archived' ? (
|
||
canEditRooms ? (
|
||
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
|
||
<Button size="small" icon={<UndoOutlined />} type="link">
|
||
恢复
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null
|
||
) : (
|
||
<>
|
||
<PermissionButton
|
||
permission="room:view"
|
||
size="small"
|
||
type="link"
|
||
onClick={async () => {
|
||
const rec = record as { id: number };
|
||
setDrawerRoom(record);
|
||
setDrawerOpen(true);
|
||
await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]);
|
||
}}
|
||
>
|
||
查看
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="room:edit"
|
||
size="small"
|
||
onClick={() => {
|
||
const rec = record as { id: number };
|
||
setEditing(rec);
|
||
form.setFieldsValue(rec);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
{canDeleteRooms ? (
|
||
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
||
<Button size="small" icon={<InboxOutlined />}>
|
||
归档
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
],
|
||
[
|
||
showArchived,
|
||
buildings,
|
||
handleBatchDelete,
|
||
handleRestore,
|
||
handleArchive,
|
||
fetchBeds,
|
||
fetchLockers,
|
||
],
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div className="responsive-toolbar">
|
||
<Space wrap className="responsive-toolbar__group">
|
||
<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: '维护中' },
|
||
]}
|
||
/>
|
||
<Select
|
||
placeholder="租赁类型"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
value={filterRentalCategory}
|
||
onChange={setFilterRentalCategory}
|
||
options={[
|
||
{ value: 'long', label: '长租' },
|
||
{ value: 'short', label: '短租' },
|
||
]}
|
||
/>
|
||
<Button
|
||
type={showArchived ? 'primary' : 'default'}
|
||
onClick={() => {
|
||
setShowArchived(!showArchived);
|
||
setFilterStatus(undefined);
|
||
setSelectedRowKeys([]);
|
||
}}
|
||
>
|
||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||
</Button>
|
||
</Space>
|
||
<Space wrap className="responsive-toolbar__group">
|
||
{showArchived && canEditRooms ? (
|
||
<Popconfirm
|
||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 间宿舍?`}
|
||
onConfirm={handleBatchRestore}
|
||
okText="恢复"
|
||
cancelText="取消"
|
||
disabled={selectedRowKeys.length === 0}
|
||
>
|
||
<Button
|
||
type="primary"
|
||
icon={<UndoOutlined />}
|
||
disabled={selectedRowKeys.length === 0}
|
||
loading={batchLoading}
|
||
>
|
||
批量恢复
|
||
</Button>
|
||
</Popconfirm>
|
||
) : !showArchived && canDeleteRooms ? (
|
||
<Popconfirm
|
||
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
||
onConfirm={handleBatchDelete}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
disabled={selectedRowKeys.length === 0}
|
||
>
|
||
<Button
|
||
danger
|
||
icon={<InboxOutlined />}
|
||
disabled={selectedRowKeys.length === 0}
|
||
loading={batchLoading}
|
||
>
|
||
批量归档
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null}
|
||
{!showArchived ? (
|
||
<PermissionButton
|
||
permission="room:create"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
添加宿舍
|
||
</PermissionButton>
|
||
) : null}
|
||
{!showArchived && hasPermission('room:create') ? (
|
||
<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>
|
||
) : null}
|
||
<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}
|
||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||
pagination={{
|
||
defaultPageSize: 20,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [20, 50, 100],
|
||
showTotal: (total) => `共 ${total} 间`,
|
||
}}
|
||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||
}}
|
||
/>
|
||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||
|
||
<Modal
|
||
title={editing ? '编辑宿舍' : '添加宿舍'}
|
||
open={modalOpen && canSaveRoom}
|
||
onOk={canSaveRoom ? handleSave : undefined}
|
||
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>
|
||
|
||
<Drawer
|
||
title={`${drawerRoom?.roomNumber} 房间详情`}
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
setDrawerRoom(null);
|
||
}}
|
||
width={640}
|
||
destroyOnClose
|
||
>
|
||
<Tabs
|
||
defaultActiveKey="info"
|
||
items={[
|
||
{
|
||
key: 'info',
|
||
label: '基本信息',
|
||
children: drawerRoom && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div>
|
||
<strong>房间号:</strong>
|
||
{drawerRoom.roomNumber}
|
||
</div>
|
||
<div>
|
||
<strong>楼栋:</strong>
|
||
{drawerRoom.building || '-'}
|
||
</div>
|
||
<div>
|
||
<strong>楼层:</strong>
|
||
{drawerRoom.floor ?? '-'}
|
||
</div>
|
||
<div>
|
||
<strong>类型:</strong>
|
||
{drawerRoom.roomType || '-'}
|
||
</div>
|
||
<div>
|
||
<strong>额定人数:</strong>
|
||
{drawerRoom.capacity}
|
||
</div>
|
||
<div>
|
||
<strong>租赁类别:</strong>
|
||
{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
|
||
</div>
|
||
<div>
|
||
<strong>月租金:</strong>
|
||
{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
|
||
</div>
|
||
<div>
|
||
<strong>状态:</strong>
|
||
<Tag color={statusMap[drawerRoom.status]?.color}>
|
||
{statusMap[drawerRoom.status]?.text}
|
||
</Tag>
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'beds',
|
||
label: `床位管理 (${beds.length})`,
|
||
children: (
|
||
<div>
|
||
{canEditRooms ? (
|
||
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined />}
|
||
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||
onClick={() => {
|
||
setBedEditing(null);
|
||
bedForm.resetFields();
|
||
setBedModalOpen(true);
|
||
}}
|
||
>
|
||
添加床位
|
||
</Button>
|
||
<Popconfirm
|
||
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
|
||
description={
|
||
remainingBedSlots > 0 ? (
|
||
<InputNumber
|
||
min={1}
|
||
max={remainingBedSlots}
|
||
defaultValue={defaultBatchBedCount}
|
||
id="batch-bed-count"
|
||
style={{ width: 80 }}
|
||
/>
|
||
) : (
|
||
'如需增加床位,请先调整宿舍额定人数'
|
||
)
|
||
}
|
||
onConfirm={() => {
|
||
const input = document.getElementById(
|
||
'batch-bed-count',
|
||
) as HTMLInputElement;
|
||
handleBatchBeds(
|
||
input
|
||
? parseInt(input.value) || defaultBatchBedCount
|
||
: defaultBatchBedCount,
|
||
);
|
||
}}
|
||
okText="生成"
|
||
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||
>
|
||
<Button
|
||
size="small"
|
||
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||
>
|
||
批量生成
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
) : null}
|
||
<Table
|
||
dataSource={beds}
|
||
rowKey="id"
|
||
pagination={false}
|
||
size="small"
|
||
columns={[
|
||
{
|
||
title: '编号',
|
||
dataIndex: 'bedNumber',
|
||
width: 80,
|
||
render: (v: string, r: BedItem) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveBedCell(r, 'bedNumber', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s: string, r: BedItem) => (
|
||
<EditableCell
|
||
value={s}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'available', label: '空闲' },
|
||
{ value: 'occupied', label: '占用' },
|
||
{ value: 'maintenance', label: '维修' },
|
||
]}
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveBedCell(r, 'status', next)}
|
||
>
|
||
{(() => {
|
||
const map: Record<string, { text: string; color: string }> = {
|
||
available: { text: '空闲', color: 'green' },
|
||
occupied: { text: '占用', color: 'blue' },
|
||
maintenance: { text: '维修', color: 'orange' },
|
||
};
|
||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||
})()}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '备注',
|
||
dataIndex: 'notes',
|
||
render: (v: string, r: BedItem) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="textarea"
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveBedCell(r, 'notes', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 120,
|
||
render: (_: any, r: any) => (
|
||
<Space size="small">
|
||
<PermissionButton
|
||
permission="room:edit"
|
||
size="small"
|
||
type="link"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onClick={() => {
|
||
setBedEditing(r);
|
||
bedForm.setFieldsValue(r);
|
||
setBedModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
{r.status !== 'occupied' && canEditRooms && (
|
||
<Popconfirm
|
||
title="确定归档?"
|
||
onConfirm={() => handleDeleteBed(r.id)}
|
||
>
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
danger
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
>
|
||
归档
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'lockers',
|
||
label: `柜子管理 (${lockers.length})`,
|
||
children: (
|
||
<div>
|
||
{canEditRooms ? (
|
||
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined />}
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onClick={() => {
|
||
setLockerEditing(null);
|
||
lockerForm.resetFields();
|
||
setLockerModalOpen(true);
|
||
}}
|
||
>
|
||
添加柜子
|
||
</Button>
|
||
<Popconfirm
|
||
title="批量生成柜子"
|
||
description={
|
||
<InputNumber
|
||
min={1}
|
||
max={20}
|
||
defaultValue={4}
|
||
id="batch-locker-count"
|
||
style={{ width: 80 }}
|
||
/>
|
||
}
|
||
onConfirm={() => {
|
||
const input = document.getElementById(
|
||
'batch-locker-count',
|
||
) as HTMLInputElement;
|
||
handleBatchLockers(input ? parseInt(input.value) || 4 : 4);
|
||
}}
|
||
okText="生成"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
>
|
||
<Button size="small" disabled={drawerRoom?.status === 'archived'}>
|
||
批量生成
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
) : null}
|
||
<Table
|
||
dataSource={lockers}
|
||
rowKey="id"
|
||
pagination={false}
|
||
size="small"
|
||
columns={[
|
||
{
|
||
title: '编号',
|
||
dataIndex: 'lockerNumber',
|
||
width: 80,
|
||
render: (v: string, r: LockerItem) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveLockerCell(r, 'lockerNumber', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s: string, r: LockerItem) => (
|
||
<EditableCell
|
||
value={s}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'available', label: '空闲' },
|
||
{ value: 'occupied', label: '占用' },
|
||
{ value: 'maintenance', label: '维修' },
|
||
]}
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveLockerCell(r, 'status', next)}
|
||
>
|
||
{(() => {
|
||
const map: Record<string, { text: string; color: string }> = {
|
||
available: { text: '空闲', color: 'green' },
|
||
occupied: { text: '占用', color: 'blue' },
|
||
maintenance: { text: '维修', color: 'orange' },
|
||
};
|
||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||
})()}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '备注',
|
||
dataIndex: 'notes',
|
||
render: (v: string, r: LockerItem) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="textarea"
|
||
permission="room:edit"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onSave={(next) => saveLockerCell(r, 'notes', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 120,
|
||
render: (_: any, r: any) => (
|
||
<Space size="small">
|
||
<PermissionButton
|
||
permission="room:edit"
|
||
size="small"
|
||
type="link"
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
onClick={() => {
|
||
setLockerEditing(r);
|
||
lockerForm.setFieldsValue(r);
|
||
setLockerModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
{r.status !== 'occupied' && canEditRooms && (
|
||
<Popconfirm
|
||
title="确定归档?"
|
||
onConfirm={() => handleDeleteLocker(r.id)}
|
||
>
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
danger
|
||
disabled={drawerRoom?.status === 'archived'}
|
||
>
|
||
归档
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Drawer>
|
||
|
||
<Modal
|
||
title={bedEditing ? '编辑床位' : '添加床位'}
|
||
open={bedModalOpen && canEditRooms}
|
||
onOk={canEditRooms ? handleSaveBed : undefined}
|
||
onCancel={() => {
|
||
setBedModalOpen(false);
|
||
setBedEditing(null);
|
||
}}
|
||
confirmLoading={savingBed}
|
||
okText="保存"
|
||
>
|
||
<Form form={bedForm} layout="vertical">
|
||
<Form.Item name="bedNumber" label="床位编号" rules={[{ required: true }]}>
|
||
<Input placeholder="如:1号床" />
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'available', label: '空闲' },
|
||
{ value: 'maintenance', label: '维修中' },
|
||
]}
|
||
placeholder="默认为空闲"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={lockerEditing ? '编辑柜子' : '添加柜子'}
|
||
open={lockerModalOpen && canEditRooms}
|
||
onOk={canEditRooms ? handleSaveLocker : undefined}
|
||
onCancel={() => {
|
||
setLockerModalOpen(false);
|
||
setLockerEditing(null);
|
||
}}
|
||
confirmLoading={savingLocker}
|
||
okText="保存"
|
||
>
|
||
<Form form={lockerForm} layout="vertical">
|
||
<Form.Item name="lockerNumber" label="柜子编号" rules={[{ required: true }]}>
|
||
<Input placeholder="如:1号柜" />
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'available', label: '空闲' },
|
||
{ value: 'maintenance', label: '维修中' },
|
||
]}
|
||
placeholder="默认为空闲"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RoomsPage;
|