feat: 支持考试批量归档与恢复
All checks were successful
CI / check (pull_request) Successful in 2m4s

This commit is contained in:
2026-07-25 09:45:58 +08:00
parent 0ef86e65ce
commit 095eccea76
8 changed files with 393 additions and 6 deletions

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
import { Button, Card, Checkbox, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
import { CalendarOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import ExamFormModal from './ExamFormModal';
import { selectAllExamIds, toggleExamSelection } from './selection';
import type { ClassOption, ExamFormValues, ExamItem } from './types';
import { EXAM_TYPE_OPTIONS } from './types';
import './style.css';
@@ -16,12 +17,14 @@ const ExamsPage: React.FC = () => {
const [data, setData] = useState<ExamItem[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(false);
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 loadClasses = useCallback(async () => {
const result = await api.get<ClassOption[]>('/classes');
@@ -29,6 +32,7 @@ const ExamsPage: React.FC = () => {
}, []);
const loadExams = useCallback(async () => {
setSelectedExamIds([]);
setLoading(true);
try {
const params = new URLSearchParams();
@@ -92,6 +96,46 @@ const ExamsPage: React.FC = () => {
}
};
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 api.put<{ archived: number; skipped: number }>('/exams/batch-archive', {
ids: selectedExamIds,
});
message.success(
`已归档 ${result.archived} 场考试${result.skipped ? `,跳过 ${result.skipped}` : ''}`,
);
} else {
const result = await api.put<{ restored: number; skipped: number }>('/exams/batch-restore', {
ids: selectedExamIds,
});
message.success(
`已恢复 ${result.restored} 场考试${result.skipped ? `,跳过 ${result.skipped}` : ''}`,
);
}
setSelectedExamIds([]);
await loadExams();
} catch (error) {
message.error((error as { message?: string })?.message || '批量操作失败');
} finally {
setBatchLoading(false);
}
};
return (
<div className="exam-page">
<div className="exam-toolbar">
@@ -101,12 +145,40 @@ const ExamsPage: React.FC = () => {
<Select value={classId} onChange={setClassId} 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>
<span className="exam-archive-toggle">
<InboxOutlined />
<Switch size="small" checked={showArchived} onChange={setShowArchived} />
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
{!showArchived ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
) : null}
</Space>
</div>
@@ -119,9 +191,24 @@ const ExamsPage: React.FC = () => {
return (
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
<Card
className="exam-card"
className={`exam-card${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
loading={loading}
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
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>,

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { selectAllExamIds, toggleExamSelection } from './selection';
describe('考试批量选择', () => {
it('可以选择和取消单场考试且不会重复选择', () => {
expect(toggleExamSelection([1], 2, true)).toEqual([1, 2]);
expect(toggleExamSelection([1, 2], 2, true)).toEqual([1, 2]);
expect(toggleExamSelection([1, 2], 1, false)).toEqual([2]);
});
it('全选只包含当前结果并去重,取消全选后清空', () => {
expect(selectAllExamIds([1, 2, 2, 3], true)).toEqual([1, 2, 3]);
expect(selectAllExamIds([1, 2, 3], false)).toEqual([]);
});
});

View File

@@ -0,0 +1,13 @@
export const toggleExamSelection = (
selectedIds: number[],
examId: number,
checked: boolean,
): number[] => {
if (checked) {
return selectedIds.includes(examId) ? selectedIds : [...selectedIds, examId];
}
return selectedIds.filter((id) => id !== examId);
};
export const selectAllExamIds = (examIds: number[], checked: boolean): number[] =>
checked ? [...new Set(examIds)] : [];

View File

@@ -25,6 +25,11 @@
border-radius: 8px;
}
.exam-card-selected {
border-color: #1677ff;
box-shadow: 0 0 0 1px #1677ff;
}
.exam-card .ant-card-head-title {
min-width: 0;
}