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,5 +1,11 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { classesSchema } from '../../api/schemas';
import {
App,
Table,
Button,
Input,
@@ -17,14 +23,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
// ---- Types ----
import { usePermission } from '../../hooks/usePermission';
interface ClassItem {
id: number;
@@ -56,8 +61,6 @@ interface ClassFormValues {
notes?: string;
}
// ---- Constants ----
const STATUS_MAP: Record<string, { color: string; text: string }> = {
enrolling: { color: 'blue', text: '招生中' },
active: { color: 'green', text: '在读' },
@@ -72,12 +75,11 @@ const TYPE_MAP: Record<string, string> = {
sprint: '冲刺营',
};
// ---- Component ----
const ClassesPage: React.FC = () => {
const { modal } = App.useApp();
const navigate = useNavigate();
const [data, setData] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const { hasPermission } = usePermission();
const canPurgeClass = hasPermission('class:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ClassItem | null>(null);
const [searchText, setSearchText] = useState('');
@@ -89,34 +91,74 @@ const ClassesPage: React.FC = () => {
const handleArchive = async (id: number, archive: boolean) => {
try {
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setLoading(false);
}
}, [filterStatus, filterType, showArchived]);
const handlePurge = (record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data = [],
isLoading,
isFetching,
} = useQuery<ClassItem[]>({
queryKey: ['classes', filterStatus, filterType, showArchived],
queryFn: async () => {
try {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
return validateResponse<ClassItem[]>(
classesSchema,
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing ? api.put(`/classes/${editing.id}`, payload) : api.post('/classes', payload),
{ invalidate: [['classes']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: ClassItem; field: string; value: unknown }) =>
api.put(`/classes/${record.id}`, { [field]: value }),
{ invalidate: [['classes']] },
);
const archiveMutation = useApiMutation(
async ({ id, archive }: { id: number; archive: boolean }) =>
api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`),
{ invalidate: [['classes']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classes/${id}/permanent`),
{ invalidate: [['classes']] },
);
const filtered = useMemo(() => {
if (!searchText) return data;
@@ -152,17 +194,11 @@ const ClassesPage: React.FC = () => {
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
};
if (editing) {
await api.put(`/classes/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classes', payload);
message.success('创建成功');
}
await saveMutation.mutateAsync(payload);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -170,11 +206,14 @@ const ClassesPage: React.FC = () => {
const saveCell = useCallback(
async (record: ClassItem, field: string, value: unknown) => {
await api.put(`/classes/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[fetchData],
[saveCellMutation],
);
const columns: ColumnsType<ClassItem> = useMemo(
@@ -196,6 +235,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '编码',
dataIndex: 'code',
@@ -212,6 +252,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '班型',
dataIndex: 'classType',
@@ -229,6 +270,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '开班日期',
dataIndex: 'startDate',
@@ -245,6 +287,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '学员',
width: 100,
@@ -259,6 +302,7 @@ const ClassesPage: React.FC = () => {
>{`${r.studentCount || 0}/${r.maxStudents || '-'}`}</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -282,6 +326,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '操作',
width: 280,
@@ -298,11 +343,18 @@ const ClassesPage: React.FC = () => {
</PermissionButton>
{r.isArchived ? (
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
<>
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
{canPurgeClass ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
) : null}
</>
) : (
<Popconfirm
title="归档后可恢复,确认归档?"
@@ -317,7 +369,7 @@ const ClassesPage: React.FC = () => {
),
},
],
[saveCell],
[saveCell, canPurgeClass, handlePurge],
);
return (