2026-09-01 17:03:41 +08:00

280 lines
11 KiB
TypeScript

'use client'
import Link from 'next/link'
import {
BadgeCheck,
BellRing,
Blocks,
BookOpen,
Bot,
BriefcaseBusiness,
Building2,
ChartColumn,
ChartNoAxesCombined,
ChevronDown,
ClipboardCheck,
Database,
Gauge,
GaugeCircle,
LibraryBig,
Network,
Settings2,
ShieldCheck,
SlidersHorizontal,
TrendingUp,
UserRound,
UsersRound,
Workflow,
X,
type LucideIcon,
} from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import DepartmentAccountMenu from './DepartmentAccountMenu'
import DepartmentHeader from './DepartmentHeader'
import { WORKSPACE_SECTIONS, type WorkspaceSectionKey } from './workspace-navigation'
const SECTION_ICONS: Record<WorkspaceSectionKey, LucideIcon> = {
dashboard: Gauge,
business: BriefcaseBusiness,
assets: Bot,
organization: UsersRound,
governance: Settings2,
}
const ITEM_ICONS: Record<string, LucideIcon> = {
'core-metrics': GaugeCircle,
'business-trends': TrendingUp,
'team-capacity': UsersRound,
'brand-building': BadgeCheck,
'performance-conversion': ChartNoAxesCombined,
'material-library': LibraryBig,
'brand-knowledge': BookOpen,
'automation-workflows': Workflow,
'value-metrics': ChartColumn,
'people-structure': Network,
'team-functions': Blocks,
'performance-review': ClipboardCheck,
'roles-permissions': ShieldCheck,
'user-permissions': UserRound,
'data-definitions': Database,
'editing-capabilities': SlidersHorizontal,
notifications: BellRing,
}
function readPathname() {
return typeof window === 'undefined' ? '/' : window.location.pathname
}
function getPageTitle(pathname: string) {
if (pathname === '/') return '部门总览'
for (const section of WORKSPACE_SECTIONS) {
const item = section.items.find((candidate) => pathname.startsWith(candidate.href))
if (item) return item.title
}
return '品牌中心部门看板'
}
export default function DepartmentShell({ children }: { children: ReactNode }) {
const [pathname, setPathname] = useState('/')
const [navigationCollapsed, setNavigationCollapsed] = useState(false)
const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(false)
const navigationTriggerRef = useRef<HTMLButtonElement>(null)
const mobileDialogRef = useRef<HTMLDivElement>(null)
const contentRootRef = useRef<HTMLDivElement>(null)
const activeSection = WORKSPACE_SECTIONS.find((section) => section.items.some((item) => pathname.startsWith(item.href)))
const [expandedSections, setExpandedSections] = useState<WorkspaceSectionKey[]>(activeSection ? [activeSection.key] : ['dashboard'])
useEffect(() => {
setPathname(readPathname())
}, [])
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const media = window.matchMedia('(max-width: 1023px)')
const syncViewport = () => {
setIsMobileViewport(media.matches)
if (!media.matches) setMobileNavigationOpen(false)
}
syncViewport()
media.addEventListener('change', syncViewport)
return () => media.removeEventListener('change', syncViewport)
}, [])
useEffect(() => {
if (!mobileNavigationOpen) return
const dialog = mobileDialogRef.current
const contentRoot = contentRootRef.current
const navigationTrigger = navigationTriggerRef.current
const previousOverflow = document.body.style.overflow
contentRoot?.setAttribute('inert', '')
document.body.style.overflow = 'hidden'
const focusableSelector = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
const focusables = dialog ? Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)) : []
;(dialog?.querySelector<HTMLElement>('[aria-current="page"]') || focusables[0])?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
setMobileNavigationOpen(false)
return
}
if (event.key !== 'Tab' || focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = previousOverflow
contentRoot?.removeAttribute('inert')
navigationTrigger?.focus()
}
}, [mobileNavigationOpen])
useEffect(() => {
if (activeSection && !expandedSections.includes(activeSection.key)) {
setExpandedSections((current) => [...current, activeSection.key])
}
}, [activeSection, expandedSections])
const toggleSection = (key: WorkspaceSectionKey) => {
setExpandedSections((current) => current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key])
}
const toggleNavigation = () => {
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 1023px)').matches) {
setMobileNavigationOpen((current) => !current)
} else {
setNavigationCollapsed((current) => !current)
}
}
const sidebar = (
<aside aria-label="品牌中心主导航" className="flex h-full min-h-0 flex-col bg-[#06192d] text-white">
<div className={`flex h-[72px] shrink-0 items-center border-b border-white/10 ${navigationCollapsed ? 'justify-center px-3' : 'gap-3 px-5'}`}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#1677ff] text-white">
<Building2 className="h-5 w-5" aria-hidden="true" />
</span>
{!navigationCollapsed && <span className="truncate text-lg font-bold tracking-[-0.02em]">品牌中心</span>}
<button
type="button"
aria-label="关闭部门导航"
onClick={() => setMobileNavigationOpen(false)}
className="ml-auto flex h-9 w-9 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] lg:hidden"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<nav aria-label="部门工作区" className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{WORKSPACE_SECTIONS.map((section) => {
const Icon = SECTION_ICONS[section.key]
const expanded = expandedSections.includes(section.key)
const sectionActive = activeSection?.key === section.key
return (
<div key={section.key}>
<button
type="button"
aria-expanded={expanded}
aria-controls={`department-nav-${section.key}`}
title={navigationCollapsed ? section.title : undefined}
onClick={() => navigationCollapsed ? setNavigationCollapsed(false) : toggleSection(section.key)}
className={`flex min-h-11 w-full items-center rounded-lg text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] ${navigationCollapsed ? 'justify-center px-2' : 'gap-3 px-3'} ${sectionActive ? 'text-white' : 'text-[#aebdcd] hover:bg-white/[0.08] hover:text-white'}`}
>
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{!navigationCollapsed && (
<>
<span className="min-w-0 flex-1 truncate">{section.title}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`} aria-hidden="true" />
</>
)}
</button>
{!navigationCollapsed && expanded && (
<div id={`department-nav-${section.key}`} className="ml-5 mt-1 space-y-1 border-l border-white/10 pl-3">
{section.items.map((item) => {
const active = pathname.startsWith(item.href)
const ItemIcon = ITEM_ICONS[item.key] || Icon
return (
<Link
key={item.key}
href={item.href}
onClick={() => setMobileNavigationOpen(false)}
aria-current={active ? 'page' : undefined}
className={`flex min-h-10 items-center gap-2.5 rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] ${active ? 'bg-[#1677ff] font-semibold text-white' : 'text-[#9fb0c2] hover:bg-white/[0.08] hover:text-white'}`}
>
<ItemIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.status === 'connected' && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#31d0aa]" aria-label="已接入" />}
</Link>
)
})}
</div>
)}
</div>
)
})}
</div>
</nav>
<div className={`shrink-0 border-t border-white/10 py-3 ${navigationCollapsed ? 'px-3' : 'px-4'}`}>
<DepartmentAccountMenu
collapsed={navigationCollapsed}
onNavigate={() => setMobileNavigationOpen(false)}
/>
</div>
</aside>
)
return (
<div className="min-h-screen bg-[#f2f4f7] text-[#101828]">
<div id="department-desktop-navigation" className={`fixed inset-y-0 left-0 z-40 hidden transition-[width] duration-200 lg:block ${navigationCollapsed ? 'w-20' : 'w-[272px]'}`}>
{sidebar}
</div>
{mobileNavigationOpen && (
<>
<div aria-hidden="true" className="fixed inset-0 z-40 bg-[#06192d]/55 backdrop-blur-[2px] lg:hidden" onClick={() => setMobileNavigationOpen(false)} />
<div
ref={mobileDialogRef}
id="department-mobile-navigation"
role="dialog"
aria-modal="true"
aria-label="品牌中心移动导航"
className="fixed inset-y-0 left-0 z-50 w-[min(86vw,304px)] lg:hidden"
>
{sidebar}
</div>
</>
)}
<div ref={contentRootRef} className={`min-h-screen transition-[padding] duration-200 ${navigationCollapsed ? 'lg:pl-20' : 'lg:pl-[272px]'}`}>
<DepartmentHeader
ref={navigationTriggerRef}
currentTitle={getPageTitle(pathname)}
navigationCollapsed={navigationCollapsed}
navigationExpanded={isMobileViewport ? mobileNavigationOpen : !navigationCollapsed}
navigationControls={isMobileViewport ? 'department-mobile-navigation' : 'department-desktop-navigation'}
onToggleNavigation={toggleNavigation}
/>
{children}
</div>
</div>
)
}