Files
gongxue-base/apps/admin/src/pages/Wallets/index.tsx
wangziqi 67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

472 lines
14 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';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
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 [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,
isError,
refetch,
} = useQuery<WalletRow[]>({
queryKey: ['wallets', keyword, debtOnly, roomType],
queryFn: async () => {
return validateResponse<WalletRow[]>(
walletsSchema,
await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly, roomType },
}),
);
},
});
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;
// RouteKeeper 保活页面切回时刷新余额,避免费用/账单操作后数据陈旧
useVisibleRefetch(['wallets']);
const selectedStudentId = selected?.studentId;
const {
data: transactions = [],
isLoading: txLoading,
isFetching: txFetching,
isError: txError,
refetch: refetchTransactions,
} = useQuery<WalletTransaction[]>({
queryKey: ['wallets', 'transactions', selectedStudentId],
enabled: drawerOpen && !!selectedStudentId,
queryFn: async () => {
return (
(await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: selectedStudentId },
})) || []
);
},
});
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 = useCallback(
(row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
},
[form, setSelected],
);
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 = useCallback(
(row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
},
[setSelected, setDrawerOpen],
);
const columns = useMemo(
() => [
{
title: '学生',
render: (_: unknown, row: WalletRow) => (
<>
<strong>{row.studentName}</strong>
<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>
),
},
],
[openChange, showTransactions],
);
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>
{isError ? (
<QueryErrorState
title="钱包数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<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%' }} prefix="¥" />
</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%' }} prefix="¥" />
</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);
}}
>
{txError ? (
<QueryErrorState
compact
title="流水加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchTransactions()}
/>
) : (
<Table
rowKey="id"
dataSource={transactions}
loading={txLoading || txFetching}
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;