forked from wangziqi/gongxue-base
新增考试归档与恢复功能
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Card, Descriptions, Empty, Space, Spin, Table, Tooltip } from 'antd';
|
import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, Tooltip } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
@@ -67,11 +67,14 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
const saveScore = async (row: ScoreRow, value: number | undefined) => {
|
const saveScore = useCallback(
|
||||||
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
|
async (row: ScoreRow, value: number | undefined) => {
|
||||||
message.success('成绩已保存');
|
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
|
||||||
await load();
|
message.success('成绩已保存');
|
||||||
};
|
await load();
|
||||||
|
},
|
||||||
|
[id, load],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<ScoreRow>>(() => {
|
const columns = useMemo<ColumnsType<ScoreRow>>(() => {
|
||||||
if (!detail) return [];
|
if (!detail) return [];
|
||||||
@@ -93,16 +96,20 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
dataIndex: 'score',
|
dataIndex: 'score',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (value: number | null, row: ScoreRow) => (
|
render: (value: number | null, row: ScoreRow) => (
|
||||||
<EditableCell<number | undefined>
|
detail.status === 'archived' ? (
|
||||||
value={value ?? undefined}
|
value ?? '-'
|
||||||
editor="money"
|
) : (
|
||||||
min={0}
|
<EditableCell<number | undefined>
|
||||||
max={999.99}
|
value={value ?? undefined}
|
||||||
permission="exam:view"
|
editor="money"
|
||||||
onSave={(next) => saveScore(row, next)}
|
min={0}
|
||||||
>
|
max={999.99}
|
||||||
{value ?? '-'}
|
permission="exam:view"
|
||||||
</EditableCell>
|
onSave={(next) => saveScore(row, next)}
|
||||||
|
>
|
||||||
|
{value ?? '-'}
|
||||||
|
</EditableCell>
|
||||||
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,7 +127,7 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
{ title: '考试日期', width: 110, render: () => detail.examDate },
|
{ title: '考试日期', width: 110, render: () => detail.examDate },
|
||||||
{ title: '关联报读(班级名)', width: 180, render: () => detail.className },
|
{ title: '关联报读(班级名)', width: 180, render: () => detail.className },
|
||||||
];
|
];
|
||||||
}, [detail]);
|
}, [detail, saveScore]);
|
||||||
|
|
||||||
if (loading && !detail)
|
if (loading && !detail)
|
||||||
return (
|
return (
|
||||||
@@ -139,8 +146,12 @@ const ExamDetailPage: React.FC = () => {
|
|||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<h2>{detail.examName}</h2>
|
<h2>{detail.examName}</h2>
|
||||||
|
{detail.status === 'archived' ? <Tag>已归档</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{detail.status === 'archived' ? (
|
||||||
|
<Alert type="info" showIcon message="该考试已归档,成绩仅供查看。如需继续录入,请先在考试列表中恢复。" />
|
||||||
|
) : null}
|
||||||
<Card className="exam-summary">
|
<Card className="exam-summary">
|
||||||
<Descriptions column={{ xs: 1, sm: 2, lg: 5 }}>
|
<Descriptions column={{ xs: 1, sm: 2, lg: 5 }}>
|
||||||
<Descriptions.Item label="考试类型">{detail.examType}</Descriptions.Item>
|
<Descriptions.Item label="考试类型">{detail.examType}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Card, Col, Empty, Form, Input, Progress, Row, Select, Space, Tag } from 'antd';
|
import { Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
|
||||||
import { CalendarOutlined, 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';
|
||||||
@@ -21,6 +21,7 @@ const ExamsPage: React.FC = () => {
|
|||||||
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 loadClasses = useCallback(async () => {
|
const loadClasses = useCallback(async () => {
|
||||||
const result = await api.get<ClassOption[]>('/classes');
|
const result = await api.get<ClassOption[]>('/classes');
|
||||||
@@ -34,6 +35,7 @@ const ExamsPage: React.FC = () => {
|
|||||||
if (keyword.trim()) params.set('keyword', keyword.trim());
|
if (keyword.trim()) params.set('keyword', keyword.trim());
|
||||||
if (examType) params.set('examType', examType);
|
if (examType) params.set('examType', examType);
|
||||||
if (classId) params.set('classId', String(classId));
|
if (classId) params.set('classId', String(classId));
|
||||||
|
params.set('isArchived', String(showArchived));
|
||||||
const result = await api.get<ExamItem[]>(`/exams?${params.toString()}`);
|
const result = await api.get<ExamItem[]>(`/exams?${params.toString()}`);
|
||||||
setData(result ?? []);
|
setData(result ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -41,7 +43,7 @@ const ExamsPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [classId, examType, keyword]);
|
}, [classId, examType, keyword, showArchived]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadClasses().catch((error: { message?: string }) => message.error(error?.message || '加载班级失败'));
|
void loadClasses().catch((error: { message?: string }) => message.error(error?.message || '加载班级失败'));
|
||||||
@@ -80,6 +82,16 @@ const ExamsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeArchiveStatus = async (exam: ExamItem, archive: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/exams/${exam.id}/${archive ? 'archive' : 'restore'}`);
|
||||||
|
message.success(archive ? '考试已归档' : '考试已恢复');
|
||||||
|
await loadExams();
|
||||||
|
} catch (error) {
|
||||||
|
message.error((error as { message?: string })?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exam-page">
|
<div className="exam-page">
|
||||||
<div className="exam-toolbar">
|
<div className="exam-toolbar">
|
||||||
@@ -89,6 +101,11 @@ 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>
|
||||||
|
<span className="exam-archive-toggle">
|
||||||
|
<InboxOutlined />
|
||||||
|
归档
|
||||||
|
<Switch size="small" checked={showArchived} onChange={setShowArchived} />
|
||||||
|
</span>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +122,27 @@ const ExamsPage: React.FC = () => {
|
|||||||
className="exam-card"
|
className="exam-card"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
|
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
|
||||||
extra={<Tag color="green">成绩录入</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>,
|
||||||
|
exam.status === 'archived' ? (
|
||||||
|
<Popconfirm
|
||||||
|
key="restore"
|
||||||
|
title="确认恢复该考试?"
|
||||||
|
onConfirm={() => changeArchiveStatus(exam, false)}
|
||||||
|
>
|
||||||
|
<span>恢复</span>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<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>科目</span><strong>{exam.subject}</strong></div>
|
||||||
|
|||||||
@@ -68,6 +68,13 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exam-archive-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
.exam-toolbar > .ant-space,
|
.exam-toolbar > .ant-space,
|
||||||
.exam-toolbar .ant-input-affix-wrapper,
|
.exam-toolbar .ant-input-affix-wrapper,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
@@ -22,6 +23,16 @@ export class QueryExamDto {
|
|||||||
@IsOptional() @IsString() keyword?: string;
|
@IsOptional() @IsString() keyword?: string;
|
||||||
@IsOptional() @IsString() examType?: string;
|
@IsOptional() @IsString() examType?: string;
|
||||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (value === 'true' || value === '1') return true;
|
||||||
|
if (value === 'false' || value === '0') return false;
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
isArchived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateExamScoreValueDto {
|
export class UpdateExamScoreValueDto {
|
||||||
|
|||||||
@@ -71,6 +71,48 @@ export class ExamsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/archive')
|
||||||
|
@RequirePermission('exam:view')
|
||||||
|
async archive(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Request() req: AuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
const result = await this.service.archive(id, 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: '归档考试',
|
||||||
|
targetId: id,
|
||||||
|
targetType: 'exam',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/restore')
|
||||||
|
@RequirePermission('exam:view')
|
||||||
|
async restore(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Request() req: AuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
const result = await this.service.restore(id, 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: '恢复考试',
|
||||||
|
targetId: id,
|
||||||
|
targetType: 'exam',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':examId/scores/:scoreId')
|
@Put(':examId/scores/:scoreId')
|
||||||
@RequirePermission('exam:view')
|
@RequirePermission('exam:view')
|
||||||
async updateScore(
|
async updateScore(
|
||||||
@@ -100,5 +142,4 @@ export class ExamsController {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
import { ExamScore } from '../entities';
|
import { ExamScore } from '../entities';
|
||||||
|
import { QueryExamDto } from './dto/exam.dto';
|
||||||
import { ExamsService } from './exams.service';
|
import { ExamsService } from './exams.service';
|
||||||
|
|
||||||
function createService(transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>) {
|
function createService(
|
||||||
|
transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>,
|
||||||
|
repositories: {
|
||||||
|
examRepo?: Record<string, jest.Mock>;
|
||||||
|
scoreRepo?: Record<string, jest.Mock>;
|
||||||
|
classTeacherRepo?: Record<string, jest.Mock>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
return new ExamsService(
|
return new ExamsService(
|
||||||
|
(repositories.examRepo ?? {}) as never,
|
||||||
|
(repositories.scoreRepo ?? {}) as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
(repositories.classTeacherRepo ?? {
|
||||||
{} as never,
|
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never,
|
}) as never,
|
||||||
{ transaction } as never,
|
{ transaction } as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,4 +134,122 @@ describe('ExamsService', () => {
|
|||||||
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
|
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists active exams by default and archived exams on request', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await service.findAll({});
|
||||||
|
await service.findAll({ isArchived: true });
|
||||||
|
|
||||||
|
expect(examRepo.find).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ status: 'active' }) }),
|
||||||
|
);
|
||||||
|
expect(examRepo.find).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ status: 'archived' }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the accessible class filter for archived exams', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await service.findAll({ isArchived: true }, [3, 5]);
|
||||||
|
|
||||||
|
expect(examRepo.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ status: 'archived', classId: expect.anything() }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives an exam without changing score rows', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const scoreRepo = { update: jest.fn(), save: jest.fn() };
|
||||||
|
const service = createService(async () => undefined, { examRepo, scoreRepo });
|
||||||
|
|
||||||
|
await expect(service.archive(8, 1, true)).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'archived' });
|
||||||
|
expect(scoreRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(scoreRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects archiving an archived exam', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await expect(service.archive(8, 1, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(examRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores an archived exam and rejects restoring an active exam', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'archived' })
|
||||||
|
.mockResolvedValueOnce({ id: 9, classId: 3, status: 'active' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const service = createService(async () => undefined, { examRepo });
|
||||||
|
|
||||||
|
await expect(service.restore(8, 1, true)).resolves.toEqual({ success: true });
|
||||||
|
await expect(service.restore(9, 1, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(examRepo.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires class access before archiving an exam', async () => {
|
||||||
|
const examRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const classTeacherRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
|
||||||
|
|
||||||
|
await expect(service.archive(8, 21, false)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(classTeacherRepo.findOne).toHaveBeenCalledWith({ where: { userId: 21, classId: 3 } });
|
||||||
|
expect(examRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects score updates for archived exams', async () => {
|
||||||
|
const manager = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
|
||||||
|
};
|
||||||
|
const service = createService(async (run) => run(manager));
|
||||||
|
|
||||||
|
await expect(service.updateScore(8, 1, 90, 1, true)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QueryExamDto - query transformation', () => {
|
||||||
|
const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['false', false],
|
||||||
|
['0', false],
|
||||||
|
['true', true],
|
||||||
|
['1', true],
|
||||||
|
])('transforms isArchived=%s to %s', async (input, expected) => {
|
||||||
|
await expect(
|
||||||
|
pipe.transform(
|
||||||
|
{ isArchived: input },
|
||||||
|
{ type: 'query', metatype: QueryExamDto, data: undefined },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ isArchived: expected });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class ExamsService {
|
|||||||
|
|
||||||
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
|
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
status: 'active',
|
status: query.isArchived ? 'archived' : 'active',
|
||||||
};
|
};
|
||||||
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
||||||
if (query.examType) where.examType = query.examType;
|
if (query.examType) where.examType = query.examType;
|
||||||
@@ -131,6 +131,24 @@ export class ExamsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archive(id: number, userId: number, canManageAll: boolean) {
|
||||||
|
const exam = await this.examRepo.findOne({ where: { id } });
|
||||||
|
if (!exam) throw new NotFoundException('考试不存在');
|
||||||
|
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||||
|
if (exam.status === 'archived') throw new BadRequestException('考试已归档');
|
||||||
|
await this.examRepo.update(id, { status: 'archived' });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(id: number, userId: number, canManageAll: boolean) {
|
||||||
|
const exam = await this.examRepo.findOne({ where: { id } });
|
||||||
|
if (!exam) throw new NotFoundException('考试不存在');
|
||||||
|
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||||
|
if (exam.status !== 'archived') throw new BadRequestException('考试未归档');
|
||||||
|
await this.examRepo.update(id, { status: 'active' });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
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