Files
aurak/web/components/views/QuestionBankDetailView.tsx
T
Developer 0b0a060967 fix: 全部TS错误修复(25->0) + 证书API 500修复 + i18n缺失key补全 + 类型定义修正
- 证书API 500修复: AssessmentCertificate实体注册到app.module.ts
- 前端TS错误25个清零: i18n key 17个, 类型定义8个
- i18n补全: 17个缺失key添加到zh/en/ja
- KnowledgeFile类型: 添加title, content字段
- importService: 改用apiClient.request替代raw fetch
- ModeSelector: 移除jsx prop
- questionBankService: .ok -> .status >= 400
- NotebookDetailView: .filter -> .items.filter
- ImportTasksDrawer: tasks.items提取
- API端点审计: 16/16通过
- 数据库Schema审计: 25表288列一致
- AGENTS.md更新
2026-05-18 08:30:59 +08:00

396 lines
25 KiB
TypeScript
Raw 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 } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronLeft, Plus, Sparkles, Send, Check, X,
Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
AlertCircle, Hash, Layers
} from 'lucide-react';
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
import { templateService } from '../../services/templateService';
import { AssessmentTemplate } from '../../types';
const QUESTION_TYPES = [
{ value: 'SHORT_ANSWER', label: '简答题' },
{ value: 'MULTIPLE_CHOICE', label: '选择题' },
{ value: 'TRUE_FALSE', label: '判断题' },
];
const DIFFICULTIES = [
{ value: 'STANDARD', label: '标准' },
{ value: 'ADVANCED', label: '高级' },
{ value: 'SPECIALIST', label: '专家' },
];
const DIMENSIONS = [
{ value: 'PROMPT', label: 'Prompt' },
{ value: 'LLM', label: 'LLM' },
{ value: 'IDE', label: 'IDE' },
{ value: 'DEV_PATTERN', label: '开发模式' },
{ value: 'WORK_CAPABILITY', label: '工作能力' },
];
const typeIcons: Record<string, React.ReactNode> = {
SHORT_ANSWER: <FileText size={12} />,
MULTIPLE_CHOICE: <Layers size={12} />,
TRUE_FALSE: <Check size={12} />,
};
export default function QuestionBankDetailView() {
const { id: bankId } = useParams<{ id: string }>();
const navigate = useNavigate();
if (!bankId) {
return (
<div className="p-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4">
<ChevronLeft size={20} />
</button>
<div className="text-red-500">ID</div>
</div>
);
}
const [bank, setBank] = useState<QuestionBank | null>(null);
const [items, setItems] = useState<QuestionBankItem[]>([]);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [showAddItem, setShowAddItem] = useState(false);
const [showGenerate, setShowGenerate] = useState(false);
const [editingItem, setEditingItem] = useState<QuestionBankItem | null>(null);
const [itemForm, setItemForm] = useState<CreateQuestionBankItemDto>({
questionText: '',
questionType: 'SHORT_ANSWER',
keyPoints: [],
difficulty: 'STANDARD',
dimension: 'WORK_CAPABILITY',
});
const [keyPointsInput, setKeyPointsInput] = useState('');
const [generateForm, setGenerateForm] = useState({
count: 5,
knowledgeBaseContent: '',
});
const [generating, setGenerating] = useState(false);
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
const fetchData = async () => {
try { setLoading(true);
const bankData = await questionBankService.getBank(bankId);
setBank(bankData);
const itemsData = await questionBankService.getBankItems(bankId);
setItems(itemsData);
} catch (err: any) { setError(err.message || '加载失败');
} finally { setLoading(false); }
};
const fetchTemplates = async () => {
try { const data = await templateService.getAll(); setTemplates(data);
} catch (err) { console.error('加载模板失败:', err); }
};
const handleCreateItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!itemForm.questionText.trim()) return;
setSaving(true);
try {
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
fetchData();
} catch (err: any) { alert('创建失败: ' + (err.message || '未知错误'));
} finally { setSaving(false); }
};
const handleUpdateItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingItem || !itemForm.questionText.trim()) return;
setSaving(true);
try {
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
fetchData();
} catch (err: any) { alert('更新失败: ' + (err.message || '未知错误'));
} finally { setSaving(false); }
};
const handleDeleteItem = async (itemId: string) => {
if (!confirm('确定要删除这道题目吗?')) return;
try { await questionBankService.deleteItem(bankId, itemId); fetchData();
} catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); }
};
const handleGenerate = async () => {
setGenerating(true);
try {
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
setShowGenerate(false);
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
fetchData();
} catch (err: any) { alert('生成失败: ' + (err.message || '未知错误'));
} finally { setGenerating(false); }
};
const handleSubmitForReview = async () => {
if (!confirm('确定要提交审核吗?')) return;
try { await questionBankService.submitForReview(bankId); fetchData();
} catch (err: any) { alert('提交失败: ' + (err.message || '未知错误')); }
};
const handlePublish = async () => {
if (!confirm('确定要发布题库吗?')) return;
try { await questionBankService.publishBank(bankId); fetchData();
} catch (err: any) { alert('发布失败: ' + (err.message || '未知错误')); }
};
const handleApproveItem = async (itemId: string) => {
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); fetchData();
} catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); }
};
const openEditItem = (item: QuestionBankItem) => {
setEditingItem(item);
setItemForm({ questionText: item.questionText, questionType: item.questionType, options: item.options || [], keyPoints: item.keyPoints, difficulty: item.difficulty, dimension: item.dimension });
setKeyPointsInput(item.keyPoints.join('\n'));
setShowAddItem(true);
};
const closeItemForm = () => { setShowAddItem(false); setEditingItem(null); };
const getStatusBadge = (status: string) => {
switch (status) {
case 'PUBLISHED': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200/50"></span>;
case 'PENDING_REVIEW': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-amber-50 text-amber-600 border border-amber-200/50"></span>;
default: return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-slate-50 text-slate-500 border border-slate-200/50">稿</span>;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-30" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"><ChevronLeft size={20} /> </button>
<div className="flex items-center gap-2 text-red-500 bg-red-50 rounded-2xl p-4 border border-red-100"><AlertCircle size={18} /><span className="text-sm font-bold">{error}</span></div>
</div>
);
}
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
const publishedItems = items.filter(i => i.status === 'PUBLISHED');
return (
<div className="space-y-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-2">
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest"></span>
</button>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm"><BookOpen size={28} /></div>
<div>
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
<p className="text-sm text-slate-500 mt-1">{bank?.description || '暂无描述'}</p>
<div className="flex items-center gap-3 mt-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><Brain size={12} className="text-blue-500" />{templates.find(t => t.id === bank?.templateId)?.name || '未关联模板'}</span>
{getStatusBadge(bank?.status || 'DRAFT')}
</div>
</div>
</div>
<div className="flex gap-2">
{bank?.status === 'DRAFT' && (
<button onClick={handleSubmitForReview} className="px-5 py-3 bg-amber-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-amber-100 hover:bg-amber-600 transition-all active:scale-95">
<Send size={16} />
</button>
)}
{bank?.status === 'PENDING_REVIEW' && (
<button onClick={handlePublish} className="px-5 py-3 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={16} />
</button>
)}
<button onClick={() => setShowGenerate(true)} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
<Sparkles size={16} /> AI生成
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ label: '总题目数', value: items.length, color: 'blue', icon: <FileText size={16} /> },
{ label: '待审核', value: pendingItems.length, color: 'amber', icon: <Send size={16} /> },
{ label: '已发布', value: publishedItems.length, color: 'emerald', icon: <Check size={16} /> },
].map((stat, i) => (
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}
className={`bg-${stat.color}-50/50 border border-${stat.color}-100/50 rounded-2xl p-4`}>
<div className="flex items-center justify-between mb-2">
<span className={`text-[10px] font-black uppercase tracking-widest text-${stat.color}-500`}>{stat.label}</span>
<span className={`text-${stat.color}-500`}>{stat.icon}</span>
</div>
<div className={`text-3xl font-black text-${stat.color}-700`}>{stat.value}</div>
</motion.div>
))}
</div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-black text-slate-900"></h2>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: '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} />
</button>
</div>
{items.length === 0 ? (
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
<FileText className="w-14 h-14 text-slate-200 mx-auto mb-4" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs"></p>
<p className="text-slate-300 text-xs mt-2">使AI生成</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
<AnimatePresence mode="popLayout">
{items.map((item, idx) => (
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ delay: idx * 0.03 }}
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-32 h-32 bg-blue-500/5 rounded-full blur-3xl -mr-16 -mt-16" />
<div className="flex items-start justify-between relative z-10">
<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]}{QUESTION_TYPES.find(t => t.value === item.questionType)?.label}</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{DIFFICULTIES.find(d => d.value === item.difficulty)?.label}</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{DIMENSIONS.find(d => d.value === item.dimension)?.label}</span>
{getStatusBadge(item.status)}
</div>
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
{item.keyPoints.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">:</span>
{item.keyPoints.map((kp, i) => <span key={i} className="px-2.5 py-1 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-lg border border-amber-100/50">{kp}</span>)}
</div>
)}
{item.basis && <div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium"></span><span>{item.basis}</span></div>}
</div>
<div className="flex gap-1 ml-4 shrink-0">
{item.status === 'PENDING_REVIEW' && <button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title="通过"><Check size={15} /></button>}
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title="编辑"><Edit2 size={15} /></button>
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-xl transition-all" title="删除"><Trash2 size={15} /></button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{createPortal(
<AnimatePresence>
{(showAddItem || editingItem) && (
<div key="question-item-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={closeItemForm} className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" />
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
<h3 className="text-xl font-black text-slate-900">{editingItem ? '编辑题目' : '添加题目'}</h3>
</div>
<button onClick={closeItemForm} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
</div>
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> *</label>
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})}
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入题目内容" rows={3} required />
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> </label>
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})}
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
{QUESTION_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-blue-500" /> </label>
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})}
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> </label>
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})}
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
{DIMENSIONS.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> </label>
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)}
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="要点1
要点2
要点3" rows={4} />
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"></button>
<button type="submit" form="item-form" disabled={saving}
className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">
{saving && <Loader2 size={16} className="animate-spin" />}{saving ? '保存中...' : (editingItem ? '更新' : '添加')}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
{createPortal(
<AnimatePresence>
{showGenerate && (
<div key="generate-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowGenerate(false)} className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" />
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
<h3 className="text-xl font-black text-slate-900">AI生成题目</h3>
</div>
<button onClick={() => setShowGenerate(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="p-8 space-y-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-purple-500" /> </label>
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})}
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-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-purple-500" /> </label>
<textarea value={generateForm.knowledgeBaseContent} onChange={(e) => setGenerateForm({...generateForm, knowledgeBaseContent: e.target.value})}
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-purple-500/10 focus:border-purple-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入知识库内容作为生成依据..." rows={4} />
</div>
<div className="flex gap-3 pt-4">
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"></button>
<button onClick={handleGenerate} disabled={generating}
className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
{generating ? <><Loader2 size={16} className="animate-spin" /> ...</> : <><Sparkles size={16} /> </>}</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}