Files
aurak/web/components/views/QuestionBankView.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

282 lines
11 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 { useNavigate } from 'react-router-dom';
import { Plus, BookOpen, ChevronRight, Trash2, Edit2 } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { templateService } from '../../services/templateService';
import { questionBankService } from '../../services/questionBankService';
import { AssessmentTemplate } from '../../types';
interface QuestionBankViewProps {
isAdmin?: boolean;
}
interface QuestionBank {
id: string;
name: string;
description?: string;
status: string;
templateId?: string;
createdAt: string;
}
export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const navigate = useNavigate();
const [banks, setBanks] = useState<QuestionBank[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showDrawer, setShowDrawer] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
templateId: ''
});
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const res = await apiClient.request('/question-banks', {});
if (!res.ok) throw new Error(res.status.toString());
const data = await res.json();
setBanks(Array.isArray(data) ? data : (data.data || []));
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
};
const openDrawer = () => {
setFormData({ name: '', description: '', templateId: '' });
setLoadingTemplates(true);
templateService.getAll()
.then(data => setTemplates(data))
.catch(err => console.error('加载模板失败:', err))
.finally(() => setLoadingTemplates(false));
setShowDrawer(true);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
setSaving(true);
try {
const payload: any = {
name: formData.name,
description: formData.description,
};
if (formData.templateId) {
payload.templateId = formData.templateId;
}
const res = await apiClient.request('/question-banks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
let msg = res.status.toString();
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
throw new Error(msg);
}
setShowDrawer(false);
fetchData();
} catch (err: any) {
console.error('创建失败:', err);
alert('创建失败: ' + (err.message || '未知错误'));
} finally {
setSaving(false);
}
};
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
e.stopPropagation();
if (!confirm(`确定要删除题库"${bankName}"吗?此操作不可恢复。`)) return;
setDeletingId(bankId);
try {
await questionBankService.deleteBank(bankId);
fetchData();
} catch (err: any) {
console.error('删除失败:', err);
alert('删除失败: ' + (err.message || '未知错误'));
} finally {
setDeletingId(null);
}
};
const handleCardClick = (bank: QuestionBank) => {
navigate(`/question-banks/${bank.id}`);
};
return (
<div className="p-6 bg-white min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<button
onClick={openDrawer}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={18} />
<span></span>
</button>
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : error ? (
<div className="text-center py-8 text-red-500">: {error}</div>
) : banks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{banks.map((bank) => (
<div
key={bank.id}
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer group relative"
onClick={() => handleCardClick(bank)}
>
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
className="p-1.5 text-gray-400 hover:text-blue-600 rounded-md bg-white border shadow-sm"
title="编辑"
>
<Edit2 size={14} />
</button>
<button
onClick={(e) => handleDelete(e, bank.id, bank.name)}
disabled={deletingId === bank.id}
className="p-1.5 text-gray-400 hover:text-red-600 rounded-md bg-white border shadow-sm disabled:opacity-50"
title="删除"
>
{deletingId === bank.id ? (
<span className="w-3.5 h-3.5 border-2 border-red-500 border-t-transparent rounded-full animate-spin block"></span>
) : (
<Trash2 size={14} />
)}
</button>
</div>
<h3 className="font-semibold pr-16">{bank.name}</h3>
<p className="text-sm text-gray-500 mt-1">{bank.description || '暂无描述'}</p>
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<span className={`text-xs px-2 py-0.5 rounded-full ${
bank.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' :
bank.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-700' :
bank.status === 'REJECTED' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{bank.status === 'PUBLISHED' ? '已发布' :
bank.status === 'PENDING_REVIEW' ? '待审核' :
bank.status === 'REJECTED' ? '已否决' : '草稿'}
</span>
<span className="text-xs text-gray-400">
{new Date(bank.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
{/* Drawer */}
<>
{showDrawer && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={() => setShowDrawer(false)}
/>
)}
<div
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${showDrawer ? '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 flex items-center gap-2">
<Plus className="w-6 h-6 text-blue-600" />
</h2>
<button
onClick={() => setShowDrawer(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: 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="输入题库名称"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({...formData, description: 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>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<select
value={formData.templateId}
onChange={(e) => setFormData({...formData, templateId: 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"
disabled={loadingTemplates}
>
<option value=""></option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTemplates && <span className="text-xs text-slate-500">...</span>}
</div>
</form>
</div>
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="create-form"
disabled={saving || !formData.name.trim()}
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 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
>
<Plus size={20} />
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</>
</div>
);
}