feat: 考核系统升级 P0+P1+P2 — 体验/题库/配置增强
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -57,6 +57,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
const [autoSubmitted, setAutoSubmitted] = useState(false);
|
||||
const [showCertModal, setShowCertModal] = useState(false);
|
||||
const [certData, setCertData] = useState<any>(null);
|
||||
// P0: Flagged questions for review
|
||||
const [flaggedQuestions, setFlaggedQuestions] = useState<Set<number>>(new Set());
|
||||
// P0: Submit confirmation modal
|
||||
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
|
||||
const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout;
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,6 +245,28 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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<AssessmentViewProps> = ({
|
||||
<div className="flex-1 flex flex-col border-r border-slate-200/60 transition-all duration-500">
|
||||
<div className="flex-none px-6 py-3 bg-white/50 border-b border-slate-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||
<span className="text-xs font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||
{progressLabel}
|
||||
</span>
|
||||
{/* P0: Question nav dots */}
|
||||
{state?.questions && state.questions.length > 1 && (
|
||||
<div className="hidden md:flex items-center gap-1 ml-2">
|
||||
{state.questions.map((_: any, qi: number) => (
|
||||
<div key={qi} className={cn("w-2 h-2 rounded-full transition-all", qi === currentIndex ? "bg-indigo-600 w-3" : flaggedQuestions.has(qi) ? "bg-amber-400 ring-1 ring-amber-300" : "bg-slate-200")} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<span className="text-[10px] font-bold text-slate-400 animate-pulse flex items-center gap-1.5 uppercase tracking-widest">
|
||||
<div className="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" />
|
||||
@@ -641,7 +675,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSubmitAnswer()}
|
||||
onClick={confirmAndSubmit}
|
||||
disabled={!selectedChoice || isLoading || isTimedOut}
|
||||
className={cn(
|
||||
"w-full mt-3 h-14 flex items-center justify-center gap-2 rounded-2xl transition-all shadow-lg text-white font-bold",
|
||||
@@ -662,7 +696,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !isTimedOut) {
|
||||
e.preventDefault();
|
||||
handleSubmitAnswer();
|
||||
confirmAndSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={isTimedOut ? t('timeLimitExceeded') : t('typeAnswerPlaceholder')}
|
||||
@@ -771,6 +805,20 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
<strong>{t('assessmentGuide')}</strong> {t('assessmentGuideDesc')}
|
||||
</div>
|
||||
</div>
|
||||
{/* P0: Flag button */}
|
||||
{state?.questions && state.questions.length > 0 && (
|
||||
<button
|
||||
onClick={toggleFlag}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-lg text-xs font-bold transition-all',
|
||||
flaggedQuestions.has(currentIndex)
|
||||
? 'bg-amber-50 text-amber-600 border border-amber-200'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
)}
|
||||
>
|
||||
{flaggedQuestions.has(currentIndex) ? '🏷️ 已标记' : '🏷️ 标记'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -983,7 +1031,23 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
<div className="flex flex-col h-full bg-white animate-in flex-1">
|
||||
{renderHeader()}
|
||||
|
||||
{showCertModal && certData && createPortal(
|
||||
{showSubmitConfirm && createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-3xl p-8 w-full max-w-sm shadow-2xl border border-white/20 text-center">
|
||||
<div className="w-14 h-14 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle size={28} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-slate-900 mb-2">提交答案确认</h3>
|
||||
<p className="text-sm text-slate-500 mb-6">你已完成部分题目,确定要提交全部答案吗?已答题目将无法修改。</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowSubmitConfirm(false)} className="flex-1 py-3 bg-white border border-slate-200 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-50 transition-all">继续答题</button>
|
||||
<button onClick={async () => { setShowSubmitConfirm(false); await handleSubmitAnswer(); }} className="flex-1 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg">确认提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{showCertModal && certData && createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => setShowCertModal(false)} />
|
||||
<div className="relative bg-white rounded-3xl shadow-2xl max-w-lg w-full p-8 max-h-[80vh] overflow-y-auto">
|
||||
|
||||
Reference in New Issue
Block a user