Files
gongxue-base/apps/admin/src/pages/Exams/index.tsx
wangziqi 1b4ba893fd feat: 空状态引导全面应用与考勤批量标记
admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

aislop scan: 5 引擎 0 issues
2026-08-07 18:00:29 +08:00

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;