import React, { useMemo, useState } from 'react'; import { useDebounceValue } from 'usehooks-ts'; import { App, Button, Card, Checkbox, Col, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag, } from 'antd'; import { CalendarOutlined, DeleteOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import { useNavigate } from 'react-router'; import api from '../../api'; import { message } from '../../ui/app-message'; import ExamFormModal from './ExamFormModal'; import { selectAllExamIds, toggleExamSelection } from './selection'; import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues, type ExamItem } from './types'; import './style.css'; import { usePermission } from '../../hooks/usePermission'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { classOptionsSchema, examsSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; const ExamsPage: React.FC = () => { const { modal } = App.useApp(); const navigate = useNavigate(); const { hasPermission } = usePermission(); const canPurgeExam = hasPermission('exam:purge'); const [form] = Form.useForm(); const examFormGuard = useDirtyGuard(form); const [batchLoading, setBatchLoading] = useState(false); const [saving, setSaving] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [keyword, setKeyword] = useState(''); const [examType, setExamType] = useState(); const [classId, setClassId] = useState(); const [showArchived, setShowArchived] = useState(false); const [selectedExamIds, setSelectedExamIds] = useState([]); // 考试创建成功后的「下一步:去详情录成绩」引导 const [examCreatedId, setExamCreatedId] = useState(null); const [debouncedFilters] = useDebounceValue( { keyword, examType, classId, showArchived }, 200, ); const { data: classes = [] } = useQuery({ queryKey: ['exams', 'classes'], queryFn: async () => { try { return ( validateResponse( classOptionsSchema, await api.get('/classes'), ) ?? [] ); } catch (error: unknown) { message.error(getErrorMessage(error, '加载班级失败')); return []; } }, }); const { data = [], isFetching, isError, refetch } = useQuery({ queryKey: [ 'exams', debouncedFilters.keyword, debouncedFilters.examType, debouncedFilters.classId, debouncedFilters.showArchived, ], queryFn: async () => { const params = new URLSearchParams(); if (debouncedFilters.keyword.trim()) params.set('keyword', debouncedFilters.keyword.trim()); if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType); if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId)); params.set('isArchived', String(debouncedFilters.showArchived)); return ( validateResponse( examsSchema, await api.get(`/exams?${params.toString()}`), ) ?? [] ); }, }); const loading = isFetching; // RouteKeeper 保活页面切回时刷新考试列表 useVisibleRefetch(['exams']); const saveMutation = useApiMutation( async (payload: Record) => api.post('/exams', payload), { invalidate: [['exams']] }, ); const archiveMutation = useApiMutation( async ({ id, archive }: { id: number; archive: boolean }) => api.put(`/exams/${id}/${archive ? 'archive' : 'restore'}`), { invalidate: [['exams']] }, ); const purgeMutation = useApiMutation( async (id: number) => api.delete(`/exams/${id}/permanent`), { invalidate: [['exams']] }, ); const batchPurgeMutation = useApiMutation( async (ids: number[]) => api.post<{ deleted: number; skipped: number }>('/exams/batch-permanent-delete', { ids }), { invalidate: [['exams']] }, ); const batchArchiveMutation = useApiMutation( async (ids: number[]) => api.put<{ archived: number; skipped: number }>('/exams/batch-archive', { ids }), { invalidate: [['exams']] }, ); const batchRestoreMutation = useApiMutation( async (ids: number[]) => api.put<{ restored: number; skipped: number }>('/exams/batch-restore', { ids }), { invalidate: [['exams']] }, ); const updateKeyword = (value: string) => { setKeyword(value); setSelectedExamIds([]); }; const updateExamType = (value: string | undefined) => { setExamType(value); setSelectedExamIds([]); }; const updateClassId = (value: number | undefined) => { setClassId(value); setSelectedExamIds([]); }; const classOptions = useMemo( () => classes.map((item) => ({ value: item.id, label: item.name })), [classes], ); const openCreate = () => { form.resetFields(); form.setFieldValue('examDate', dayjs()); examFormGuard.snapshot(); setModalOpen(true); }; const submit = async () => { try { const values = await form.validateFields(); setSaving(true); const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') }; const created = (await saveMutation.mutateAsync(payload)) as { id?: number }; message.success('考试已创建'); setModalOpen(false); if (created?.id != null) setExamCreatedId(created.id); } catch { // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; const changeArchiveStatus = async (exam: ExamItem, archive: boolean) => { try { await archiveMutation.mutateAsync({ id: exam.id, archive }); message.success(archive ? '考试已归档' : '考试已恢复'); } catch { // 错误提示由 useApiMutation 统一处理 } }; const handlePurge = (exam: ExamItem) => { modal.confirm({ title: `永久删除考试「${exam.examName}」?`, content: '删除后不可恢复,该考试及其成绩记录将被物理删除。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await purgeMutation.mutateAsync(exam.id); message.success('已永久删除(不可恢复)'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }; const batchPurge = async () => { if (selectedExamIds.length === 0 || batchLoading) return; setBatchLoading(true); try { const result = await batchPurgeMutation.mutateAsync(selectedExamIds); message.success( `已永久删除 ${result.deleted} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`, ); setSelectedExamIds([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const allCurrentSelected = data.length > 0 && selectedExamIds.length === data.length; const partiallySelected = selectedExamIds.length > 0 && !allCurrentSelected; const toggleSelectAll = (checked: boolean) => { setSelectedExamIds( selectAllExamIds( data.map((exam) => exam.id), checked, ), ); }; const changeArchiveView = (checked: boolean) => { setSelectedExamIds([]); setShowArchived(checked); }; const batchChangeArchiveStatus = async (archive: boolean) => { if (selectedExamIds.length === 0 || batchLoading) return; setBatchLoading(true); try { if (archive) { const result = await batchArchiveMutation.mutateAsync(selectedExamIds); message.success( `已归档 ${result.archived} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`, ); } else { const result = await batchRestoreMutation.mutateAsync(selectedExamIds); message.success( `已恢复 ${result.restored} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`, ); } setSelectedExamIds([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; return (
{examCreatedId !== null && ( { navigate(`/exams/${examCreatedId}`); setExamCreatedId(null); }, }} onClose={() => setExamCreatedId(null)} /> )}
updateKeyword(event.target.value)} prefix={} placeholder="搜索考试名称" allowClear /> toggleSelectAll(event.target.checked)} > 全选当前结果 void batchChangeArchiveStatus(!showArchived)} > {showArchived && canPurgeExam ? ( void batchPurge()} okText="永久删除" okButtonProps={{ danger: true }} > ) : null} 归档 {!showArchived ? ( ) : null}
{isError ? ( void refetch()} /> ) : data.length === 0 && !loading ? (
, onClick: openCreate } : undefined } />
) : ( {data.map((exam) => { const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100); return ( { setSelectedExamIds((current) => toggleExamSelection(current, exam.id, event.target.checked), ); }} /> {exam.examType} {exam.examName} } extra={ {exam.status === 'archived' ? '已归档' : '成绩录入'} } actions={[ navigate(`/exams/${exam.id}`)}> 查看成绩 , exam.status === 'archived' ? ( <> changeArchiveStatus(exam, false)} > 恢复 {canPurgeExam ? ( handlePurge(exam)} okText="永久删除" okButtonProps={{ danger: true }} > 删除 ) : null} ) : ( changeArchiveStatus(exam, true)} > 归档 ), ]} >
科目 {exam.subject}
班级 {exam.className}
日期 {exam.examDate}
成绩录入 {exam.enteredScores}/{exam.totalStudents}
); })}
)} examFormGuard.confirmClose(() => setModalOpen(false))} onSubmit={() => void submit()} />
); }; export default ExamsPage;