Files
gongxue-base/apps/admin/src/pages/Occupancies/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

627 lines
22 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.

// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Alert, App, Form } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
import { buildOccupancyColumns } from './OccupancyColumns';
import type { OccupancyRow } from './OccupancyColumns';
import {
BatchCheckOutModal,
CheckInModal,
CheckOutModal,
TransferModal,
} from './OccupancyModals';
import { usePermission } from '../../hooks/usePermission';
import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import { occupanciesSchema } from '../../api/schemas';
import { OccupanciesTableArea } from './OccupanciesTableArea';
import { OccupanciesToolbar } from './OccupanciesToolbar';
import { useOccupancyMutations } from './useOccupancyMutations';
import { QueryErrorState } from '../../components/QueryState';
import { NextStepHint } from '../../components/NextStepHint';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface StudentLookupRow {
id: number;
name: string;
studentNo?: string;
idNumber?: string;
phone?: string;
status?: string;
}
interface RoomOverviewRow {
id: number;
roomNumber: string;
building?: string;
capacity?: number;
currentCount?: number;
floor?: number | null;
roomType?: string;
status?: string;
}
const OccupanciesPage: React.FC = () => {
const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission, permissionsReady } = usePermission();
const canCheckIn = permissionsReady && hasPermission('occupancy:checkin');
const canCheckOut = permissionsReady && hasPermission('occupancy:checkout');
const canTransfer = permissionsReady && hasPermission('occupancy:transfer');
const canDelete = permissionsReady && hasPermission('occupancy:delete');
const canPurge = permissionsReady && hasPermission('occupancy:purge');
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(null);
// 入住成功后的「下一步」引导提示
const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null);
const [viewMode, setViewMode] = useState<OccupancyView>('active');
const viewPolicy = occupancyViewPolicy(viewMode);
const [autoDeposit, setAutoDeposit] = useState(true);
const [depositAmount, setDepositAmount] = useState(500);
const [searchText, setSearchText] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const changeViewMode = (mode: OccupancyView) => {
setViewMode(mode);
setSelectedRowKeys([]);
};
const changeDateRange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => {
setDateRange(dates);
setSelectedRowKeys([]);
};
const {
data: fetchResult = { data: [], students: [], rooms: [] },
isLoading,
isFetching,
isError,
refetch,
} = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({
queryKey: ['occupancies', viewMode, dateRange],
queryFn: async () => {
const [occRes, stuRes, rmRes] = await Promise.allSettled([
api.get<OccupancyRow[]>('/occupancies', {
params: {
...occupancyParamsForView(viewMode),
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
},
}),
api.get<StudentLookupRow[]>('/students/basic-lookups'),
api.get<RoomOverviewRow[]>('/rooms/overview'),
]);
const labels = ['入住数据', '学生列表', '房间列表'];
[stuRes, rmRes].forEach((res, i) => {
if (res.status === 'rejected') {
message.warning(`${labels[i + 1]}加载失败`);
}
});
if (occRes.status === 'rejected') {
throw occRes.reason;
}
return {
data: validateResponse<OccupancyRow[]>(occupanciesSchema, occRes.value),
students: stuRes.status === 'fulfilled' ? stuRes.value : [],
rooms: rmRes.status === 'fulfilled' ? rmRes.value : [],
};
},
});
const data = fetchResult.data;
const students = fetchResult.students;
const rooms = fetchResult.rooms;
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新入住列表
useVisibleRefetch(['occupancies']);
const {
checkInMutation,
checkOutMutation,
transferMutation,
batchCheckOutMutation,
batchDeleteMutation,
batchRestoreMutation,
archiveMutation,
purgeMutation,
batchPurgeMutation,
importMutation,
} = useOccupancyMutations();
const [saving, setSaving] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm();
const [transferForm] = Form.useForm();
const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false);
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
const [transferResourcesLoading, setTransferResourcesLoading] = useState(false);
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
const activeOccupancyByStudentId = useMemo(() => {
const map = new Map<number, OccupancyRow>();
data.forEach((item) => {
if (!item.checkOutDate && item.status !== 'archived') map.set(item.studentId, item);
});
return map;
}, [data]);
const isRoomSelectable = useCallback((room: RoomOverviewRow) => {
const currentCount = Number(room.currentCount || 0);
const capacity = Number(room.capacity || 0);
return room.status !== 'archived' && room.status !== 'maintenance' && currentCount < capacity;
}, []);
const roomOptionLabel = useCallback((room: RoomOverviewRow) => {
const base = `${room.roomNumber} (${room.building || ''}) [${room.currentCount}/${room.capacity}]`;
if (room.status === 'maintenance') return `${base} · 维修中`;
if (room.status === 'archived') return `${base} · 已归档`;
if (Number(room.currentCount || 0) >= Number(room.capacity || 0)) return `${base} · 已满`;
return base;
}, []);
const selectedBatchRecords = useMemo(
() => data.filter((item) => selectedRowKeys.includes(item.id) && !item.checkOutDate),
[data, selectedRowKeys],
);
const latestSelectedCheckInDate = useMemo(
() =>
selectedBatchRecords
.map((item) => item.checkInDate)
.filter((date): date is string => Boolean(date))
.reduce((latest: string | undefined, date) =>
!latest || date > latest ? date : latest,
undefined),
[selectedBatchRecords],
);
const latestSelectedBillingStartDate = useMemo(
() =>
selectedBatchRecords
.map((item) => item.billingStartDate || item.checkInDate)
.filter((date): date is string => Boolean(date))
.reduce((latest: string | undefined, date) =>
!latest || date > latest ? date : latest,
undefined),
[selectedBatchRecords],
);
const dateNotBefore =
(start: string | Dayjs | null | undefined, messageText: string) =>
(_: unknown, value?: Dayjs | null) => {
if (!value || !start) return Promise.resolve();
const startDate = dayjs.isDayjs(start) ? start : dayjs(start);
return value.isBefore(startDate, 'day')
? Promise.reject(new Error(messageText))
: Promise.resolve();
};
const handleRoomChange = async (roomId: number) => {
checkInForm.setFieldValue('bedId', undefined);
checkInForm.setFieldValue('lockerId', undefined);
setAvailableBeds([]);
setAvailableLockers([]);
if (!roomId) return;
setAvailableResourcesLoading(true);
try {
const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
]);
setAvailableBeds(beds);
setAvailableLockers(lockers);
if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
if (beds.length === 0) message.warning('该宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
} catch (e: any) {
console.error(e);
setAvailableBeds([]);
setAvailableLockers([]);
message.error(e?.message || '宿舍床位和柜子加载失败');
} finally {
setAvailableResourcesLoading(false);
}
};
const handleTransferRoomChange = async (roomId: number) => {
transferForm.setFieldValue('newBedId', undefined);
transferForm.setFieldValue('newLockerId', undefined);
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
if (!roomId) return;
setTransferResourcesLoading(true);
try {
const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
]);
setTransferAvailableBeds(beds);
setTransferAvailableLockers(lockers);
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
if (beds.length === 0) message.warning('目标宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
} catch (e: any) {
console.error(e);
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
message.error(e?.message || '目标宿舍床位和柜子加载失败');
} finally {
setTransferResourcesLoading(false);
}
};
const filteredData = useMemo(() => {
if (!searchText) return data;
const keyword = searchText.toLowerCase();
return data.filter(
(r: any) =>
r.student?.name?.toLowerCase().includes(keyword) ||
r.room?.roomNumber?.toLowerCase().includes(keyword),
);
}, [data, searchText]);
const handleCheckIn = async () => {
const values = await checkInForm.validateFields();
setSaving(true);
try {
await checkInMutation.mutateAsync(buildCheckInPayload(values));
message.success('入住登记成功');
setCheckInModal(false);
checkInForm.resetFields();
// 引导业务闭环的下一步:录费用 → 生成账单
setNextStepHint('billing');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleCheckOut = async () => {
const values = await checkOutForm.validateFields();
setSaving(true);
try {
await checkOutMutation.mutateAsync({
id: checkOutModal.id,
payload: {
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'),
checkOutReason: values.checkOutReason,
},
});
message.success('退宿成功');
setCheckOutModal(null);
checkOutForm.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleTransfer = async () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
await transferMutation.mutateAsync({
id: transferModal.id,
payload: buildTransferPayload(values),
});
message.success('换房成功');
setTransferModal(null);
transferForm.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleBatchCheckOut = async () => {
if (batchLoading) return;
const values = await batchCheckOutForm.validateFields();
setBatchLoading(true);
try {
const res: any = await batchCheckOutMutation.mutateAsync({
ids: selectedRowKeys,
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'),
checkOutReason: values.checkOutReason,
});
message.success(res.message || `已成功退宿 ${res.success}`);
setBatchCheckOutModal(false);
batchCheckOutForm.resetFields();
setSelectedRowKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchDelete = 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);
}
};
const handleBatchRestore = 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);
}
};
const handlePurge = useCallback(
(id: number, studentName: string) => {
modal.confirm({
title: `永久删除入住记录(${studentName}`,
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 columns = useMemo(
() =>
buildOccupancyColumns({
readonly: viewPolicy.readonly,
canPurge,
canDelete,
onPurge: handlePurge,
onArchive: (id) => archiveMutation.mutateAsync(id),
onCheckOut: (record) => {
setCheckOutModal(record);
checkOutForm.setFieldsValue({ checkOutDate: dayjs() });
},
onTransfer: (record) => {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
transferForm.resetFields();
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
},
}),
[
viewPolicy.readonly,
canPurge,
canDelete,
handlePurge,
archiveMutation,
checkOutForm,
transferForm,
],
);
const rowSelection = useMemo(
() => ({
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
getCheckboxProps: (record: any) =>
viewMode === 'active' ? { disabled: !!record.checkOutDate } : {},
}),
[selectedRowKeys, viewMode],
);
return (
<div>
<Alert
title="一站式导入"
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
<OccupanciesToolbar
viewMode={viewMode}
onChangeViewMode={changeViewMode}
onSearch={setSearchText}
dateRange={dateRange}
onChangeDateRange={changeDateRange}
canCheckIn={canCheckIn}
onCheckIn={() => {
checkInForm.resetFields();
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
const today = dayjs();
checkInForm.setFieldsValue({
checkInDate: today,
billingStartDate: today,
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
});
setCheckInModal(true);
}}
onImport={async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams();
if (autoDeposit) { params.set('autoDeposit', 'true'); params.set('depositAmount', String(depositAmount)); }
try {
const res: any = await importMutation.mutateAsync({
formData,
params: params.toString(),
});
if (res.errors?.length > 0) {
modal.warning({
title: res.message,
content: res.errors.join('\n'),
width: 500,
});
} else {
message.success(res.message);
}
onSuccess?.(res);
} catch (e) {
onError?.(e as Error);
}
}}
autoDeposit={autoDeposit}
onAutoDepositChange={setAutoDeposit}
depositAmount={depositAmount}
onDepositAmountChange={setDepositAmount}
onDownloadTemplate={() => {
void import('../../utils/download').then(({ downloadBlob }) =>
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败')),
);
}}
onExport={() => {
void import('../../utils/download').then(({ downloadBlob }) => {
const params = viewMode === 'active' ? '?active=true' : '';
const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败'));
});
}}
/>
{nextStepHint === 'billing' && (
<NextStepHint
title="入住登记完成"
description="接下来可以为该学生录入公共/个人费用,再生成账单完成计费闭环。"
action={{ label: '去费用管理', onClick: () => navigate('/expenses') }}
onClose={() => setNextStepHint(null)}
/>
)}
{isError ? (
<QueryErrorState
title="入住数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<OccupanciesTableArea
columns={columns}
data={filteredData}
loading={loading}
selectedRowKeys={selectedRowKeys}
rowSelection={rowSelection}
batchAction={viewPolicy.batchAction}
canDelete={canDelete}
canPurge={canPurge}
batchLoading={batchLoading}
onBatchCheckOut={() => {
batchCheckOutForm.resetFields();
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() });
setBatchCheckOutModal(true);
}}
onBatchDelete={handleBatchDelete}
onBatchRestore={handleBatchRestore}
onBatchPurge={handleBatchPurge}
onClearSelection={() => setSelectedRowKeys([])}
/>
)}
<CheckInModal
open={checkInModal}
canCheckIn={canCheckIn}
saving={saving}
form={checkInForm}
students={students}
activeOccupancyByStudentId={activeOccupancyByStudentId}
rooms={rooms}
roomOptionLabel={roomOptionLabel}
isRoomSelectable={isRoomSelectable}
onRoomChange={handleRoomChange}
availableBeds={availableBeds}
availableLockers={availableLockers}
availableResourcesLoading={availableResourcesLoading}
selectedCheckInRoomId={selectedCheckInRoomId}
dateNotBefore={dateNotBefore}
onOk={handleCheckIn}
onCancel={() => { setCheckInModal(false); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); }}
/>
<CheckOutModal
record={checkOutModal}
canCheckOut={canCheckOut}
saving={saving}
form={checkOutForm}
dateNotBefore={dateNotBefore}
onOk={handleCheckOut}
onCancel={() => setCheckOutModal(null)}
/>
<BatchCheckOutModal
open={batchCheckOutModal}
canCheckOut={canCheckOut}
selectedRowKeys={selectedRowKeys}
latestSelectedCheckInDate={latestSelectedCheckInDate}
latestSelectedBillingStartDate={latestSelectedBillingStartDate}
data={data}
form={batchCheckOutForm}
dateNotBefore={dateNotBefore}
onOk={handleBatchCheckOut}
onCancel={() => setBatchCheckOutModal(false)}
/>
<TransferModal
record={transferModal}
canTransfer={canTransfer}
saving={saving}
form={transferForm}
rooms={rooms}
roomOptionLabel={roomOptionLabel}
isRoomSelectable={isRoomSelectable}
onRoomChange={handleTransferRoomChange}
transferAvailableBeds={transferAvailableBeds}
transferAvailableLockers={transferAvailableLockers}
transferResourcesLoading={transferResourcesLoading}
selectedTransferRoomId={selectedTransferRoomId}
dateNotBefore={dateNotBefore}
onOk={handleTransfer}
onCancel={() => { setTransferModal(null); transferForm.resetFields(); setTransferAvailableBeds([]); setTransferAvailableLockers([]); }}
/>
</div>
);
};
export default OccupanciesPage;