M3: console.log -> Logger + UI redesign (QuestionBank) + S7/A9/A10/A11/U11 bug fixes + #1/#2/#3/#4 enhancements + i18n for QuestionBank pages
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, BookOpen, ChevronRight, Trash2, Edit2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOpen, FileText, Layers, Loader2, Plus, Search, Trash2, Edit2, AlertCircle, Check, Clock, XCircle } from 'lucide-react';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { templateService } from '../../services/templateService';
|
||||
import { questionBankService } from '../../services/questionBankService';
|
||||
import { AssessmentTemplate } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface QuestionBankViewProps {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface QuestionBank {
|
||||
interface QuestionBankItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -19,25 +22,27 @@ interface QuestionBank {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
type StatusFilter = 'ALL' | 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED';
|
||||
|
||||
export default function QuestionBankView({ isAdmin: _isAdmin }: QuestionBankViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [banks, setBanks] = useState<QuestionBank[]>([]);
|
||||
const { t } = useLanguage();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const [banks, setBanks] = useState<QuestionBankItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
templateId: ''
|
||||
});
|
||||
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);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -47,7 +52,8 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
const data = await res.json();
|
||||
setBanks(Array.isArray(data) ? data : (data.data || []));
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载失败');
|
||||
setError(err.message || t('actionFailed'));
|
||||
showError(err.message || t('actionFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +64,7 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
setLoadingTemplates(true);
|
||||
templateService.getAll()
|
||||
.then(data => setTemplates(data))
|
||||
.catch(err => console.error('加载模板失败:', err))
|
||||
.catch(() => showError(t('actionFailed')))
|
||||
.finally(() => setLoadingTemplates(false));
|
||||
setShowDrawer(true);
|
||||
};
|
||||
@@ -66,17 +72,10 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
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 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' },
|
||||
@@ -88,12 +87,11 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
setShowDrawer(false);
|
||||
showSuccess(t('questionBankCreated'));
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('创建失败:', err);
|
||||
alert('创建失败: ' + (err.message || '未知错误'));
|
||||
showError(err.message || t('actionFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -101,182 +99,280 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`确定要删除题库"${bankName}"吗?此操作不可恢复。`)) return;
|
||||
|
||||
const ok = await confirm({ message: t('confirmDeleteBank').replace('$1', bankName), confirmLabel: t('delete'), cancelLabel: t('cancel') });
|
||||
if (!ok) return;
|
||||
setDeletingId(bankId);
|
||||
try {
|
||||
await questionBankService.deleteBank(bankId);
|
||||
const res = await apiClient.request(`/question-banks/${bankId}`, { method: 'DELETE' });
|
||||
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);
|
||||
}
|
||||
showSuccess(t('confirm'));
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('删除失败:', err);
|
||||
alert('删除失败: ' + (err.message || '未知错误'));
|
||||
showError(err.message || t('questionBankDeleteFailed'));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (bank: QuestionBank) => {
|
||||
const handleCardClick = (bank: QuestionBankItem) => {
|
||||
navigate(`/question-banks/${bank.id}`);
|
||||
};
|
||||
|
||||
const filteredBanks = useMemo(() => {
|
||||
let result = banks;
|
||||
if (statusFilter !== 'ALL') result = result.filter(b => b.status === statusFilter);
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(b => b.name.toLowerCase().includes(q) || (b.description || '').toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [banks, statusFilter, searchQuery]);
|
||||
|
||||
const STATUS_TABS: { key: StatusFilter; label: string; icon: React.ReactNode; count: (b: QuestionBankItem[]) => number }[] = [
|
||||
{ key: 'ALL', label: t('all'), icon: <Layers size={14} />, count: (b) => b.length },
|
||||
{ key: 'PUBLISHED', label: t('published'), icon: <Check size={14} />, count: (b) => b.filter(i => i.status === 'PUBLISHED').length },
|
||||
{ key: 'DRAFT', label: t('draft'), icon: <FileText size={14} />, count: (b) => b.filter(i => i.status === 'DRAFT').length },
|
||||
{ key: 'PENDING_REVIEW', label: t('pendingReview'), icon: <Clock size={14} />, count: (b) => b.filter(i => i.status === 'PENDING_REVIEW').length },
|
||||
];
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: banks.length,
|
||||
published: banks.filter(b => b.status === 'PUBLISHED').length,
|
||||
draft: banks.filter(b => b.status === 'DRAFT').length,
|
||||
pending: banks.filter(b => b.status === 'PENDING_REVIEW').length,
|
||||
}), [banks]);
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PUBLISHED: t('published'),
|
||||
PENDING_REVIEW: t('pendingReview'),
|
||||
REJECTED: t('rejected'),
|
||||
DRAFT: t('draft'),
|
||||
};
|
||||
|
||||
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
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900">{t('questionBankManagement')}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">{t('questionBankManagementDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openDrawer}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-600/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>创建题库</span>
|
||||
{t('createQuestionBank')}
|
||||
</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>
|
||||
{!loading && !error && banks.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: t('totalBanks'), value: stats.total, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <Layers size={16} className="text-slate-500" /> },
|
||||
{ label: t('published'), value: stats.published, color: 'bg-emerald-50 border-emerald-200/50 text-emerald-700', icon: <Check size={16} className="text-emerald-500" /> },
|
||||
{ label: t('draft'), value: stats.draft, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <FileText size={16} className="text-slate-500" /> },
|
||||
{ label: t('pendingReview'), value: stats.pending, color: 'bg-amber-50 border-amber-200/50 text-amber-700', icon: <Clock size={16} className="text-amber-500" /> },
|
||||
].map((stat, i) => (
|
||||
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
||||
className={`${stat.color} rounded-2xl border p-4`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
|
||||
{stat.icon}
|
||||
</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 className="text-2xl font-black">{stat.value}</div>
|
||||
</motion.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>
|
||||
{!loading && !error && banks.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchQuestionBanksPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-slate-50 rounded-2xl p-1 border border-slate-200">
|
||||
{STATUS_TABS.map((tab) => {
|
||||
const active = statusFilter === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
active
|
||||
? 'bg-white text-slate-900 shadow-sm border border-slate-200/50'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<span className={`${active ? 'bg-slate-100 text-slate-600' : 'bg-white/50 text-slate-400'} px-1.5 py-0.5 rounded-lg text-[10px] font-black`}>
|
||||
{tab.count(banks)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
|
||||
<AlertCircle size={20} />
|
||||
<span className="text-sm font-bold">{t('actionFailed')}</span>
|
||||
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
|
||||
</div>
|
||||
) : banks.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||
<BookOpen size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-2">{t('noQuestionBanks')}</p>
|
||||
<p className="text-slate-300 text-xs mb-6">{t('createFirstBank')}</p>
|
||||
<button onClick={openDrawer} className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest hover:bg-blue-700 transition-all active:scale-[0.98] shadow-lg shadow-blue-600/20">
|
||||
<Plus size={18} /> {t('createQuestionBank')}
|
||||
</button>
|
||||
</div>
|
||||
) : filteredBanks.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||
<Search size={32} className="text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-400 font-bold text-xs uppercase tracking-widest">{t('noMatchingQuestionBanks')}</p>
|
||||
<p className="text-slate-300 text-xs mt-2">{t('tryChangingFilter')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredBanks.map((bank) => (
|
||||
<motion.div
|
||||
key={bank.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
onClick={() => handleCardClick(bank)}
|
||||
className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className={`absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl -mr-16 -mt-16 ${
|
||||
bank.status === 'PUBLISHED' ? 'bg-emerald-500/5' :
|
||||
bank.status === 'PENDING_REVIEW' ? 'bg-amber-500/5' :
|
||||
bank.status === 'REJECTED' ? 'bg-red-500/5' : 'bg-blue-500/5'
|
||||
}`} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-black text-base text-slate-900 pr-8 line-clamp-1">{bank.name}</h3>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute top-0 right-0">
|
||||
<button onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button onClick={(e) => handleDelete(e, bank.id, bank.name)} disabled={deletingId === bank.id}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all disabled:opacity-50" title={t('delete')}>
|
||||
{deletingId === bank.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{bank.description || t('noDescription')}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-50">
|
||||
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${
|
||||
bank.status === 'PUBLISHED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200/50' :
|
||||
bank.status === 'PENDING_REVIEW' ? 'bg-amber-50 text-amber-600 border-amber-200/50' :
|
||||
bank.status === 'REJECTED' ? 'bg-red-50 text-red-500 border-red-200/50' :
|
||||
'bg-slate-50 text-slate-500 border-slate-200/50'
|
||||
}`}>
|
||||
{statusLabels[bank.status] || bank.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">
|
||||
{new Date(bank.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showDrawer && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={() => setShowDrawer(false)} className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40" />
|
||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center"><Plus size={22} /></div>
|
||||
<h2 className="text-lg font-black text-slate-900">{t('createQuestionBank')}</h2>
|
||||
</div>
|
||||
<button onClick={() => setShowDrawer(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><XCircle size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form id="create-form" onSubmit={handleCreate} className="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">
|
||||
<BookOpen size={12} className="text-blue-500" /> {t('name')} <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-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={t('name')} required autoFocus />
|
||||
</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-blue-500" /> {t('description')}
|
||||
</label>
|
||||
<input type="text" value={formData.description} onChange={(e) => setFormData({ ...formData, description: 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={t('description')} />
|
||||
</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">
|
||||
<Layers size={12} className="text-blue-500" /> {t('linkTemplate')}
|
||||
</label>
|
||||
<select value={formData.templateId} onChange={(e) => setFormData({ ...formData, templateId: 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 cursor-pointer"
|
||||
disabled={loadingTemplates}>
|
||||
<option value="">{t('noTemplate')}</option>
|
||||
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{loadingTemplates && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Loader2 size={12} className="animate-spin text-slate-400" />
|
||||
<span className="text-[10px] text-slate-400 font-medium">{t('loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 border-t border-slate-100">
|
||||
<button type="submit" form="create-form" disabled={saving || !formData.name.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-blue-600 text-white font-black uppercase tracking-widest text-xs rounded-[1.25rem] hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-blue-100">
|
||||
{saving ? <Loader2 size={18} className="animate-spin" /> : <Plus size={18} />}
|
||||
{saving ? t('creating') : t('createQuestionBank')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user