admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
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<ExamFormValues>();
|
|
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<string>();
|
|
const [classId, setClassId] = useState<number>();
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
|
|
// 考试创建成功后的「下一步:去详情录成绩」引导
|
|
const [examCreatedId, setExamCreatedId] = useState<number | null>(null);
|
|
const [debouncedFilters] = useDebounceValue(
|
|
{ keyword, examType, classId, showArchived },
|
|
200,
|
|
);
|
|
|
|
const { data: classes = [] } = useQuery<ClassOption[]>({
|
|
queryKey: ['exams', 'classes'],
|
|
queryFn: async () => {
|
|
try {
|
|
return (
|
|
validateResponse<ClassOption[]>(
|
|
classOptionsSchema,
|
|
await api.get<ClassOption[]>('/classes'),
|
|
) ?? []
|
|
);
|
|
} catch (error: unknown) {
|
|
message.error(getErrorMessage(error, '加载班级失败'));
|
|
return [];
|
|
}
|
|
},
|
|
});
|
|
|
|
const { data = [], isFetching, isError, refetch } = useQuery<ExamItem[]>({
|
|
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<ExamItem[]>(
|
|
examsSchema,
|
|
await api.get<ExamItem[]>(`/exams?${params.toString()}`),
|
|
) ?? []
|
|
);
|
|
},
|
|
});
|
|
const loading = isFetching;
|
|
// RouteKeeper 保活页面切回时刷新考试列表
|
|
useVisibleRefetch(['exams']);
|
|
|
|
const saveMutation = useApiMutation(
|
|
async (payload: Record<string, unknown>) => 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 (
|
|
<div className="exam-page">
|
|
{examCreatedId !== null && (
|
|
<NextStepHint
|
|
title="考试已创建"
|
|
description="接下来可以在考试详情中添加学生名单、录入成绩。"
|
|
action={{
|
|
label: '去考试详情',
|
|
onClick: () => {
|
|
navigate(`/exams/${examCreatedId}`);
|
|
setExamCreatedId(null);
|
|
},
|
|
}}
|
|
onClose={() => setExamCreatedId(null)}
|
|
/>
|
|
)}
|
|
<div className="exam-toolbar">
|
|
<Space wrap>
|
|
<Input
|
|
value={keyword}
|
|
onChange={(event) => updateKeyword(event.target.value)}
|
|
prefix={<SearchOutlined />}
|
|
placeholder="搜索考试名称"
|
|
allowClear
|
|
/>
|
|
<Select
|
|
value={examType}
|
|
onChange={updateExamType}
|
|
options={EXAM_TYPE_OPTIONS}
|
|
placeholder="考试类型"
|
|
allowClear
|
|
style={{ width: 140 }}
|
|
/>
|
|
<Select
|
|
value={classId}
|
|
onChange={updateClassId}
|
|
options={classOptions}
|
|
placeholder="考试班级"
|
|
allowClear
|
|
showSearch
|
|
optionFilterProp="label"
|
|
style={{ width: 180 }}
|
|
/>
|
|
</Space>
|
|
<Space wrap>
|
|
<Checkbox
|
|
checked={allCurrentSelected}
|
|
indeterminate={partiallySelected}
|
|
disabled={data.length === 0 || loading || batchLoading}
|
|
onChange={(event) => toggleSelectAll(event.target.checked)}
|
|
>
|
|
全选当前结果
|
|
</Checkbox>
|
|
<Popconfirm
|
|
title={showArchived ? '确认恢复选中的考试?' : '确认归档选中的考试?'}
|
|
description={
|
|
showArchived
|
|
? `将恢复选中的 ${selectedExamIds.length} 场考试。`
|
|
: `将归档选中的 ${selectedExamIds.length} 场考试,归档后成绩将变为只读。`
|
|
}
|
|
disabled={selectedExamIds.length === 0 || batchLoading}
|
|
onConfirm={() => void batchChangeArchiveStatus(!showArchived)}
|
|
>
|
|
<Button
|
|
danger={!showArchived}
|
|
loading={batchLoading}
|
|
disabled={selectedExamIds.length === 0}
|
|
>
|
|
{showArchived ? '批量恢复' : '批量归档'}
|
|
</Button>
|
|
</Popconfirm>
|
|
{showArchived && canPurgeExam ? (
|
|
<Popconfirm
|
|
title={`确认永久删除选中的 ${selectedExamIds.length} 场考试?`}
|
|
description="删除后不可恢复,相关成绩将一并清除。"
|
|
disabled={selectedExamIds.length === 0 || batchLoading}
|
|
onConfirm={() => void batchPurge()}
|
|
okText="永久删除"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Button
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
loading={batchLoading}
|
|
disabled={selectedExamIds.length === 0}
|
|
>
|
|
批量删除
|
|
</Button>
|
|
</Popconfirm>
|
|
) : null}
|
|
<span className="exam-archive-toggle">
|
|
<InboxOutlined />
|
|
归档
|
|
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
|
|
</span>
|
|
{!showArchived ? (
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
|
创建考试
|
|
</Button>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
|
|
{isError ? (
|
|
<QueryErrorState
|
|
title="考试数据加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetch()}
|
|
/>
|
|
) : data.length === 0 && !loading ? (
|
|
<div className="exam-empty">
|
|
<QueryEmpty
|
|
description="暂无考试"
|
|
action={
|
|
!showArchived
|
|
? { label: '创建考试', icon: <PlusOutlined />, onClick: openCreate }
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Row gutter={[16, 16]}>
|
|
{data.map((exam) => {
|
|
const percent =
|
|
exam.totalStudents === 0
|
|
? 0
|
|
: Math.round((exam.enteredScores / exam.totalStudents) * 100);
|
|
return (
|
|
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
|
|
<Card
|
|
className={`exam-card${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
|
|
loading={loading}
|
|
title={
|
|
<Space>
|
|
<Checkbox
|
|
aria-label={`选择考试 ${exam.examName}`}
|
|
checked={selectedExamIds.includes(exam.id)}
|
|
disabled={batchLoading}
|
|
onChange={(event) => {
|
|
setSelectedExamIds((current) =>
|
|
toggleExamSelection(current, exam.id, event.target.checked),
|
|
);
|
|
}}
|
|
/>
|
|
<Tag color="blue">{exam.examType}</Tag>
|
|
<span>{exam.examName}</span>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Tag color={exam.status === 'archived' ? 'default' : 'green'}>
|
|
{exam.status === 'archived' ? '已归档' : '成绩录入'}
|
|
</Tag>
|
|
}
|
|
actions={[
|
|
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>
|
|
查看成绩
|
|
</span>,
|
|
exam.status === 'archived' ? (
|
|
<>
|
|
<Popconfirm
|
|
key="restore"
|
|
title="确认恢复该考试?"
|
|
onConfirm={() => changeArchiveStatus(exam, false)}
|
|
>
|
|
<span>恢复</span>
|
|
</Popconfirm>
|
|
{canPurgeExam ? (
|
|
<Popconfirm
|
|
key="purge"
|
|
title="确认永久删除该考试?"
|
|
description="删除后不可恢复,成绩记录将一并清除。"
|
|
onConfirm={() => handlePurge(exam)}
|
|
okText="永久删除"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<span className="exam-purge-action">删除</span>
|
|
</Popconfirm>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<Popconfirm
|
|
key="archive"
|
|
title="确认归档该考试?"
|
|
description={`当前已录入 ${exam.enteredScores}/${exam.totalStudents} 人,归档后成绩将变为只读。`}
|
|
onConfirm={() => changeArchiveStatus(exam, true)}
|
|
>
|
|
<span>归档</span>
|
|
</Popconfirm>
|
|
),
|
|
]}
|
|
>
|
|
<div className="exam-meta">
|
|
<span>科目</span>
|
|
<strong>{exam.subject}</strong>
|
|
</div>
|
|
<div className="exam-meta">
|
|
<span>
|
|
<TeamOutlined /> 班级
|
|
</span>
|
|
<strong>{exam.className}</strong>
|
|
</div>
|
|
<div className="exam-meta">
|
|
<span>
|
|
<CalendarOutlined /> 日期
|
|
</span>
|
|
<strong>{exam.examDate}</strong>
|
|
</div>
|
|
<div className="exam-progress">
|
|
<div>
|
|
<span>成绩录入</span>
|
|
<strong>
|
|
{exam.enteredScores}/{exam.totalStudents}
|
|
</strong>
|
|
</div>
|
|
<Progress percent={percent} size="small" />
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
);
|
|
})}
|
|
</Row>
|
|
)}
|
|
|
|
<ExamFormModal
|
|
open={modalOpen}
|
|
editing={false}
|
|
saving={saving}
|
|
form={form}
|
|
classes={classes}
|
|
onCancel={() => examFormGuard.confirmClose(() => setModalOpen(false))}
|
|
onSubmit={() => void submit()}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExamsPage;
|