Files
gongxue-base/apps/admin/src/pages/Occupancies/index.tsx
wangziqi 00e2bc5acf fix(admin): 通知分页、登录过期提示、附件打开反馈与导出统一 loading
- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断;
  加载失败显示错误态与重试
- AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出
- AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应"
- 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈;
  接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮
- 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
2026-08-07 17:33:58 +08:00

635 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';
import { useDownload } from '../../hooks/useDownload';
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],
);
const { downloading: templateDownloading, run: runTemplateDownload } = useDownload();
const { downloading: exportDownloading, run: runExportDownload } = useDownload();
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 runTemplateDownload('/occupancies/template', '入住名单导入模板.xlsx', {
successMsg: '模板已下载',
errorMsg: '下载失败',
});
}}
templateLoading={templateDownloading}
onExport={() => {
const params = viewMode === 'active' ? '?active=true' : '';
const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';
void runExportDownload('/occupancies/export' + params, filename, {
successMsg: '入住记录已导出',
errorMsg: '导出失败',
});
}}
exportLoading={exportDownloading}
/>
{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;