feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -27,13 +27,21 @@ const statusLabels: Record<string, string> = {
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
const HTML_ESCAPE_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
['&', '&'],
|
||||
['<', '<'],
|
||||
['>', '>'],
|
||||
['"', '"'],
|
||||
["'", '''],
|
||||
];
|
||||
|
||||
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)}`;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user