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

618 lines
22 KiB
TypeScript

// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { App, Button, Form, Space, Tabs } from 'antd';
import dayjs from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import { useApiMutation } from '../../hooks/useApiMutation';
import { useDownload } from '../../hooks/useDownload';
import { validateResponse } from '../../utils/validate';
import {
expenseLookupsSchema,
expenseRecordsSchema,
expenseRoomsListSchema,
expenseStudentLookupsSchema,
} from '../../api/schemas';
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
import { ExpenseTablePanel } from './ExpenseTablePanel';
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
const ExpensesPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const canPurgeExpense = hasPermission('expense:purge');
const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false);
const [utilityModal, setUtilityModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm();
const [personalForm] = Form.useForm();
const [utilityForm] = Form.useForm();
const [roomSearch, setRoomSearch] = useState('');
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
const [personalSearch, setPersonalSearch] = useState('');
const [personalTypeFilter, setPersonalTypeFilter] = useState<string | undefined>(undefined);
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active');
const { downloading: utilityTemplateDownloading, run: runUtilityTemplateDownload } =
useDownload();
const { downloading: personalTemplateDownloading, run: runPersonalTemplateDownload } =
useDownload();
const { downloading: personalExportDownloading, run: runPersonalExportDownload } = useDownload();
const {
data: typeLookups = { typeOptions: [], personalTypeOptions: [], typeMap: {} },
} = useQuery<{
typeOptions: { value: string; label: string }[];
personalTypeOptions: { value: string; label: string }[];
typeMap: Record<string, string>;
}>({
queryKey: ['expense-lookups'],
queryFn: async () => {
try {
return validateResponse(expenseLookupsSchema, await api.get('/expenses/lookups'));
} catch {
return { typeOptions: [], personalTypeOptions: [], typeMap: {} };
}
},
});
const { typeOptions, personalTypeOptions, typeMap } = typeLookups;
const {
data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] },
isLoading,
isFetching,
isError,
refetch,
} = useQuery<{
rooms: any[];
personal: any[];
students: any[];
roomsList: any[];
}>({
queryKey: ['expenses', showArchived ? 'archived' : 'active'],
queryFn: async () => {
const [rooms, personal, students, roomsList] = await Promise.all([
api.get('/expenses/room', {
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/personal', {
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/student-lookups'),
api.get('/rooms'),
]);
return {
rooms: validateResponse(expenseRecordsSchema, rooms),
personal: validateResponse(expenseRecordsSchema, personal),
students: validateResponse(expenseStudentLookupsSchema, students),
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
};
},
});
const roomExpenses = expenseResult.rooms;
const personalExpenses = expenseResult.personal;
const students = expenseResult.students;
const rooms = expenseResult.roomsList;
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新费用列表
useVisibleRefetch(['expenses']);
const mutations = {
saveRoom: useApiMutation(
async (payload: Record<string, unknown>) =>
editingRoom
? api.put(`/expenses/room/${editingRoom.id}`, payload)
: api.post('/expenses/room', payload),
{ invalidate: [['expenses']] },
),
saveRoomCell: useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/expenses/room/${record.id}`, { [field]: value }),
{ invalidate: [['expenses']] },
),
savePersonal: useApiMutation(
async (payload: Record<string, unknown>) =>
editingPersonal
? api.put(`/expenses/personal/${editingPersonal.id}`, payload)
: api.post('/expenses/personal', payload),
{ invalidate: [['expenses']] },
),
savePersonalCell: useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/expenses/personal/${record.id}`, { [field]: value }),
{ invalidate: [['expenses']] },
),
period: useApiMutation(
async ({ id, periodStart, periodEnd }: { id: number; periodStart: string; periodEnd: string }) =>
api.put(`/expenses/room/${id}`, { periodStart, periodEnd }),
{ invalidate: [['expenses']] },
),
utility: useApiMutation(
async (payload: Record<string, unknown>) => api.post('/expenses/student-utility', payload),
{ invalidate: [['expenses'], ['bills']] },
),
importUtility: useApiMutation(
async (formData: FormData) =>
api.post('/expenses/utility/import', formData),
{ invalidate: [['expenses']] },
),
importPersonal: useApiMutation(
async (formData: FormData) =>
api.post('/expenses/personal/import', formData),
{ invalidate: [['expenses']] },
),
archiveRoom: useApiMutation(
async (id: number) => api.delete(`/expenses/room/${id}`),
{ invalidate: [['expenses']] },
),
archivePersonal: useApiMutation(
async (id: number) => api.delete(`/expenses/personal/${id}`),
{ invalidate: [['expenses']] },
),
batchDeleteRoom: useApiMutation(
async (ids: number[]) => api.post('/expenses/room/batch-delete', { ids }),
{ invalidate: [['expenses']] },
),
batchDeletePersonal: useApiMutation(
async (ids: number[]) => api.post('/expenses/personal/batch-delete', { ids }),
{ invalidate: [['expenses']] },
),
batchRestoreRoom: useApiMutation(
async (ids: number[]) => api.put('/expenses/room/batch-restore', { ids }),
{ invalidate: [['expenses']] },
),
batchRestorePersonal: useApiMutation(
async (ids: number[]) => api.put('/expenses/personal/batch-restore', { ids }),
{ invalidate: [['expenses']] },
),
purgeRoom: useApiMutation(
async (id: number) => api.delete(`/expenses/room/${id}/permanent`),
{ invalidate: [['expenses']] },
),
purgePersonal: useApiMutation(
async (id: number) => api.delete(`/expenses/personal/${id}/permanent`),
{ invalidate: [['expenses']] },
),
};
const handleBatchDeleteRoom = async () => {
setBatchLoading(true);
try {
await mutations.batchDeleteRoom.mutateAsync(selectedRoomKeys);
message.success('批量归档成功');
setSelectedRoomKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchDeletePersonal = async () => {
setBatchLoading(true);
try {
await mutations.batchDeletePersonal.mutateAsync(selectedPersonalKeys);
message.success('批量归档成功');
setSelectedPersonalKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchRestoreRoom = async () => {
setBatchLoading(true);
try {
await mutations.batchRestoreRoom.mutateAsync(selectedRoomKeys);
message.success('批量恢复成功');
setSelectedRoomKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchRestorePersonal = async () => {
setBatchLoading(true);
try {
await mutations.batchRestorePersonal.mutateAsync(selectedPersonalKeys);
message.success('批量恢复成功');
setSelectedPersonalKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handlePurgeRoom = (id: number) => {
modal.confirm({
title: '永久删除宿舍费用?',
content: '删除后不可恢复。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await mutations.purgeRoom.mutateAsync(id);
message.success('已永久删除');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurgePersonal = (id: number) => {
modal.confirm({
title: '永久删除个人费用?',
content: '删除后不可恢复。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await mutations.purgePersonal.mutateAsync(id);
message.success('已永久删除');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handleBatchPurgeRoom = async () => {
setBatchLoading(true);
try {
await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id)));
message.success('批量永久删除成功');
setSelectedRoomKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchPurgePersonal = async () => {
setBatchLoading(true);
try {
await Promise.all(selectedPersonalKeys.map((id) => mutations.purgePersonal.mutateAsync(id)));
message.success('批量永久删除成功');
setSelectedPersonalKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const changeArchiveView = (archived: boolean) => {
setShowArchived(archived);
setSelectedRoomKeys([]);
setSelectedPersonalKeys([]);
};
const filteredRoomExpenses = useMemo(() => {
return roomExpenses.filter((r: any) => {
if (roomSearch) {
const s = roomSearch.toLowerCase();
if (!r.room?.roomNumber?.toLowerCase().includes(s)) return false;
}
if (roomTypeFilter && r.expenseType !== roomTypeFilter) return false;
return true;
});
}, [roomExpenses, roomSearch, roomTypeFilter]);
const filteredPersonalExpenses = useMemo(() => {
return personalExpenses.filter((r: any) => {
if (personalSearch) {
const s = personalSearch.toLowerCase();
if (!r.student?.name?.toLowerCase().includes(s)) return false;
}
if (personalTypeFilter && r.expenseType !== personalTypeFilter) return false;
return true;
});
}, [personalExpenses, personalSearch, personalTypeFilter]);
const handleRoomExpense = async () => {
setSaving(true);
try {
const values = await roomForm.validateFields();
const payload = {
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
};
await mutations.saveRoom.mutateAsync(payload);
message.success(editingRoom ? '更新成功' : '录入成功');
setRoomModal(false);
setEditingRoom(null);
roomForm.resetFields();
} catch {
// 校验错误静默,接口错误由 useApiMutation 统一提示
} finally {
setSaving(false);
}
};
const handleStudentUtility = async () => {
const values = await utilityForm.validateFields();
setSaving(true);
try {
const result: any = await mutations.utility.mutateAsync({
studentId: values.studentId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
});
const bill = result.bill;
message.success(
`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,
);
setUtilityModal(false);
utilityForm.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handlePersonalExpense = async () => {
setSaving(true);
try {
const values = await personalForm.validateFields();
const payload = {
studentId: values.studentId,
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
description: values.description,
};
await mutations.savePersonal.mutateAsync(payload);
message.success(editingPersonal ? '更新成功' : '录入成功');
setPersonalModal(false);
setEditingPersonal(null);
personalForm.resetFields();
} catch {
// 校验错误静默,接口错误由 useApiMutation 统一提示
} finally {
setSaving(false);
}
};
const openEditRoom = (record: any) => {
setEditingRoom(record);
roomForm.setFieldsValue({
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
period: record.periodStart ? [dayjs(record.periodStart), dayjs(record.periodEnd)] : undefined,
description: record.description,
});
setRoomModal(true);
};
const openEditPersonal = (record: any) => {
setEditingPersonal(record);
personalForm.setFieldsValue({
studentId: record.studentId,
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
expenseDate: record.expenseDate ? dayjs(record.expenseDate) : undefined,
description: record.description,
});
setPersonalModal(true);
};
const saveRoomCell = useCallback(
async (record: any, field: string, value: unknown) => {
try {
await mutations.saveRoomCell.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[mutations.saveRoomCell],
);
const savePersonalCell = useCallback(
async (record: any, field: string, value: unknown) => {
try {
await mutations.savePersonalCell.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[mutations.savePersonalCell],
);
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button
type={!showArchived ? 'primary' : 'default'}
onClick={() => changeArchiveView(false)}
>
</Button>
<Button type={showArchived ? 'primary' : 'default'} onClick={() => changeArchiveView(true)}>
</Button>
</Space>
{isError ? (
<QueryErrorState
title="费用数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Tabs
items={[
{
key: 'room',
label: '宿舍费用',
children: (
<ExpenseTablePanel
kind="room"
searchText={roomSearch}
onSearchChange={setRoomSearch}
typeFilter={roomTypeFilter}
onTypeFilterChange={setRoomTypeFilter}
typeOptions={typeOptions}
typeMap={typeMap}
data={filteredRoomExpenses}
loading={loading}
selectedKeys={selectedRoomKeys}
onSelect={setSelectedRoomKeys}
rooms={rooms}
students={students}
readonly={expenseViewPolicy.readonly}
showArchived={showArchived}
canPurgeExpense={canPurgeExpense}
batchLoading={batchLoading}
canImport={hasPermission('expense:create')}
onBatchRestore={handleBatchRestoreRoom}
onBatchPurge={handleBatchPurgeRoom}
onBatchDelete={handleBatchDeleteRoom}
onSaveCell={saveRoomCell}
onPeriodSave={async (id, periodStart, periodEnd) => {
try {
await mutations.period.mutateAsync({ id, periodStart, periodEnd });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
onEdit={openEditRoom}
onArchive={(id) => mutations.archiveRoom.mutateAsync(id)}
onPurge={handlePurgeRoom}
onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
onTemplateDownload={() => {
void runUtilityTemplateDownload('/expenses/utility/template', '水电费导入模板.xlsx', {
successMsg: '模板已下载',
errorMsg: '下载失败',
});
}}
templateLoading={utilityTemplateDownloading}
onAddUtility={() => setUtilityModal(true)}
/>
),
},
{
key: 'personal',
label: '个人附加费',
children: (
<ExpenseTablePanel
kind="personal"
searchText={personalSearch}
onSearchChange={setPersonalSearch}
typeFilter={personalTypeFilter}
onTypeFilterChange={setPersonalTypeFilter}
typeOptions={personalTypeOptions}
typeMap={typeMap}
data={filteredPersonalExpenses}
loading={loading}
selectedKeys={selectedPersonalKeys}
onSelect={setSelectedPersonalKeys}
rooms={rooms}
students={students}
readonly={expenseViewPolicy.readonly}
showArchived={showArchived}
canPurgeExpense={canPurgeExpense}
batchLoading={batchLoading}
canImport={hasPermission('expense:create')}
onBatchRestore={handleBatchRestorePersonal}
onBatchPurge={handleBatchPurgePersonal}
onBatchDelete={handleBatchDeletePersonal}
onSaveCell={savePersonalCell}
onPeriodSave={async () => undefined}
onEdit={openEditPersonal}
onArchive={(id) => mutations.archivePersonal.mutateAsync(id)}
onPurge={handlePurgePersonal}
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
onTemplateDownload={() => {
void runPersonalTemplateDownload('/expenses/personal/template', '个人附加费导入模板.xlsx', {
successMsg: '模板已下载',
errorMsg: '下载失败',
});
}}
templateLoading={personalTemplateDownloading}
onExport={() => {
void runPersonalExportDownload('/expenses/personal/export', '个人附加费导出.xlsx', {
successMsg: '导出成功',
errorMsg: '导出失败',
});
}}
exportLoading={personalExportDownloading}
/>
),
},
]}
/>
)}
<RoomExpenseModal
open={roomModal}
editing={!!editingRoom}
saving={saving}
form={roomForm}
rooms={rooms}
typeOptions={typeOptions}
onOk={handleRoomExpense}
onCancel={() => {
setRoomModal(false);
setEditingRoom(null);
}}
/>
<UtilityModal
open={utilityModal}
saving={saving}
form={utilityForm}
students={students}
onOk={handleStudentUtility}
onCancel={() => setUtilityModal(false)}
/>
<PersonalExpenseModal
open={personalModal}
editing={!!editingPersonal}
saving={saving}
form={personalForm}
students={students}
rooms={rooms}
personalTypeOptions={personalTypeOptions}
onOk={handlePersonalExpense}
onCancel={() => {
setPersonalModal(false);
setEditingPersonal(null);
}}
/>
</div>
);
};
export default ExpensesPage;