feat: end-to-end choice question support in assessment pipeline

- Data pathway: flow options through questions, answerKey in graph state
- Interviewer: format MULTIPLE_CHOICE with A/B/C/D options
- Grader: instant choice scoring (zero LLM), compare correctAnswer
- AssessmentView: render choice buttons vs textarea based on questionType
- Security: sanitizeStateForClient strips correctAnswer/judgment/answerKey
- Bank detection: check PUBLISHED items (not PUBLISHED bank status)
- Batch UI: select all / batch approve / batch reject on detail view
This commit is contained in:
Developer
2026-05-21 10:06:33 +08:00
parent 57898f939c
commit 3993099907
7 changed files with 228 additions and 24 deletions
+62 -2
View File
@@ -51,6 +51,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
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 isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout;
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -232,10 +233,18 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
};
const handleSubmitAnswer = async () => {
if (!session || !inputValue.trim() || isLoading || isTimedOut) return;
const currentQuestion = state?.questions?.[state.currentQuestionIndex || 0] as any;
const isChoice = currentQuestion?.questionType === 'MULTIPLE_CHOICE' && currentQuestion?.options?.length > 0;
const answer = inputValue.trim();
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...');
@@ -507,6 +516,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
!(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];
@@ -586,6 +599,52 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{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"
)}
>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-xl text-xs font-black mr-3 shrink-0 border-2 border-current">
{letter}
</span>
{opt}
</button>
);
})}
</div>
<button
onClick={handleSubmitAnswer}
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}
@@ -614,6 +673,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
</button>
</div>
)}
</div>
</div>
@@ -76,6 +76,48 @@ export default function QuestionBankDetailView() {
const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
const [generating, setGenerating] = useState(false);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const selectableItems = items.filter(i => i.status === 'PENDING_REVIEW');
const allSelected = selectableItems.length > 0 && selectableItems.every(i => selectedItemIds.has(i.id));
const toggleSelectAll = () => {
if (allSelected) {
setSelectedItemIds(new Set());
} else {
setSelectedItemIds(new Set(selectableItems.map(i => i.id)));
}
};
const toggleSelectItem = (itemId: string) => {
setSelectedItemIds(prev => {
const next = new Set(prev);
if (next.has(itemId)) next.delete(itemId); else next.add(itemId);
return next;
});
};
const handleBatchApprove = async () => {
const ids = Array.from(selectedItemIds);
if (ids.length === 0) return;
try {
await questionBankService.batchReviewItems(bankId, ids, true);
showSuccess(`已通过 ${ids.length} 道题目`);
setSelectedItemIds(new Set());
fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleBatchReject = async () => {
const ids = Array.from(selectedItemIds);
if (ids.length === 0) return;
try {
await questionBankService.batchReviewItems(bankId, ids, false);
showSuccess(`已驳回 ${ids.length} 道题目`);
setSelectedItemIds(new Set());
fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
@@ -304,10 +346,28 @@ export default function QuestionBankDetailView() {
<div className="flex items-center justify-between">
<h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }}
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
<Plus size={16} /> {t('addQuestion')}
</button>
<div className="flex items-center gap-2">
{selectedItemIds.size > 0 && (
<>
<button onClick={handleBatchApprove}
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
<Check size={14} /> ({selectedItemIds.size})
</button>
<button onClick={handleBatchReject}
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-red-100 hover:bg-red-600 transition-all active:scale-95">
<X size={14} />
</button>
</>
)}
<button onClick={toggleSelectAll}
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 hover:bg-slate-200 transition-all">
{allSelected ? '取消全选' : '全选'}
</button>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }}
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
<Plus size={16} /> {t('addQuestion')}
</button>
</div>
</div>
{items.length === 0 ? (
@@ -327,6 +387,11 @@ export default function QuestionBankDetailView() {
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
<div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
<div className="relative z-10 flex items-start justify-between">
{item.status === 'PENDING_REVIEW' && (
<input type="checkbox" checked={selectedItemIds.has(item.id)}
onChange={() => toggleSelectItem(item.id)}
className="mt-1.5 mr-3 w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 shrink-0 cursor-pointer" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span>