UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
|
import React, { useState, useMemo, useCallback } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { validateResponse } from '../../utils/validate';
|
|
import { roomsOverviewSchema } from '../../api/schemas';
|
|
import {
|
|
App,
|
|
Form,
|
|
} from 'antd';
|
|
import type { UploadRequestOption } from '@rc-component/upload/lib/interface';
|
|
import api from '../../api';
|
|
import { downloadBlob } from '../../utils/download';
|
|
import { message } from '../../ui/app-message';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
|
import { selectArchiveRecords } from '../archive-view';
|
|
import { getErrorMessage } from '../../utils/error';
|
|
import { QueryErrorState } from '../../components/QueryState';
|
|
import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns';
|
|
import { RoomDetailArea } from './RoomModals';
|
|
import { RoomsToolbar } from './RoomsToolbar';
|
|
import { RoomsTable } from './RoomsTable';
|
|
import { useRoomItemMutations, useRoomMutations } from './useRoomMutations';
|
|
|
|
const RoomsPage: React.FC = () => {
|
|
const { modal } = App.useApp();
|
|
const { hasPermission, permissionsReady } = usePermission();
|
|
const canEditRooms = permissionsReady && hasPermission('room:edit');
|
|
const canCreateRooms = permissionsReady && hasPermission('room:create');
|
|
const canDeleteRooms = permissionsReady && hasPermission('room:delete');
|
|
const canPurgeRooms = permissionsReady && hasPermission('room:purge');
|
|
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<BedItem[]>([]);
|
|
const [lockers, setLockers] = useState<LockerItem[]>([]);
|
|
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);
|
|
const [bedsError, setBedsError] = useState(false);
|
|
const [lockersError, setLockersError] = useState(false);
|
|
|
|
const {
|
|
data = [],
|
|
isLoading,
|
|
isFetching,
|
|
isError,
|
|
refetch,
|
|
} = useQuery<any[]>({
|
|
queryKey: ['rooms', 'overview', showArchived],
|
|
queryFn: async () => {
|
|
const params: any = { includeArchived: showArchived ? 'true' : undefined };
|
|
const res: any = await api.get('/rooms/overview', { params });
|
|
return selectArchiveRecords(
|
|
validateResponse<any[]>(roomsOverviewSchema, res),
|
|
showArchived ? 'archived' : 'active',
|
|
);
|
|
},
|
|
});
|
|
const loading = isLoading || isFetching;
|
|
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
|
useVisibleRefetch(['rooms']);
|
|
|
|
const {
|
|
saveMutation,
|
|
saveRoomCellMutation,
|
|
archiveMutation,
|
|
restoreMutation,
|
|
purgeMutation,
|
|
batchDeleteMutation,
|
|
batchRestoreMutation,
|
|
batchPurgeMutation,
|
|
importMutation,
|
|
} = useRoomMutations(editing);
|
|
const {
|
|
saveBedCellMutation,
|
|
saveLockerCellMutation,
|
|
saveBedMutation,
|
|
deleteBedMutation,
|
|
batchBedsMutation,
|
|
saveLockerMutation,
|
|
deleteLockerMutation,
|
|
batchLockersMutation,
|
|
} = useRoomItemMutations();
|
|
|
|
const handleBatchDelete = useCallback(async () => {
|
|
if (batchLoading) return;
|
|
setBatchLoading(true);
|
|
try {
|
|
const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys);
|
|
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 间`);
|
|
setSelectedRowKeys([]);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setBatchLoading(false);
|
|
}
|
|
}, [batchLoading, batchDeleteMutation, selectedRowKeys]);
|
|
|
|
const handleBatchRestore = useCallback(async () => {
|
|
if (batchLoading) return;
|
|
setBatchLoading(true);
|
|
try {
|
|
const res = await batchRestoreMutation.mutateAsync(selectedRowKeys);
|
|
message.success(
|
|
`已批量恢复 ${res.restored} 间${res.skipped ? `,跳过 ${res.skipped} 间` : ''}`,
|
|
);
|
|
setSelectedRowKeys([]);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setBatchLoading(false);
|
|
}
|
|
}, [batchLoading, batchRestoreMutation, selectedRowKeys]);
|
|
|
|
const buildings = useMemo(() => {
|
|
const set = new Set(data.flatMap((r: any) => (r.building ? [r.building] : [])));
|
|
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();
|
|
setSaving(true);
|
|
try {
|
|
await saveMutation.mutateAsync(values);
|
|
message.success(
|
|
editing ? '更新成功' : `创建成功,已自动生成 ${values.capacity} 张床位`,
|
|
);
|
|
setModalOpen(false);
|
|
form.resetFields();
|
|
setEditing(null);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveRoomCell = useCallback(
|
|
async (record: any, field: string, value: unknown) => {
|
|
try {
|
|
await saveRoomCellMutation.mutateAsync({ record, field, value });
|
|
message.success('已保存');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
[saveRoomCellMutation],
|
|
);
|
|
|
|
const saveBedCell = async (record: BedItem, field: string, value: unknown) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await saveBedCellMutation.mutateAsync({
|
|
roomId: drawerRoom.id,
|
|
bedId: record.id,
|
|
field,
|
|
value,
|
|
});
|
|
message.success('已保存');
|
|
await fetchBeds(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const saveLockerCell = async (record: LockerItem, field: string, value: unknown) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await saveLockerCellMutation.mutateAsync({
|
|
roomId: drawerRoom.id,
|
|
lockerId: record.id,
|
|
field,
|
|
value,
|
|
});
|
|
message.success('已保存');
|
|
await fetchLockers(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const fetchBeds = useCallback(async (roomId: number) => {
|
|
try {
|
|
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
|
|
setBeds(res);
|
|
setBedsError(false);
|
|
} catch (e: unknown) {
|
|
setBedsError(true);
|
|
message.error(getErrorMessage(e, '加载床位失败'));
|
|
}
|
|
}, []);
|
|
|
|
const fetchLockers = useCallback(async (roomId: number) => {
|
|
try {
|
|
const res = await api.get<LockerItem[]>(`/rooms/${roomId}/lockers`);
|
|
setLockers(res);
|
|
setLockersError(false);
|
|
} catch (e: unknown) {
|
|
setLockersError(true);
|
|
message.error(getErrorMessage(e, '加载柜子失败'));
|
|
}
|
|
}, []);
|
|
|
|
const handleSaveBed = async () => {
|
|
if (!drawerRoom) return;
|
|
const values = await bedForm.validateFields();
|
|
setSavingBed(true);
|
|
try {
|
|
await saveBedMutation.mutateAsync({
|
|
roomId: drawerRoom.id,
|
|
bedId: bedEditing?.id,
|
|
values,
|
|
});
|
|
message.success(bedEditing ? '更新成功' : '添加成功');
|
|
setBedModalOpen(false);
|
|
bedForm.resetFields();
|
|
setBedEditing(null);
|
|
await fetchBeds(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSavingBed(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteBed = async (id: number) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await deleteBedMutation.mutateAsync({ roomId: drawerRoom.id, bedId: id });
|
|
message.success('已归档');
|
|
await fetchBeds(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const handleBatchBeds = async (count: number) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await batchBedsMutation.mutateAsync({ roomId: drawerRoom.id, count });
|
|
message.success(`已生成 ${count} 张床位`);
|
|
await fetchBeds(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const handleSaveLocker = async () => {
|
|
if (!drawerRoom) return;
|
|
const values = await lockerForm.validateFields();
|
|
setSavingLocker(true);
|
|
try {
|
|
await saveLockerMutation.mutateAsync({
|
|
roomId: drawerRoom.id,
|
|
lockerId: lockerEditing?.id,
|
|
values,
|
|
});
|
|
message.success(lockerEditing ? '更新成功' : '添加成功');
|
|
setLockerModalOpen(false);
|
|
lockerForm.resetFields();
|
|
setLockerEditing(null);
|
|
await fetchLockers(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSavingLocker(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteLocker = async (id: number) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await deleteLockerMutation.mutateAsync({ roomId: drawerRoom.id, lockerId: id });
|
|
message.success('已归档');
|
|
await fetchLockers(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const handleBatchLockers = async (count: number) => {
|
|
if (!drawerRoom) return;
|
|
try {
|
|
await batchLockersMutation.mutateAsync({ roomId: drawerRoom.id, count });
|
|
message.success(`已生成 ${count} 个柜子`);
|
|
await fetchLockers(drawerRoom.id);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const handleArchive = useCallback(
|
|
async (id: number) => {
|
|
try {
|
|
await archiveMutation.mutateAsync(id);
|
|
message.success('已归档');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
[archiveMutation],
|
|
);
|
|
|
|
const handleRestore = useCallback(
|
|
async (id: number) => {
|
|
try {
|
|
await restoreMutation.mutateAsync(id);
|
|
message.success('已恢复');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
[restoreMutation],
|
|
);
|
|
|
|
const handlePurge = useCallback(
|
|
(id: number, roomNumber: string) => {
|
|
modal.confirm({
|
|
title: `永久删除宿舍「${roomNumber}」?`,
|
|
content: '删除后不可恢复,该宿舍及其床位、柜子、查寝记录将无法找回。确定继续?',
|
|
okText: '永久删除',
|
|
okButtonProps: { danger: true },
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await purgeMutation.mutateAsync(id);
|
|
message.success('已永久删除(不可恢复)');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[modal, purgeMutation],
|
|
);
|
|
|
|
const handleBatchPurge = async () => {
|
|
if (batchLoading) return;
|
|
setBatchLoading(true);
|
|
try {
|
|
const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys);
|
|
message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 间宿舍`);
|
|
setSelectedRowKeys([]);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setBatchLoading(false);
|
|
}
|
|
};
|
|
|
|
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 = useRoomColumns({
|
|
canEditRooms,
|
|
canDeleteRooms,
|
|
canPurgeRooms,
|
|
onSaveRoomCell: saveRoomCell,
|
|
onRestore: handleRestore,
|
|
onArchive: handleArchive,
|
|
onPurge: handlePurge,
|
|
onView: (record) => {
|
|
setDrawerRoom(record);
|
|
setDrawerOpen(true);
|
|
void Promise.all([fetchBeds(record.id), fetchLockers(record.id)]);
|
|
},
|
|
onEdit: (record) => {
|
|
setEditing(record);
|
|
form.setFieldsValue(record);
|
|
setModalOpen(true);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<RoomsToolbar
|
|
onSearch={setSearchText}
|
|
buildings={buildings}
|
|
filterBuilding={filterBuilding}
|
|
onFilterBuilding={setFilterBuilding}
|
|
filterStatus={filterStatus}
|
|
onFilterStatus={setFilterStatus}
|
|
filterRentalCategory={filterRentalCategory}
|
|
onFilterRentalCategory={setFilterRentalCategory}
|
|
showArchived={showArchived}
|
|
onToggleArchived={() => {
|
|
setShowArchived(!showArchived);
|
|
setFilterStatus(undefined);
|
|
setSelectedRowKeys([]);
|
|
}}
|
|
selectedRowKeys={selectedRowKeys}
|
|
batchLoading={batchLoading}
|
|
canEditRooms={canEditRooms}
|
|
canPurgeRooms={canPurgeRooms}
|
|
canDeleteRooms={canDeleteRooms}
|
|
hasCreatePermission={hasPermission('room:create')}
|
|
onBatchRestore={handleBatchRestore}
|
|
onBatchPurge={handleBatchPurge}
|
|
onBatchDelete={handleBatchDelete}
|
|
onAddRoom={() => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setModalOpen(true);
|
|
}}
|
|
onImport={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 importMutation.mutateAsync(formData);
|
|
message.success(res.message || '导入成功');
|
|
onSuccess?.(res);
|
|
} catch (e) {
|
|
onError?.(e as Error);
|
|
}
|
|
}}
|
|
onDownloadTemplate={handleDownloadTemplate}
|
|
onExport={handleExport}
|
|
/>
|
|
|
|
{isError ? (
|
|
<QueryErrorState
|
|
title="宿舍列表加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetch()}
|
|
/>
|
|
) : (
|
|
<RoomsTable
|
|
columns={columns}
|
|
data={filteredData}
|
|
loading={loading}
|
|
selectedRowKeys={selectedRowKeys}
|
|
onSelect={setSelectedRowKeys}
|
|
/>
|
|
)}
|
|
|
|
<RoomDetailArea
|
|
modalOpen={modalOpen}
|
|
canSaveRoom={canSaveRoom}
|
|
editing={!!editing}
|
|
saving={saving}
|
|
form={form}
|
|
onSaveRoom={handleSave}
|
|
onCloseRoomModal={() => {
|
|
setModalOpen(false);
|
|
setEditing(null);
|
|
}}
|
|
drawerOpen={drawerOpen}
|
|
drawerRoom={drawerRoom}
|
|
beds={beds}
|
|
lockers={lockers}
|
|
bedsError={bedsError}
|
|
lockersError={lockersError}
|
|
onRetryBeds={() => {
|
|
if (drawerRoom) void fetchBeds(drawerRoom.id);
|
|
}}
|
|
onRetryLockers={() => {
|
|
if (drawerRoom) void fetchLockers(drawerRoom.id);
|
|
}}
|
|
canEditRooms={canEditRooms}
|
|
remainingBedSlots={remainingBedSlots}
|
|
defaultBatchBedCount={defaultBatchBedCount}
|
|
onCloseDrawer={() => {
|
|
setDrawerOpen(false);
|
|
setDrawerRoom(null);
|
|
}}
|
|
onAddBed={() => {
|
|
setBedEditing(null);
|
|
bedForm.resetFields();
|
|
setBedModalOpen(true);
|
|
}}
|
|
onBatchBeds={handleBatchBeds}
|
|
onEditBed={(r) => {
|
|
setBedEditing(r);
|
|
bedForm.setFieldsValue(r);
|
|
setBedModalOpen(true);
|
|
}}
|
|
onDeleteBed={handleDeleteBed}
|
|
onSaveBedCell={saveBedCell}
|
|
onAddLocker={() => {
|
|
setLockerEditing(null);
|
|
lockerForm.resetFields();
|
|
setLockerModalOpen(true);
|
|
}}
|
|
onBatchLockers={handleBatchLockers}
|
|
onEditLocker={(r) => {
|
|
setLockerEditing(r);
|
|
lockerForm.setFieldsValue(r);
|
|
setLockerModalOpen(true);
|
|
}}
|
|
onDeleteLocker={handleDeleteLocker}
|
|
onSaveLockerCell={saveLockerCell}
|
|
bedModalOpen={bedModalOpen}
|
|
bedEditing={!!bedEditing}
|
|
savingBed={savingBed}
|
|
bedForm={bedForm}
|
|
onSaveBed={handleSaveBed}
|
|
onCloseBedModal={() => {
|
|
setBedModalOpen(false);
|
|
setBedEditing(null);
|
|
}}
|
|
lockerModalOpen={lockerModalOpen}
|
|
lockerEditing={!!lockerEditing}
|
|
savingLocker={savingLocker}
|
|
lockerForm={lockerForm}
|
|
onSaveLocker={handleSaveLocker}
|
|
onCloseLockerModal={() => {
|
|
setLockerModalOpen(false);
|
|
setLockerEditing(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RoomsPage;
|