forked from wangziqi/gongxue-base
admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues
643 lines
21 KiB
TypeScript
643 lines
21 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
||
import {
|
||
App,
|
||
Table,
|
||
Button,
|
||
Modal,
|
||
Form,
|
||
DatePicker,
|
||
Space,
|
||
Tag,
|
||
Descriptions,
|
||
Popconfirm,
|
||
Input,
|
||
Select,
|
||
Spin,
|
||
} from 'antd';
|
||
import {
|
||
FileTextOutlined,
|
||
InboxOutlined,
|
||
DownloadOutlined,
|
||
FilePdfOutlined,
|
||
} from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||
import { NextStepHint } from '../../components/NextStepHint';
|
||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||
import { useDownload } from '../../hooks/useDownload';
|
||
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' },
|
||
partially_paid: { text: '部分支付', color: 'gold' },
|
||
paid: { text: '已支付', color: 'green' },
|
||
cancelled: { text: '已取消', color: 'default' },
|
||
};
|
||
|
||
const typeMap: Record<string, string> = {
|
||
water: '水费',
|
||
electricity: '电费',
|
||
cleaning: '保洁费',
|
||
damage: '损坏赔偿',
|
||
penalty: '罚款',
|
||
other: '其他',
|
||
};
|
||
|
||
const BillsPage: React.FC = () => {
|
||
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[]>([]);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
const [filterExpenseType, setFilterExpenseType] = useState<string | undefined>(undefined);
|
||
const [generateForm] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
// 生成账单成功后的「下一步」引导提示
|
||
const [billGeneratedHint, setBillGeneratedHint] = useState(false);
|
||
|
||
const {
|
||
data: bills = [],
|
||
isLoading,
|
||
isFetching,
|
||
isError,
|
||
refetch,
|
||
} = useQuery({
|
||
queryKey: ['bills', filterStatus, filterExpenseType],
|
||
queryFn: async () => {
|
||
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 }));
|
||
},
|
||
});
|
||
const loading = isLoading || isFetching;
|
||
// RouteKeeper 保活页面切回时刷新账单列表
|
||
useVisibleRefetch(['bills']);
|
||
|
||
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) => {
|
||
if (searchText) {
|
||
const s = searchText.toLowerCase();
|
||
const matchName = b.student?.name?.toLowerCase().includes(s);
|
||
const matchPeriod = `${b.periodStart} ~ ${b.periodEnd}`.includes(s);
|
||
if (!matchName && !matchPeriod) return false;
|
||
}
|
||
if (filterStatus && b.status !== filterStatus) return false;
|
||
return true;
|
||
});
|
||
}, [bills, searchText, filterStatus]);
|
||
|
||
const handleGenerate = async () => {
|
||
const values = await generateForm.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const res: any = await generateMutation.mutateAsync({
|
||
operationId: newOperationId(),
|
||
billingMonth: values.billingMonth.format('YYYY-MM'),
|
||
});
|
||
message.success(res.message || '生成成功');
|
||
setGenerateModal(false);
|
||
generateForm.resetFields();
|
||
setBillGeneratedHint(true);
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const openGenerateModal = () => {
|
||
generateForm.resetFields();
|
||
setGenerateModal(true);
|
||
};
|
||
|
||
const showDetail = useCallback(async (id: number) => {
|
||
setDetailLoading(true);
|
||
try {
|
||
const res = await api.get(`/bills/${id}`);
|
||
setDetailModal(res);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载失败,请稍后重试');
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const handleCancel = useCallback(
|
||
(id: number) => {
|
||
let reason = '';
|
||
modal.confirm({
|
||
title: '取消账单并退回已扣余额',
|
||
content: (
|
||
<Input.TextArea
|
||
placeholder="请输入取消原因"
|
||
maxLength={300}
|
||
onChange={(event) => {
|
||
reason = event.target.value;
|
||
}}
|
||
/>
|
||
),
|
||
okText: '确认取消',
|
||
cancelText: '返回',
|
||
onOk: async () => {
|
||
if (!reason.trim()) {
|
||
message.error('请输入取消原因');
|
||
throw new Error('reason required');
|
||
}
|
||
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
|
||
message.success('账单已取消,已扣余额已冲正退回');
|
||
},
|
||
});
|
||
},
|
||
[modal, cancelMutation],
|
||
);
|
||
|
||
const handleArchive = useCallback(
|
||
async (id: number) => {
|
||
try {
|
||
await archiveMutation.mutateAsync(id);
|
||
message.success('账单已归档');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
},
|
||
[archiveMutation],
|
||
);
|
||
|
||
const handlePurge = useCallback(
|
||
(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 统一处理
|
||
}
|
||
},
|
||
});
|
||
},
|
||
[modal, purgeMutation],
|
||
);
|
||
|
||
const batchArchive = async () => {
|
||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||
if (batchLoading) return;
|
||
setBatchLoading(true);
|
||
try {
|
||
await batchArchiveMutation.mutateAsync(selectedRows);
|
||
message.success(`已归档 ${selectedRows.length} 条账单`);
|
||
setSelectedRows([]);
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
};
|
||
|
||
const { downloading: exportExcelDownloading, run: runExportExcel } = useDownload();
|
||
|
||
const handleExportExcel = () => {
|
||
void runExportExcel(`/bills/export/excel`, `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, {
|
||
successMsg: 'Excel 导出成功',
|
||
errorMsg: '导出失败',
|
||
});
|
||
};
|
||
|
||
const handleExportPdf = useCallback(async (billId: number) => {
|
||
const printWindow = window.open('', '_blank');
|
||
if (!printWindow) {
|
||
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
|
||
return;
|
||
}
|
||
|
||
printWindow.document.write(
|
||
'<p style="font-family:sans-serif;padding:24px">正在加载账单...</p>',
|
||
);
|
||
try {
|
||
const bill = await api.get<BillPrintData>(`/bills/${billId}`);
|
||
printWindow.document.open();
|
||
printWindow.document.write(buildBillPrintHtml(bill));
|
||
printWindow.document.close();
|
||
} catch (error: any) {
|
||
printWindow.close();
|
||
message.error(error?.message || '账单加载失败');
|
||
}
|
||
}, []);
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||
{
|
||
title: '账单周期',
|
||
width: 200,
|
||
render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`,
|
||
},
|
||
{
|
||
title: '分摊费用',
|
||
dataIndex: 'sharedAmount',
|
||
width: 120,
|
||
align: 'right' as const,
|
||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '个人费用',
|
||
dataIndex: 'personalAmount',
|
||
width: 120,
|
||
align: 'right' as const,
|
||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '总计',
|
||
dataIndex: 'totalAmount',
|
||
width: 100,
|
||
align: 'right' as const,
|
||
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
|
||
},
|
||
{
|
||
title: '已扣余额',
|
||
dataIndex: 'paidAmount',
|
||
width: 110,
|
||
render: (value: number) => (
|
||
<span style={{ color: '#389e0d' }}>¥{(value ?? 0).toFixed(2)}</span>
|
||
),
|
||
},
|
||
{
|
||
title: '待补缴',
|
||
dataIndex: 'outstandingAmount',
|
||
width: 110,
|
||
render: (value: number) => (
|
||
<strong style={{ color: value > 0 ? '#cf1322' : '#389e0d' }}>
|
||
¥{(value ?? 0).toFixed(2)}
|
||
</strong>
|
||
),
|
||
},
|
||
{
|
||
title: '钱包余额',
|
||
dataIndex: 'walletBalance',
|
||
width: 110,
|
||
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
|
||
},
|
||
{
|
||
title: '生成时间',
|
||
dataIndex: 'generatedAt',
|
||
width: 160,
|
||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 320,
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
<PermissionButton
|
||
permission="bill:view"
|
||
size="small"
|
||
type="link"
|
||
onClick={() => showDetail(record.id)}
|
||
>
|
||
详情
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="bill:export-pdf"
|
||
size="small"
|
||
icon={<FilePdfOutlined />}
|
||
onClick={() => handleExportPdf(record.id)}
|
||
>
|
||
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"
|
||
size="small"
|
||
danger
|
||
onClick={() => handleCancel(record.id)}
|
||
>
|
||
取消并冲正
|
||
</PermissionButton>
|
||
)}
|
||
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
|
||
<Popconfirm
|
||
title="确定归档此未支付账单?"
|
||
onConfirm={() => handleArchive(record.id)}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="bill:delete"
|
||
size="small"
|
||
danger
|
||
icon={<InboxOutlined />}
|
||
>
|
||
归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
],
|
||
[showDetail, handleArchive, handleCancel, handleExportPdf, canPurgeBill, handlePurge],
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div className="responsive-toolbar">
|
||
<Space wrap className="responsive-toolbar__group">
|
||
<Input.Search
|
||
placeholder="搜索学生姓名或账单周期"
|
||
allowClear
|
||
style={{ width: 220 }}
|
||
onSearch={(v) => setSearchText(v)}
|
||
onChange={(e) => {
|
||
if (!e.target.value) setSearchText('');
|
||
}}
|
||
/>
|
||
<Select
|
||
placeholder="状态筛选"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
value={filterStatus}
|
||
onChange={(v) => setFilterStatus(v)}
|
||
options={[
|
||
{ value: 'unpaid', label: '待支付' },
|
||
{ value: 'partially_paid', label: '部分支付' },
|
||
{ value: 'paid', label: '已支付' },
|
||
{ value: 'cancelled', label: '已取消' },
|
||
]}
|
||
/>
|
||
<Select
|
||
placeholder="费用类型"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
value={filterExpenseType}
|
||
onChange={setFilterExpenseType}
|
||
options={[
|
||
{ value: 'water', label: '水费' },
|
||
{ value: 'electricity', label: '电费' },
|
||
{ value: 'cleaning', label: '保洁费' },
|
||
{ value: 'rent', label: '租金' },
|
||
{ value: 'other', label: '其他' },
|
||
]}
|
||
/>
|
||
<Popconfirm
|
||
title={`确定归档选中的 ${selectedRows.length} 条账单?`}
|
||
onConfirm={batchArchive}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
disabled={selectedRows.length === 0}
|
||
>
|
||
<PermissionButton
|
||
permission="bill:delete"
|
||
danger
|
||
disabled={selectedRows.length === 0}
|
||
icon={<InboxOutlined />}
|
||
>
|
||
批量归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</Space>
|
||
<Space wrap className="responsive-toolbar__group">
|
||
<PermissionButton
|
||
permission="bill:generate"
|
||
type="primary"
|
||
icon={<FileTextOutlined />}
|
||
onClick={openGenerateModal}
|
||
>
|
||
生成账单
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="bill:export-excel"
|
||
icon={<DownloadOutlined />}
|
||
loading={exportExcelDownloading}
|
||
onClick={handleExportExcel}
|
||
>
|
||
导出Excel
|
||
</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
{billGeneratedHint && (
|
||
<NextStepHint
|
||
title="账单已生成"
|
||
description="请核对账单明细,确认后标记已付,完成「住宿→计费」闭环。"
|
||
action={{
|
||
label: '筛选待确认账单',
|
||
onClick: () => {
|
||
// 账单生成后即为 unpaid(待支付)状态
|
||
setFilterStatus('unpaid');
|
||
setBillGeneratedHint(false);
|
||
},
|
||
}}
|
||
onClose={() => setBillGeneratedHint(false)}
|
||
/>
|
||
)}
|
||
{isError ? (
|
||
<QueryErrorState
|
||
title="账单数据加载失败"
|
||
description="请检查网络后重试。"
|
||
onRetry={() => void refetch()}
|
||
/>
|
||
) : (
|
||
<Table
|
||
scroll={{ x: 1400 }}
|
||
columns={columns}
|
||
dataSource={filteredBills}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||
locale={{
|
||
emptyText: (
|
||
<QueryEmpty
|
||
description="暂无账单"
|
||
action={
|
||
hasPermission('bill:generate')
|
||
? { label: '生成账单', onClick: openGenerateModal }
|
||
: undefined
|
||
}
|
||
/>
|
||
),
|
||
}}
|
||
rowSelection={{
|
||
selectedRowKeys: selectedRows,
|
||
onChange: (keys) => setSelectedRows(keys as number[]),
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<Modal
|
||
title="生成账单"
|
||
open={generateModal}
|
||
onOk={handleGenerate}
|
||
onCancel={() => setGenerateModal(false)}
|
||
okText="生成"
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={generateForm} layout="vertical">
|
||
<Form.Item
|
||
name="billingMonth"
|
||
label="账单月份"
|
||
rules={[{ required: true, message: '请选择账单月份' }]}
|
||
extra="只能选择已结束月份,每个月只能生成一次账单"
|
||
>
|
||
<DatePicker
|
||
style={{ width: '100%' }}
|
||
picker="month"
|
||
placeholder="选择月份"
|
||
format="YYYY-MM"
|
||
disabledDate={(current) =>
|
||
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="账单详情"
|
||
open={!!detailModal}
|
||
onCancel={() => setDetailModal(null)}
|
||
footer={null}
|
||
width={800}
|
||
>
|
||
{detailModal && (
|
||
<Spin spinning={detailLoading}>
|
||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={statusMap[detailModal.status]?.color}>
|
||
{statusMap[detailModal.status]?.text}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="账单周期">
|
||
{detailModal.periodStart} ~ {detailModal.periodEnd}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="生成时间">
|
||
{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="分摊费用">
|
||
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="个人费用">
|
||
¥{Number(detailModal.personalAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="合计" span={2}>
|
||
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
||
¥{Number(detailModal.totalAmount).toFixed(2)}
|
||
</strong>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="已扣余额">
|
||
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="待补缴">
|
||
¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="当前钱包余额">
|
||
¥{Number(detailModal.walletBalance || 0).toFixed(2)}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<h4>费用明细</h4>
|
||
<Table
|
||
scroll={{ x: 700 }}
|
||
dataSource={detailModal.items || []}
|
||
rowKey="id"
|
||
pagination={false}
|
||
size="small"
|
||
columns={[
|
||
{ title: '类型', dataIndex: 'expenseType', render: (v: string) => typeMap[v] || v },
|
||
{ title: '说明', dataIndex: 'description' },
|
||
{
|
||
title: '计费天数',
|
||
dataIndex: 'days',
|
||
render: (v: number) => (v > 0 ? `${v}天` : '-'),
|
||
},
|
||
{
|
||
title: '宿舍总人天',
|
||
dataIndex: 'totalRoomDays',
|
||
render: (v: number) => (v > 0 ? `${v}天` : '-'),
|
||
},
|
||
{
|
||
title: '宿舍总费用',
|
||
dataIndex: 'roomTotalAmount',
|
||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '应分摊',
|
||
dataIndex: 'studentAmount',
|
||
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
|
||
},
|
||
]}
|
||
/>
|
||
</Spin>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BillsPage;
|