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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() === ''
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
196
apps/web/src/lib/navigation/product-ia.ts
Normal file
196
apps/web/src/lib/navigation/product-ia.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user