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 = { unpaid: { text: '待支付', color: 'orange' }, partially_paid: { text: '部分支付', color: 'gold' }, paid: { text: '已支付', color: 'green' }, cancelled: { text: '已取消', color: 'default' }, }; const typeMap: Record = { 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(null); const [selectedRows, setSelectedRows] = useState([]); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterExpenseType, setFilterExpenseType] = useState(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 = {}; if (filterStatus) params.status = filterStatus; if (filterExpenseType) params.expenseType = filterExpenseType; return validateResponse(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: ( { 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( '

正在加载账单...

', ); try { const bill = await api.get(`/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) => ¥{v.toFixed(2)}, }, { title: '已扣余额', dataIndex: 'paidAmount', width: 110, render: (value: number) => ( ¥{(value ?? 0).toFixed(2)} ), }, { title: '待补缴', dataIndex: 'outstandingAmount', width: 110, render: (value: number) => ( 0 ? '#cf1322' : '#389e0d' }}> ¥{(value ?? 0).toFixed(2)} ), }, { title: '钱包余额', dataIndex: 'walletBalance', width: 110, render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {statusMap[s]?.text}, }, { title: '生成时间', dataIndex: 'generatedAt', width: 160, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), }, { title: '操作', width: 320, render: (_: any, record: any) => ( showDetail(record.id)} > 详情 } onClick={() => handleExportPdf(record.id)} > PDF {record.status === 'cancelled' && canPurgeBill ? ( ) : null} {record.status !== 'cancelled' && ( handleCancel(record.id)} > 取消并冲正 )} {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( handleArchive(record.id)} okText="归档" cancelText="取消" > } > 归档 )} ), }, ], [showDetail, handleArchive, handleCancel, handleExportPdf, canPurgeBill, handlePurge], ); return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} /> } > 批量归档 } onClick={openGenerateModal} > 生成账单 } loading={exportExcelDownloading} onClick={handleExportExcel} > 导出Excel
{billGeneratedHint && ( { // 账单生成后即为 unpaid(待支付)状态 setFilterStatus('unpaid'); setBillGeneratedHint(false); }, }} onClose={() => setBillGeneratedHint(false)} /> )} {isError ? ( void refetch()} /> ) : ( `共 ${total} 条` }} locale={{ emptyText: ( ), }} rowSelection={{ selectedRowKeys: selectedRows, onChange: (keys) => setSelectedRows(keys as number[]), }} /> )} setGenerateModal(false)} okText="生成" confirmLoading={saving} >
!!current && !current.endOf('month').isBefore(dayjs(), 'day') } />
setDetailModal(null)} footer={null} width={800} > {detailModal && ( {detailModal.student?.name} {statusMap[detailModal.status]?.text} {detailModal.periodStart} ~ {detailModal.periodEnd} {dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')} ¥{Number(detailModal.sharedAmount).toFixed(2)} ¥{Number(detailModal.personalAmount).toFixed(2)} ¥{Number(detailModal.totalAmount).toFixed(2)} ¥{Number(detailModal.paidAmount || 0).toFixed(2)} ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} ¥{Number(detailModal.walletBalance || 0).toFixed(2)}

费用明细

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) => ¥{v.toFixed(2)}, }, ]} /> )} ); }; export default BillsPage;