import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Modal, Form, DatePicker, Space, message, 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'; const { RangePicker } = DatePicker; const statusMap: Record = { draft: { text: '草稿', color: 'default' }, confirmed: { text: '已确认', color: 'blue' }, paid: { text: '已支付', color: 'green' }, }; const typeMap: Record = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', other: '其他', }; 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 handleGenerate = async () => { setSaving(true); const values = await generateForm.validateFields(); try { const res: any = await api.post('/bills/generate', { periodStart: values.period[0].format('YYYY-MM-DD'), periodEnd: values.period[1].format('YYYY-MM-DD'), }); message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); fetchData(); } catch (e: any) { 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) { setDetailModal({ ...detailModal, status }); } } catch (e: any) { message.error(e?.message || '操作失败'); } }; const batchUpdateStatus = async (status: string) => { if (selectedRows.length === 0) return message.warning('请先选择账单'); 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('请先选择账单'); 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 = (billId: number) => { downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() => message.error('导出失败'), ); }; 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: 'amountAfterDeposit', width: 130, render: (v: number, r: any) => { const has = Number(r.availableDeposit || 0) > 0; if (!has) return -; const after = Number(v ?? r.totalAmount).toFixed(2); const applied = Number(r.depositApplied || 0).toFixed(2); return ( ¥{after} ); }, }, { 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, 'confirmed')} > 确认 )} {record.status === 'confirmed' && ( 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('confirmed')} disabled={selectedRows.length === 0} loading={batchLoading} > 批量确认 batchUpdateStatus('paid')} disabled={selectedRows.length === 0} loading={batchLoading} > 批量标记已付 } loading={batchLoading} > 批量删除 } onClick={() => { generateForm.resetFields(); setGenerateModal(true); }} > 生成账单 } onClick={handleExportExcel} > 导出Excel
}} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} rowSelection={{ selectedRowKeys: selectedRows, onChange: (keys) => setSelectedRows(keys as number[]), }} /> setGenerateModal(false)} okText="生成" confirmLoading={saving} >
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.availableDeposit).toFixed(2)} 本账单可抵扣: -¥{Number(detailModal.depositApplied || 0).toFixed(2)} 抵扣后实付: ¥ {Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).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;