From 46ca2eadc31a9361830f809397546a289ec0d301 Mon Sep 17 00:00:00 2001 From: OG T Date: Wed, 8 Apr 2026 18:12:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Sprint=205=20Phase=201.1=20?= =?UTF-8?q?=E2=80=94=20PageTabs=20=E5=85=B1=E7=94=A8=E9=A0=81=E7=B1=A4?= =?UTF-8?q?=E5=85=83=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/layout/page-tabs.tsx | 172 +++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 apps/web/src/components/layout/page-tabs.tsx diff --git a/apps/web/src/components/layout/page-tabs.tsx b/apps/web/src/components/layout/page-tabs.tsx new file mode 100644 index 00000000..43b6bb4c --- /dev/null +++ b/apps/web/src/components/layout/page-tabs.tsx @@ -0,0 +1,172 @@ +'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, Suspense, type ReactNode } from 'react' +import { useSearchParams, useRouter, usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' + +// ============================================================================= +// 型別 +// ============================================================================= + +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() { + return ( +
+
+
+
+
+ ) +} + +// ============================================================================= +// 元件 +// ============================================================================= + +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) + + // 切換 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() + // @ts-expect-error — Next.js router.push 型別限制,但動態路徑是合法的 + router.push(`${pathname}${query ? `?${query}` : ''}`, { scroll: false }) + } + }, [syncWithUrl, searchParams, router, pathname, defaultTab, tabs]) + + // 找到目前的 Tab 內容 + const activeContent = useMemo(() => { + return tabs.find(t => t.id === activeTab)?.content ?? tabs[0]?.content + }, [tabs, activeTab]) + + return ( + <> + {/* Tab Bar */} +
+ {tabs.map(tab => { + const isActive = tab.id === activeTab + return ( + + ) + })} +
+ + {/* Tab 內容 */} + }> + {activeContent} + + + ) +} + +export default PageTabs