feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,4 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
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,
@@ -30,6 +34,14 @@ interface WalletRow {
roomNumber?: string;
}
interface WalletTransaction {
id: number;
createdAt: string;
type: string;
amount: number;
balanceAfter: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值',
adjustment: '调账',
@@ -38,14 +50,11 @@ const transactionNames: Record<string, string> = {
};
const WalletsPage: React.FC = () => {
const [rows, setRows] = useState<WalletRow[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [roomType, setRoomType] = useState<string | undefined>();
const [roomTypes, setRoomTypes] = useState<string[]>([]);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [transactions, setTransactions] = useState<WalletTransaction[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [batchForm] = Form.useForm();
@@ -53,38 +62,66 @@ const WalletsPage: React.FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly, roomType },
});
setRows(data as WalletRow[]);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
} finally {
setLoading(false);
}
}, [keyword, debtOnly, roomType]);
useEffect(() => {
void fetchRows();
}, [fetchRows]);
useEffect(() => {
const fetchRoomTypes = async () => {
const {
data: rows = [],
isLoading,
isFetching,
refetch,
} = useQuery<WalletRow[]>({
queryKey: ['wallets', keyword, debtOnly, roomType],
queryFn: async () => {
try {
setRoomTypes((await api.get('/wallets/room-types')) as string[]);
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 [];
}
};
void fetchRoomTypes();
}, []);
},
});
const loading = isLoading || isFetching;
const fetchRows = useCallback(() => refetch(), [refetch]);
useEffect(() => {
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([]);
}, [keyword, debtOnly, roomType]);
};
const updateRoomType = (value: string | undefined) => {
setRoomType(value);
setSelectedRowKeys([]);
};
const updateDebtOnly = (value: boolean) => {
setDebtOnly(value);
setSelectedRowKeys([]);
};
const openChange = (row: WalletRow) => {
setSelected(row);
@@ -101,7 +138,7 @@ const WalletsPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/change-balance', {
const result: any = await changeMutation.mutateAsync({
operationId: newOperationId(),
studentId: selected.studentId,
...values,
@@ -112,9 +149,8 @@ const WalletsPage: React.FC = () => {
);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
await fetchRows();
} catch (error: any) {
message.error(error?.message || '余额操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -124,7 +160,7 @@ const WalletsPage: React.FC = () => {
const values = await batchForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/batch-change-balance', {
const result: any = await batchChangeMutation.mutateAsync({
operationId: newOperationId(),
studentIds: selectedRowKeys,
...values,
@@ -146,9 +182,8 @@ const WalletsPage: React.FC = () => {
setBatchModalOpen(false);
setSelectedRowKeys([]);
batchForm.resetFields();
await fetchRows();
} catch (error: any) {
message.error(error?.message || '批量余额操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -159,10 +194,13 @@ const WalletsPage: React.FC = () => {
setDrawerOpen(true);
try {
setTransactions(
(await api.get('/wallets/transactions', { params: { studentId: row.studentId } })) as any[],
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: any) {
message.error(error?.message || '加载流水失败');
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
};
@@ -184,8 +222,8 @@ const WalletsPage: React.FC = () => {
title: '可用余额',
dataIndex: 'balance',
render: (value: number) => (
<strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>
¥{Number(value).toFixed(2)}
<strong style={{ color: value > 0 ? '#1677ff' : undefined }}>
¥{value.toFixed(2)}
</strong>
),
},
@@ -193,8 +231,8 @@ const WalletsPage: React.FC = () => {
title: '未付账单',
dataIndex: 'outstandingAmount',
render: (value: number) =>
Number(value) > 0 ? (
<Tag color="red">¥{Number(value).toFixed(2)}</Tag>
value > 0 ? (
<Tag color="red">¥{value.toFixed(2)}</Tag>
) : (
<Tag color="green"></Tag>
),
@@ -238,19 +276,19 @@ const WalletsPage: React.FC = () => {
allowClear
placeholder="搜索姓名或学号"
style={{ width: 240 }}
onSearch={setKeyword}
onChange={(event) => !event.target.value && setKeyword('')}
onSearch={updateKeyword}
onChange={(event) => !event.target.value && updateKeyword('')}
/>
<Select
allowClear
placeholder="按房型筛选"
style={{ width: 180 }}
value={roomType}
onChange={setRoomType}
onChange={updateRoomType}
options={roomTypes.map((type) => ({ label: type, value: type }))}
/>
<span></span>
<Switch checked={debtOnly} onChange={setDebtOnly} />
<Switch checked={debtOnly} onChange={updateDebtOnly} />
</Space>
<Space wrap>
<PermissionButton
@@ -297,6 +335,7 @@ const WalletsPage: React.FC = () => {
]}
/>
</Form.Item>
<Form.Item
name="amount"
label="变动金额"
@@ -305,6 +344,7 @@ const WalletsPage: React.FC = () => {
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea maxLength={300} />
</Form.Item>
@@ -331,6 +371,7 @@ const WalletsPage: React.FC = () => {
]}
/>
</Form.Item>
<Form.Item
name="amount"
label="变动金额(元/人)"
@@ -339,6 +380,7 @@ const WalletsPage: React.FC = () => {
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea maxLength={300} />
</Form.Item>
@@ -346,7 +388,7 @@ const WalletsPage: React.FC = () => {
</Modal>
<Drawer
title={`${selected?.studentName || ''} - 余额流水`}
width={680}
size={680}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
@@ -372,15 +414,15 @@ const WalletsPage: React.FC = () => {
title: '金额',
dataIndex: 'amount',
render: (value: number) => (
<span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>
{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}
<span style={{ color: value >= 0 ? '#389e0d' : '#cf1322' }}>
{value >= 0 ? '+' : ''}¥{value.toFixed(2)}
</span>
),
},
{
title: '变动后余额',
dataIndex: 'balanceAfter',
render: (value: number) => `¥${Number(value).toFixed(2)}`,
render: (value: number) => `¥${value.toFixed(2)}`,
},
{
title: '关联账单',