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

@@ -0,0 +1,166 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React from 'react';
import {
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
} from 'antd';
const { RangePicker } = DatePicker;
export const RoomExpenseModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
rooms: any[];
typeOptions: Array<{ value: string; label: string }>;
onOk: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑宿舍费用' : '录入宿舍费用'}
open={open}
onOk={onOk}
onCancel={onCancel}
okText={editing ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({
value: r.id,
label: `${r.roomNumber} (${r.building || ''})`,
}))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={typeOptions} />
</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%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
};
export const UtilityModal: React.FC<{
open: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
students: any[];
onOk: () => void;
onCancel: () => void;
}> = ({ open, saving, form, students, onOk, onCancel }) => {
return (
<Modal
title="添加学生水电费并立即出账"
open={open}
onOk={onOk}
onCancel={onCancel}
okText="生成账单并扣余额"
confirmLoading={saving}
>
<Form form={form} 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>
);
};
export const PersonalExpenseModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
students: any[];
rooms: any[];
personalTypeOptions: Array<{ value: string; label: string }>;
onOk: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑个人费用' : '录入个人附加费'}
open={open}
onOk={onOk}
onCancel={onCancel}
okText={editing ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={students.map((s: any) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="roomId" label="关联宿舍">
<Select
allowClear
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={personalTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
};

View File

@@ -0,0 +1,524 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React from 'react';
import {
Button,
Empty,
Input,
Popconfirm,
Select,
Space,
Table,
Tag,
Upload,
} from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
ExportOutlined,
InboxOutlined,
UndoOutlined,
UploadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
export const EXPENSE_FIELDS = {
roomId: 'roomId',
expenseType: 'expenseType',
amount: 'amount',
description: 'description',
studentId: 'studentId',
expenseDate: 'expenseDate',
} as const;
export interface ExpenseTablePanelProps {
kind: 'room' | 'personal';
searchText: string;
onSearchChange: (value: string) => void;
typeFilter: string | undefined;
onTypeFilterChange: (value?: string) => void;
typeOptions: Array<{ value: string; label: string }>;
typeMap: Record<string, string>;
data: any[];
loading: boolean;
selectedKeys: number[];
onSelect: (keys: number[]) => void;
rooms: any[];
students: any[];
readonly: boolean;
showArchived: boolean;
canPurgeExpense: boolean;
batchLoading: boolean;
canImport: boolean;
onBatchRestore: () => void;
onBatchPurge: () => void;
onBatchDelete: () => void;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onPeriodSave: (id: number, periodStart: string, periodEnd: string) => Promise<void> | void;
onEdit: (record: any) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number) => void;
onImport: (formData: FormData) => Promise<any>;
onTemplateDownload: () => void;
onExport?: () => void;
onAddUtility?: () => void;
}
export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
kind,
searchText,
onSearchChange,
typeFilter,
onTypeFilterChange,
typeOptions,
typeMap,
data,
loading,
selectedKeys,
onSelect,
rooms,
students,
readonly,
showArchived,
canPurgeExpense,
batchLoading,
canImport,
onBatchRestore,
onBatchPurge,
onBatchDelete,
onSaveCell,
onPeriodSave,
onEdit,
onArchive,
onPurge,
onImport,
onTemplateDownload,
onExport,
onAddUtility,
}) => {
const isRoom = kind === 'room';
const noun = isRoom ? '费用' : '个人费用';
const EditableExpenseCell = ({
value,
editor,
min,
max,
required,
options,
onSave,
children,
}: {
value: unknown;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
max?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
onSave: (value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
max={max}
required={required}
options={options}
permission="expense:edit"
disabled={readonly}
onSave={async (next) => {
await onSave(next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const renderExpenseActions = (record: any) => {
if (showArchived) {
return (
<Space>
<Tag color="#999"></Tag>
{canPurgeExpense ? (
<Button size="small" danger type="link" onClick={() => onPurge(record.id)}>
</Button>
) : null}
</Space>
);
}
return (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await onArchive(record.id);
message.success('归档成功');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<PermissionButton permission="expense:delete" size="small" danger icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
</Space>
);
};
const columns = isRoom
? [
{
title: '宿舍',
width: 120,
render: (_: any, r: any) => (
<EditableExpenseCell
value={r.roomId}
editor="select"
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.roomId, next)}
>
{r.room?.roomNumber || '-'}
</EditableExpenseCell>
),
},
{
title: '费用类型',
width: 100,
dataIndex: 'expenseType',
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="select"
options={typeOptions}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseType, next)}
>
<Tag>{typeMap[v] || v}</Tag>
</EditableExpenseCell>
),
},
{
title: '金额',
dataIndex: 'amount',
width: 100,
render: (v: number, r: any) => (
<EditableExpenseCell
value={v}
editor="money"
min={0.01}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.amount, next)}
>
{`¥${v.toFixed(2)}`}
</EditableExpenseCell>
),
},
{
title: '账单周期',
width: 200,
render: (_: any, r: any) => (
<EditableCell
value={[r.periodStart, r.periodEnd]}
editor="date-range"
permission="expense:edit"
disabled={readonly}
required
onSave={async (next) => {
const [periodStart, periodEnd] = next as [string, string];
await onPeriodSave(r.id, periodStart, periodEnd);
}}
>{`${r.periodStart} ~ ${r.periodEnd}`}</EditableCell>
),
},
{
title: '说明',
dataIndex: 'description',
width: 150,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="textarea"
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.description, next)}
>
{v || '-'}
</EditableExpenseCell>
),
},
{
title: '录入时间',
width: 160,
dataIndex: 'createdAt',
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
width: 120,
render: (_: any, record: any) => renderExpenseActions(record),
},
]
: [
{
title: '学生',
width: 120,
render: (_: any, r: any) => (
<EditableExpenseCell
value={r.studentId}
editor="select"
options={students.map((item) => ({ value: item.id, label: item.name }))}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.studentId, next)}
>
{r.student?.name || '-'}
</EditableExpenseCell>
),
},
{
title: '费用类型',
width: 100,
dataIndex: 'expenseType',
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="select"
options={typeOptions}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseType, next)}
>
<Tag color="orange">{typeMap[v] || v}</Tag>
</EditableExpenseCell>
),
},
{
title: '金额',
dataIndex: 'amount',
render: (v: number, r: any) => (
<EditableExpenseCell
value={v}
editor="money"
min={0.01}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.amount, next)}
>
{`¥${v.toFixed(2)}`}
</EditableExpenseCell>
),
},
{
title: '日期',
dataIndex: 'expenseDate',
width: 110,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="date"
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseDate, next)}
>
{v}
</EditableExpenseCell>
),
},
{
title: '说明',
dataIndex: 'description',
width: 150,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="textarea"
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.description, next)}
>
{v || '-'}
</EditableExpenseCell>
),
},
{
title: '操作',
width: 120,
render: (_: any, record: any) => renderExpenseActions(record),
},
];
return (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder={isRoom ? '搜索宿舍号' : '搜索学生姓名'}
allowClear
style={{ width: 160 }}
value={searchText}
onSearch={(v) => onSearchChange(v)}
onChange={(e) => {
if (!e.target.value) onSearchChange('');
}}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={typeFilter}
onChange={onTypeFilterChange}
options={typeOptions}
/>
{canImport && !showArchived && (
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
try {
const formData = new FormData();
formData.append('file', file);
const res: any = await onImport(formData);
if (isRoom && res.errors?.length > 0) {
message.warning(res.message || '导入完成');
res.errors.forEach((e: string) => message.warning(e));
} else {
message.success(res.message || '导入完成');
if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e));
}
onSuccess?.(res);
} catch (e) {
onError?.(e as Error);
}
}}
>
<Button icon={<UploadOutlined />}>
{isRoom ? '导入水电费Excel' : '导入个人附加费'}
</Button>
</Upload>
)}
{!showArchived && (
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={onTemplateDownload}
>
{isRoom ? '下载水电费模板' : '下载模板'}
</PermissionButton>
)}
{onExport && !showArchived ? (
<PermissionButton
permission="expense:view"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
) : null}
{isRoom && onAddUtility && !showArchived ? (
<PermissionButton
permission="expense:create"
icon={<ThunderboltOutlined />}
onClick={onAddUtility}
>
</PermissionButton>
) : null}
</Space>
<Space>
{showArchived ? (
<>
<Popconfirm
title={`确定恢复选中的 ${selectedKeys.length}${noun}`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<PermissionButton
permission="expense:edit"
type="primary"
icon={<UndoOutlined />}
loading={batchLoading}
disabled={selectedKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
{canPurgeExpense ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedKeys.length}${noun}?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
loading={batchLoading}
disabled={selectedKeys.length === 0}
>
</Button>
</Popconfirm>
) : null}
</>
) : (
<Popconfirm
title={`确定归档选中的 ${selectedKeys.length}${noun}`}
onConfirm={onBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
disabled={selectedKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => onSelect(keys as number[]),
}}
/>
</>
);
};

File diff suppressed because it is too large Load Diff