feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user