288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import {
|
|
BadgeCheck,
|
|
BellRing,
|
|
Blocks,
|
|
BookOpen,
|
|
Bot,
|
|
BriefcaseBusiness,
|
|
Building2,
|
|
ChartColumn,
|
|
ChartNoAxesCombined,
|
|
ChevronDown,
|
|
ClipboardCheck,
|
|
Database,
|
|
Eye,
|
|
Gauge,
|
|
GaugeCircle,
|
|
HeartPulse,
|
|
LibraryBig,
|
|
Network,
|
|
Palette,
|
|
Rocket,
|
|
Settings2,
|
|
ShieldCheck,
|
|
SlidersHorizontal,
|
|
TrendingUp,
|
|
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,
|
|
'brand-health': HeartPulse,
|
|
'team-capacity': UsersRound,
|
|
'management-focus': Eye,
|
|
'product-launch': Rocket,
|
|
'brand-building': BadgeCheck,
|
|
'creative-production': Palette,
|
|
'performance-conversion': ChartNoAxesCombined,
|
|
'project-collaboration': Network,
|
|
'material-library': LibraryBig,
|
|
'brand-knowledge': BookOpen,
|
|
'automation-workflows': Workflow,
|
|
'value-metrics': ChartColumn,
|
|
'people-structure': Network,
|
|
'team-functions': Blocks,
|
|
'performance-review': ClipboardCheck,
|
|
'people-workload': UsersRound,
|
|
'roles-permissions': ShieldCheck,
|
|
'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>
|
|
)
|
|
}
|