feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
MessageSquare,
Database,
Settings,
LogOut,
LayoutGrid,
LayoutTemplate,
Plus,
Menu,
BookOpen,
Library,
HardDrive,
Building2,
ChevronRight,
Bot,
Blocks,
ClipboardCheck,
BarChart3
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../../contexts/LanguageContext';
import { cn } from '../../utils/cn';
interface SidebarItemProps {
icon: React.ElementType;
label: string;
path: string;
isActive: boolean;
onClick: (path: string) => void;
badge?: number;
}
const SidebarItem = ({ icon: Icon, label, path, isActive, onClick, badge }: SidebarItemProps) => (
<button
onClick={() => onClick(path)}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group",
isActive
? "bg-blue-50 text-blue-700 font-semibold"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900 border border-transparent"
)}
>
<div className="flex items-center gap-3">
<Icon size={18} className={cn("transition-colors", isActive ? "text-blue-600" : "text-slate-400 group-hover:text-slate-600")} />
<span className="text-[14px]">{label}</span>
</div>
{badge !== undefined && badge > 0 && (
<span className={cn(
"px-2 py-0.5 text-[11px] font-bold rounded-full",
isActive ? "bg-blue-100 text-blue-700" : "bg-slate-100 text-slate-400"
)}>
{badge}
</span>
)}
</button>
);
const WorkspaceLayout: React.FC = () => {
const { user, logout, availableTenants, activeTenant, switchTenant } = useAuth();
const { t, language } = useLanguage();
const isZh = language === 'zh';
const navigate = useNavigate();
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [showTenantMenu, setShowTenantMenu] = useState(false);
const handleNavClick = (path: string) => {
navigate(path);
};
const buildTenantTree = (memberships: typeof availableTenants) => {
const map = new Map<string, any>();
const roots: any[] = [];
memberships.forEach(m => {
map.set(m.tenantId, { ...m, children: [] });
});
memberships.forEach(m => {
const node = map.get(m.tenantId)!;
const parentId = m.tenant.parentId;
if (parentId && map.has(parentId)) {
const parent = map.get(parentId)!;
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
const renderTenantItem = (membership: any, depth = 0) => (
<React.Fragment key={membership.tenantId}>
<button
onClick={() => {
switchTenant(membership.tenantId);
setShowTenantMenu(false);
}}
className={cn(
"w-full text-left px-4 py-3 rounded-xl flex items-center justify-between group transition-colors",
activeTenant?.tenantId === membership.tenantId
? "bg-blue-50 text-blue-700"
: "hover:bg-slate-50 text-slate-600"
)}
style={{ paddingLeft: `${depth * 1.5 + 1}rem` }}
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">{membership.tenant.name}</span>
<span className="text-[10px] font-medium opacity-60 uppercase tracking-tight">{membership.role}</span>
</div>
{activeTenant?.tenantId === membership.tenantId && (
<div className="w-1.5 h-1.5 bg-blue-600 rounded-full shrink-0" />
)}
</button>
{membership.children?.map((child: any) => renderTenantItem(child, depth + 1))}
</React.Fragment>
);
const tenantTree = buildTenantTree(availableTenants);
return (
<div className="flex h-screen bg-[#FCFDFF] text-slate-900 font-sans selection:bg-blue-100 selection:text-blue-900">
{/* Sidebar */}
<aside className={cn(
"bg-white border-r border-slate-200/60 flex flex-col transition-all duration-300 overflow-hidden",
isSidebarOpen ? "w-[260px]" : "w-0 border-r-0"
)}>
{/* Brand */}
<div className="h-16 flex items-center px-6 border-b border-slate-100/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200 text-white font-bold shrink-0">
<BookOpen size={18} />
</div>
<h1 className="text-xl font-bold tracking-tight text-slate-900">AuraK</h1>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-6 overflow-y-auto">
<div className="space-y-0.5">
<SidebarItem
icon={MessageSquare}
label={t('navChat')}
path="/chat"
isActive={location.pathname.startsWith('/chat')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Bot}
label={t('navAgent')}
path="/agents"
isActive={location.pathname.startsWith('/agents')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Blocks}
label={t('navPlugin')}
path="/plugins"
isActive={location.pathname.startsWith('/plugins')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Database}
label={t('navKnowledge')}
path="/knowledge"
isActive={location.pathname === '/knowledge' || location.pathname.startsWith('/knowledge/')}
onClick={handleNavClick}
/>
<SidebarItem
icon={BarChart3}
label={isZh ? '评估统计' : 'Assessment Stats'}
path="/assessment-stats"
isActive={location.pathname === '/assessment-stats'}
onClick={handleNavClick}
/>
{(activeTenant?.features?.isNotebookEnabled ?? true) && (
<SidebarItem
icon={BookOpen}
label={t('navNotebook')}
path="/notebook"
isActive={location.pathname.startsWith('/notebook')}
onClick={handleNavClick}
/>
)}
</div>
<div className="space-y-0.5">
<SidebarItem
icon={Settings}
label={t('tabSettings')}
path="/settings"
isActive={location.pathname.startsWith('/settings')}
onClick={handleNavClick}
/>
</div>
</nav>
<div className="p-4 border-t border-slate-100/80 mt-auto">
<button
onClick={logout}
className="flex items-center gap-2 px-3 py-2 text-[13px] font-medium text-red-500 hover:bg-red-50 w-full rounded-lg transition-colors"
>
<LogOut size={14} />
{t('logout')}
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Top Header */}
<header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
<div className="flex items-center gap-4 flex-1">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 text-slate-400 rounded-lg hover:bg-slate-100 transition-colors"
>
<Menu size={20} />
</button>
{/* Tenant Switcher */}
<div className="relative">
<button
onClick={() => setShowTenantMenu(!showTenantMenu)}
className="flex items-center gap-2 px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl hover:bg-slate-100 transition-all group"
>
<Building2 size={16} className="text-blue-600" />
<span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || t('selectOrganization')}</span>
<ChevronRight size={14} className={cn("text-slate-400 transition-transform", showTenantMenu ? "rotate-90" : "")} />
</button>
<AnimatePresence>
{showTenantMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowTenantMenu(false)} />
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute top-full mt-2 left-0 w-64 bg-white border border-slate-200 rounded-2xl shadow-xl z-50 overflow-hidden"
>
<div className="p-3 border-b border-slate-100">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2">{t('navTenants')}</span>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{tenantTree.map(membership => renderTenantItem(membership))}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 px-3 py-1.5 bg-slate-50/50 border border-slate-100 rounded-2xl">
<div className="flex items-center justify-center w-8 h-8 text-xs font-bold text-white bg-indigo-600 rounded-full shrink-0 shadow-sm">
{user?.displayName?.[0]?.toUpperCase() || user?.username?.[0]?.toUpperCase() || 'A'}
</div>
<div className="flex flex-col">
<p className="text-sm font-bold text-slate-900 truncate max-w-[120px]">
{user?.displayName || user?.username}
</p>
<span className="text-[9px] font-black text-blue-600 uppercase tracking-tighter">
{activeTenant?.role?.replace('_', ' ') || user?.role?.replace('_', ' ') || 'USER'}
</span>
</div>
</div>
</div>
</header>
{/* Page Content Rendered Here via Outlet */}
<div className="flex-1 bg-[#FCFDFF] overflow-hidden flex flex-col relative">
<div className="max-w-[1400px] w-full mx-auto h-full relative">
<Outlet />
</div>
</div>
</main>
</div>
);
};
export default WorkspaceLayout;