fix(web): 首席架構師 P0 修正 — i18n 硬編碼 + 效能輪詢
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

C1: 首頁 4 Tab 30+ 處硬編碼中文改為 useTranslations
  - 新增 dashboard.tabs.* / alertEvents / approve / reject 等 30+ i18n key
  - zh-TW + en 雙語同步
C3: automation/operations Loading 改用 LobsterLoading (i18n)
I1: 100ms setInterval 改為 popstate + 1s 低頻備援 (效能 10x 改善)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 11:01:07 +08:00
parent 5ea6c3fb91
commit 7153395267
5 changed files with 106 additions and 38 deletions

View File

@@ -163,7 +163,39 @@
"connectionError": "Connection failed", "connectionError": "Connection failed",
"metaVersion": "Version", "metaVersion": "Version",
"metaStats": "Stats", "metaStats": "Stats",
"metaUpdatedAt": "Updated" "metaUpdatedAt": "Updated",
"tabs": {
"overview": "Overview",
"alerts": "Alerts & Approvals",
"stream": "Activity Stream",
"disposition": "Disposition Stats"
},
"alertEvents": "Alert Events",
"noActiveAlerts": "No active alerts",
"pendingApprovalsTitle": "Pending Approvals",
"noPendingApprovals": "No pending approvals",
"approve": "Approve",
"reject": "Reject",
"activityStream": "System Activity Stream",
"sseConnected": "SSE Connected",
"sseDisconnected": "Disconnected",
"waitingEvents": "Waiting for events...",
"statusLabel": "Status",
"hostsLabel": "Hosts",
"eventsCount": "{count} events",
"noDispositionData": "No disposition data available",
"totalDispositions": "Total Dispositions",
"autoRate": "Automation Rate",
"humanRate": "Human Intervention Rate",
"autoRepairLabel": "Auto Repair",
"humanApprovedLabel": "Human Approved",
"manualResolvedLabel": "Manual Resolved",
"coldStartLabel": "Cold Start",
"dispositionBreakdown": "Disposition Breakdown",
"hostView": "Hosts",
"topoView": "Topology",
"waitingHostData": "Waiting for host data...",
"dashboardConnecting": "Dashboard API connecting..."
}, },
"openclaw": { "openclaw": {
"name": "OpenClaw", "name": "OpenClaw",

View File

@@ -164,7 +164,39 @@
"connectionError": "無法連線", "connectionError": "無法連線",
"metaVersion": "版本", "metaVersion": "版本",
"metaStats": "統計", "metaStats": "統計",
"metaUpdatedAt": "更新" "metaUpdatedAt": "更新",
"tabs": {
"overview": "戰情總覽",
"alerts": "告警 & 授權",
"stream": "活動串流",
"disposition": "處置統計"
},
"alertEvents": "告警事件",
"noActiveAlerts": "目前無活躍告警",
"pendingApprovalsTitle": "待批准授權",
"noPendingApprovals": "無待批准項目",
"approve": "批准",
"reject": "拒絕",
"activityStream": "系統活動串流",
"sseConnected": "SSE 連線中",
"sseDisconnected": "連線中斷",
"waitingEvents": "等待即時事件...",
"statusLabel": "狀態",
"hostsLabel": "主機",
"eventsCount": "{count} 筆",
"noDispositionData": "目前無處置統計資料",
"totalDispositions": "處置總次數",
"autoRate": "自動化率",
"humanRate": "人工介入率",
"autoRepairLabel": "自動修復",
"humanApprovedLabel": "人工審核",
"manualResolvedLabel": "手動處理",
"coldStartLabel": "冷啟動",
"dispositionBreakdown": "處置方式分佈",
"hostView": "主機",
"topoView": "拓撲",
"waitingHostData": "等待主機資料...",
"dashboardConnecting": "Dashboard API 連線中"
}, },
"openclaw": { "openclaw": {
"name": "OpenClaw", "name": "OpenClaw",

View File

@@ -11,13 +11,15 @@ import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout' import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs' import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { LobsterLoading } from '@/components/shared/lobster-loading'
const AutoRepairContent = lazy(() => import('@/app/[locale]/auto-repair/page')) const AutoRepairContent = lazy(() => import('@/app/[locale]/auto-repair/page'))
const NeuralCommandContent = lazy(() => import('@/app/[locale]/neural-command/page')) const NeuralCommandContent = lazy(() => import('@/app/[locale]/neural-command/page'))
const DriftContent = lazy(() => import('@/app/[locale]/drift/page')) const DriftContent = lazy(() => import('@/app/[locale]/drift/page'))
// C3 修正: 用 LobsterLoading 取代硬編碼「載入中」
function Loading() { function Loading() {
return <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div> return <LobsterLoading size="sm" />
} }
export default function AutomationPage({ params }: { params: { locale: string } }) { export default function AutomationPage({ params }: { params: { locale: string } }) {

View File

@@ -11,6 +11,7 @@ import { lazy, Suspense } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout' import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs' import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { LobsterLoading } from '@/components/shared/lobster-loading'
const DeploymentsContent = lazy(() => import('@/app/[locale]/deployments/page')) const DeploymentsContent = lazy(() => import('@/app/[locale]/deployments/page'))
const TicketsContent = lazy(() => import('@/app/[locale]/tickets/page')) const TicketsContent = lazy(() => import('@/app/[locale]/tickets/page'))
@@ -19,7 +20,7 @@ const ActionLogsContent = lazy(() => import('@/app/[locale]/action-logs/page'))
const BillingContent = lazy(() => import('@/app/[locale]/billing/page')) const BillingContent = lazy(() => import('@/app/[locale]/billing/page'))
function Loading() { function Loading() {
return <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div> return <LobsterLoading size="sm" />
} }
export default function OperationsPage({ params }: { params: { locale: string } }) { export default function OperationsPage({ params }: { params: { locale: string } }) {

View File

@@ -57,9 +57,9 @@ function AlertsAndApprovalsTab() {
<div style={{ display: 'flex', gap: 20, padding: '16px 20px', flex: 1, overflow: 'hidden' }}> <div style={{ display: 'flex', gap: 20, padding: '16px 20px', flex: 1, overflow: 'hidden' }}>
{/* 左: 告警列表 */} {/* 左: 告警列表 */}
<div style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}> ({alerts.length})</div> <div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}>{t('alertEvents')} ({alerts.length})</div>
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}></div> <div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}>{t('noActiveAlerts')}</div>
) : alerts.map((a: any, i: number) => ( ) : alerts.map((a: any, i: number) => (
<div key={a.incident_id || i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '10px 14px', marginBottom: 8 }}> <div key={a.incident_id || i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '10px 14px', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -72,16 +72,16 @@ function AlertsAndApprovalsTab() {
</div> </div>
{/* 右: 授權待批准 */} {/* 右: 授權待批准 */}
<div style={{ width: 400, flexShrink: 0, overflowY: 'auto' }}> <div style={{ width: 400, flexShrink: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}> ({approvals.length})</div> <div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}>{t('pendingApprovalsTitle')} ({approvals.length})</div>
{approvals.length === 0 ? ( {approvals.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}></div> <div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}>{t('noPendingApprovals')}</div>
) : approvals.map((ap: any, i: number) => ( ) : approvals.map((ap: any, i: number) => (
<div key={ap.id || i} style={{ background: '#faf9f3', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '10px 12px', marginBottom: 8 }}> <div key={ap.id || i} style={{ background: '#faf9f3', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '10px 12px', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#cc2200' }}>{ap.action || ap.title || '--'}</div> <div style={{ fontSize: 12, fontWeight: 600, color: '#cc2200' }}>{ap.action || ap.title || '--'}</div>
<div style={{ fontSize: 10, color: '#555550', marginTop: 2, fontFamily: "'JetBrains Mono', monospace" }}>{ap.resource || '--'}</div> <div style={{ fontSize: 10, color: '#555550', marginTop: 2, fontFamily: "'JetBrains Mono', monospace" }}>{ap.resource || '--'}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}> <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<button style={{ flex: 1, padding: '5px 0', border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}></button> <button style={{ flex: 1, padding: '5px 0', border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}>{t('approve')}</button>
<button style={{ flex: 1, padding: '5px 0', border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}></button> <button style={{ flex: 1, padding: '5px 0', border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}>{t('reject')}</button>
</div> </div>
</div> </div>
))} ))}
@@ -95,7 +95,7 @@ function AlertsAndApprovalsTab() {
// ============================================================================= // =============================================================================
function ActivityStreamTab() { function ActivityStreamTab() {
const tc = useTranslations('common') const t = useTranslations('dashboard')
const [events, setEvents] = useState<any[]>([]) const [events, setEvents] = useState<any[]>([])
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
@@ -116,19 +116,19 @@ function ActivityStreamTab() {
<div style={{ padding: '16px 20px', flex: 1, overflowY: 'auto' }}> <div style={{ padding: '16px 20px', flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: connected ? '#22C55E' : '#cc2200' }} /> <div style={{ width: 6, height: 6, borderRadius: '50%', background: connected ? '#22C55E' : '#cc2200' }} />
<span style={{ fontSize: 14, fontWeight: 700 }}></span> <span style={{ fontSize: 14, fontWeight: 700 }}>{t('activityStream')}</span>
<span style={{ fontSize: 11, color: '#87867f' }}>{connected ? 'SSE 連線中' : '連線中斷'} · {events.length} </span> <span style={{ fontSize: 11, color: '#87867f' }}>{connected ? t('sseConnected') : t('sseDisconnected')} · {events.length}</span>
</div> </div>
{events.length === 0 ? ( {events.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div> <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{t('waitingEvents')}</div>
) : events.map((ev, i) => ( ) : events.map((ev, i) => (
<div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: '0.5px solid #f0ede5', fontSize: 12 }}> <div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: '0.5px solid #f0ede5', fontSize: 12 }}>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 60, flexShrink: 0 }}>{ev._time}</span> <span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 60, flexShrink: 0 }}>{ev._time}</span>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: ev.type === 'HOST_UPDATE' ? '#22C55E' : '#4A90D9', marginTop: 5, flexShrink: 0 }} /> <span style={{ width: 5, height: 5, borderRadius: '50%', background: ev.type === 'HOST_UPDATE' ? '#22C55E' : '#4A90D9', marginTop: 5, flexShrink: 0 }} />
<span style={{ flex: 1, lineHeight: 1.4 }}> <span style={{ flex: 1, lineHeight: 1.4 }}>
<b>{ev.type || 'EVENT'}</b> <b>{ev.type || 'EVENT'}</b>
{ev.data?.overall_status && ` · 狀態: ${ev.data.overall_status}`} {ev.data?.overall_status && ` · ${t('statusLabel')}: ${ev.data.overall_status}`}
{ev.data?.hosts && ` · ${ev.data.hosts.length} 主機`} {ev.data?.hosts && ` · ${ev.data.hosts.length} ${t('hostsLabel')}`}
</span> </span>
</div> </div>
))} ))}
@@ -141,7 +141,7 @@ function ActivityStreamTab() {
// ============================================================================= // =============================================================================
function DispositionTab() { function DispositionTab() {
const tc = useTranslations('common') const t = useTranslations('dashboard')
const [data, setData] = useState<any>(null) const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -158,8 +158,8 @@ function DispositionTab() {
const s = data?.summary const s = data?.summary
if (!s || s.total === 0) return ( if (!s || s.total === 0) return (
<div style={{ padding: 48, textAlign: 'center', color: '#87867f' }}> <div style={{ padding: 48, textAlign: 'center', color: '#87867f' }}>
<div style={{ fontSize: 32, marginBottom: 8, opacity: 0.3 }}>📊</div> <div style={{ fontSize: 32, marginBottom: 8, opacity: 0.3 }}>--</div>
<div></div> <div>{t('noDispositionData')}</div>
</div> </div>
) )
@@ -170,25 +170,25 @@ function DispositionTab() {
{/* KPI 3 卡 */} {/* KPI 3 卡 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}> <div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div> <div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}>{t('totalDispositions')}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#4A90D9', marginTop: 4 }}>{s.total}</div> <div style={{ fontSize: 28, fontWeight: 700, color: '#4A90D9', marginTop: 4 }}>{s.total}</div>
</div> </div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}> <div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div> <div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}>{t('autoRate')}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#22C55E', marginTop: 4 }}>{autoRate}%</div> <div style={{ fontSize: 28, fontWeight: 700, color: '#22C55E', marginTop: 4 }}>{autoRate}%</div>
</div> </div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}> <div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div> <div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}>{t('humanRate')}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#F59E0B', marginTop: 4 }}>{Math.round(s.human_rate * 100)}%</div> <div style={{ fontSize: 28, fontWeight: 700, color: '#F59E0B', marginTop: 4 }}>{Math.round(s.human_rate * 100)}%</div>
</div> </div>
</div> </div>
{/* 四大計數 */} {/* 四大計數 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 16 }}>
{[ {[
{ label: '自動修復', count: s.auto_repair, color: '#22C55E', bg: 'rgba(34,197,94,0.06)', border: 'rgba(34,197,94,0.25)' }, { label: t('autoRepairLabel'), count: s.auto_repair, color: '#22C55E', bg: 'rgba(34,197,94,0.06)', border: 'rgba(34,197,94,0.25)' },
{ label: '人工審核', count: s.human_approved, color: '#F59E0B', bg: 'rgba(249,115,22,0.06)', border: 'rgba(249,115,22,0.25)' }, { label: t('humanApprovedLabel'), count: s.human_approved, color: '#F59E0B', bg: 'rgba(249,115,22,0.06)', border: 'rgba(249,115,22,0.25)' },
{ label: '手動處理', count: s.manual_resolved, color: '#A855F7', bg: 'rgba(168,85,247,0.06)', border: 'rgba(168,85,247,0.25)' }, { label: t('manualResolvedLabel'), count: s.manual_resolved, color: '#A855F7', bg: 'rgba(168,85,247,0.06)', border: 'rgba(168,85,247,0.25)' },
{ label: '冷啟動', count: s.cold_start_trust, color: '#4A90D9', bg: 'rgba(59,130,246,0.06)', border: 'rgba(59,130,246,0.25)' }, { label: t('coldStartLabel'), count: s.cold_start_trust, color: '#4A90D9', bg: 'rgba(59,130,246,0.06)', border: 'rgba(59,130,246,0.25)' },
].map(item => ( ].map(item => (
<div key={item.label} style={{ background: item.bg, border: `0.5px solid ${item.border}`, borderRadius: 10, padding: 14, textAlign: 'center' }}> <div key={item.label} style={{ background: item.bg, border: `0.5px solid ${item.border}`, borderRadius: 10, padding: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: item.color, marginBottom: 6 }}>{item.label}</div> <div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: item.color, marginBottom: 6 }}>{item.label}</div>
@@ -198,7 +198,7 @@ function DispositionTab() {
</div> </div>
{/* 堆疊分佈條 */} {/* 堆疊分佈條 */}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}> <div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 8 }}></div> <div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 8 }}>{t('dispositionBreakdown')}</div>
<div style={{ display: 'flex', height: 12, borderRadius: 6, overflow: 'hidden' }}> <div style={{ display: 'flex', height: 12, borderRadius: 6, overflow: 'hidden' }}>
{s.total > 0 && <> {s.total > 0 && <>
<div style={{ width: `${(s.auto_repair/s.total)*100}%`, background: '#22C55E' }} /> <div style={{ width: `${(s.auto_repair/s.total)*100}%`, background: '#22C55E' }} />
@@ -729,26 +729,27 @@ export default function Home({ params }: { params: { locale: string } }) {
// Sprint 5: 4 Tab 配置 (統帥批准 2026-04-08) // Sprint 5: 4 Tab 配置 (統帥批准 2026-04-08)
const alertsCount = incidents?.length ?? 0 const alertsCount = incidents?.length ?? 0
const tabs: TabConfig[] = [ const tabs: TabConfig[] = [
{ id: 'overview', label: '戰情總覽', content: null }, // Tab 1 用現有內容,下方直接渲染 { id: 'overview', label: tDashboard('tabs.overview'), content: null },
{ id: 'alerts', label: '告警 & 授權', badge: alertsCount > 0 ? alertsCount : undefined, content: <AlertsAndApprovalsTab /> }, { id: 'alerts', label: tDashboard('tabs.alerts'), badge: alertsCount > 0 ? alertsCount : undefined, content: <AlertsAndApprovalsTab /> },
{ id: 'stream', label: '活動串流', content: <ActivityStreamTab /> }, { id: 'stream', label: tDashboard('tabs.stream'), content: <ActivityStreamTab /> },
{ id: 'disposition', label: '處置統計', content: <DispositionTab /> }, { id: 'disposition', label: tDashboard('tabs.disposition'), content: <DispositionTab /> },
] ]
// Sprint 5: 從 URL 讀取當前 Tab // Sprint 5: 從 URL 讀取當前 Tab
const [activeTabId, setActiveTabId] = useState('overview') const [activeTabId, setActiveTabId] = useState('overview')
const [infraView, setInfraView] = useState<'host' | 'topo'>('host') const [infraView, setInfraView] = useState<'host' | 'topo'>('host')
// 每 100ms 檢查 URL query 變化PageTabs 用 router.push 更新) // I1 修正: popstate 取代 100ms 輪詢
useEffect(() => { useEffect(() => {
const check = () => { const syncTab = () => {
const params = new URLSearchParams(window.location.search) const tab = new URLSearchParams(window.location.search).get('tab') || 'overview'
const tab = params.get('tab') || 'overview'
setActiveTabId(prev => prev !== tab ? tab : prev) setActiveTabId(prev => prev !== tab ? tab : prev)
} }
check() syncTab()
const interval = setInterval(check, 100) window.addEventListener('popstate', syncTab)
return () => clearInterval(interval) // PageTabs router.push 後 URL 改變,用 hashchange + 定期低頻同步(1s)作為備援
const fallback = setInterval(syncTab, 1000)
return () => { window.removeEventListener('popstate', syncTab); clearInterval(fallback) }
}, []) }, [])
return ( return (