Files
NextEdu/src/modules/layout/components/app-sidebar.tsx
SpecialX 125f7ec54c
Some checks failed
CI / build-deploy (push) Has been cancelled
refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验
- UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内
- 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过)
- 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007)
- 项目规则: 架构图优先规则,改码必同步图
- 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫
- 无障碍: skip-link、aria-label、prefers-reduced-motion
- 性能: next/font优化、next/image、代码分割
2026-06-16 23:38:33 +08:00

181 lines
7.0 KiB
TypeScript

"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronRight } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks"
import { Permissions, type Permission } from "@/shared/types/permissions"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
interface AppSidebarProps {
mode?: "mobile" | "desktop"
}
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const pathname = usePathname()
const { permissions, hasRole } = usePermission()
// Determine which role's nav config to use based on permissions
let currentRole: Role = "teacher"
if (permissions.includes(Permissions.SCHOOL_MANAGE)) {
currentRole = "admin"
} else if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
currentRole = "student"
} else if (hasRole("parent")) {
currentRole = "parent"
}
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
// Filter nav items by permission
const navItems = allNavItems.filter((item) => {
if (!item.permission) return true
return permissions.includes(item.permission as Permission)
}).map((item) => ({
...item,
items: item.items?.filter((subItem) => {
if (!subItem.permission) return true
return permissions.includes(subItem.permission as Permission)
}),
}))
// Ensure consistent state for hydration
if (!expanded && mode === 'mobile') return null
return (
<div className="flex h-full flex-col gap-4">
{/* Sidebar Header */}
<div className={cn("flex h-16 items-center border-b px-4 transition-all duration-300", !expanded && !isMobile ? "justify-center px-2" : "justify-between")}>
{expanded || isMobile ? (
<Link href="/" className="flex items-center gap-2 font-bold">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
NE
</div>
<span className="truncate text-lg">Next_Edu</span>
</Link>
) : (
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
NE
</div>
)}
</div>
{/* Navigation */}
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-2 py-4">
<TooltipProvider delayDuration={0}>
{navItems.map((item, index) => {
const isActive = pathname.startsWith(item.href)
const hasChildren = item.items && item.items.length > 0
if (!expanded && !isMobile) {
// Collapsed Mode (Icon Only + Tooltip)
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Link
href={item.href}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex size-10 items-center justify-center rounded-md transition-colors",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<item.icon className="size-5" />
<span className="sr-only">{item.title}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
)
}
// Expanded Mode
if (hasChildren) {
return (
<Collapsible key={index} defaultOpen={isActive} className="group/collapsible">
<CollapsibleTrigger asChild>
<button
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex w-full items-center justify-between rounded-md p-2 text-sm font-medium transition-colors",
isActive && "text-sidebar-accent-foreground"
)}
>
<div className="flex items-center gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</div>
<ChevronRight className="text-muted-foreground size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up overflow-hidden">
<div className="ml-6 mt-1 flex flex-col gap-1 border-l pl-2">
{item.items?.map((subItem, subIndex) => (
<Link
key={subIndex}
href={subItem.href}
className={cn(
"text-muted-foreground hover:text-foreground block rounded-md px-2 py-1 text-sm transition-colors",
pathname === subItem.href && "text-foreground font-medium"
)}
>
{subItem.title}
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<Link
key={index}
href={item.href}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex items-center gap-2 rounded-md p-2 text-sm font-medium transition-colors",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<item.icon className="size-4" />
<span>{item.title}</span>
</Link>
)
})}
</TooltipProvider>
</nav>
</ScrollArea>
{/* Sidebar Footer */}
<div className="p-4">
{!isMobile && (
<button
onClick={toggleSidebar}
className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors"
>
{expanded ? "Collapse" : <ChevronRight className="size-4" />}
</button>
)}
</div>
</div>
)
}
AppSidebar.displayName = "AppSidebar"