feat(web): Sprint 5 Phase 1.1 — PageTabs 共用頁籤元件
This commit is contained in:
172
apps/web/src/components/layout/page-tabs.tsx
Normal file
172
apps/web/src/components/layout/page-tabs.tsx
Normal 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
|
||||
Reference in New Issue
Block a user