forked from hangshuo652/aurak
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:
@@ -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;
|
||||
Reference in New Issue
Block a user