Files
gongxue-base/apps/admin/src/pages/Rooms/index.tsx

1287 lines
45 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, 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;