Files
awoooi/apps/web/src/components/layout/page-tabs.tsx
Your Name 77867357d5
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m26s
CD Pipeline / build-and-deploy (push) Successful in 4m37s
CD Pipeline / post-deploy-checks (push) Successful in 2m35s
fix(web): 穩定 P2-105 行動版顯示
2026-06-13 07:54:06 +08:00

230 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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