Files
gongxue-base/apps/admin/src/pages/Wallets/index.tsx

441 lines
13 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, { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { roomTypesSchema, walletsSchema } from '../../api/schemas';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
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';
import { newOperationId } from '../../utils/operation-id';
interface WalletRow {
studentId: number;
studentName: string;
studentNo?: string;
balance: number;
outstandingAmount: number;
roomType?: string;
roomNumber?: string;
}
interface WalletTransaction {
id: number;
createdAt: string;
type: string;
amount: number;
balanceAfter: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值',
adjustment: '调账',
bill_payment: '账单扣款',
bill_refund: '账单冲正',
};
const WalletsPage: React.FC = () => {
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [roomType, setRoomType] = useState<string | undefined>();
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<WalletTransaction[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [batchForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const {
data: rows = [],
isLoading,
isFetching,
refetch,
} = useQuery<WalletRow[]>({
queryKey: ['wallets', keyword, debtOnly, roomType],
queryFn: async () => {
try {
return validateResponse<WalletRow[]>(
walletsSchema,
await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly, roomType },
}),
);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
return [];
}
},
});
const { data: roomTypes = [] } = useQuery<string[]>({
queryKey: ['wallets', 'room-types'],
queryFn: async () => {
try {
return validateResponse<string[]>(
roomTypesSchema,
await api.get('/wallets/room-types'),
);
} catch (error: any) {
message.error(error?.message || '加载房型失败');
return [];
}
},
});
const loading = isLoading || isFetching;
const fetchRows = useCallback(() => refetch(), [refetch]);
const changeMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/wallets/change-balance', payload),
{ invalidate: [['wallets']] },
);
const batchChangeMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post('/wallets/batch-change-balance', payload),
{ invalidate: [['wallets']] },
);
const updateKeyword = (value: string) => {
setKeyword(value);
setSelectedRowKeys([]);
};
const updateRoomType = (value: string | undefined) => {
setRoomType(value);
setSelectedRowKeys([]);
};
const updateDebtOnly = (value: boolean) => {
setDebtOnly(value);
setSelectedRowKeys([]);
};
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
setBatchModalOpen(true);
};
const submitChange = async () => {
if (!selected) return;
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await changeMutation.mutateAsync({
operationId: newOperationId(),
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);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const submitBatchChange = async () => {
const values = await batchForm.validateFields();
setSaving(true);
try {
const result: any = await batchChangeMutation.mutateAsync({
operationId: newOperationId(),
studentIds: selectedRowKeys,
...values,
});
const paid = (result.results || []).reduce((sum: number, item: any) => {
return (
sum +
(item.payments || []).reduce(
(paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0),
0,
)
);
}, 0);
message.success(
paid > 0
? `已批量更新 ${selectedRowKeys.length} 名学生余额,并自动补扣历史账单`
: `已批量更新 ${selectedRowKeys.length} 名学生余额`,
);
setBatchModalOpen(false);
setSelectedRowKeys([]);
batchForm.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const showTransactions = async (row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
};
const columns = useMemo(
() => [
{
title: '学生',
render: (_: unknown, row: WalletRow) => (
<>
<strong>{row.studentName}</strong>
<div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div>
<div style={{ color: '#999' }}>
{row.roomType ? `${row.roomType}${row.roomNumber ? ` · ${row.roomNumber}` : ''}` : '未入住'}
</div>
</>
),
},
{
title: '可用余额',
dataIndex: 'balance',
render: (value: number) => (
<strong style={{ color: value > 0 ? '#1677ff' : undefined }}>
¥{value.toFixed(2)}
</strong>
),
},
{
title: '未付账单',
dataIndex: 'outstandingAmount',
render: (value: number) =>
value > 0 ? (
<Tag color="red">¥{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={updateKeyword}
onChange={(event) => !event.target.value && updateKeyword('')}
/>
<Select
allowClear
placeholder="按房型筛选"
style={{ width: 180 }}
value={roomType}
onChange={updateRoomType}
options={roomTypes.map((type) => ({ label: type, value: type }))}
/>
<span></span>
<Switch checked={debtOnly} onChange={updateDebtOnly} />
</Space>
<Space wrap>
<PermissionButton
permission="wallet:edit"
type="primary"
icon={<PlusOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={openBatchChange}
>
/
</PermissionButton>
<Button icon={<ReloadOutlined />} onClick={fetchRows}>
</Button>
</Space>
</div>
<Table
rowKey="studentId"
loading={loading}
dataSource={rows}
columns={columns}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
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>
<Modal
title={`批量余额操作(${selectedRowKeys.length} 人)`}
open={batchModalOpen}
onCancel={() => setBatchModalOpen(false)}
onOk={submitBatchChange}
confirmLoading={saving}
okText="确认批量修改"
>
<Form form={batchForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<strong>{selectedRowKeys.length}</strong>{' '}
</div>
<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 || ''} - 余额流水`}
size={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: value >= 0 ? '#389e0d' : '#cf1322' }}>
{value >= 0 ? '+' : ''}¥{value.toFixed(2)}
</span>
),
},
{
title: '变动后余额',
dataIndex: 'balanceAfter',
render: (value: number) => `¥${value.toFixed(2)}`,
},
{
title: '关联账单',
dataIndex: 'billId',
render: (value: number) => (value ? `#${value}` : '-'),
},
{ title: '说明', dataIndex: 'description' },
]}
/>
</Drawer>
</div>
);
};
export default WalletsPage;