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>
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
}
|