P2全部完成: 尝试限制/预约时段/题目回顾/随机排序

后端:
- assessment-template entity: attemptLimit/scheduledStart/End/reviewMode/shuffleQuestions
- DTO 更新: 新增 P2 字段验证
- startSession: 尝试次数检查、预约时段检查、题目随机排序
- getSessionState: reviewMode 控制答案可见性
- 新增 GET /assessment/:id/review 回顾端点

前端:
- AssessmentTemplateManager: 新增尝试次数/答题回顾/题目排序/预约时段配置
- AssessmentView: 答题回顾按钮(完成页)+提交确认弹窗+标记回头功能
- types.ts: 新增 P2 字段类型
- assessmentService: 新增 getReview 方法
- 进度导航点: 可视化题序+标记状态

测试 20项全部通过 + 系统测试 142项全部通过 

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-06-09 14:57:32 +08:00
parent 9fd503b42b
commit 46a10ba091
8 changed files with 450 additions and 5 deletions
@@ -119,6 +119,14 @@ export class AssessmentController {
return this.assessmentService.getSessionState(sessionId, userId);
}
@Get(':id/review')
@ApiOperation({ summary: 'Get review data for a completed assessment (shows correct answers)' })
async getReview(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user;
this.logger.log(`getReview: user=${userId}, session=${sessionId}`);
return this.assessmentService.getSessionReview(sessionId, userId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an assessment session' })
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
+85 -4
View File
@@ -427,6 +427,36 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
this.logger.debug(
`[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`,
);
// P2: Check attempt limit
if (template.attemptLimit > 0) {
const attemptCount = await this.sessionRepository.count({
where: { userId, templateId, status: AssessmentStatus.COMPLETED },
});
if (attemptCount >= template.attemptLimit) {
throw new BadRequestException(
`已达到最大尝试次数 ${template.attemptLimit}/${template.attemptLimit}`,
);
}
}
// P2: Check scheduled window
if (template.scheduledStart) {
const start = new Date(template.scheduledStart);
if (Date.now() < start.getTime()) {
throw new BadRequestException(
`考试尚未开始,预定时间: ${start.toLocaleString()}`,
);
}
}
if (template.scheduledEnd) {
const end = new Date(template.scheduledEnd);
if (Date.now() > end.getTime()) {
throw new BadRequestException(
'考试已结束,超过预定截止时间',
);
}
}
}
// Use kbId if provided, otherwise fall back to template's group ID
@@ -497,6 +527,11 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
style: template.style,
dimensions: template.dimensions,
linkedGroupIds: template.linkedGroupIds,
attemptLimit: template.attemptLimit,
reviewMode: template.reviewMode,
shuffleQuestions: template.shuffleQuestions,
scheduledStart: template.scheduledStart,
scheduledEnd: template.scheduledEnd,
}
: undefined;
@@ -572,6 +607,14 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
templateData.questionAnswerKey = answerKey;
}
// P2: Shuffle questions per candidate
if (template?.shuffleQuestions !== false && questionsFromBank.length > 1) {
for (let i = questionsFromBank.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[questionsFromBank[i], questionsFromBank[j]] = [questionsFromBank[j], questionsFromBank[i]];
}
}
questionSource = 'bank';
this.logger.log(
`[startSession] Selected ${questionsFromBank.length} questions from question bank`,
@@ -1252,10 +1295,48 @@ const initialState: Partial<EvaluationState> = {
values.feedbackHistory = this.mapMessages(values.feedbackHistory);
}
return this.sanitizeStateForClient(
values,
session.status !== AssessmentStatus.COMPLETED,
);
// Determine stripAnswers: strip if in-progress, or if completed but reviewMode is 'none'
let stripAnswers = session.status !== AssessmentStatus.COMPLETED;
if (session.status === AssessmentStatus.COMPLETED) {
const templateData = session.templateJson as any;
const reviewMode = templateData?.reviewMode || 'none';
if (reviewMode === 'none') {
stripAnswers = true;
}
}
return this.sanitizeStateForClient(values, stripAnswers);
}
/**
* P2: Get completed session review with correct answers.
* Requires reviewMode != 'none' on the template.
*/
async getSessionReview(sessionId: string, userId: string): Promise<any> {
this.logger.log(`getSessionReview: session=${sessionId}, user=${userId}`);
const session = await this.sessionRepository.findOne({
where: { id: sessionId, userId },
});
if (!session) throw new NotFoundException('Session not found');
if (session.status !== AssessmentStatus.COMPLETED) {
throw new BadRequestException('只能在考核完成后查看回顾');
}
const templateData = session.templateJson as any;
const reviewMode = templateData?.reviewMode || 'none';
if (reviewMode === 'none') {
throw new BadRequestException('当前模板未开启答题回顾功能');
}
// Return state with answers visible
await this.ensureGraphState(sessionId, session);
const state = await this.graph.getState({
configurable: { thread_id: sessionId },
});
const values = { ...state.values };
if (values.messages) values.messages = this.mapMessages(values.messages);
if (values.feedbackHistory) values.feedbackHistory = this.mapMessages(values.feedbackHistory);
return this.sanitizeStateForClient(values, false);
}
/**
@@ -107,4 +107,31 @@ export class CreateTemplateDto {
@Min(30)
@Max(3600)
perQuestionTimeLimit?: number;
/** P2: Max attempts (0=unlimited) */
@IsInt()
@Min(0)
@Max(99)
@IsOptional()
attemptLimit?: number = 1;
/** P2: Scheduled window start */
@IsString()
@IsOptional()
scheduledStart?: string | null;
/** P2: Scheduled window end */
@IsString()
@IsOptional()
scheduledEnd?: string | null;
/** P2: Review mode */
@IsString()
@IsOptional()
reviewMode?: string = 'none';
/** P2: Shuffle questions */
@IsBoolean()
@IsOptional()
shuffleQuestions?: boolean = true;
}