forked from hangshuo652/aurak
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user