347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* Sidebar - Operator workflow navigation
|
||
* ======================================
|
||
* Nothing.tech 視覺憲法:
|
||
* - 純白背景 (bg-white)
|
||
* - 極細右邊框 (border-r-[0.5px] border-neutral-200)
|
||
* - 無陰影
|
||
* - 單色圖示
|
||
*
|
||
* IA v2 (2026-06-04):
|
||
* - 頂層按照 operator 工作流排序,不按照技術模組排序。
|
||
* - AwoooP / Runs / Work Items / Approvals / Alerts 直接成為全域入口。
|
||
* - 頁內 tabs 只保留同一頁的視角切換,避免把任務入口藏在第二層。
|
||
*
|
||
* Phase 19: 使用 Z_INDEX.SIDEBAR (40)
|
||
* @see lib/constants/z-index.ts
|
||
*/
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { Z_INDEX } from '@/lib/constants/z-index'
|
||
// Phase 8.0 #15: SSE 驅動的待簽核數量
|
||
import { useTranslations } from 'next-intl'
|
||
import { usePathname } from 'next/navigation'
|
||
import Link from 'next/link'
|
||
import { cn } from '@/lib/utils'
|
||
import {
|
||
Activity,
|
||
Bell,
|
||
BookOpen,
|
||
BrainCircuit,
|
||
Building2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ClipboardList,
|
||
DollarSign,
|
||
FileText,
|
||
GitBranch,
|
||
HelpCircle,
|
||
LayoutDashboard,
|
||
Monitor,
|
||
Package,
|
||
Radar,
|
||
Rocket,
|
||
Route,
|
||
Settings,
|
||
ShieldCheck,
|
||
Terminal,
|
||
Ticket,
|
||
Wrench,
|
||
} from 'lucide-react'
|
||
// Phase 8.0 #15: 改用 approval store SSE (移除 polling)
|
||
import { useApprovalStore } from '@/stores/approval.store'
|
||
|
||
// =============================================================================
|
||
// Types
|
||
// =============================================================================
|
||
|
||
interface SidebarProps {
|
||
locale: string
|
||
collapsed?: boolean
|
||
compact?: boolean
|
||
onToggle?: () => void
|
||
className?: string
|
||
}
|
||
|
||
type NavItemConfig = {
|
||
id: string
|
||
href: string
|
||
labelKey: string
|
||
Icon: typeof LayoutDashboard
|
||
aliases?: string[]
|
||
exact?: boolean
|
||
badge?: boolean // 是否顯示動態徽章
|
||
}
|
||
|
||
type NavSection = {
|
||
sectionKey: string
|
||
items: NavItemConfig[]
|
||
}
|
||
|
||
// ============================================================
|
||
// 2026-06-04: Operator-first IA
|
||
// ============================================================
|
||
// 參考 Material / Atlassian / Carbon 的 app shell 模式:
|
||
// 側欄放高頻任務與產品導航,Header 留給搜尋與全域工具。
|
||
// ============================================================
|
||
|
||
const NAV_SECTIONS: NavSection[] = [
|
||
{
|
||
sectionKey: 'queues',
|
||
items: [
|
||
{ id: 'command-center', href: '/', labelKey: 'commandCenter', Icon: LayoutDashboard },
|
||
{ id: 'delivery', href: '/delivery', labelKey: 'delivery', Icon: Rocket },
|
||
{ id: 'awooop-home', href: '/awooop', labelKey: 'awooopHome', Icon: BrainCircuit, exact: true },
|
||
{ id: 'awooop-work-items', href: '/awooop/work-items', labelKey: 'workItems', Icon: ClipboardList },
|
||
{ id: 'awooop-runs', href: '/awooop/runs', labelKey: 'runMonitor', Icon: Activity },
|
||
{ id: 'awooop-approvals', href: '/awooop/approvals', labelKey: 'approvalQueue', Icon: ShieldCheck, badge: true },
|
||
{ id: 'awooop-contracts', href: '/awooop/contracts', labelKey: 'contracts', Icon: FileText },
|
||
{ id: 'awooop-tenants', href: '/awooop/tenants', labelKey: 'tenants', Icon: Building2 },
|
||
{ id: 'alerts', href: '/alerts', labelKey: 'alerts', Icon: Bell },
|
||
],
|
||
},
|
||
{
|
||
sectionKey: 'truth',
|
||
items: [
|
||
{ id: 'observability', href: '/observability', labelKey: 'observability', Icon: Monitor },
|
||
{ id: 'automation', href: '/automation', labelKey: 'automation', Icon: Wrench },
|
||
{ id: 'governance', href: '/governance', labelKey: 'governance', Icon: ShieldCheck },
|
||
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge', Icon: BookOpen },
|
||
{ id: 'iwooos-security', href: '/iwooos', labelKey: 'iwooos', Icon: Radar, aliases: ['/security-compliance'] },
|
||
],
|
||
},
|
||
{
|
||
sectionKey: 'ops',
|
||
items: [
|
||
{ id: 'operations', href: '/operations', labelKey: 'operationsOverview', Icon: Package },
|
||
{ id: 'topology', href: '/topology', labelKey: 'topology', Icon: Route },
|
||
{ id: 'deployments', href: '/deployments', labelKey: 'deployments', Icon: GitBranch },
|
||
{ id: 'tickets', href: '/tickets', labelKey: 'tickets', Icon: Ticket },
|
||
{ id: 'cost', href: '/cost', labelKey: 'cost', Icon: DollarSign },
|
||
],
|
||
},
|
||
{
|
||
// Legacy: 經典 AI 中心 (既有決策保留)
|
||
sectionKey: 'legacy',
|
||
items: [
|
||
{ id: 'terminal', href: '/terminal', labelKey: 'terminal', Icon: Terminal },
|
||
{ id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings },
|
||
{ id: 'classic', href: '/classic', labelKey: 'classicAICenter', Icon: LayoutDashboard },
|
||
],
|
||
},
|
||
]
|
||
|
||
const BOTTOM_NAV_ITEMS: NavItemConfig[] = [
|
||
{ id: 'help', href: '/help', labelKey: 'help', Icon: HelpCircle },
|
||
]
|
||
|
||
// =============================================================================
|
||
// Component
|
||
// =============================================================================
|
||
|
||
export function Sidebar({
|
||
locale,
|
||
collapsed = false,
|
||
compact = false,
|
||
onToggle,
|
||
className,
|
||
}: SidebarProps) {
|
||
const t = useTranslations('nav')
|
||
const tSection = useTranslations('navSection')
|
||
const tSidebar = useTranslations('sidebar')
|
||
const pathname = usePathname()
|
||
|
||
// Phase 8.0 #15: 改用 SSE 驅動的 pending count (移除 30s polling)
|
||
// 防止 SSR hydration mismatch: 只在 client 顯示 badge
|
||
const [mounted, setMounted] = useState(false)
|
||
const pendingApprovals = useApprovalStore(state => state.pendingApprovals)
|
||
const pendingCount = pendingApprovals.length
|
||
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
}, [])
|
||
|
||
const isRouteActive = (href: string, exact = false) => {
|
||
const fullHref = `/${locale}${href === '/' ? '' : href}`
|
||
if (href === '/') {
|
||
return pathname === `/${locale}` || pathname === `/${locale}/`
|
||
}
|
||
if (exact) {
|
||
return pathname === fullHref || pathname === `${fullHref}/`
|
||
}
|
||
return pathname === fullHref || pathname.startsWith(fullHref + '/')
|
||
}
|
||
|
||
const isActive = (item: NavItemConfig) => (
|
||
isRouteActive(item.href, item.exact) || item.aliases?.some(alias => isRouteActive(alias)) === true
|
||
)
|
||
const sidebarWidth = compact && collapsed ? 48 : collapsed ? 64 : 224
|
||
|
||
return (
|
||
<aside
|
||
className={cn(
|
||
'fixed left-0 flex flex-col',
|
||
className
|
||
)}
|
||
style={{
|
||
top: 68,
|
||
bottom: 0,
|
||
zIndex: Z_INDEX.SIDEBAR,
|
||
background: '#faf9f3',
|
||
borderRight: '0.5px solid #e0ddd4',
|
||
width: sidebarWidth,
|
||
transition: 'width 0.3s ease',
|
||
}}
|
||
>
|
||
|
||
{/* 導航列表 - Operator workflow sections */}
|
||
<nav className="flex-1 py-2 overflow-y-auto">
|
||
{NAV_SECTIONS.map(section => (
|
||
<div key={section.sectionKey} style={{ marginBottom: 4 }}>
|
||
{!collapsed && (
|
||
<div style={{
|
||
fontSize: 10,
|
||
color: '#b0ad9f',
|
||
letterSpacing: '1.5px',
|
||
textTransform: 'uppercase' as const,
|
||
padding: '12px 12px 3px',
|
||
fontFamily: 'var(--font-body), monospace',
|
||
borderTop: '0.5px solid #e0ddd4',
|
||
marginTop: 4,
|
||
}}>
|
||
{tSection(section.sectionKey)}
|
||
</div>
|
||
)}
|
||
{section.items.map(item => {
|
||
const active = isActive(item)
|
||
const count = item.badge && mounted ? pendingCount : 0
|
||
return (
|
||
<Link
|
||
key={item.id}
|
||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||
title={collapsed ? t(item.labelKey) : undefined}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: collapsed ? 0 : 8,
|
||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||
padding: collapsed ? '8px 0' : '6px 12px',
|
||
fontSize: 14,
|
||
color: active ? '#141413' : '#87867f',
|
||
fontWeight: active ? 600 : 400,
|
||
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
||
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
||
textDecoration: 'none',
|
||
transition: 'all 0.15s',
|
||
position: 'relative' as const,
|
||
minHeight: collapsed ? 36 : 32,
|
||
}}
|
||
>
|
||
<item.Icon size={15} aria-hidden="true" />
|
||
{!collapsed && (
|
||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
||
{t(item.labelKey)}
|
||
</span>
|
||
)}
|
||
{item.badge && count > 0 && (
|
||
<span style={{
|
||
marginLeft: 'auto',
|
||
fontSize: 12,
|
||
background: '#d97757',
|
||
color: 'white',
|
||
borderRadius: 10,
|
||
padding: '1px 5px',
|
||
fontWeight: 700,
|
||
}}>
|
||
{count > 99 ? '99+' : count}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
|
||
{/* Separator */}
|
||
<div style={{ borderTop: '0.5px solid #e0ddd4', margin: '6px 0' }} />
|
||
|
||
{/* 底部菜單 */}
|
||
{BOTTOM_NAV_ITEMS.map(item => {
|
||
const active = isActive(item)
|
||
return (
|
||
<Link
|
||
key={item.id}
|
||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||
title={collapsed ? t(item.labelKey) : undefined}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: collapsed ? 0 : 8,
|
||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||
padding: collapsed ? '7px 0' : '5px 12px',
|
||
fontSize: 13,
|
||
color: active ? '#141413' : '#87867f',
|
||
fontWeight: active ? 600 : 400,
|
||
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
||
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
||
textDecoration: 'none',
|
||
transition: 'all 0.15s',
|
||
minHeight: collapsed ? 34 : 30,
|
||
}}
|
||
>
|
||
<item.Icon size={15} aria-hidden="true" />
|
||
{!collapsed && (
|
||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
||
{t(item.labelKey)}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{/* 底部區域 - 版本號 */}
|
||
<div style={{
|
||
borderTop: '0.5px solid #e0ddd4',
|
||
padding: collapsed ? '8px 0' : '8px 12px',
|
||
textAlign: collapsed ? 'center' : 'left',
|
||
}}>
|
||
{!collapsed && (
|
||
<span style={{ fontSize: 10, color: '#b0ad9f', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
v1.0.0
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 折疊按鈕 */}
|
||
<button
|
||
onClick={onToggle}
|
||
style={{
|
||
position: 'absolute',
|
||
right: -12,
|
||
top: 80,
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: '50%',
|
||
background: '#faf9f3',
|
||
border: '0.5px solid #e0ddd4',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
|
||
>
|
||
{collapsed ? (
|
||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||
) : (
|
||
<ChevronLeft className="w-3 h-3 text-neutral-400" />
|
||
)}
|
||
</button>
|
||
</aside>
|
||
)
|
||
}
|