feat: add student wallet utility billing

This commit is contained in:
2026-07-14 20:39:32 +08:00
parent b480070e69
commit c75a08affe
29 changed files with 986 additions and 548 deletions

View File

@@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms'));
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
const ExpensesPage = lazy(() => import('./pages/Expenses'));
const BillsPage = lazy(() => import('./pages/Bills'));
const WalletsPage = lazy(() => import('./pages/Wallets'));
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
const UsersPage = lazy(() => import('./pages/Users'));
@@ -134,6 +135,14 @@ const App: React.FC = () => {
</PermissionRoute>
}
/>
<Route
path="wallets"
element={
<PermissionRoute permission="wallet:view">
<WalletsPage />
</PermissionRoute>
}
/>
<Route
path="bills"
element={

View File

@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
],
},
@@ -111,8 +112,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
normalized.add('academic');
}
if (
permissions.includes('room:view') &&
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
(permissions.includes('room:view') &&
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
permissions.includes('wallet:view')
) {
normalized.add('accommodation');
}

View File

@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
expense: <DollarOutlined />,
bill: <FileTextOutlined />,
deposit: <WalletOutlined />,
wallet: <WalletOutlined />,
classroom: <ReadOutlined />,
rental: <FileProtectOutlined />,
organization: <TagsOutlined />,

View File

@@ -10,7 +10,6 @@ import {
Popconfirm,
Input,
Select,
Tooltip,
Spin,
Empty,
} from 'antd';
@@ -26,174 +25,23 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
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: '保洁费',
rent: '租金',
damage: '损坏赔偿',
penalty: '罚款',
other: '其他',
};
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
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 `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #000;
background: #f5f5f5;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 50px;
background: #fff;
}
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
.amount-summary { font-size: 12px; line-height: 1.75; }
.total { color: #007aff; font-size: 14px; font-weight: 700; }
.deposit { color: #52c41a; font-size: 11px; }
.deposit-deducted { color: #fa8c16; font-size: 11px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
th { color: #333; font-weight: 700; }
td.amount, th.amount { text-align: right; white-space: nowrap; }
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
.print-actions {
position: fixed;
right: 18px;
top: 18px;
display: flex;
gap: 8px;
}
.print-actions button {
height: 32px;
padding: 0 12px;
border: 1px solid #1f6feb;
border-radius: 4px;
background: #1f6feb;
color: #fff;
cursor: pointer;
}
@media print {
body { background: #fff; }
.page { margin: 0; }
.print-actions { display: none; }
}
</style>
</head>
<body>
<div class="print-actions">
<button onclick="window.print()">打印 / 另存为 PDF</button>
</div>
<main class="page">
<h1>恭学教育基地水电费账单</h1>
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
<div class="basic-info">
<div>学生姓名: ${escapeHtml(studentName)}</div>
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
<div>账单状态: ${escapeHtml(status)}</div>
</div>
<section>
<div class="section-title">费用汇总</div>
<div class="amount-summary">
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${
hasDeposit || depositDeducted > 0
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
: ''
}
</div>
</section>
<section>
<div class="section-title">费用明细</div>
<table>
<thead>
<tr>
<th style="width: 22%;">费用类型</th>
<th>说明</th>
<th style="width: 11%;">天数</th>
<th style="width: 12%;">总人天</th>
<th class="amount" style="width: 16%;">金额(元)</th>
</tr>
</thead>
<tbody>
${
items.length
? items
.map(
(item: any) => `<tr>
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
<td>${escapeHtml(item.description || '-')}</td>
<td>${escapeHtml(item.days || 0)}</td>
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
}
</tbody>
</table>
</section>
<div class="footer">
本账单由恭学教育基地管理系统自动生成
</div>
</main>
<script>
window.addEventListener('load', () => {
setTimeout(() => window.print(), 250);
});
</script>
</body>
</html>`;
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -239,34 +87,19 @@ const BillsPage: React.FC = () => {
});
}, [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;
setSaving(true);
const values = await generateForm.validateFields();
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();
fetchData();
} catch (e: any) {
// Ant Design 的表单校验失败会 reject字段本身已展示错误无需再弹“生成失败”。
if (!e?.errorFields) {
message.error(e?.message || '生成失败');
}
message.error(e?.message || '生成失败');
} finally {
setSaving(false);
}
@@ -284,43 +117,29 @@ const BillsPage: React.FC = () => {
}
};
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 handleCancel = async (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 api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
fetchData();
},
});
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('账单已删除');
message.success('删除成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
} catch (error: any) { message.error(error?.message || '删除失败'); }
};
const batchDelete = async () => {
@@ -346,24 +165,11 @@ const BillsPage: React.FC = () => {
);
};
const handleExportPdf = useCallback(async (billId: number) => {
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.error('无法打开打印窗口,请允许浏览器弹窗后重试');
return;
}
printWindow.document.write('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
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 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 || '-' },
@@ -390,26 +196,16 @@ const BillsPage: React.FC = () => {
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
{
title: '可用押金',
dataIndex: 'availableDeposit',
width: 120,
render: (v: number) =>
v > 0 ? (
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
title: '已扣余额', dataIndex: 'paidAmount', width: 110,
render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
},
{
title: '已扣押金',
dataIndex: 'depositDeductedAmount',
width: 120,
render: (v: number) =>
Number(v || 0) > 0 ? (
<span style={{ color: '#fa8c16' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
},
{
title: '钱包余额', dataIndex: 'walletBalance', width: 110,
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
},
{
title: '状态',
@@ -436,25 +232,6 @@ const BillsPage: React.FC = () => {
>
</PermissionButton>
{record.status === 'draft' && (
<Tooltip
title={
record.depositSufficient
? '确认后将自动从该学生押金余额中扣除账单金额'
: '押金不足,请先到押金管理收取押金'
}
>
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
disabled={!record.depositSufficient}
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
</Tooltip>
)}
<PermissionButton
permission="bill:export-pdf"
size="small"
@@ -463,26 +240,20 @@ const BillsPage: React.FC = () => {
>
PDF
</PermissionButton>
<Popconfirm
title="确定删除此账单?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="bill:delete"
size="small"
danger
disabled={record.status === 'paid'}
icon={<DeleteOutlined />}
>
{record.status !== 'cancelled' && (
<PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
</PermissionButton>
</Popconfirm>
)}
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}></PermissionButton>
</Popconfirm>
)}
</Space>
),
},
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
], [showDetail, handleDelete, handleCancel, handleExportPdf]);
return (
<div>
@@ -512,31 +283,25 @@ const BillsPage: React.FC = () => {
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'draft', label: '草稿' },
{ 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:'其他'}]} />
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={!canBatchConfirm}
>
</PermissionButton>
<Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete}
okText="删除"
cancelText="取消"
disabled={!canBatchDelete}
disabled={selectedRows.length === 0}
>
<PermissionButton
permission="bill:delete"
danger
disabled={!canBatchDelete}
disabled={selectedRows.length === 0}
icon={<DeleteOutlined />}
>
@@ -570,12 +335,7 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRows,
@@ -603,9 +363,7 @@ const BillsPage: React.FC = () => {
picker="month"
placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) =>
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
}
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
/>
</Form.Item>
</Form>
@@ -645,37 +403,11 @@ const BillsPage: React.FC = () => {
</strong>
</Descriptions.Item>
</Descriptions>
{(Number(detailModal.availableDeposit || 0) > 0
|| Number(detailModal.depositDeductedAmount || 0) > 0
|| detailModal.status === 'draft') && (
<div
style={{
marginBottom: 16,
padding: 12,
background: detailModal.depositSufficient || detailModal.status === 'paid' ? '#f6ffed' : '#fff2f0',
border: `1px solid ${detailModal.depositSufficient || detailModal.status === 'paid' ? '#b7eb8f' : '#ffccc7'}`,
borderRadius: 8,
}}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
</div>
<Space size={24} wrap>
<span>
<strong style={{ color: '#52c41a' }}>
¥{Number(detailModal.availableDeposit).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
</strong>
</span>
</Space>
</div>
)}
<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 }}

View File

@@ -46,10 +46,12 @@ const ExpensesPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false);
const [utilityModal, setUtilityModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm();
const [personalForm] = Form.useForm();
const [utilityForm] = Form.useForm();
const [roomSearch, setRoomSearch] = useState('');
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
const [personalSearch, setPersonalSearch] = useState('');
@@ -192,6 +194,27 @@ const ExpensesPage: React.FC = () => {
}
};
const handleStudentUtility = async () => {
const values = await utilityForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/expenses/student-utility', {
studentId: values.studentId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
});
const bill = result.bill;
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
setUtilityModal(false);
utilityForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
finally { setSaving(false); }
};
const handlePersonalExpense = async () => {
setSaving(true);
try {
@@ -560,6 +583,13 @@ const ExpensesPage: React.FC = () => {
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="expense:create"
icon={<PlusOutlined />}
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
>
</PermissionButton>
<PermissionButton
permission="expense:create"
type="primary"
@@ -639,6 +669,19 @@ const ExpensesPage: React.FC = () => {
</Form>
</Modal>
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
<Form form={utilityForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
</Form>
</Modal>
<Modal
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
open={personalModal}

View File

@@ -0,0 +1,102 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
interface WalletRow {
studentId: number;
studentName: string;
studentNo?: string;
balance: number;
outstandingAmount: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
};
const WalletsPage: React.FC = () => {
const [rows, setRows] = useState<WalletRow[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
setRows(data as WalletRow[]);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
} finally { setLoading(false); }
}, [keyword, debtOnly]);
useEffect(() => { void fetchRows(); }, [fetchRows]);
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const submitChange = async () => {
if (!selected) return;
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
await fetchRows();
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
finally { setSaving(false); }
};
const showTransactions = async (row: WalletRow) => {
setSelected(row); setDrawerOpen(true);
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
catch (error: any) { message.error(error?.message || '加载流水失败'); }
};
const columns = useMemo(() => [
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green"></Tag> },
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>/</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}></Button></Space> },
], []);
return <div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span></span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
<Button icon={<ReloadOutlined />} onClick={fetchRows}></Button>
</div>
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} />
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
<Form form={form} layout="vertical">
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
{ title: '说明', dataIndex: 'description' },
]} />
</Drawer>
</div>;
};
export default WalletsPage;