Files
gongxue-base/apps/admin/src/pages/Wallets/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

473 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, QueryEmpty } 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}
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
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;