230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
'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
|