// 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, Select, Space, Tag, Modal, Form, InputNumber, DatePicker, Popconfirm, Card, Switch, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; 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'; import { usePermission } from '../../hooks/usePermission'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; interface ClassItem { id: number; name: string; code: string; classType: string; startDate: string | null; endDate: string | null; status: string; headTeacherId: number | null; lifeTeacherId: number | null; academicTeacherId: number | null; maxStudents: number; notes: string | null; studentCount: number; isArchived: boolean; createdAt: string; updatedAt: string; } interface ClassFormValues { name: string; code: string; classType: string; startDate?: dayjs.Dayjs; endDate?: dayjs.Dayjs; maxStudents?: number; status?: string; notes?: string; } const STATUS_MAP: Record = { enrolling: { color: 'blue', text: '招生中' }, active: { color: 'green', text: '在读' }, ended: { color: 'default', text: '结课' }, suspended: { color: 'orange', text: '停课' }, }; const TYPE_MAP: Record = { culture: '文化课', professional: '专业课', bootcamp: '集训营', sprint: '冲刺营', }; const ClassesPage: React.FC = () => { const { modal } = App.useApp(); const navigate = useNavigate(); const { hasPermission } = usePermission(); const canPurgeClass = hasPermission('class:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); const [filterType, setFilterType] = useState(); const [form] = Form.useForm(); const classFormGuard = useDirtyGuard(form); const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); const { data = [], isLoading, isFetching, isError, refetch, } = useQuery({ queryKey: ['classes', filterStatus, filterType, showArchived], queryFn: async () => { const params: Record = {}; if (filterStatus) params.status = filterStatus; if (filterType) params.classType = filterType; params.isArchived = showArchived; return validateResponse( classesSchema, await api.get('/classes', { params } as Record), ); }, }); const loading = isLoading || isFetching; // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 useVisibleRefetch(['classes']); const saveMutation = useApiMutation( async (payload: Record) => 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 handleArchive = useCallback( async (id: number, archive: boolean) => { try { await archiveMutation.mutateAsync({ id, archive }); message.success(archive ? '已归档' : '已恢复'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [archiveMutation], ); const handlePurge = useCallback( (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 统一处理 } }, }); }, [modal, purgeMutation], ); const filtered = useMemo(() => { if (!searchText) return data; const q = searchText.toLowerCase(); return data.filter( (c) => c.name?.toLowerCase().includes(q) || c.code?.toLowerCase().includes(q), ); }, [data, searchText]); const handleCreate = () => { setEditing(null); form.resetFields(); classFormGuard.snapshot(); setModalOpen(true); }; const handleEdit = useCallback( (record: ClassItem) => { setEditing(record); form.setFieldsValue({ ...record, notes: record.notes ?? undefined, startDate: record.startDate ? dayjs(record.startDate) : undefined, endDate: record.endDate ? dayjs(record.endDate) : undefined, }); classFormGuard.snapshot(); setModalOpen(true); }, [form, classFormGuard], ); const handleSubmit = async () => { setSaving(true); try { const values = await form.validateFields(); const payload = { ...values, startDate: values.startDate?.format('YYYY-MM-DD'), endDate: values.endDate?.format('YYYY-MM-DD'), }; await saveMutation.mutateAsync(payload); message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = useCallback( async (record: ClassItem, field: string, value: unknown) => { try { await saveCellMutation.mutateAsync({ record, field, value }); message.success('已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [saveCellMutation], ); const columns: ColumnsType = useMemo( () => [ { title: '班级名称', dataIndex: 'name', width: 120, sorter: (a, b) => a.name.localeCompare(b.name), render: (v: string, r: ClassItem) => ( saveCell(r, 'name', next)} > {v} ), }, { title: '编码', dataIndex: 'code', width: 140, render: (v: string, r: ClassItem) => ( saveCell(r, 'code', next)} > {v} ), }, { title: '班型', dataIndex: 'classType', width: 100, render: (v: string, r: ClassItem) => ( ({ value, label }))} permission="class:edit" disabled={r.isArchived} onSave={(next) => saveCell(r, 'classType', next)} > {TYPE_MAP[v] || v} ), }, { title: '开班日期', dataIndex: 'startDate', width: 110, render: (v: string | null, r: ClassItem) => ( saveCell(r, 'startDate', next)} > {v || '-'} ), }, { title: '学员', width: 100, render: (_: unknown, r: ClassItem) => ( saveCell(r, 'maxStudents', next)} >{`${r.studentCount || 0}/${r.maxStudents || '-'}`} ), }, { title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: ClassItem) => ( ({ value, label: item.text, }))} permission="class:edit" disabled={r.isArchived} onSave={(next) => saveCell(r, 'status', next)} > {(() => { const cfg = STATUS_MAP[v] || { color: 'default', text: v }; return {cfg.text}; })()} ), }, { title: '操作', width: 280, render: (_: unknown, r: ClassItem) => ( handleEdit(r)}> 编辑 {r.isArchived ? ( <> handleArchive(r.id, false)}> 恢复 {canPurgeClass ? ( ) : null} ) : ( handleArchive(r.id, true)} > 归档 )} ), }, ], [saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive], ); return ( } value={searchText} onChange={(e) => setSearchText(e.target.value)} style={{ width: 200 }} /> ({ value: k, label: v.text }))} /> } onClick={handleCreate} > 创建班级 归档 {isError ? ( void refetch()} /> ) : ( columns={columns} dataSource={filtered} rowKey="id" loading={loading} locale={{ emptyText: ( , onClick: handleCreate } : undefined } /> ), }} pagination={{ defaultPageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100], }} scroll={{ x: 1100 }} /> )} classFormGuard.confirmClose(() => setModalOpen(false))} confirmLoading={saving} width={600} >
({ value: k, label: v.text }))} />
); }; export default ClassesPage;