import React, { useEffect, useState, useMemo } from 'react'; import { Table, Button, Modal, Form, DatePicker, Space, message, Tag, Descriptions, Popconfirm, Input, Select, Tooltip, } from 'antd'; import { FileTextOutlined, DeleteOutlined, DownloadOutlined, FilePdfOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; 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 [generateForm] = Form.useForm(); const fetchData = async () => { setLoading(true); try { const res: any = await api.get('/bills'); setBills(res); } catch (e) { console.error(e); } setLoading(false); }; useEffect(() => { 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 () => { 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 || '生成失败'); } }; const showDetail = async (id: number) => { try { const res = await api.get(`/bills/${id}`); setDetailModal(res); } catch (e) { console.error(e); } }; 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('请先选择账单'); try { await api.put('/bills/batch/status', { ids: selectedRows, status }); message.success(`已批量更新 ${selectedRows.length} 条账单`); setSelectedRows([]); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } }; 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('请先选择账单'); try { await api.post('/bills/batch/delete', { ids: selectedRows }); message.success(`已删除 ${selectedRows.length} 条账单`); setSelectedRows([]); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } }; const handleExportExcel = () => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = localStorage.getItem('token'); const url = `${baseURL}/bills/export/excel`; const a = document.createElement('a'); // 使用 fetch 来携带 token fetch(url, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { const blobUrl = URL.createObjectURL(blob); a.href = blobUrl; a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`; a.click(); URL.revokeObjectURL(blobUrl); message.success('Excel 导出成功'); }) .catch(() => message.error('导出失败')); }; const handleExportPdf = (billId: number) => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = localStorage.getItem('token'); fetch(`${baseURL}/bills/export/pdf/${billId}`, { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `账单_${billId}.pdf`; a.click(); URL.revokeObjectURL(url); }) .catch(() => message.error('导出失败')); }; const columns = [ { title: '学生', render: (_: any, r: any) => r.student?.name || '-' }, { title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` }, { title: '分摊费用', dataIndex: 'sharedAmount', render: (v: number) => `¥${Number(v).toFixed(2)}`, }, { title: '个人费用', dataIndex: 'personalAmount', render: (v: number) => `¥${Number(v).toFixed(2)}`, }, { title: '总计', dataIndex: 'totalAmount', render: (v: number) => ¥{Number(v).toFixed(2)}, }, { title: '可用押金', dataIndex: 'availableDeposit', render: (v: number) => v > 0 ? ( ¥{Number(v).toFixed(2)} ) : ( - ), }, { title: '抵扣后应付', dataIndex: 'amountAfterDeposit', 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', render: (s: string) => {statusMap[s]?.text}, }, { title: '生成时间', dataIndex: 'generatedAt', 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="取消" > ), }, ]; return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} />