feat(web): consolidate product IA shell
Some checks failed
Code Review / ai-code-review (push) Successful in 18s
CD Pipeline / tests (push) Failing after 1m37s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-26 18:30:46 +08:00
parent 91d566a4bb
commit 6814104aaa
14 changed files with 806 additions and 648 deletions

View File

@@ -18,22 +18,15 @@ import { useTranslations } from 'next-intl'
import { useRouter, usePathname } from 'next/navigation'
import { useLocale } from 'next-intl'
import {
Activity,
BookOpen,
BrainCircuit,
ClipboardList,
GitBranch,
Home,
Radar,
Search,
Settings,
ShieldCheck,
Terminal,
Wrench,
Zap,
} from 'lucide-react'
import { useTerminalStore } from '@/stores/terminal.store'
import { Z_INDEX } from '@/lib/constants/z-index'
import {
PRODUCT_BOTTOM_NAV_ITEMS,
PRODUCT_NAV_SECTIONS,
} from '@/lib/navigation/product-ia'
interface PaletteItem {
id: string
@@ -45,8 +38,18 @@ interface PaletteItem {
secondary?: boolean
}
const COMMAND_PALETTE_PRIMARY_NAV_IDS = new Set([
'command-center',
'awooop-overview',
'observability',
'iwooos-security',
'knowledge-automation',
'operations',
])
export function CommandPalette() {
const t = useTranslations('commandPalette')
const tNav = useTranslations('nav')
const router = useRouter()
const locale = useLocale()
const pathname = usePathname()
@@ -63,6 +66,29 @@ export function CommandPalette() {
setOpen(false)
}
const navigationItems: PaletteItem[] = [
...PRODUCT_NAV_SECTIONS.flatMap(section => section.items),
...PRODUCT_BOTTOM_NAV_ITEMS,
].map((item) => {
const path = item.href === '/' ? '' : item.href
return {
id: `nav-${item.id}`,
label: tNav(item.labelKey),
group: t('groupNav'),
icon: <item.Icon size={14} />,
action: () => nav(path),
keywords: [
item.id,
item.href,
item.labelKey,
...(item.aliases ?? []),
...(item.relatedPaths ?? []),
],
secondary: !COMMAND_PALETTE_PRIMARY_NAV_IDS.has(item.id),
}
})
const items: PaletteItem[] = [
// 快速動作
{
@@ -73,146 +99,7 @@ export function CommandPalette() {
action: () => { openTerminal(); setOpen(false) },
keywords: ['terminal', '終端', 'omni', 'cmd', '指令'],
},
// 導航
{
id: 'home',
label: t('actionGoHome'),
group: t('groupNav'),
icon: <Home size={14} />,
action: () => nav(''),
keywords: ['home', '首頁', '指令中心', 'dashboard'],
},
{
id: 'approvals',
label: t('actionGoApprovals'),
group: t('groupNav'),
icon: <Zap size={14} />,
action: () => nav('/authorizations'),
keywords: ['授權', 'approve', '批准', 'authorization'],
secondary: true,
},
{
id: 'observability',
label: t('actionGoObservability'),
group: t('groupNav'),
icon: <Activity size={14} />,
action: () => nav('/observability'),
keywords: ['observability', '可觀測性', 'monitor', '監控'],
},
{
id: 'awooop-home',
label: t('actionGoAwoooP'),
group: t('groupNav'),
icon: <BrainCircuit size={14} />,
action: () => nav('/awooop'),
keywords: ['awooop', 'operator', 'console', '操作控制台', '總覽'],
},
{
id: 'knowledge-automation',
label: t('actionGoKnowledgeAutomation'),
group: t('groupNav'),
icon: <BookOpen size={14} />,
action: () => nav('/knowledge-base'),
keywords: ['knowledge', 'automation', '自動化', '知識', 'kb', 'playbook', 'runbook'],
},
{
id: 'governance-security',
label: t('actionGoGovernanceSecurity'),
group: t('groupNav'),
icon: <ShieldCheck size={14} />,
action: () => nav('/governance'),
keywords: ['governance', 'security', 'iwooos', 'code review', '治理', '安全', '資安', '審查'],
},
{
id: 'awooop-work-items',
label: t('actionGoWorkItems'),
group: t('groupNav'),
icon: <ClipboardList size={14} />,
action: () => nav('/awooop/work-items'),
keywords: ['work items', '工作鏈路', '工作項', 'recurrence'],
secondary: true,
},
{
id: 'awooop-runs',
label: t('actionGoRuns'),
group: t('groupNav'),
icon: <Activity size={14} />,
action: () => nav('/awooop/runs'),
keywords: ['runs', 'run monitor', '執行監控', 'run 監控'],
secondary: true,
},
{
id: 'awooop-approvals',
label: t('actionGoApprovalQueue'),
group: t('groupNav'),
icon: <ShieldCheck size={14} />,
action: () => nav('/awooop/approvals'),
keywords: ['approval queue', '審批佇列', '人工閘門', 'approve'],
secondary: true,
},
{
id: 'automation',
label: t('actionGoAutomation'),
group: t('groupNav'),
icon: <Wrench size={14} />,
action: () => nav('/automation'),
keywords: ['automation', '自動化', 'auto'],
secondary: true,
},
{
id: 'operations',
label: t('actionGoOperations'),
group: t('groupNav'),
icon: <GitBranch size={14} />,
action: () => nav('/operations'),
keywords: ['operations', '營運', 'ops'],
},
{
id: 'iwooos',
label: t('actionGoIwooos'),
group: t('groupNav'),
icon: <Radar size={14} />,
action: () => nav('/iwooos'),
keywords: [
'iwooos',
'information security',
'security',
'安全',
'安全合規',
'compliance',
'合規',
'資安網',
'資安態勢',
],
secondary: true,
},
{
id: 'knowledge',
label: t('actionGoKnowledge'),
group: t('groupNav'),
icon: <BookOpen size={14} />,
action: () => nav('/knowledge-base'),
keywords: ['knowledge', '知識', '殿堂', 'kb'],
secondary: true,
},
{
id: 'settings',
label: t('actionGoSettings'),
group: t('groupNav'),
icon: <Settings size={14} />,
action: () => nav('/settings'),
keywords: ['settings', '設定', 'config'],
secondary: true,
},
{
id: 'terminal-page',
label: t('actionGoTerminal'),
group: t('groupNav'),
icon: <Terminal size={14} />,
action: () => nav('/terminal'),
keywords: ['terminal', '終端', 'shell'],
secondary: true,
},
...navigationItems,
]
const filtered = query.trim() === ''

View File

@@ -1,229 +0,0 @@
'use client'
/**
* PageTabs — 共用頁籤容器元件
* ============================
* Sprint 5: 多頁籤整合介面的核心元件
*
* 功能:
* - Tab 切換 + URL query 同步 (?tab=alerts)
* - Badge 數字 (如告警數)
* - 瀏覽器後退回到上一個 Tab
* - React.lazy + Suspense 按需載入
* - 骨架屏 Loading 狀態
*
* 設計規範:
* - Tab Bar 高度: 36px
* - Active: 底部 2px accent (#d97757) + 文字加粗
* - 字體: DM Mono 12px
* - 邊框: 0.5px solid #e0ddd4
*
* 建立時間: 2026-04-08 (台北時區)
* 建立者: Claude Code (Sprint 5 Phase 1)
*/
import { useState, useCallback, useMemo, useEffect, Suspense, type ReactNode } from 'react'
import type { Route } from 'next'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
// =============================================================================
// 型別
// =============================================================================
export interface TabConfig {
/** Tab ID (用於 URL query: ?tab=alerts) */
id: string
/** 顯示名稱 */
label: string
/** Badge 數字 (如告警數, 待審批數) */
badge?: number
/** Tab 內容 (React 元件) */
content: ReactNode
}
export interface PageTabsProps {
/** Tab 配置清單 */
tabs: TabConfig[]
/** 預設顯示的 Tab ID */
defaultTab?: string
/** 是否同步 URL query (?tab=xxx) */
syncWithUrl?: boolean
}
// =============================================================================
// 骨架屏
// =============================================================================
function TabSkeleton() {
const t = useTranslations('dashboard')
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<style>{`
@keyframes tab-lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-3px)} }
@keyframes tab-lobster-wave { 0%,100%{opacity:.4} 50%{opacity:1} }
`}</style>
<div style={{ animation: 'tab-lobster-bob 1.2s ease-in-out infinite' }}>
<svg width="32" height="36" viewBox="0 0 18 20" fill="none">
<ellipse cx="9" cy="13" rx="5.5" ry="6.5" fill="#E85530" opacity="0.9" />
<circle cx="9" cy="7.5" r="4.5" fill="#E85530" opacity="0.9" />
<circle cx="7" cy="6.5" r="1" fill="#b03a1a" />
<circle cx="11" cy="6.5" r="1" fill="#b03a1a" />
<path d="M3.5 10 Q1 9 1.5 12 Q2 14 4 13" stroke="#E85530" strokeWidth="1.2" fill="none" strokeLinecap="round" />
<ellipse cx="1.5" cy="12" rx="1.2" ry="1.5" fill="#E85530" opacity="0.7" transform="rotate(-10 1.5 12)" />
<path d="M14.5 10 Q17 9 16.5 12 Q16 14 14 13" stroke="#E85530" strokeWidth="1.2" fill="none" strokeLinecap="round" />
<ellipse cx="16.5" cy="12" rx="1.2" ry="1.5" fill="#E85530" opacity="0.7" transform="rotate(10 16.5 12)" />
<path d="M7 3 Q5 1 3 2" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round" />
<path d="M11 3 Q13 1 15 2" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round" />
<path d="M6 19 Q9 21 12 19" stroke="#E85530" strokeWidth="1.2" fill="none" strokeLinecap="round" />
</svg>
</div>
<span style={{ fontSize: 12, color: '#87867f', fontFamily: "'DM Mono', monospace", animation: 'tab-lobster-wave 2s ease-in-out infinite' }}>
{t('loading')}
</span>
</div>
)
}
// =============================================================================
// 元件
// =============================================================================
export function PageTabs({ tabs, defaultTab, syncWithUrl = true }: PageTabsProps) {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
// 從 URL 讀取當前 Tab或使用預設值
const urlTab = syncWithUrl ? searchParams.get('tab') : null
const initialTab = urlTab || defaultTab || tabs[0]?.id || ''
const [activeTab, setActiveTab] = useState(initialTab)
const tabIds = tabs.map(t => t.id).join('|')
const firstTabId = tabs[0]?.id || ''
const knownTabIds = useMemo(() => tabIds.split('|'), [tabIds])
const urlActiveTab = syncWithUrl && urlTab && knownTabIds.includes(urlTab) ? urlTab : null
const resolvedActiveTab = urlActiveTab || activeTab || defaultTab || firstTabId
useEffect(() => {
const nextTab = (syncWithUrl ? urlTab : null) || defaultTab || firstTabId
if (!nextTab || !knownTabIds.includes(nextTab)) return
setActiveTab(prev => prev === nextTab ? prev : nextTab)
}, [defaultTab, firstTabId, knownTabIds, syncWithUrl, urlTab])
// 切換 Tab
const switchTab = useCallback((tabId: string) => {
setActiveTab(tabId)
if (syncWithUrl) {
const params = new URLSearchParams(searchParams.toString())
if (tabId === (defaultTab || tabs[0]?.id)) {
params.delete('tab')
} else {
params.set('tab', tabId)
}
const query = params.toString()
const nextPath = `${pathname}${query ? `?${query}` : ''}` as Route
router.push(nextPath, { scroll: false })
}
}, [syncWithUrl, searchParams, router, pathname, defaultTab, tabs])
// 找到目前的 Tab 內容
const activeContent = useMemo(() => {
return tabs.find(t => t.id === resolvedActiveTab)?.content ?? tabs[0]?.content
}, [tabs, resolvedActiveTab])
return (
<>
{/* Tab Bar */}
<div
className="page-tabs-bar"
style={{
height: 36,
display: 'flex',
alignItems: 'stretch',
borderBottom: '0.5px solid #e0ddd4',
background: '#fff',
flexShrink: 0,
padding: '0 20px',
maxWidth: '100%',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{tabs.map(tab => {
const isActive = tab.id === resolvedActiveTab
return (
<button
key={tab.id}
data-tab-id={tab.id}
className="page-tabs-button"
onClick={() => switchTab(tab.id)}
style={{
padding: '0 14px',
fontSize: 12,
fontWeight: isActive ? 600 : 500,
color: isActive ? '#d97757' : '#87867f',
background: 'transparent',
border: 'none',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: isActive ? '#d97757' : 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.12s',
fontFamily: "'DM Mono', monospace",
}}
>
{tab.label}
{tab.badge != null && tab.badge > 0 && (
<span
style={{
background: '#cc2200',
color: '#fff',
fontSize: 8,
padding: '0 5px',
borderRadius: 4,
fontWeight: 700,
minWidth: 14,
textAlign: 'center',
}}
>
{tab.badge}
</span>
)}
</button>
)
})}
</div>
<style jsx global>{`
@media (max-width: 640px) {
.page-tabs-bar {
height: auto !important;
min-height: 36px;
flex-wrap: wrap;
overflow-x: hidden !important;
padding: 0 10px !important;
}
.page-tabs-button {
flex: 1 1 auto;
min-width: min(100%, 86px);
justify-content: center;
padding: 0 8px !important;
white-space: normal;
overflow-wrap: anywhere;
}
}
`}</style>
{/* Tab 內容 */}
<Suspense fallback={<TabSkeleton />}>
{activeContent}
</Suspense>
</>
)
}
export default PageTabs

View File

@@ -26,26 +26,14 @@ import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import {
BarChart3,
Bell,
BookOpen,
BrainCircuit,
Building2,
ChevronLeft,
ChevronRight,
ClipboardList,
FileText,
GitPullRequest,
HelpCircle,
LayoutDashboard,
Monitor,
Package,
PlayCircle,
Radar,
Settings,
ShieldCheck,
Terminal,
} from 'lucide-react'
import {
PRODUCT_BOTTOM_NAV_ITEMS,
PRODUCT_NAV_SECTIONS,
type ProductNavItem,
} from '@/lib/navigation/product-ia'
// Phase 8.0 #15: 改用 approval store SSE (移除 polling)
import { useApprovalStore } from '@/stores/approval.store'
@@ -61,22 +49,6 @@ interface SidebarProps {
className?: string
}
type NavItemConfig = {
id: string
href: string
labelKey: string
Icon: typeof LayoutDashboard
aliases?: string[]
relatedPaths?: string[]
exact?: boolean
badge?: boolean // 是否顯示動態徽章
}
type NavSection = {
sectionKey: string
items: NavItemConfig[]
}
// ============================================================
// 2026-06-26: Operator-first IA repair
// ============================================================
@@ -84,129 +56,6 @@ type NavSection = {
// 側欄放高頻任務與產品導航,頁面內不再重複用二層分頁藏入口。
// ============================================================
const NAV_SECTIONS: NavSection[] = [
{
sectionKey: 'workspaces',
items: [
{ id: 'command-center', href: '/', labelKey: 'commandCenter', Icon: LayoutDashboard },
{
id: 'awooop-overview',
href: '/awooop',
labelKey: 'awooopHome',
Icon: BrainCircuit,
exact: true,
},
],
},
{
sectionKey: 'queues',
items: [
{
id: 'awooop-work-items',
href: '/awooop/work-items',
labelKey: 'workItems',
Icon: ClipboardList,
},
{
id: 'awooop-runs',
href: '/awooop/runs',
labelKey: 'runMonitor',
Icon: PlayCircle,
},
{
id: 'awooop-approvals',
href: '/awooop/approvals',
labelKey: 'approvalQueue',
Icon: ShieldCheck,
aliases: ['/authorizations'],
badge: true,
},
{
id: 'awooop-contracts',
href: '/awooop/contracts',
labelKey: 'contracts',
Icon: FileText,
},
{
id: 'awooop-tenants',
href: '/awooop/tenants',
labelKey: 'tenants',
Icon: Building2,
},
],
},
{
sectionKey: 'monitoring',
items: [
{
id: 'observability',
href: '/observability',
labelKey: 'observability',
Icon: Monitor,
aliases: ['/monitoring', '/apm', '/errors', '/apps', '/services', '/topology'],
},
{
id: 'alerts',
href: '/alerts',
labelKey: 'alerts',
Icon: Bell,
aliases: ['/alert-operation-logs', '/notifications'],
},
{
id: 'iwooos-security',
href: '/governance',
labelKey: 'iwooos',
Icon: ShieldCheck,
aliases: ['/security-compliance'],
relatedPaths: ['/iwooos', '/security', '/compliance'],
},
{
id: 'code-review',
href: '/code-review',
labelKey: 'codeReview',
Icon: GitPullRequest,
},
],
},
{
sectionKey: 'knowledge',
items: [
{
id: 'knowledge-automation',
href: '/knowledge-base',
labelKey: 'knowledgeAutomation',
Icon: BookOpen,
aliases: ['/knowledge', '/automation', '/auto-repair', '/drift', '/neural-command'],
},
{
id: 'reports',
href: '/reports',
labelKey: 'reports',
Icon: BarChart3,
},
{
id: 'operations',
href: '/operations',
labelKey: 'operationsOverview',
Icon: Package,
aliases: ['/deployments', '/tickets', '/cost', '/billing', '/action-logs'],
},
],
},
{
sectionKey: 'system',
items: [
{ id: 'terminal', href: '/terminal', labelKey: 'terminal', Icon: Terminal },
{ id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings },
{ id: 'classic', href: '/classic', labelKey: 'classicAICenter', Icon: Radar },
],
},
]
const BOTTOM_NAV_ITEMS: NavItemConfig[] = [
{ id: 'help', href: '/help', labelKey: 'help', Icon: HelpCircle },
]
// =============================================================================
// Component
// =============================================================================
@@ -244,7 +93,7 @@ export function Sidebar({
return pathname === fullHref || pathname.startsWith(fullHref + '/')
}
const isActive = (item: NavItemConfig) => (
const isActive = (item: ProductNavItem) => (
isRouteActive(item.href, item.exact) ||
item.aliases?.some(alias => isRouteActive(alias)) === true ||
item.relatedPaths?.some(path => isRouteActive(path)) === true
@@ -270,7 +119,7 @@ export function Sidebar({
{/* 導航列表 - Operator workflow sections */}
<nav className="flex-1 py-2 overflow-y-auto">
{NAV_SECTIONS.map(section => (
{PRODUCT_NAV_SECTIONS.map(section => (
<div key={section.sectionKey} style={{ marginBottom: 4 }}>
{!collapsed && (
<div style={{
@@ -340,7 +189,7 @@ export function Sidebar({
<div style={{ borderTop: '0.5px solid #e0ddd4', margin: '6px 0' }} />
{/* 底部菜單 */}
{BOTTOM_NAV_ITEMS.map(item => {
{PRODUCT_BOTTOM_NAV_ITEMS.map(item => {
const active = isActive(item)
return (
<Link