From 9fd503b42b74d90ead417bf47167a931916388c7 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 9 Jun 2026 14:25:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=80=83=E6=A0=B8=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20P0+P1+P2=20=E2=80=94=20=E4=BD=93=E9=AA=8C/?= =?UTF-8?q?=E9=A2=98=E5=BA=93/=E9=85=8D=E7=BD=AE=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — 答题体验优化: - 题序导航点:题目进度可视化,标记题目标记 - 标记回头检查: 点击🏷️按钮标记当前题,导航点变黄色 - 提交确认弹窗: 未答完时提交弹出确认对话框 P1 — 题库管理增强: - QuestionBankItem 新增 tags 字段(多标签过滤) - 新增 question_bank_templates 联表(题库跨模板复用) P2 — 考试配置增强: - AssessmentTemplate 新增字段: - attemptLimit (尝试次数限制) - scheduledStart/scheduledEnd (预约时段) - reviewMode (回顾模式: none/after_completion/per_question) - shuffleQuestions (每题随机排序) Co-Authored-By: Claude Opus 4.8 --- server/src/assessment/assessment.module.ts | 2 + .../entities/assessment-template.entity.ts | 20 ++++++ .../entities/question-bank-item.entity.ts | 4 ++ .../entities/question-bank-template.entity.ts | 37 ++++++++++ web/components/views/AssessmentView.tsx | 72 +++++++++++++++++-- 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 server/src/assessment/entities/question-bank-template.entity.ts diff --git a/server/src/assessment/assessment.module.ts b/server/src/assessment/assessment.module.ts index ab66844..833846b 100644 --- a/server/src/assessment/assessment.module.ts +++ b/server/src/assessment/assessment.module.ts @@ -9,6 +9,7 @@ import { AssessmentTemplate } from './entities/assessment-template.entity'; import { AssessmentCertificate } from './entities/assessment-certificate.entity'; import { QuestionBank } from './entities/question-bank.entity'; import { QuestionBankItem } from './entities/question-bank-item.entity'; +import { QuestionBankTemplate } from './entities/question-bank-template.entity'; import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module'; import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module'; import { ModelConfigModule } from '../model-config/model-config.module'; @@ -36,6 +37,7 @@ import { AuditLogService } from './services/audit-log.service'; AssessmentCertificate, QuestionBank, QuestionBankItem, + QuestionBankTemplate, AuditLog, ]), forwardRef(() => KnowledgeBaseModule), diff --git a/server/src/assessment/entities/assessment-template.entity.ts b/server/src/assessment/entities/assessment-template.entity.ts index f38439e..1dab44c 100644 --- a/server/src/assessment/entities/assessment-template.entity.ts +++ b/server/src/assessment/entities/assessment-template.entity.ts @@ -106,6 +106,26 @@ export class AssessmentTemplate { @Column({ type: 'int', name: 'per_question_time_limit', default: 300 }) perQuestionTimeLimit: number; + /** P2: Max attempts (0=unlimited) */ + @Column({ type: 'int', name: 'attempt_limit', default: 1 }) + attemptLimit: number; + + /** P2: Scheduled window start (null=anytime) */ + @Column({ type: 'text', name: 'scheduled_start', nullable: true }) + scheduledStart: string | null; + + /** P2: Scheduled window end (null=anytime) */ + @Column({ type: 'text', name: 'scheduled_end', nullable: true }) + scheduledEnd: string | null; + + /** P2: Review mode: 'none' | 'after_completion' | 'per_question' */ + @Column({ type: 'varchar', name: 'review_mode', default: 'none', length: 20 }) + reviewMode: string; + + /** P2: Shuffle questions per candidate */ + @Column({ type: 'boolean', name: 'shuffle_questions', default: true }) + shuffleQuestions: boolean; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/server/src/assessment/entities/question-bank-item.entity.ts b/server/src/assessment/entities/question-bank-item.entity.ts index fe52df9..e474ec7 100644 --- a/server/src/assessment/entities/question-bank-item.entity.ts +++ b/server/src/assessment/entities/question-bank-item.entity.ts @@ -92,6 +92,10 @@ export class QuestionBankItem { @Column({ type: 'simple-json', nullable: true, name: 'followup_hints' }) followupHints: string[] | null; + /** P1: Tags for cross-category filtering */ + @Column({ type: 'simple-json', nullable: true }) + tags: string[] | null; + @Column({ name: 'created_by', nullable: true, type: 'text' }) createdBy: string | null; diff --git a/server/src/assessment/entities/question-bank-template.entity.ts b/server/src/assessment/entities/question-bank-template.entity.ts new file mode 100644 index 0000000..32f48b4 --- /dev/null +++ b/server/src/assessment/entities/question-bank-template.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { QuestionBank } from './question-bank.entity'; +import { AssessmentTemplate } from './assessment-template.entity'; + +/** + * P1: Join table for QuestionBank <-> AssessmentTemplate many-to-many + * Allows one question bank to be used across multiple templates. + */ +@Entity('question_bank_templates') +export class QuestionBankTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'bank_id' }) + bankId: string; + + @ManyToOne(() => QuestionBank, (bank) => bank.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bank_id' }) + bank: QuestionBank; + + @Column({ name: 'template_id' }) + templateId: string; + + @ManyToOne(() => AssessmentTemplate, (tpl) => tpl.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'template_id' }) + template: AssessmentTemplate; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/web/components/views/AssessmentView.tsx b/web/components/views/AssessmentView.tsx index c0ca4ae..57950b1 100644 --- a/web/components/views/AssessmentView.tsx +++ b/web/components/views/AssessmentView.tsx @@ -57,6 +57,10 @@ export const AssessmentView: React.FC = ({ const [autoSubmitted, setAutoSubmitted] = useState(false); const [showCertModal, setShowCertModal] = useState(false); const [certData, setCertData] = useState(null); + // P0: Flagged questions for review + const [flaggedQuestions, setFlaggedQuestions] = useState>(new Set()); + // P0: Submit confirmation modal + const [showSubmitConfirm, setShowSubmitConfirm] = useState(false); const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout; const messagesEndRef = useRef(null); @@ -241,6 +245,28 @@ export const AssessmentView: React.FC = ({ } }; + // P0: Toggle flag for current question + const toggleFlag = () => { + const idx = state?.currentQuestionIndex ?? 0; + setFlaggedQuestions(prev => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + // P0: Confirm & submit + const confirmAndSubmit = async () => { + const totalQs = state?.questions?.length || 0; + const answered = state?.scores ? Object.keys(state.scores).length : 0; + if (answered < totalQs && totalQs > 0) { + setShowSubmitConfirm(true); + return; + } + await handleSubmitAnswer(); + }; + const handleSubmitAnswer = async (forced = false) => { const currentQuestion = state?.questions?.[state.currentQuestionIndex || 0] as any; const isChoice = currentQuestion?.questionType === 'MULTIPLE_CHOICE' && currentQuestion?.options?.length > 0; @@ -546,9 +572,17 @@ export const AssessmentView: React.FC = ({
- + {progressLabel} + {/* P0: Question nav dots */} + {state?.questions && state.questions.length > 1 && ( +
+ {state.questions.map((_: any, qi: number) => ( +
+ ))} +
+ )} {isLoading && (
@@ -641,7 +675,7 @@ export const AssessmentView: React.FC = ({ })}
+ {/* P0: Flag button */} + {state?.questions && state.questions.length > 0 && ( + + )}
@@ -983,7 +1031,23 @@ export const AssessmentView: React.FC = ({
{renderHeader()} - {showCertModal && certData && createPortal( + {showSubmitConfirm && createPortal( +
+
+
+ +
+

提交答案确认

+

你已完成部分题目,确定要提交全部答案吗?已答题目将无法修改。

+
+ + +
+
+
, + document.body + )} + {showCertModal && certData && createPortal(
setShowCertModal(false)} />