Files
awoooi/apps/web/src/components/layout/sidebar.tsx
OG T e17248fd10
Some checks failed
E2E Health Check / e2e-health (push) Successful in 16s
CD Pipeline / build-and-deploy (push) Has been cancelled
fix: 首席架構師審查修復 — i18n/CD/時區/死碼清理
P0 前端 i18n 合規 (6 檔案):
- settings/page.tsx: 全面改用 useTranslations('settings')
- auto-repair/page.tsx: 30+ 處硬編碼改用 t('autoRepair.*')
- sidebar.tsx: sectionLabel 改用 tSection(),aria-label 國際化
- openclaw-panel.tsx: STATUS_MESSAGES 改用 tPanel(),Production 改用 tBrand
- alerts/page.tsx: StatPill label 改用 t('incident.severity.*')

P1 CD Pipeline:
- cd.yaml: runs-on 改 self-hosted (ADR-039)
- Telegram Secret 注入失敗改為 exit 1 (ADR-035)
- kubectl patch op:replace → op:add (首次部署相容)

P2 後端:
- langfuse_client.py: 移除 v4.x 死碼分支 (SDK 鎖定 <3.0.0)
- ai.py: 標記 TODO(R4) Router 瘦身

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:02:41 +08:00

354 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* Sidebar - Phase 7.0 極簡五柱導航
* =================================
* Nothing.tech 視覺憲法:
* - 純白背景 (bg-white)
* - 極細右邊框 (border-r-[0.5px] border-neutral-200)
* - 無陰影
* - 單色圖示
*
* 5 大核心樞紐:
* 1. 全局戰情室 (/)
* 2. 授權中心 (/authorizations) - 含動態徽章
* 3. 行動日誌 (/action-logs)
* 4. 知識殿堂 (/knowledge-base)
* 5. 系統設定 (/settings)
*
* 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 {
LayoutDashboard, ShieldCheck, Bell, Monitor, Activity,
Bug, GitBranch, Shield, ClipboardCheck,
Wrench, Package, Ticket, DollarSign, Zap, FileText,
BookOpen, Terminal, AppWindow, Server,
Users, BellRing, CreditCard, HelpCircle, Settings,
ChevronLeft, ChevronRight,
} from 'lucide-react'
// Phase 8.0 #15: 改用 approval store SSE (移除 polling)
import { useApprovalStore } from '@/stores/approval.store'
// =============================================================================
// Types
// =============================================================================
interface SidebarProps {
locale: string
collapsed?: boolean
onToggle?: () => void
className?: string
}
type NavItemConfig = {
id: string
href: string
labelKey: string
Icon: typeof LayoutDashboard
badge?: boolean // 是否顯示動態徽章
}
type NavSection = {
sectionKey: string
sectionLabel: string
items: NavItemConfig[]
}
// ============================================================
// AI中心 v6 — 4分區 Sidebar統帥批准 2026-04-01
// ============================================================
const NAV_SECTIONS: NavSection[] = [
{
sectionKey: 'aiCore',
sectionLabel: '',
items: [
{ id: 'ai-center', href: '/', labelKey: 'dashboard', Icon: LayoutDashboard },
{ id: 'authorizations', href: '/authorizations', labelKey: 'approvals', Icon: ShieldCheck, badge: true },
{ id: 'alerts', href: '/alerts', labelKey: 'alerts', Icon: Bell },
],
},
{
sectionKey: 'monitoring',
sectionLabel: '',
items: [
{ id: 'monitoring', href: '/monitoring', labelKey: 'monitoring', Icon: Monitor },
{ id: 'apm', href: '/apm', labelKey: 'apm', Icon: Activity },
{ id: 'errors', href: '/errors', labelKey: 'errors', Icon: Bug },
{ id: 'topology', href: '/topology', labelKey: 'topology', Icon: GitBranch },
{ id: 'security', href: '/security', labelKey: 'security', Icon: Shield },
{ id: 'compliance', href: '/compliance', labelKey: 'compliance', Icon: ClipboardCheck },
],
},
{
sectionKey: 'ops',
sectionLabel: '',
items: [
{ id: 'auto-repair', href: '/auto-repair', labelKey: 'autoRepair', Icon: Wrench },
{ id: 'deployments', href: '/deployments', labelKey: 'deployments', Icon: Package },
{ id: 'tickets', href: '/tickets', labelKey: 'tickets', Icon: Ticket },
{ id: 'cost', href: '/cost', labelKey: 'cost', Icon: DollarSign },
{ id: 'action-logs', href: '/action-logs', labelKey: 'actions', Icon: Zap },
{ id: 'reports', href: '/reports', labelKey: 'reports', Icon: FileText },
],
},
{
sectionKey: 'knowledge',
sectionLabel: '',
items: [
{ id: 'knowledge-base', href: '/knowledge-base', labelKey: 'knowledge', Icon: BookOpen },
{ id: 'terminal', href: '/terminal', labelKey: 'terminal', Icon: Terminal },
{ id: 'apps', href: '/apps', labelKey: 'apps', Icon: AppWindow },
{ id: 'services', href: '/services', labelKey: 'services', Icon: Server },
],
},
]
const BOTTOM_NAV_ITEMS: NavItemConfig[] = [
{ id: 'users', href: '/users', labelKey: 'users', Icon: Users },
{ id: 'notifications', href: '/notifications', labelKey: 'notifications', Icon: BellRing },
{ id: 'billing', href: '/billing', labelKey: 'billing', Icon: CreditCard },
{ id: 'help', href: '/help', labelKey: 'help', Icon: HelpCircle },
{ id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings },
]
// =============================================================================
// Component
// =============================================================================
export function Sidebar({
locale,
collapsed = false,
onToggle,
className,
}: SidebarProps) {
const t = useTranslations('nav')
const tBrand = useTranslations('brand')
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 isActive = (href: string) => {
const fullHref = `/${locale}${href === '/' ? '' : href}`
if (href === '/') {
return pathname === `/${locale}` || pathname === `/${locale}/`
}
return pathname === fullHref || pathname.startsWith(fullHref + '/')
}
return (
<aside
className={cn(
'fixed left-0 top-0 h-screen',
'flex flex-col',
// Phase 7.0: 純白極簡
'bg-white',
'border-r-[0.5px] border-neutral-200',
// 寬度動畫
'transition-all duration-300 ease-out',
collapsed ? 'w-16' : 'w-56',
className
)}
style={{ zIndex: Z_INDEX.SIDEBAR }}
>
{/* Logo 區 - NemoClaw + 品牌名稱 (VT323 點陣字體) */}
<div className={cn(
'flex items-center border-b-[0.5px] border-neutral-200',
'transition-all duration-300',
collapsed ? 'h-16 justify-center px-2' : 'h-16 px-4 gap-3'
)}>
{/* NemoClaw Mini SVG */}
<div style={{ width: 36, height: 36, flexShrink: 0 }}>
<svg width="36" height="36" viewBox="0 0 140 140" className="drop-shadow-sm" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="sb-ceramic" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FFFFFF" />
<stop offset="40%" stopColor="#F8F8F8" />
<stop offset="70%" stopColor="#E8E8E8" />
<stop offset="100%" stopColor="#D8D8D8" />
</linearGradient>
<radialGradient id="sb-led" cx="40%" cy="35%" r="60%">
<stop offset="0%" stopColor="#7AB8F5" />
<stop offset="100%" stopColor="#2B6CB0" />
</radialGradient>
</defs>
{/* Body */}
<circle cx="70" cy="70" r="32" fill="url(#sb-ceramic)" stroke="#E0E0E0" strokeWidth="1" />
<circle cx="70" cy="70" r="16" fill="url(#sb-led)">
<animate attributeName="r" values="14;17;14" dur="2s" repeatCount="indefinite" />
</circle>
<circle cx="70" cy="70" r="8" fill="white" opacity="0.8" />
{/* Top arm */}
<path d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6" stroke="url(#sb-ceramic)" strokeWidth="6" strokeLinecap="round" fill="none" />
<path d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6" stroke="#4A90D9" strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.5" />
{/* Left arm */}
<path d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82" stroke="url(#sb-ceramic)" strokeWidth="6" strokeLinecap="round" fill="none" />
<path d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82" stroke="#4A90D9" strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.5" />
{/* Right arm */}
<path d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82" stroke="url(#sb-ceramic)" strokeWidth="6" strokeLinecap="round" fill="none" />
<path d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82" stroke="#4A90D9" strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.5" />
{/* Bottom arms */}
<path d="M 48 92 L 28 112 L 16 116" stroke="url(#sb-ceramic)" strokeWidth="6" strokeLinecap="round" fill="none" />
<path d="M 92 92 L 112 112 L 124 116" stroke="url(#sb-ceramic)" strokeWidth="6" strokeLinecap="round" fill="none" />
{/* Orbit ring */}
<circle cx="70" cy="70" r="42" fill="none" stroke="#4A90D9" strokeWidth="1" strokeDasharray="6 6" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 70 70" to="360 70 70" dur="8s" repeatCount="indefinite" />
</circle>
</svg>
</div>
{/* Brand name - VT323 點陣字體 (hidden when collapsed) */}
{!collapsed && (
<span className="font-dot-matrix text-xl text-neutral-900 tracking-widest uppercase">
{tBrand('name')}
</span>
)}
</div>
{/* 導航列表 - AI中心 v6 4分區 */}
<nav className="flex-1 py-2 overflow-y-auto">
{/* 4 分區菜單 */}
{NAV_SECTIONS.map(section => (
<div key={section.sectionKey} style={{ marginBottom: 4 }}>
{/* 分區標題collapsed 時隱藏)*/}
{!collapsed && (
<div style={{
fontSize: 11,
color: '#b0ad9f',
letterSpacing: '1.5px',
textTransform: 'uppercase' as const,
padding: '8px 12px 3px',
fontFamily: 'monospace',
}}>
{tSection(section.sectionKey)}
</div>
)}
{section.items.map(item => {
const active = isActive(item.href)
const count = item.badge && mounted ? pendingCount : 0
return (
<Link
key={item.id}
href={`/${locale}${item.href === '/' ? '' : item.href}`}
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,
}}
>
<item.Icon size={15} />
{!collapsed && <span>{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.href)
return (
<Link
key={item.id}
href={`/${locale}${item.href === '/' ? '' : item.href}`}
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',
}}
>
<item.Icon size={15} />
{!collapsed && <span>{t(item.labelKey)}</span>}
</Link>
)
})}
</nav>
{/* 底部區域 - 極簡版本號 */}
<div className={cn(
'border-t-[0.5px] border-neutral-200 py-3',
collapsed ? 'px-2 text-center' : 'px-4'
)}>
{!collapsed && (
<span className="text-[10px] font-mono text-neutral-300 uppercase tracking-widest">
v1.0.0
</span>
)}
</div>
{/* 折疊按鈕 */}
<button
onClick={onToggle}
className={cn(
'absolute -right-3 top-20',
'w-6 h-6 rounded-full',
'bg-white border-[0.5px] border-neutral-200',
'flex items-center justify-center',
'hover:bg-neutral-50',
'transition-all duration-150'
)}
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>
)
}