Files
gongxue-base/apps/admin/src/pages/Bills/index.tsx
wangziqi 1b4ba893fd feat: 空状态引导全面应用与考勤批量标记
admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

aislop scan: 5 引擎 0 issues
2026-08-07 18:00:29 +08:00

643 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;