forked from wangziqi/gongxue-base
feat: 支持考试批量归档与恢复
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { CalendarOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import ExamFormModal from './ExamFormModal';
|
import ExamFormModal from './ExamFormModal';
|
||||||
|
import { selectAllExamIds, toggleExamSelection } from './selection';
|
||||||
import type { ClassOption, ExamFormValues, ExamItem } from './types';
|
import type { ClassOption, ExamFormValues, ExamItem } from './types';
|
||||||
import { EXAM_TYPE_OPTIONS } from './types';
|
import { EXAM_TYPE_OPTIONS } from './types';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
@@ -16,12 +17,14 @@ const ExamsPage: React.FC = () => {
|
|||||||
const [data, setData] = useState<ExamItem[]>([]);
|
const [data, setData] = useState<ExamItem[]>([]);
|
||||||
const [classes, setClasses] = useState<ClassOption[]>([]);
|
const [classes, setClasses] = useState<ClassOption[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [examType, setExamType] = useState<string>();
|
const [examType, setExamType] = useState<string>();
|
||||||
const [classId, setClassId] = useState<number>();
|
const [classId, setClassId] = useState<number>();
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
|
||||||
|
|
||||||
const loadClasses = useCallback(async () => {
|
const loadClasses = useCallback(async () => {
|
||||||
const result = await api.get<ClassOption[]>('/classes');
|
const result = await api.get<ClassOption[]>('/classes');
|
||||||
@@ -29,6 +32,7 @@ const ExamsPage: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadExams = useCallback(async () => {
|
const loadExams = useCallback(async () => {
|
||||||
|
setSelectedExamIds([]);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
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 (
|
return (
|
||||||
<div className="exam-page">
|
<div className="exam-page">
|
||||||
<div className="exam-toolbar">
|
<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 }} />
|
<Select value={classId} onChange={setClassId} options={classOptions} placeholder="考试班级" allowClear showSearch optionFilterProp="label" style={{ width: 180 }} />
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap>
|
<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">
|
<span className="exam-archive-toggle">
|
||||||
<InboxOutlined />
|
<InboxOutlined />
|
||||||
归档
|
归档
|
||||||
<Switch size="small" checked={showArchived} onChange={setShowArchived} />
|
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
|
||||||
</span>
|
</span>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
{!showArchived ? (
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,9 +191,24 @@ const ExamsPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
|
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
|
||||||
<Card
|
<Card
|
||||||
className="exam-card"
|
className={`exam-card${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
|
||||||
loading={loading}
|
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>}
|
extra={<Tag color={exam.status === 'archived' ? 'default' : 'green'}>{exam.status === 'archived' ? '已归档' : '成绩录入'}</Tag>}
|
||||||
actions={[
|
actions={[
|
||||||
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>查看成绩</span>,
|
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>查看成绩</span>,
|
||||||
|
|||||||
15
apps/admin/src/pages/Exams/selection.integration.test.ts
Normal file
15
apps/admin/src/pages/Exams/selection.integration.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/admin/src/pages/Exams/selection.ts
Normal file
13
apps/admin/src/pages/Exams/selection.ts
Normal 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)] : [];
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exam-card-selected {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 1px #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
.exam-card .ant-card-head-title {
|
.exam-card .ant-card-head-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
63
apps/server/src/exams/exams.controller.spec.ts
Normal file
63
apps/server/src/exams/exams.controller.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { PIPES_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||||
|
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||||
|
import { ExamsController } from './exams.controller';
|
||||||
|
|
||||||
|
describe('ExamsController batch archive and restore', () => {
|
||||||
|
const req = {
|
||||||
|
user: {
|
||||||
|
id: 7,
|
||||||
|
username: 'admin',
|
||||||
|
isSuperAdmin: true,
|
||||||
|
permissions: ['exam:view'],
|
||||||
|
},
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['batchArchive', 'batchRestore'] as const)(
|
||||||
|
'%s uses the existing exam permission',
|
||||||
|
(method) => {
|
||||||
|
const handler = ExamsController.prototype[method] as (...args: never[]) => unknown;
|
||||||
|
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => {
|
||||||
|
const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[];
|
||||||
|
expect(pipes).toHaveLength(1);
|
||||||
|
const metadata = { type: 'body' as const, metatype: BatchIdsDto, data: undefined };
|
||||||
|
await expect(pipes[0].transform({ ids: [] }, metadata)).rejects.toBeDefined();
|
||||||
|
await expect(pipes[0].transform({ ids: [0] }, metadata)).rejects.toBeDefined();
|
||||||
|
await expect(
|
||||||
|
pipes[0].transform({ ids: [1], unexpected: true }, metadata),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes ids and access context to services and writes batch audit logs', async () => {
|
||||||
|
const service = {
|
||||||
|
batchArchive: jest.fn().mockResolvedValue({ archived: 1, skipped: 1 }),
|
||||||
|
batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 1 }),
|
||||||
|
};
|
||||||
|
const log = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const controller = new ExamsController(service as never, { log } as never);
|
||||||
|
|
||||||
|
await expect(controller.batchArchive({ ids: [1, 2] }, req)).resolves.toEqual({
|
||||||
|
archived: 1,
|
||||||
|
skipped: 1,
|
||||||
|
});
|
||||||
|
await expect(controller.batchRestore({ ids: [3, 4] }, req)).resolves.toEqual({
|
||||||
|
restored: 1,
|
||||||
|
skipped: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.batchArchive).toHaveBeenCalledWith([1, 2], 7, true);
|
||||||
|
expect(service.batchRestore).toHaveBeenCalledWith([3, 4], 7, true);
|
||||||
|
expect(log.mock.calls.map(([entry]) => [entry.action, entry.detail])).toEqual([
|
||||||
|
['批量归档考试', 'IDs: 1,2'],
|
||||||
|
['批量恢复考试', 'IDs: 3,4'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
|
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
|
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
|
||||||
@@ -46,6 +47,48 @@ export class ExamsController {
|
|||||||
return this.service.findAll(query, classIds);
|
return this.service.findAll(query, classIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('batch-archive')
|
||||||
|
@RequirePermission('exam:view')
|
||||||
|
async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
|
const result = await this.service.batchArchive(
|
||||||
|
dto.ids,
|
||||||
|
req.user.id,
|
||||||
|
this.canManageAll(req),
|
||||||
|
);
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
module: '考试管理',
|
||||||
|
action: '批量归档考试',
|
||||||
|
detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('batch-restore')
|
||||||
|
@RequirePermission('exam:view')
|
||||||
|
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
|
const result = await this.service.batchRestore(
|
||||||
|
dto.ids,
|
||||||
|
req.user.id,
|
||||||
|
this.canManageAll(req),
|
||||||
|
);
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
module: '考试管理',
|
||||||
|
action: '批量恢复考试',
|
||||||
|
detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('exam:view')
|
@RequirePermission('exam:view')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||||
import { ExamScore } from '../entities';
|
import { ExamScore } from '../entities';
|
||||||
import { QueryExamDto } from './dto/exam.dto';
|
import { QueryExamDto } from './dto/exam.dto';
|
||||||
import { ExamsService } from './exams.service';
|
import { ExamsService } from './exams.service';
|
||||||
@@ -23,6 +23,19 @@ function createService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateQb(affected = 1) {
|
||||||
|
const qb = {
|
||||||
|
update: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
where: jest.fn(),
|
||||||
|
execute: jest.fn().mockResolvedValue({ affected }),
|
||||||
|
};
|
||||||
|
qb.update.mockReturnValue(qb);
|
||||||
|
qb.set.mockReturnValue(qb);
|
||||||
|
qb.where.mockReturnValue(qb);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ExamsService', () => {
|
describe('ExamsService', () => {
|
||||||
it('creates score rows from the active class roster snapshot', async () => {
|
it('creates score rows from the active class roster snapshot', async () => {
|
||||||
const members = [
|
const members = [
|
||||||
@@ -224,6 +237,102 @@ describe('ExamsService', () => {
|
|||||||
expect(examRepo.update).not.toHaveBeenCalled();
|
expect(examRepo.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects empty and invalid ids for batch archive and restore', async () => {
|
||||||
|
const service = createService(async () => undefined, { examRepo: { find: jest.fn() } });
|
||||||
|
|
||||||
|
for (const call of [
|
||||||
|
(ids: number[]) => service.batchArchive(ids, 1, true),
|
||||||
|
(ids: number[]) => service.batchRestore(ids, 1, true),
|
||||||
|
]) {
|
||||||
|
await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => {
|
||||||
|
const qb = updateQb();
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 8, classId: 3, status: 'active' },
|
||||||
|
{ id: 9, classId: 4, status: 'archived' },
|
||||||
|
]),
|
||||||
|
createQueryBuilder: jest.fn(() => qb),
|
||||||
|
};
|
||||||
|
const scoreRepo = { update: jest.fn(), save: jest.fn() };
|
||||||
|
const service = createService(async () => undefined, { examRepo, scoreRepo });
|
||||||
|
|
||||||
|
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
|
||||||
|
message: '已批量归档 1 场考试',
|
||||||
|
archived: 1,
|
||||||
|
skipped: 1,
|
||||||
|
});
|
||||||
|
expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
|
||||||
|
expect(qb.set).toHaveBeenCalledWith({ status: 'archived' });
|
||||||
|
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
|
||||||
|
expect(scoreRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(scoreRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores archived exams and skips active exams', async () => {
|
||||||
|
const qb = updateQb();
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 8, classId: 3, status: 'archived' },
|
||||||
|
{ id: 9, classId: 4, status: 'active' },
|
||||||
|
]),
|
||||||
|
createQueryBuilder: jest.fn(() => qb),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
|
||||||
|
message: '已批量恢复 1 场考试',
|
||||||
|
restored: 1,
|
||||||
|
skipped: 1,
|
||||||
|
});
|
||||||
|
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
|
||||||
|
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a batch when any exam is missing before updating', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks access for every selected exam before a batch update', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 8, classId: 3, status: 'active' },
|
||||||
|
{ id: 9, classId: 4, status: 'active' },
|
||||||
|
]),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const classTeacherRepo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 1 })
|
||||||
|
.mockResolvedValueOnce(null),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
|
||||||
|
|
||||||
|
await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { userId: 21, classId: 3 },
|
||||||
|
});
|
||||||
|
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { userId: 21, classId: 4 },
|
||||||
|
});
|
||||||
|
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects score updates for archived exams', async () => {
|
it('rejects score updates for archived exams', async () => {
|
||||||
const manager = {
|
const manager = {
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
|
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
|
||||||
|
|||||||
@@ -149,6 +149,58 @@ export class ExamsService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchArchive(ids: number[], userId: number, canManageAll: boolean) {
|
||||||
|
const exams = await this.findBatchExams(ids, userId, canManageAll, '归档');
|
||||||
|
const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
|
||||||
|
const archived = await this.updateBatchStatus(targetIds, 'archived');
|
||||||
|
return {
|
||||||
|
message: `已批量归档 ${archived} 场考试`,
|
||||||
|
archived,
|
||||||
|
skipped: exams.length - targetIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchRestore(ids: number[], userId: number, canManageAll: boolean) {
|
||||||
|
const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复');
|
||||||
|
const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
|
||||||
|
const restored = await this.updateBatchStatus(targetIds, 'active');
|
||||||
|
return {
|
||||||
|
message: `已批量恢复 ${restored} 场考试`,
|
||||||
|
restored,
|
||||||
|
skipped: exams.length - targetIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findBatchExams(
|
||||||
|
ids: number[],
|
||||||
|
userId: number,
|
||||||
|
canManageAll: boolean,
|
||||||
|
action: '归档' | '恢复',
|
||||||
|
) {
|
||||||
|
const uniqueIds = [...new Set(ids || [])];
|
||||||
|
if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`);
|
||||||
|
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
||||||
|
throw new BadRequestException('考试 ID 无效');
|
||||||
|
}
|
||||||
|
const exams = await this.examRepo.find({ where: { id: In(uniqueIds) } });
|
||||||
|
if (exams.length !== uniqueIds.length) throw new NotFoundException('部分考试不存在');
|
||||||
|
for (const exam of exams) {
|
||||||
|
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||||
|
}
|
||||||
|
return exams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateBatchStatus(ids: number[], status: 'active' | 'archived') {
|
||||||
|
if (ids.length === 0) return 0;
|
||||||
|
const result = await this.examRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({ status })
|
||||||
|
.where('id IN (:...ids)', { ids })
|
||||||
|
.execute();
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
||||||
const rows = members.map((member) =>
|
const rows = members.map((member) =>
|
||||||
manager.create(ExamScore, {
|
manager.create(ExamScore, {
|
||||||
|
|||||||
Reference in New Issue
Block a user