import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Modal, Form, DatePicker, Space, Tag, Descriptions, Popconfirm, Input, Select, Tooltip, Spin, Empty, } from 'antd'; import { FileTextOutlined, DeleteOutlined, DownloadOutlined, FilePdfOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; const statusMap: Record = { draft: { text: '草稿', color: 'default' }, paid: { text: '已支付', color: 'green' }, }; const typeMap: Record = { water: '水费', electricity: '电费', cleaning: '保洁费', rent: '租金', damage: '损坏赔偿', penalty: '罚款', other: '其他', }; const escapeHtml = (value: unknown) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`; const buildBillPrintHtml = (bill: any) => { const studentName = bill.student?.name || '-'; const status = statusMap[bill.status]?.text || bill.status || '-'; const generatedAt = bill.generatedAt ? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm') : dayjs().format('YYYY-MM-DD HH:mm'); const hasDeposit = Number(bill.availableDeposit || 0) > 0; const depositDeducted = Number(bill.depositDeductedAmount || 0); const items = bill.items || []; return ` 账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}

恭学教育基地水电费账单

生成时间: ${escapeHtml(generatedAt)}
学生姓名: ${escapeHtml(studentName)}
计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}
账单状态: ${escapeHtml(status)}
费用汇总
分摊费用: ${escapeHtml(money(bill.sharedAmount))}
个人费用: ${escapeHtml(money(bill.personalAmount))}
应付总额: ${escapeHtml(money(bill.totalAmount))}
${ hasDeposit || depositDeducted > 0 ? `
可用押金: ${escapeHtml(money(bill.availableDeposit))}
${depositDeducted > 0 ? `
已扣押金: -${escapeHtml(money(depositDeducted))}
` : ''}` : '' }
费用明细
${ items.length ? items .map( (item: any) => ``, ) .join('') : '' }
费用类型 说明 天数 总人天 金额(元)
${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')} ${escapeHtml(item.description || '-')} ${escapeHtml(item.days || 0)} ${escapeHtml(item.totalRoomDays || 0)} ${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}
暂无费用明细
`; }; const BillsPage: React.FC = () => { const [bills, setBills] = useState([]); const [loading, setLoading] = useState(false); 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 fetchData = useCallback(async () => { setLoading(true); try { const params: Record = {}; 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]); useEffect(() => { fetchData(); }, [fetchData]); 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 selectedBillRows = useMemo( () => bills.filter((bill: any) => selectedRows.includes(bill.id)), [bills, selectedRows], ); const canBatchConfirm = selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient); const canBatchDelete = selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid'); const handleGenerate = async () => { if (saving) return; try { const values = await generateForm.validateFields(); setSaving(true); const res: any = await api.post('/bills/generate', { billingMonth: values.billingMonth.format('YYYY-MM'), }); message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); void fetchData(); } catch (e: any) { // Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。 if (!e?.errorFields) { message.error(e?.message || '生成失败'); } } finally { setSaving(false); } }; const showDetail = 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 updateStatus = async (id: number, status: string) => { try { await api.put(`/bills/${id}/status`, { status }); message.success('账单已确认支付,押金已自动扣除'); fetchData(); if (detailModal?.id === id) { void showDetail(id); } } catch (e: any) { message.error(e?.message || '操作失败'); } }; const batchUpdateStatus = async (status: string) => { if (selectedRows.length === 0) return message.warning('请先选择账单'); if (batchLoading) return; setBatchLoading(true); try { await api.put('/bills/batch/status', { ids: selectedRows, status }); message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`); setSelectedRows([]); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setBatchLoading(false); } }; const handleDelete = async (id: number) => { try { await api.delete(`/bills/${id}`); message.success('账单已删除'); fetchData(); } catch (e: any) { message.error(e?.message || '删除失败'); } }; const batchDelete = async () => { if (selectedRows.length === 0) return message.warning('请先选择账单'); if (batchLoading) return; setBatchLoading(true); try { await api.post('/bills/batch/delete', { ids: selectedRows }); message.success(`已删除 ${selectedRows.length} 条账单`); setSelectedRows([]); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setBatchLoading(false); } }; const handleExportExcel = () => { downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then( () => message.success('Excel 导出成功'), () => message.error('导出失败'), ); }; 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 (e: any) { printWindow.close(); message.error(e?.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) => `¥${Number(v).toFixed(2)}`, }, { title: '个人费用', dataIndex: 'personalAmount', width: 120, align: 'right' as const, render: (v: number) => `¥${Number(v).toFixed(2)}`, }, { title: '总计', dataIndex: 'totalAmount', width: 100, align: 'right' as const, render: (v: number) => ¥{Number(v).toFixed(2)}, }, { title: '可用押金', dataIndex: 'availableDeposit', width: 120, render: (v: number) => v > 0 ? ( ¥{Number(v).toFixed(2)} ) : ( - ), }, { title: '已扣押金', dataIndex: 'depositDeductedAmount', width: 120, render: (v: number) => Number(v || 0) > 0 ? ( ¥{Number(v).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)} > 详情 {record.status === 'draft' && ( updateStatus(record.id, 'paid')} > 确认支付 )} } onClick={() => handleExportPdf(record.id)} > PDF handleDelete(record.id)} okText="删除" cancelText="取消" > } > 删除 ), }, ], [showDetail, updateStatus, handleDelete, handleExportPdf]); return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} /> batchUpdateStatus('paid')} disabled={!canBatchConfirm} > 批量确认支付 } > 批量删除 } onClick={() => { generateForm.resetFields(); setGenerateModal(true); }} > 生成账单 } onClick={handleExportExcel} > 导出Excel
`共 ${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.availableDeposit || 0) > 0 || Number(detailModal.depositDeductedAmount || 0) > 0 || detailModal.status === 'draft') && (
押金联动(确认支付时实时扣除,不在生成账单时冻结)
当前可用押金: ¥{Number(detailModal.availableDeposit).toFixed(2)} 已扣押金: -¥{Number(detailModal.depositDeductedAmount || 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) => `¥${Number(v).toFixed(2)}`, }, { title: '应分摊', dataIndex: 'studentAmount', render: (v: number) => ¥{Number(v).toFixed(2)}, }, ]} /> )} ); }; export default BillsPage;