Some checks failed
CI / build-deploy (push) Has been cancelled
- 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、代码分割
181 lines
7.0 KiB
TypeScript
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"
|