Files
Developer 46a10ba091 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>
2026-06-09 14:57:32 +08:00

1150 lines
68 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Brain,
Send,
Loader2,
CheckCircle,
AlertCircle,
ChevronRight,
History,
ClipboardCheck,
RefreshCcw,
FileText,
Star,
Award,
Trophy,
Trash2,
XCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { assessmentService, AssessmentSession, AssessmentState } from '../../services/assessmentService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { templateService } from '../../services/templateService';
import { KnowledgeGroup, AssessmentTemplate } from '../../types';
import { cn } from '../../src/utils/cn';
interface AssessmentViewProps {
onLogout: () => void;
onNavigate: (path: string) => void;
isAdmin: boolean;
}
export const AssessmentView: React.FC<AssessmentViewProps> = ({
onLogout,
onNavigate,
isAdmin
}) => {
const { language, t } = useLanguage();
const { confirm } = useConfirm();
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [session, setSession] = useState<AssessmentSession | null>(null);
const [state, setState] = useState<AssessmentState | null>(null);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [processStep, setProcessStep] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<AssessmentSession[]>([]);
const [loadingHistoryId, setLoadingHistoryId] = useState<string | null>(null);
const [showBasis, setShowBasis] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null);
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
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);
useEffect(() => {
const fetchGroups = async () => {
try {
const data = await knowledgeGroupService.getGroups();
setGroups(data);
} catch (err) {
console.error('Failed to fetch groups:', err);
}
};
const fetchTemplates = async () => {
try {
const data = await templateService.getAll();
setTemplates(data);
} catch (err) {
console.error('Failed to fetch templates:', err);
}
};
fetchGroups();
fetchTemplates();
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [state?.messages, isLoading]);
const fetchHistory = async () => {
try {
const data = await assessmentService.getHistory();
setHistory(data);
} catch (err) {
console.error('Failed to fetch history:', err);
}
};
useEffect(() => {
fetchHistory();
}, []);
useEffect(() => {
if (!session || session.status !== 'IN_PROGRESS') {
setTimeCheck(null);
return;
}
const checkTime = async () => {
try {
const data = await assessmentService.checkTimeLimits(session.id);
setTimeCheck(data);
if (data.isTotalTimeout || data.isQuestionTimeout) {
setError(t('timeLimitExceeded'));
if (!autoSubmitted && !isLoading) {
setAutoSubmitted(true);
await handleSubmitAnswer(true);
}
}
} catch (err) {
console.error('Failed to check time:', err);
}
};
checkTime();
const interval = setInterval(checkTime, 10000);
return () => clearInterval(interval);
}, [session]);
const isZh = language === 'zh';
const isJa = language === 'ja';
const getStatusText = (node: string) => {
const mapping: Record<string, any> = {
generator: 'statusGeneratingQuestions',
grader: 'statusEvaluatingAnswer',
interviewer: 'statusPreparingQuestion',
analyzer: 'statusGeneratingReport',
};
return t(mapping[node]) || t('statusProcessing');
};
const handleSelectHistory = async (histSession: AssessmentSession) => {
if (isLoading) return;
setLoadingHistoryId(histSession.id);
setIsLoading(true);
setError(null);
try {
const histState = await assessmentService.getSessionState(histSession.id);
setState(histState);
setSession(histSession);
} catch (err: any) {
if (histSession.status === 'IN_PROGRESS') {
setError(t('cannotResumeInProgress'));
} else {
setError(err.message || 'Failed to load historical assessment');
}
} finally {
setIsLoading(false);
setLoadingHistoryId(null);
}
};
const handleDeleteHistory = async (e: React.MouseEvent, histId: string) => {
e.stopPropagation();
const confirmed = await confirm(t('confirmDeleteAssessment'));
if (!confirmed) return;
try {
await assessmentService.deleteSession(histId);
setHistory(prev => prev.filter(h => h.id !== histId));
if (session?.id === histId) {
setSession(null);
setState(null);
}
} catch (err: any) {
console.error('Failed to delete history:', err);
setError(t('deleteAssessmentFailed'));
}
};
const handleStartAssessment = async () => {
if (!selectedTemplate) return;
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在初始化...' : isJa ? '初期化中...' : 'Initializing...');
try {
const newSession = await assessmentService.startSession(selectedGroup || undefined, language, selectedTemplate || undefined);
setSession(newSession);
for await (const event of assessmentService.startSessionStream(newSession.id)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
if (event.data) {
setState(prev => {
if (!prev) return event.data;
const prevMessages = prev.messages || [];
return {
...prev,
...event.data,
messages: event.data.messages
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
: prevMessages,
feedbackHistory: event.data.feedbackHistory
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
: (prev.feedbackHistory || []),
scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
} as any;
});
}
} else if (event.type === 'final') {
setState(event.data);
}
}
} catch (err: any) {
setError(err.message || 'Failed to start assessment');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
const handleRetry = async () => {
if (!session) return;
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在重新尝试生成...' : isJa ? '再生成中...' : 'Retrying generation...');
try {
for await (const event of assessmentService.startSessionStream(session.id)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
} else if (event.type === 'final') {
setState(event.data);
}
}
} catch (err: any) {
setError(err.message || 'Retry failed');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
// 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;
if (!forced) {
if (isChoice) {
if (!selectedChoice || isLoading || isTimedOut) return;
} else {
if (!inputValue.trim() || isLoading || isTimedOut) return;
}
}
const answer = isChoice ? (selectedChoice || '') : inputValue.trim();
setInputValue('');
setSelectedChoice(null);
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
try {
setState(prev => ({
...prev!,
messages: [
...(prev?.messages || []),
{ role: 'user' as const, content: answer, timestamp: Date.now() }
]
}));
for await (const event of assessmentService.submitAnswerStream(session.id, answer, language)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
if (event.data) {
setState(prev => {
if (!prev) return event.data;
const prevMessages = prev.messages || [];
const mergedMessages = event.data.messages
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
: prevMessages;
return {
...prev,
...event.data,
messages: mergedMessages,
feedbackHistory: event.data.feedbackHistory
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
: (prev.feedbackHistory || []),
scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
} as any;
});
}
} else if (event.type === 'final') {
setState(event.data);
if (event.data.status === 'COMPLETED') {
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
fetchHistory();
} else if (event.data.currentQuestionIndex !== undefined) {
assessmentService.nextQuestion(session.id).catch(() => {});
}
}
}
} catch (err: any) {
setError(err.message || 'Failed to submit answer');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
const renderHeader = () => (
<div className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200">
<Brain size={22} className="text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 leading-tight">{t('assessmentTitle')}</h2>
<p className="text-xs text-slate-500 font-medium">{t('assessmentDesc')}</p>
</div>
</div>
<div className="flex items-center gap-3">
{session && (
<div className="px-3 py-1.5 bg-slate-100 rounded-full flex items-center gap-2">
<div className={cn(
"w-2 h-2 rounded-full animate-pulse",
session.status === 'IN_PROGRESS' ? "bg-green-500" : "bg-blue-500"
)} />
<span className="text-xs font-bold text-slate-600 uppercase tracking-wider">
{session.status === 'IN_PROGRESS' ? t('inProgress') : t('statusReadyFragment')}
</span>
</div>
)}
<button
onClick={() => {
setSession(null);
setState(null);
setSelectedGroup(null);
}}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all"
title={t('newChat')}
>
<RefreshCcw size={18} />
</button>
</div>
</div>
);
const renderSetup = () => (
<div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
{/* Main Setup Content */}
<div className="flex-1 overflow-y-auto p-8 flex flex-col items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-xl w-full"
>
<div className="bg-white rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 p-8">
<div className="text-center mb-8">
<div className="w-20 h-20 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner">
<Brain size={40} />
</div>
<h3 className="text-2xl font-black text-slate-900 mb-2">{t('readyForAssessment')}</h3>
<p className="text-slate-500 font-medium">{t('readyForAssessmentDesc')}</p>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 ml-1">
{t('assessmentTemplates')}
</label>
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
{templates.map(template => (
<button
key={template.id}
onClick={() => setSelectedTemplate(template.id)}
className={cn(
"w-full text-left px-4 py-3 rounded-xl border-2 transition-all flex items-center justify-between",
selectedTemplate === template.id
? "border-indigo-600 bg-indigo-50/50 text-indigo-700 shadow-sm"
: "border-slate-100 hover:border-slate-200 text-slate-500 hover:bg-slate-50"
)}
>
<div className="flex flex-col">
<span className="text-sm font-bold truncate max-w-[240px]">{template.name}</span>
<span className="text-[10px] opacity-40 font-mono mt-0.5 mb-1">{template.id}</span>
<span className="text-[10px] opacity-60 font-medium">
{template.questionCount} {t('questionsCountLabel')} {
template.difficultyDistribution
? (typeof template.difficultyDistribution === 'object'
? Object.entries(template.difficultyDistribution).map(([k, v]) => `${k}:${v}`).join(', ')
: String(template.difficultyDistribution))
: ''
}
</span>
</div>
{selectedTemplate === template.id && <div className="w-1.5 h-1.5 bg-indigo-600 rounded-full" />}
</button>
))}
</div>
</div>
<button
onClick={handleStartAssessment}
disabled={!selectedTemplate || isLoading}
className={cn(
"w-full py-4 rounded-2xl font-black text-white transition-all transform hover:scale-[1.02] active:scale-[0.98] shadow-lg flex items-center justify-center gap-3",
!selectedTemplate || isLoading
? "bg-slate-300 shadow-none cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700 shadow-indigo-200"
)}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
<>
<ClipboardCheck size={20} />
<span>{t('startProfessionalEvaluation')}</span>
</>
)}
</button>
{error && (
<div className="mt-4 p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-start gap-3 animate-shake">
<AlertCircle size={20} className="text-rose-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-bold text-rose-700">{error}</p>
<button
onClick={handleRetry}
className="mt-1 text-xs font-bold text-rose-600 hover:text-rose-800 underline flex items-center gap-1"
>
<RefreshCcw size={12} />
{t('retry')}
</button>
</div>
</div>
)}
</div>
</div>
<div className="mt-8 flex gap-4 justify-center">
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
<CheckCircle size={14} className="text-emerald-500" />
{t('aiPoweredAnalysis')}
</div>
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
<CheckCircle size={14} className="text-emerald-500" />
{t('masteryScoring')}
</div>
</div>
</motion.div>
</div>
{/* Assessment History Sidebar */}
{history.length > 0 && (
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
<History size={18} className="text-indigo-600" />
{t('recentAssessments')}
</h3>
<div className="space-y-3 custom-scrollbar">
{history.map(hist => (
<div
key={hist.id}
className="w-full text-left p-4 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between group"
>
<div className="flex flex-col">
<span className="text-sm font-bold text-slate-800 truncate max-w-[180px]">
{hist.knowledgeBase?.name || hist.knowledgeGroup?.name || t('assessmentTitle')}
</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-black text-indigo-400 px-1.5 py-0.5 bg-indigo-50 rounded">
{hist.finalScore !== null && hist.finalScore !== undefined ? `${Math.round(hist.finalScore * 10) / 10}/10` : t('inProgress')}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
{new Date(hist.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleDeleteHistory(e, hist.id)}
className="w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center text-slate-400 hover:text-rose-600 hover:border-rose-100 transition-all opacity-0 group-hover:opacity-100"
title={t('delete')}
>
<Trash2 size={14} />
</button>
<button
type="button"
onClick={() => !isLoading && handleSelectHistory(hist)}
disabled={isLoading}
className={cn(
"w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center transition-all shrink-0",
isLoading ? "opacity-50 cursor-not-allowed" : "hover:bg-indigo-600 hover:text-white"
)}
title={t('view')}
>
{loadingHistoryId === hist.id ? (
<Loader2 size={14} className="animate-spin text-indigo-600 group-hover:text-white" />
) : (
<FileText size={14} />
)}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
const renderAssessment = () => {
const currentIndex = state?.currentQuestionIndex || 0;
const totalQuestions = state?.questions?.length || 0;
// 如果currentIndex已达到或超过题目数量,说明已完成
const displayNo = currentIndex >= totalQuestions ? totalQuestions : currentIndex + 1;
console.log('[AssessmentView] Counter:', { displayNo, totalQuestions, currentIndex });
const progressLabel = totalQuestions > 0
? t('questionProgress', displayNo, totalQuestions)
: t('initializingQuestion', displayNo);
const messages = state?.messages || [];
const filteredMessages = messages.filter(m =>
m.role !== 'system' &&
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
);
const currentQuestion = (state?.questions?.[state.currentQuestionIndex || 0] || {}) as any;
const isCurrentChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0;
const optionLabels = ['A', 'B', 'C', 'D'];
const feedbackHistory = state?.feedbackHistory || [];
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
const feedbackMatch = lastFeedbackMessage?.content?.toString().match(/(?:Score|得分): (\d+)\/10\n\n(?:Feedback|反馈): ([\s\S]*)/i);
const latestScore = feedbackMatch ? feedbackMatch[1] : null;
const latestFeedback = feedbackMatch ? feedbackMatch[2] : (lastFeedbackMessage?.content || null);
return (
<div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
{/* Left: Chat Area */}
<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-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" />
{processStep || t('aiIsProcessing')}
</span>
)}
{timeCheck && (
<div className={`flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full ${timeCheck.totalTimeRemaining < 60 || timeCheck.questionTimeRemaining < 30 ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-600'}`}>
<span></span>
<span>{Math.floor(timeCheck.totalTimeRemaining / 60)}:{String(timeCheck.totalTimeRemaining % 60).padStart(2, '0')}</span>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-8 custom-scrollbar">
<div className="max-w-3xl mx-auto space-y-8">
{filteredMessages.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: msg.role === 'user' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
className={cn(
"flex flex-col max-w-[85%]",
msg.role === 'user' ? "ml-auto items-end" : "mr-auto items-start"
)}
>
<div className={cn(
"px-5 py-4 rounded-2xl shadow-sm text-[15px] leading-relaxed",
msg.role === 'user'
? "bg-indigo-600 text-white rounded-tr-none"
: "bg-white text-slate-800 border border-slate-100 rounded-tl-none"
)}>
{msg.content}
</div>
<span className="mt-1.5 text-[10px] items-center uppercase tracking-widest font-bold text-slate-400">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</motion.div>
))}
{isLoading && (
<div className="flex items-start mr-auto max-w-[85%]">
<div className="px-5 py-4 bg-white border border-slate-100 rounded-2xl rounded-tl-none shadow-sm">
<div className="flex gap-1.5">
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
{isTimedOut && (
<div className="max-w-3xl mx-auto mb-3 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-bold rounded-xl text-center">
{t('timeLimitExceeded')}
</div>
)}
{isCurrentChoice ? (
<div className="max-w-3xl mx-auto space-y-3">
<div className="flex items-center gap-2 text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">
<span className="w-1 h-1 bg-indigo-400 rounded-full" />
</div>
<div className="grid gap-2">
{currentQuestion.options.map((opt: string, i: number) => {
const letter = optionLabels[i];
const isSelected = selectedChoice === letter;
return (
<button
key={letter}
onClick={() => !isTimedOut && setSelectedChoice(letter)}
disabled={isTimedOut}
className={cn(
"w-full text-left px-5 py-4 rounded-2xl border-2 transition-all text-sm font-medium",
isSelected
? "border-indigo-500 bg-indigo-50 text-indigo-700 shadow-md"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50",
isTimedOut && "opacity-50 cursor-not-allowed"
)}
>
{opt}
</button>
);
})}
</div>
<button
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",
!selectedChoice || isLoading || isTimedOut
? "bg-slate-300 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700 active:scale-[0.97]"
)}
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
<span className="text-sm"></span>
</button>
</div>
) : (
<div className="max-w-3xl mx-auto flex items-end gap-3">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !isTimedOut) {
e.preventDefault();
confirmAndSubmit();
}
}}
placeholder={isTimedOut ? t('timeLimitExceeded') : t('typeAnswerPlaceholder')}
disabled={isTimedOut}
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
rows={1}
/>
<button
onClick={handleSubmitAnswer}
disabled={!inputValue.trim() || isLoading || isTimedOut}
className={cn(
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
!inputValue.trim() || isLoading || isTimedOut
? "bg-slate-100 text-slate-400 shadow-none"
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
)}
>
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
</button>
</div>
)}
</div>
</div>
{/* Right: Feedback Panel */}
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-100">
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
<ClipboardCheck size={18} className="text-indigo-600" />
{t('liveFeedback')}
</h3>
{latestScore ? (
<div className="space-y-6">
<div className="bg-indigo-50/50 rounded-3xl p-6 text-center border border-indigo-100">
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-[0.2em] mb-2 block">{t('currentScore')}</span>
<div className="text-5xl font-black text-indigo-600">{latestScore}<span className="text-xl opacity-40">/10</span></div>
</div>
<div className="space-y-4">
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<History size={14} />
{t('aiExplanation')}
</h4>
<div className="text-sm text-slate-600 leading-relaxed font-medium bg-slate-50 rounded-2xl p-5 border border-slate-100">
{latestFeedback}
</div>
</div>
<div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100 flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-500 text-white rounded-lg flex items-center justify-center shrink-0">
<Star size={18} />
</div>
<div>
<div className="text-xs font-black text-emerald-800">{t('masteryProgress')}</div>
<div className="text-[10px] text-emerald-600 font-bold opacity-80">{t('trackedInRealTime')}</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-40 space-y-4">
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center">
<History size={32} className="text-slate-400" />
</div>
<div className="text-xs font-bold text-slate-500">
{t('submitAnswerToSeeFeedback')}
</div>
</div>
)}
{state?.questions && state.questions[state.currentQuestionIndex] && (
<div className="mt-6 pt-6 border-t border-slate-100">
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<ClipboardCheck size={14} />
{t('questionBasis')}
</div>
<button
onClick={() => setShowBasis(!showBasis)}
className="text-indigo-600 hover:text-indigo-800 lowercase tracking-normal font-bold"
>
{showBasis ? t('hideBasis') : t('viewBasis')}
</button>
</h4>
<AnimatePresence>
{showBasis && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="text-sm text-slate-600 leading-relaxed font-medium bg-indigo-50/30 rounded-2xl p-4 border border-indigo-100/50">
{state.questions[state.currentQuestionIndex].basis || "No basis provided."}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
<div className="mt-auto pt-6 border-t border-slate-100">
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-100 flex items-start gap-3">
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
<div className="text-[11px] text-amber-800 font-medium leading-relaxed">
<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>
);
};
const renderCompletion = () => (
<div className="flex-1 overflow-y-auto bg-[#F8FAFC] p-8 custom-scrollbar">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-4xl mx-auto space-y-8"
>
<div className="bg-white rounded-[40px] shadow-2xl shadow-indigo-100/50 border border-slate-100 overflow-hidden">
<div className="bg-indigo-600 p-10 text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32 blur-3xl" />
<div className="relative z-10 flex flex-col items-center text-center">
<div className="w-20 h-20 bg-white/20 backdrop-blur-md rounded-3xl flex items-center justify-center mb-6 border border-white/30 shadow-2xl">
<Trophy size={40} className="text-yellow-300" />
</div>
<h3 className="text-4xl font-black mb-2 tracking-tight">{t('level')} {state?.report?.match(/LEVEL:\s*(\w+)/i)?.[1] || 'Pending'}</h3>
<p className="text-indigo-100 font-bold uppercase tracking-[0.2em] text-sm opacity-80">{t('assessmentResultsAvailable')}</p>
</div>
</div>
<div className="p-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 -mt-20 relative z-20 mb-12">
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-amber-50 text-amber-600 rounded-xl flex items-center justify-center mb-3">
<Star size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('knowledgeCoverage')}</span>
<span className="text-2xl font-black text-slate-900">
{state?.questions && state.questions.length > 0
? `${Math.round((Object.keys(state.scores || {}).length / state.questions.length) * 100)}%`
: '0%'}
</span>
</div>
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mb-3">
<Award size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('precisionScore')}</span>
<span className="text-2xl font-black text-slate-900">
{state?.finalScore !== undefined ? (Math.round(state.finalScore * 10) / 10) : '0'}/10
</span>
</div>
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center mb-3">
<CheckCircle size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
<span className={cn(
"text-2xl font-black uppercase tracking-tighter",
state?.passed ? "text-emerald-600" : "text-rose-600"
)}>
{state?.passed ? t('verified') : t('fail')}
</span>
</div>
</div>
<div className="space-y-8">
{state?.questions && state.questions.length > 0 && (
<div>
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
<CheckCircle size={20} className="text-indigo-600" />
</h4>
<div className="space-y-4">
{state.questions.map((q: any, i: number) => {
const score = state.scores?.[q.id || (i + 1).toString()];
const isChoice = q.questionType === 'MULTIPLE_CHOICE';
const isCorrect = isChoice && q.correctAnswer && score >= 10;
return (
<div key={q.id || i} className="bg-white border border-slate-200 rounded-2xl p-5">
<div className="flex items-start gap-3">
<div className={cn(
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
isChoice
? (isCorrect ? "bg-emerald-100 text-emerald-600" : "bg-red-100 text-red-600")
: score !== undefined ? "bg-indigo-100 text-indigo-600" : "bg-slate-100 text-slate-400"
)}>
{isChoice
? (isCorrect ? <CheckCircle size={20} /> : <XCircle size={20} />)
: <span className="text-sm font-black">{score !== undefined ? score : '?'}</span>
}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-800 text-sm leading-relaxed">{q.questionText}</p>
{isChoice && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{q.options?.map((opt: string, oi: number) => {
const letter = String.fromCharCode(65 + oi);
const isAnswer = letter === q.correctAnswer;
return (
<span key={oi} className={cn(
"px-3 py-1 rounded-lg font-medium",
isAnswer ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-slate-50 text-slate-500"
)}>
{opt}
</span>
);
})}
</div>
)}
{q.judgment && (
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
<p className="text-xs text-slate-600 leading-relaxed">{q.judgment}</p>
</div>
)}
{!isChoice && score !== undefined && (
<span className="inline-block mt-2 text-xs text-slate-400">: {score}/10</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
<div>
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
<FileText size={20} className="text-indigo-600" />
{t('comprehensiveMasteryReport')}
</h4>
<div className="bg-slate-50 border border-slate-100 rounded-3xl p-8 text-slate-800 leading-relaxed font-medium assessment-report overflow-hidden whitespace-pre-wrap">
{state?.report}
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => {
setSession(null);
setState(null);
}}
className="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-black shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all active:scale-[0.98]"
>
{t('newAssessmentSession')}
</button>
<button
onClick={async () => {
if (!session) return;
try {
const result = await assessmentService.exportPdf(session.id);
const binary = atob(result.buffer);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
} catch (err) {
setError(t('exportAssessmentFailed'));
}
}}
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
>
{t('downloadPdfReport')}
</button>
<button
onClick={async () => {
if (!session) return;
try {
const result = await assessmentService.exportExcel(session.id);
const binary = atob(result.buffer);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
array[i] = binary.charCodeAt(i);
}
const blob = new Blob([array], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(t('exportAssessmentFailed'));
}
}}
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
>
{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;
try {
const cert = await assessmentService.getCertificate(session.id);
setCertData(cert);
setShowCertModal(true);
} catch (err) {
console.error('Failed to get certificate:', err);
}
}}
className="px-6 py-4 bg-amber-50 border-2 border-amber-200 text-amber-700 rounded-2xl font-bold hover:bg-amber-100 transition-all active:scale-[0.98]"
>
{t('viewCertificate')}
</button>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
return (
<div className="flex flex-col h-full bg-white animate-in flex-1">
{renderHeader()}
{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">
<button onClick={() => setShowCertModal(false)} className="absolute top-4 right-4 p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 5L15 15M15 5L5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
</button>
<div className="flex flex-col items-center text-center mb-6">
<Award size={40} className="text-indigo-600 mb-3" />
<h3 className="text-2xl font-black text-slate-900">{certData.level}</h3>
<p className="text-sm text-slate-500 font-medium mt-1">{certData.templateName || '-'}</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-slate-50 rounded-2xl p-4 text-center">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></span>
<p className="text-xl font-black text-slate-900 mt-1">{certData.totalScore}/10</p>
</div>
<div className="bg-slate-50 rounded-2xl p-4 text-center">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></span>
<p className={`text-xl font-black mt-1 ${certData.passed ? 'text-emerald-600' : 'text-rose-600'}`}>{certData.passed ? '合格' : '不合格'}</p>
</div>
</div>
{certData.dimensionScores && (
<div className="mb-6">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></span>
<div className="mt-2 space-y-1.5">
{Object.entries(certData.dimensionScores).map(([dim, score]: [string, any]) => (
<div key={dim} className="flex items-center justify-between text-sm">
<span className="font-medium text-slate-600">{dim}</span>
<span className="font-black text-slate-900">{score}/10</span>
</div>
))}
</div>
</div>
)}
{certData.questionDetails && (
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></span>
<div className="mt-2 space-y-1">
{certData.questionDetails.map((qd: any) => (
<div key={qd.index} className="text-xs text-slate-600 truncate">
<span className="font-bold text-slate-400">#{qd.index}</span> {qd.questionText}
</div>
))}
</div>
</div>
)}
</div>
</div>,
document.body
)}
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="absolute top-20 left-1/2 -translate-x-1/2 z-50 min-w-[320px] max-w-lg"
>
<div className="mx-6 p-4 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-3 shadow-xl">
<AlertCircle size={20} className="text-red-500 shrink-0" />
<p className="text-sm font-bold text-red-800 pr-2">{error}</p>
<button
onClick={() => setError(null)}
className="ml-auto p-1.5 text-red-400 hover:text-red-500 rounded-lg transition-colors"
>
<AlertCircle size={16} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{!session && renderSetup()}
{session && session.status === 'IN_PROGRESS' && renderAssessment()}
{session && session.status === 'COMPLETED' && renderCompletion()}
</div>
);
};