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
@@ -32,6 +32,11 @@ export const AssessmentTemplateManager: React.FC = () => {
passingScore: 6,
totalTimeLimit: 1800,
perQuestionTimeLimit: 300,
attemptLimit: 1,
scheduledStart: '',
scheduledEnd: '',
reviewMode: 'none',
shuffleQuestions: true,
});
const [copiedId, setCopiedId] = useState<string | null>(null);
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
@@ -79,6 +84,11 @@ export const AssessmentTemplateManager: React.FC = () => {
passingScore: template.passingScore !== null && template.passingScore !== undefined ? template.passingScore / 10 : 6,
totalTimeLimit: template.totalTimeLimit ?? 1800,
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
attemptLimit: template.attemptLimit ?? 1,
scheduledStart: template.scheduledStart || '',
scheduledEnd: template.scheduledEnd || '',
reviewMode: template.reviewMode || 'none',
shuffleQuestions: template.shuffleQuestions ?? true,
});
setDimensions(template.dimensions || []);
} else {
@@ -124,6 +134,11 @@ export const AssessmentTemplateManager: React.FC = () => {
passingScore: formData.passingScore * 10,
totalTimeLimit: formData.totalTimeLimit,
perQuestionTimeLimit: formData.perQuestionTimeLimit,
attemptLimit: formData.attemptLimit,
scheduledStart: formData.scheduledStart || null,
scheduledEnd: formData.scheduledEnd || null,
reviewMode: formData.reviewMode,
shuffleQuestions: formData.shuffleQuestions,
};
if (editingTemplate) {
@@ -454,8 +469,77 @@ export const AssessmentTemplateManager: React.FC = () => {
</div>
</div>
{/* P2: Attempt limit, Review mode, Shuffle */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:col-span-2">
<div className="space-y-1.5">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<Hash size={12} className="text-indigo-500" />
</label>
<select
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.attemptLimit}
onChange={e => setFormData({ ...formData, attemptLimit: parseInt(e.target.value) })}
>
<option value={1}>1 </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={0}></option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<FileText size={12} className="text-indigo-500" />
</label>
<select
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.reviewMode}
onChange={e => setFormData({ ...formData, reviewMode: e.target.value })}
>
<option value="none"></option>
<option value="after_completion"></option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<Sliders size={12} className="text-indigo-500" />
</label>
<select
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.shuffleQuestions ? 'shuffle' : 'ordered'}
onChange={e => setFormData({ ...formData, shuffleQuestions: e.target.value === 'shuffle' })}
>
<option value="shuffle"></option>
<option value="ordered"></option>
</select>
</div>
</div>
{/* P2: Scheduled window */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:col-span-2">
<div className="space-y-1.5">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<FileText size={12} className="text-indigo-500" />
</label>
<input type="datetime-local"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.scheduledStart}
onChange={e => setFormData({ ...formData, scheduledStart: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<FileText size={12} className="text-indigo-500" />
</label>
<input type="datetime-local"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.scheduledEnd}
onChange={e => setFormData({ ...formData, scheduledEnd: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-wider px-1 ml-1 flex items-center gap-2">
<Sliders size={12} className="text-indigo-500" />
{t('templateDimensions')} *
</label>
+20
View File
@@ -1004,6 +1004,26 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
>
{t('exportExcel')}
</button>
{/* P2: Review button (visible when reviewMode enabled) */}
{state?.templateJson?.reviewMode && state.templateJson.reviewMode !== 'none' && (
<button
onClick={async () => {
if (!session) return;
try {
const reviewData = await assessmentService.getReview(session.id);
const reviewText = (reviewData.questions || []).map((q: any, i: number) =>
`${i + 1}题: ${(q.questionText || '').substring(0, 80)}\n 正确答案: ${q.correctAnswer || '见解析'}\n 解析: ${q.judgment || '无'}`
).join('\n\n');
alert(`📋 答题回顾\n\n${reviewText || '暂无回顾数据'}`);
} catch (err: any) {
setError(err.message || '获取回顾失败');
}
}}
className="px-6 py-4 bg-emerald-50 border-2 border-emerald-200 text-emerald-700 rounded-2xl font-bold hover:bg-emerald-100 transition-all active:scale-[0.98]"
>
📋
</button>
)}
<button
onClick={async () => {
if (!session) return;