Files
gongxue-base/apps/admin/src/pages/Rooms/index.tsx
wangziqi 67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

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;