Files
aurak/web/components/views/QuestionBankDetailView.tsx
T
Developer b2c17e3eca feat: 题库管理功能完善
- QuestionBankView: 添加删除按钮、卡片点击跳转详情页
- QuestionBankDetailView: 新建题库详情页(题目CRUD/AI生成/审核)
- questionBankService: 添加generateQuestions方法
- index.tsx: 添加详情页路由
2026-05-13 21:51:33 +08:00

547 lines
21 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 { useParams, useNavigate } from 'react-router-dom';
import {
ChevronLeft, Plus, Sparkles, Send, Check, X,
Trash2, Edit2, FileText
} 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: '工作能力' },
];
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 {
const payload = {
...itemForm,
keyPoints: keyPointsInput.split('\n').filter(k => k.trim()),
};
await questionBankService.createItem(bankId, payload);
setShowAddItem(false);
setItemForm({
questionText: '',
questionType: 'SHORT_ANSWER',
keyPoints: [],
difficulty: 'STANDARD',
dimension: 'WORK_CAPABILITY',
});
setKeyPointsInput('');
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 {
const payload = {
...itemForm,
keyPoints: keyPointsInput.split('\n').filter(k => k.trim()),
};
await questionBankService.updateItem(bankId, editingItem.id, payload);
setEditingItem(null);
setItemForm({
questionText: '',
questionType: 'SHORT_ANSWER',
keyPoints: [],
difficulty: 'STANDARD',
dimension: 'WORK_CAPABILITY',
});
setKeyPointsInput('');
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'));
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'PUBLISHED':
return <span className="px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-700"></span>;
case 'PENDING_REVIEW':
return <span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700"></span>;
default:
return <span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">稿</span>;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</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="text-red-500">: {error}</div>
</div>
);
}
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
const publishedItems = items.filter(i => i.status === 'PUBLISHED');
return (
<div className="p-6 bg-white min-h-screen">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6">
<ChevronLeft size={20} />
</button>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">{bank?.name}</h1>
<p className="text-gray-500 mt-1">{bank?.description || '暂无描述'}</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm text-gray-500">
: {templates.find(t => t.id === bank?.templateId)?.name || '未关联'}
</span>
{getStatusBadge(bank?.status || 'DRAFT')}
</div>
</div>
<div className="flex gap-2">
{bank?.status === 'DRAFT' && (
<button
onClick={handleSubmitForReview}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600"
>
<Send size={18} />
</button>
)}
{bank?.status === 'PENDING_REVIEW' && (
<button
onClick={handlePublish}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Check size={18} />
</button>
)}
<button
onClick={() => setShowGenerate(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Sparkles size={18} /> AI生成
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-blue-50 rounded-lg p-4">
<div className="text-2xl font-bold text-blue-600">{items.length}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="bg-yellow-50 rounded-lg p-4">
<div className="text-2xl font-bold text-yellow-600">{pendingItems.length}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="text-2xl font-bold text-green-600">{publishedItems.length}</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); }}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={18} />
</button>
</div>
{items.length === 0 ? (
<div className="text-center py-12 text-gray-500 border-2 border-dashed rounded-lg">
<FileText size={48} className="mx-auto mb-4 text-gray-300" />
<p>使AI生成</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{QUESTION_TYPES.find(t => t.value === item.questionType)?.label}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-600">
{DIFFICULTIES.find(d => d.value === item.difficulty)?.label}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-purple-100 text-purple-600">
{DIMENSIONS.find(d => d.value === item.dimension)?.label}
</span>
{getStatusBadge(item.status)}
</div>
<p className="font-medium">{item.questionText}</p>
{item.keyPoints.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
<span className="font-medium"></span>
{item.keyPoints.map((kp, i) => (
<span key={i} className="mr-2"> {kp}</span>
))}
</div>
)}
{item.basis && (
<div className="mt-2 text-xs text-gray-500">
<span className="font-medium"></span>{item.basis}
</div>
)}
</div>
<div className="flex gap-1 ml-4">
{item.status === 'PENDING_REVIEW' && (
<button
onClick={() => handleApproveItem(item.id)}
className="p-1.5 text-green-600 hover:bg-green-50 rounded"
title="通过"
>
<Check size={16} />
</button>
)}
<button
onClick={() => openEditItem(item)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="编辑"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{showAddItem && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40" onClick={() => { setShowAddItem(false); setEditingItem(null); }} />
)}
<div className={`fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-2xl z-50 transform transition-transform duration-300 ${showAddItem || editingItem ? 'translate-x-0' : 'translate-x-full'}`}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800">
{editingItem ? '编辑题目' : '添加题目'}
</h2>
<button onClick={() => { setShowAddItem(false); setEditingItem(null); }} className="p-2 text-slate-400 hover:text-slate-600 rounded-full">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> *</label>
<textarea
value={itemForm.questionText}
onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入题目内容"
rows={3}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<select
value={itemForm.questionType}
onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
>
{QUESTION_TYPES.map(t => (<option key={t.value} value={t.value}>{t.label}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<select
value={itemForm.difficulty}
onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
>
{DIFFICULTIES.map(d => (<option key={d.value} value={d.value}>{d.label}</option>))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<select
value={itemForm.dimension}
onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
>
{DIMENSIONS.map(d => (<option key={d.value} value={d.value}>{d.label}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<textarea
value={keyPointsInput}
onChange={(e) => setKeyPointsInput(e.target.value)}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="要点1&#10;要点2&#10;要点3"
rows={4}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input
type="text"
value={itemForm.basis || ''}
onChange={(e) => setItemForm({...itemForm, basis: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="可选"
/>
</div>
</form>
</div>
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="item-form"
disabled={saving}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 disabled:opacity-50"
>
{saving ? '保存中...' : (editingItem ? '更新' : '添加')}
</button>
</div>
</div>
</div>
{showGenerate && (
<>
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40" onClick={() => setShowGenerate(false)} />
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Sparkles className="text-purple-600" size={20} />
AI生成题目
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input
type="number"
value={generateForm.count}
onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
min={1}
max={20}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<textarea
value={generateForm.knowledgeBaseContent}
onChange={(e) => setGenerateForm({...generateForm, knowledgeBaseContent: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入知识库内容作为生成依据..."
rows={4}
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowGenerate(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{generating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
...
</>
) : (
<>
<Sparkles size={18} />
</>
)}
</button>
</div>
</div>
</div>
</>
)}
</div>
);
}