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

@@ -3,30 +3,61 @@
/**
* 自動化 (/automation) — Sprint 5 整合頁面
* 整合: 自動修復 + 神經指揮 + Drift 偵測
* 全部使用 Panel 元件 (無雙重 AppLayout)
* 2026-06-26 IA reset: 不再用頁面級分頁承載主導航,改為入口卡片。
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
*/
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
Activity,
GitBranch,
Wrench,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel'
import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel'
import { DriftPanel } from '@/components/panels/DriftPanel'
export default function AutomationPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const tabs: TabConfig[] = [
{ id: 'repair', label: t('autoRepair'), content: <AutoRepairPanel /> },
{ id: 'neural', label: t('neuralCommand'), content: <NeuralCommandPanel /> },
{ id: 'drift', label: t('drift'), content: <DriftPanel /> },
const links = [
{ id: 'auto-repair', label: t('autoRepair'), href: `/${params.locale}/auto-repair`, Icon: Wrench },
{ id: 'neural-command', label: t('neuralCommand'), href: `/${params.locale}/neural-command`, Icon: Activity },
{ id: 'drift', label: t('drift'), href: `/${params.locale}/drift`, Icon: GitBranch },
]
return (
<AppLayout locale={params.locale}>
<PageTabs tabs={tabs} defaultTab="repair" syncWithUrl={true} />
<main className="min-h-[calc(100vh-116px)] max-w-full overflow-x-hidden bg-[#f5f4ed]">
<section className="border border-[#e0ddd4] bg-white p-5">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-normal text-[#141413]">
{t('knowledgeAutomation')}
</h1>
</div>
<div className="mt-5 grid min-w-0 gap-3 md:grid-cols-3">
{links.map(({ id, label, href, Icon }) => (
<Link
key={id}
href={href}
className="group min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-4 text-[#141413] no-underline transition hover:border-[#d97757] hover:bg-[#fffaf7]"
>
<div className="flex items-center justify-between gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d8d3c7] bg-white text-[#5f5d57] group-hover:text-[#d97757]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 text-right text-[11px] font-semibold text-[#87867f]">
{href.replace(`/${params.locale}`, '')}
</span>
</div>
<div className="mt-4 min-w-0 text-sm font-semibold leading-5">
{label}
</div>
</Link>
))}
</div>
</section>
</main>
</AppLayout>
)
}

View File

@@ -9,6 +9,7 @@
import { AppLayout } from "@/components/layout";
import { usePathname } from "@/i18n/routing";
import { AWOOOP_WORKFLOW_ITEMS } from "@/lib/navigation/product-ia";
import {
BrainCircuit,
CalendarDays,
@@ -18,38 +19,6 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
// =============================================================================
// 導航設定
// =============================================================================
const navItems = [
{
labelKey: "overview",
href: "/awooop" as const,
exact: true,
},
{
labelKey: "workItems",
href: "/awooop/work-items" as const,
},
{
labelKey: "runs",
href: "/awooop/runs" as const,
},
{
labelKey: "approvals",
href: "/awooop/approvals" as const,
},
{
labelKey: "contracts",
href: "/awooop/contracts" as const,
},
{
labelKey: "tenants",
href: "/awooop/tenants" as const,
},
];
// =============================================================================
// AwoooP Console 佈局
// =============================================================================
@@ -63,10 +32,10 @@ export default function AwoooPLayout({
}) {
const pathname = usePathname();
const t = useTranslations("awooop.shell");
const activeItem = navItems.find((item) => (
const activeItem = AWOOOP_WORKFLOW_ITEMS.find((item) => (
pathname === item.href ||
(!item.exact && pathname?.startsWith(item.href + "/"))
)) ?? navItems[0];
)) ?? AWOOOP_WORKFLOW_ITEMS[0];
return (
<AppLayout locale={params.locale} showBackground={false} fullBleed>

View File

@@ -14,9 +14,15 @@
*/
import { useTranslations } from 'next-intl'
import { ShieldCheck } from 'lucide-react'
import Link from 'next/link'
import {
Activity,
BrainCircuit,
ClipboardList,
Radar,
ShieldCheck,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge'
import { GlassCard } from '@/components/ui/glass-card'
import { SloTab } from './tabs/slo-tab'
@@ -34,15 +40,15 @@ export default function GovernancePage({
}) {
const t = useTranslations('governance')
const tabs: TabConfig[] = [
{ id: 'slo', label: t('tabs.slo'), content: <SloTab /> },
{ id: 'events', label: t('tabs.events'), content: <EventsTab /> },
{ id: 'queue', label: t('tabs.queue'), content: <QueueTab /> },
{ id: 'agent-market', label: t('tabs.agentMarket'), content: <AgentMarketTab /> },
{ id: 'automation-inventory', label: t('tabs.automationInventory'), content: <AutomationInventoryTab /> },
const governanceSections = [
{ id: 'slo', order: '01', label: t('tabs.slo'), content: <SloTab />, Icon: Radar },
{ id: 'events', order: '02', label: t('tabs.events'), content: <EventsTab />, Icon: Activity },
{ id: 'queue', order: '03', label: t('tabs.queue'), content: <QueueTab />, Icon: ClipboardList },
{ id: 'agent-market', order: '04', label: t('tabs.agentMarket'), content: <AgentMarketTab />, Icon: BrainCircuit },
{ id: 'automation-inventory', order: '05', label: t('tabs.automationInventory'), content: <AutomationInventoryTab />, Icon: ShieldCheck },
]
const requestedTab = Array.isArray(searchParams?.tab) ? searchParams?.tab[0] : searchParams?.tab
const defaultTab = tabs.some(tab => tab.id === requestedTab) ? requestedTab : 'slo'
const activeSection = governanceSections.find(section => section.id === requestedTab) ?? governanceSections[0]
return (
<AppLayout locale={params.locale}>
@@ -58,7 +64,36 @@ export default function GovernancePage({
<IwoooSReadOnlyBridge />
<PageTabs tabs={tabs} defaultTab={defaultTab} syncWithUrl={true} />
<section className="mb-4 grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-5">
{governanceSections.map((section) => {
const selected = section.id === activeSection.id
const Icon = section.Icon
return (
<Link
key={section.id}
href={`/${params.locale}/governance?tab=${section.id}`}
className="group min-w-0 border bg-white p-3 text-[#141413] no-underline transition hover:border-[#d97757] hover:bg-[#fffaf7]"
style={{
borderColor: selected ? '#d97757' : '#e0ddd4',
}}
>
<div className="flex items-center justify-between gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#5f5d57] group-hover:text-[#d97757]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 text-right text-[11px] font-semibold text-[#87867f]">
{section.order}
</span>
</div>
<div className="mt-3 min-w-0 text-sm font-semibold leading-5">
{section.label}
</div>
</Link>
)
})}
</section>
{activeSection.content}
</AppLayout>
)
}

View File

@@ -4,10 +4,11 @@
* 可觀測性 (/observability)
* ========================
* 首屏整合只讀 AI Agent deployment layout、監控合約矩陣與服務健康缺口
* 下方保留既有監控 / APM / 錯誤 / 應用 / 服務目錄分頁作為鑽取細節。
* 下方保留既有監控 / APM / 錯誤 / 應用 / 服務目錄獨立入口作為鑽取細節。
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
Activity,
@@ -33,12 +34,6 @@ import {
type LucideIcon,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { MonitoringPanel } from '@/components/panels/MonitoringPanel'
import { ApmPanel } from '@/components/panels/ApmPanel'
import { ErrorsPanel } from '@/components/panels/ErrorsPanel'
import { AppsPanel } from '@/components/panels/AppsPanel'
import { ServicesPanel } from '@/components/panels/ServicesPanel'
import {
apiClient,
type AiAgentAutomationInventorySnapshot,
@@ -476,31 +471,36 @@ export default function ObservabilityPage({ params }: { params: { locale: string
},
]
const tabs: TabConfig[] = [
const drilldownLinks = [
{
id: 'monitoring',
label: nav('monitoring'),
content: <MonitoringPanel />,
href: `/${params.locale}/monitoring`,
Icon: Activity,
},
{
id: 'apm',
label: nav('apm'),
content: <ApmPanel />,
href: `/${params.locale}/apm`,
Icon: Radar,
},
{
id: 'errors',
label: nav('errors'),
content: <ErrorsPanel />,
href: `/${params.locale}/errors`,
Icon: AlertTriangle,
},
{
id: 'apps',
label: nav('apps'),
content: <AppsPanel />,
href: `/${params.locale}/apps`,
Icon: Boxes,
},
{
id: 'services',
label: nav('services'),
content: <ServicesPanel />,
href: `/${params.locale}/services`,
Icon: Server,
},
]
@@ -765,7 +765,27 @@ export default function ObservabilityPage({ params }: { params: { locale: string
title={t('sections.drilldownTitle')}
detail={t('sections.drilldownDetail')}
/>
<PageTabs tabs={tabs} defaultTab="monitoring" syncWithUrl={true} />
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-5">
{drilldownLinks.map(({ id, label, href, Icon }) => (
<Link
key={id}
href={href}
className="group min-w-0 border border-[#e0ddd4] bg-white p-4 text-[#141413] no-underline transition hover:border-[#d97757] hover:bg-[#fffaf7]"
>
<div className="flex items-center justify-between gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#5f5d57] group-hover:text-[#d97757]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 text-right text-[11px] font-semibold text-[#87867f]">
{href.replace(`/${params.locale}`, '')}
</span>
</div>
<div className="mt-4 min-w-0 text-sm font-semibold leading-5">
{label}
</div>
</Link>
))}
</div>
</section>
</div>
</main>

View File

@@ -3,34 +3,67 @@
/**
* 營運 (/operations) — Sprint 5 整合頁面
* 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費
* 全部使用 Panel 元件 (無雙重 AppLayout)
* 2026-06-26 IA reset: 不再用頁面級分頁承載主導航,改為入口卡片。
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
*/
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
BarChart3,
ClipboardList,
CreditCard,
FileText,
Rocket,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel'
import { TicketsPanel } from '@/components/panels/TicketsPanel'
import { CostPanel } from '@/components/panels/CostPanel'
import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel'
import { BillingPanel } from '@/components/panels/BillingPanel'
export default function OperationsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const tabs: TabConfig[] = [
{ id: 'deployments', label: t('deployments'), content: <DeploymentsPanel /> },
{ id: 'tickets', label: t('tickets'), content: <TicketsPanel /> },
{ id: 'cost', label: t('cost'), content: <CostPanel /> },
{ id: 'logs', label: t('actions'), content: <ActionLogsPanel /> },
{ id: 'billing', label: t('billing'), content: <BillingPanel /> },
const links = [
{ id: 'deployments', label: t('deployments'), href: `/${params.locale}/deployments`, Icon: Rocket },
{ id: 'tickets', label: t('tickets'), href: `/${params.locale}/tickets`, Icon: ClipboardList },
{ id: 'cost', label: t('cost'), href: `/${params.locale}/cost`, Icon: BarChart3 },
{ id: 'actions', label: t('actions'), href: `/${params.locale}/action-logs`, Icon: FileText },
{ id: 'billing', label: t('billing'), href: `/${params.locale}/billing`, Icon: CreditCard },
]
return (
<AppLayout locale={params.locale}>
<PageTabs tabs={tabs} defaultTab="deployments" syncWithUrl={true} />
<main className="min-h-[calc(100vh-116px)] max-w-full overflow-x-hidden bg-[#f5f4ed]">
<section className="border border-[#e0ddd4] bg-white p-5">
<div className="flex min-w-0 flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-normal text-[#141413]">
{t('operationsOverview')}
</h1>
</div>
</div>
<div className="mt-5 grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-5">
{links.map(({ id, label, href, Icon }) => (
<Link
key={id}
href={href}
className="group min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-4 text-[#141413] no-underline transition hover:border-[#d97757] hover:bg-[#fffaf7]"
>
<div className="flex items-center justify-between gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d8d3c7] bg-white text-[#5f5d57] group-hover:text-[#d97757]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 text-right text-[11px] font-semibold text-[#87867f]">
{href.replace(`/${params.locale}`, '')}
</span>
</div>
<div className="mt-4 min-w-0 text-sm font-semibold leading-5">
{label}
</div>
</Link>
))}
</div>
</section>
</main>
</AppLayout>
)
}

View File

@@ -15,6 +15,7 @@
*/
import React from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { useIncidents } from '@/hooks/useIncidents'
@@ -23,7 +24,6 @@ import { useHosts, useDashboardStore, type Host } from '@/stores/dashboard.store
import { IncidentCard } from '@/components/incident'
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { PulseSkeleton, CardSkeleton } from '@/components/shared/pulse-skeleton'
import { DispositionMini } from '@/components/shared/disposition-mini'
import { RecentActivity } from '@/components/shared/recent-activity'
@@ -1730,17 +1730,25 @@ export default function Home({ params }: { params: { locale: string } }) {
// 系統健康百分比
const systemHealthPct = totalServices > 0 ? Math.round((healthyServices / totalServices) * 100) : 0
// Sprint 5: 4 Tab 配置 (人工批准 2026-04-08)
// IA reset: 首頁不再用頁面級分頁承載主導航,改為指令中心入口卡。
const alertsCount = incidents?.length ?? 0
const tabs: TabConfig[] = [
{ id: 'overview', label: tDashboard('tabs.overview'), content: null },
{ id: 'alerts', label: tDashboard('tabs.alerts'), badge: alertsCount > 0 ? alertsCount : undefined, content: <AlertsAndApprovalsTab /> },
{ id: 'stream', label: tDashboard('tabs.stream'), content: <ActivityStreamTab /> },
{ id: 'disposition', label: tDashboard('tabs.disposition'), content: <DispositionTab /> },
const homeSections: Array<{
id: string
order: string
label: string
href: string
badge?: number
content: React.ReactNode | null
}> = [
{ id: 'overview', order: '01', label: tDashboard('tabs.overview'), href: `/${locale}`, content: null },
{ id: 'alerts', order: '02', label: tDashboard('tabs.alerts'), href: `/${locale}?tab=alerts`, badge: alertsCount > 0 ? alertsCount : undefined, content: <AlertsAndApprovalsTab /> },
{ id: 'stream', order: '03', label: tDashboard('tabs.stream'), href: `/${locale}?tab=stream`, content: <ActivityStreamTab /> },
{ id: 'disposition', order: '04', label: tDashboard('tabs.disposition'), href: `/${locale}?tab=disposition`, content: <DispositionTab /> },
]
// Sprint 5: 從 URL 讀取當前 Tab
// 從 URL 讀取當前首頁區塊,保留舊 ?tab= deep link 相容。
const [activeTabId, setActiveTabId] = useState('overview')
const activeHomeSection = homeSections.find(section => section.id === activeTabId) ?? homeSections[0]
const [infraView, setInfraView] = useState<'host' | 'topo'>('topo')
const [selectedHost, setSelectedHost] = useState<Host | null>(null)
const [compactViewport, setCompactViewport] = useState(false)
@@ -1754,7 +1762,7 @@ export default function Home({ params }: { params: { locale: string } }) {
}
syncTab()
window.addEventListener('popstate', syncTab)
// PageTabs router.push 後 URL 改變,用 hashchange + 定期低頻同步(1s)作為備援
// Link navigation 後 URL 改變,用 hashchange + 定期低頻同步(1s)作為備援
const fallback = setInterval(syncTab, 1000)
return () => { window.removeEventListener('popstate', syncTab); clearInterval(fallback) }
}, [])
@@ -1995,15 +2003,71 @@ export default function Home({ params }: { params: { locale: string } }) {
return (
<AppLayout locale={locale} showBackground={false} fullBleed>
{/* Sprint 5: Tab Bar */}
<PageTabs
tabs={tabs.map(t => ({ ...t, content: t.id === 'overview' ? <></> : t.content }))}
defaultTab="overview"
syncWithUrl={true}
/>
<section
data-testid="homepage-command-entry-cards"
style={{
background: '#f5f4ed',
borderBottom: '0.5px solid #e0ddd4',
padding: compactViewport ? '10px 8px 0' : '12px 20px 0',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: compactViewport ? '1fr' : 'repeat(4, minmax(0, 1fr))',
gap: 8,
maxWidth: '100%',
}}
>
{homeSections.map(section => {
const active = section.id === activeHomeSection.id
return (
<Link
key={section.id}
href={section.href}
style={{
display: 'grid',
gap: 8,
minWidth: 0,
padding: 12,
border: `0.5px solid ${active ? '#d97757' : '#e0ddd4'}`,
background: active ? '#fffaf7' : '#fff',
color: '#141413',
textDecoration: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 11, color: active ? '#d97757' : '#87867f', fontWeight: 700 }}>
{section.order}
</span>
{section.badge != null && (
<span style={{ fontSize: 10, background: '#cc2200', color: '#fff', borderRadius: 4, padding: '1px 6px', fontWeight: 700 }}>
{section.badge}
</span>
)}
</div>
<span style={{ fontSize: 13, fontWeight: 700, overflowWrap: 'anywhere' }}>
{section.label}
</span>
</Link>
)
})}
</div>
</section>
{activeHomeSection.id !== 'overview' && (
<div style={{
minHeight: 'calc(100vh - 128px)',
background: '#f5f4ed',
fontFamily: 'var(--font-body), monospace',
overflowX: 'hidden',
}}>
{activeHomeSection.content}
</div>
)}
{/* Tab 1 戰情總覽: 顯示現有首頁完整內容 */}
{activeTabId === 'overview' && (
{activeHomeSection.id === 'overview' && (
<div style={{
display: 'flex',
flexDirection: 'column',

View File

@@ -3,6 +3,7 @@
/**
* 安全合規 (/security-compliance) — Sprint 5 整合頁面
* 整合: 安全掃描 + 合規報告
* 2026-06-26 IA reset: 此頁作為 IwoooS 過渡入口,不再用頁面級分頁承載主導航。
* @updated 2026-04-09 Claude Opus 4.6 — 改用 Panel 元件解決 double AppLayout
*/
@@ -20,9 +21,6 @@ import {
ShieldCheck,
} from "lucide-react";
import { AppLayout } from "@/components/layout";
import { PageTabs, type TabConfig } from "@/components/layout/page-tabs";
import { SecurityPanel } from "@/components/panels/SecurityPanel";
import { CompliancePanel } from "@/components/panels/CompliancePanel";
import { publicBoundaryText } from "@/lib/public-security-redaction";
const integrationItems = [
@@ -763,15 +761,40 @@ export default function SecurityCompliancePage({
}) {
const t = useTranslations("nav");
const tabs: TabConfig[] = [
{ id: "security", label: t("security"), content: <SecurityPanel /> },
{ id: "compliance", label: t("compliance"), content: <CompliancePanel /> },
const drilldownLinks = [
{ id: "security", label: t("security"), href: `/${params.locale}/security`, icon: Activity },
{ id: "compliance", label: t("compliance"), href: `/${params.locale}/compliance`, icon: ClipboardCheck },
];
return (
<AppLayout locale={params.locale}>
<SecurityComplianceFrontStage locale={params.locale} />
<PageTabs tabs={tabs} defaultTab="security" syncWithUrl={true} />
<section className="mt-4 border border-[#e0ddd4] bg-white p-4">
<div className="grid min-w-0 gap-3 md:grid-cols-2">
{drilldownLinks.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href as Route}
className="group min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-4 text-[#141413] no-underline transition hover:border-[#d97757] hover:bg-[#fffaf7]"
>
<div className="flex items-center justify-between gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d8d3c7] bg-white text-[#5f5d57] group-hover:text-[#d97757]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 text-right text-[11px] font-semibold text-[#87867f]">
{item.href.replace(`/${params.locale}`, "")}
</span>
</div>
<div className="mt-4 min-w-0 text-sm font-semibold leading-5">
{item.label}
</div>
</Link>
);
})}
</div>
</section>
</AppLayout>
);
}

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

View File

@@ -0,0 +1,196 @@
import type { LucideIcon } from 'lucide-react'
import {
BarChart3,
Bell,
BookOpen,
BrainCircuit,
Building2,
ClipboardList,
FileText,
GitPullRequest,
HelpCircle,
LayoutDashboard,
Monitor,
Package,
PlayCircle,
Radar,
Settings,
ShieldCheck,
Terminal,
} from 'lucide-react'
export type ProductNavItem = {
id: string
href: string
labelKey: string
Icon: LucideIcon
aliases?: string[]
relatedPaths?: string[]
exact?: boolean
badge?: boolean
}
export type ProductNavSection = {
sectionKey: string
items: ProductNavItem[]
}
export type WorkflowNavItem = {
labelKey: string
href: string
exact?: boolean
}
// Canonical product IA map. This is the single source for the global shell; page
// layouts can reference this map for titles and context, but must not recreate
// a second navigation tree.
export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
{
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 },
],
},
]
export const PRODUCT_BOTTOM_NAV_ITEMS: ProductNavItem[] = [
{ id: 'help', href: '/help', labelKey: 'help', Icon: HelpCircle },
]
export const AWOOOP_WORKFLOW_ITEMS: WorkflowNavItem[] = [
{
labelKey: 'overview',
href: '/awooop',
exact: true,
},
{
labelKey: 'workItems',
href: '/awooop/work-items',
},
{
labelKey: 'runs',
href: '/awooop/runs',
},
{
labelKey: 'approvals',
href: '/awooop/approvals',
},
{
labelKey: 'contracts',
href: '/awooop/contracts',
},
{
labelKey: 'tenants',
href: '/awooop/tenants',
},
]