feat(web): Sprint 5 Phase 1.1 — PageTabs 共用頁籤元件

This commit is contained in:
OG T
2026-04-08 18:12:43 +08:00
parent 11ff517406
commit 46ca2eadc3

View File

@@ -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 (
<div className="flex flex-col gap-4 p-6 animate-pulse">
<div className="h-6 w-48 rounded bg-[#e0ddd4]/50" />
<div className="h-32 rounded-lg bg-[#e0ddd4]/30" />
<div className="h-24 rounded-lg bg-[#e0ddd4]/20" />
</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)
// 切換 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 */}
<div
style={{
height: 36,
display: 'flex',
alignItems: 'stretch',
borderBottom: '0.5px solid #e0ddd4',
background: '#fff',
flexShrink: 0,
padding: '0 20px',
}}
>
{tabs.map(tab => {
const isActive = tab.id === activeTab
return (
<button
key={tab.id}
onClick={() => switchTab(tab.id)}
style={{
padding: '0 14px',
fontSize: 12,
fontWeight: isActive ? 600 : 500,
color: isActive ? '#d97757' : '#87867f',
borderBottom: `2px solid ${isActive ? '#d97757' : 'transparent'}`,
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>
{/* Tab 內容 */}
<Suspense fallback={<TabSkeleton />}>
{activeContent}
</Suspense>
</>
)
}
export default PageTabs