feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -27,13 +27,21 @@ const statusLabels: Record<string, string> = {
cancelled: '已取消',
};
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
const HTML_ESCAPE_PAIRS: ReadonlyArray<readonly [string, string]> = [
['&', '&amp;'],
['<', '&lt;'],
['>', '&gt;'],
['"', '&quot;'],
["'", '&#039;'],
];
const escapeHtml = (value: unknown) => {
let text = String(value ?? '');
for (const [from, to] of HTML_ESCAPE_PAIRS) {
text = text.replaceAll(from, to);
}
return text;
};
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import {
App,
Table,
Button,
Modal,
Form,
DatePicker,
@@ -26,6 +28,11 @@ import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
import { newOperationId } from '../../utils/operation-id';
import { usePermission } from '../../hooks/usePermission';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { billsSchema } from '../../api/schemas';
const statusMap: Record<string, { text: string; color: string }> = {
unpaid: { text: '待支付', color: 'orange' },
@@ -44,8 +51,9 @@ const typeMap: Record<string, string> = {
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const canPurgeBill = hasPermission('bill:purge');
const [generateModal, setGenerateModal] = useState(false);
const [detailModal, setDetailModal] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
@@ -57,23 +65,48 @@ const BillsPage: React.FC = () => {
const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
const res = (await api.get('/bills', { params })) as unknown[];
setBills(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [filterStatus, filterExpenseType]);
const {
data: bills = [],
isLoading,
isFetching,
} = useQuery({
queryKey: ['bills', filterStatus, filterExpenseType],
queryFn: async () => {
try {
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const loading = isLoading || isFetching;
useEffect(() => {
fetchData();
}, [fetchData]);
const generateMutation = useApiMutation(
async (payload: { operationId: string; billingMonth: string }) =>
api.post('/bills/generate', payload),
{ invalidate: [['bills']] },
);
const cancelMutation = useApiMutation(
async ({ id, reason }: { id: number; reason: string }) =>
api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason }),
{ invalidate: [['bills']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/bills/${id}`),
{ invalidate: [['bills']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/bills/${id}/permanent`),
{ invalidate: [['bills']] },
);
const batchArchiveMutation = useApiMutation(
async (ids: number[]) => api.post('/bills/batch/delete', { ids }),
{ invalidate: [['bills']] },
);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
@@ -92,16 +125,15 @@ const BillsPage: React.FC = () => {
setSaving(true);
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
const res: any = await generateMutation.mutateAsync({
operationId: newOperationId(),
billingMonth: values.billingMonth.format('YYYY-MM'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -121,7 +153,7 @@ const BillsPage: React.FC = () => {
const handleCancel = async (id: number) => {
let reason = '';
Modal.confirm({
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
@@ -139,37 +171,49 @@ const BillsPage: React.FC = () => {
message.error('请输入取消原因');
throw new Error('reason required');
}
await api.post(`/bills/${id}/cancel`, {
operationId: newOperationId(),
reason: reason.trim(),
});
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
fetchData();
},
});
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
fetchData();
} catch (error: any) {
message.error(error?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
if (batchLoading) return;
setBatchLoading(true);
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
await batchArchiveMutation.mutateAsync(selectedRows);
message.success(`已归档 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
@@ -216,28 +260,28 @@ const BillsPage: React.FC = () => {
dataIndex: 'sharedAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
},
{
title: '已扣余额',
dataIndex: 'paidAmount',
width: 110,
render: (value: number) => (
<span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>
<span style={{ color: '#389e0d' }}>¥{(value ?? 0).toFixed(2)}</span>
),
},
{
@@ -245,8 +289,8 @@ const BillsPage: React.FC = () => {
dataIndex: 'outstandingAmount',
width: 110,
render: (value: number) => (
<strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>
¥{Number(value || 0).toFixed(2)}
<strong style={{ color: value > 0 ? '#cf1322' : '#389e0d' }}>
¥{(value ?? 0).toFixed(2)}
</strong>
),
},
@@ -289,6 +333,22 @@ const BillsPage: React.FC = () => {
>
PDF
</PermissionButton>
{record.status === 'cancelled' && canPurgeBill ? (
<Button
size="small"
danger
type="link"
onClick={() =>
handlePurge(
record.id,
record.student?.name || '-',
`${record.periodStart}~${record.periodEnd}`,
)
}
>
</Button>
) : null}
{record.status !== 'cancelled' && (
<PermissionButton
permission="bill:delete"
@@ -320,7 +380,7 @@ const BillsPage: React.FC = () => {
),
},
],
[showDetail, handleArchive, handleCancel, handleExportPdf],
[showDetail, handleArchive, handleCancel, handleExportPdf, canPurgeBill, handlePurge],
);
return (
@@ -511,12 +571,12 @@ const BillsPage: React.FC = () => {
{
title: '宿舍总费用',
dataIndex: 'roomTotalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '应分摊',
dataIndex: 'studentAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
},
]}
/>