feat: preserve BI sub tab history

This commit is contained in:
kkfluous
2026-08-07 17:31:09 +08:00
parent 39e869ad39
commit 52d79ba260
7 changed files with 109 additions and 26 deletions
+8 -2
View File
@@ -24,10 +24,16 @@ export default function ElectricModule() {
meta="时间单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="electric" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="electric-view" ariaLabel="电能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<ElectricView sub={sub} />
<div
id={`electric-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`electric-view-tab-${sub}`}
>
<ElectricView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
+8 -2
View File
@@ -24,10 +24,16 @@ export default function HydrogenModule() {
meta="数据单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="hydrogen" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
<div
id={`hydrogen-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`hydrogen-view-tab-${sub}`}
>
<HydrogenView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
+4 -2
View File
@@ -11,12 +11,14 @@ interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
idPrefix: string;
ariaLabel: string;
}
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
export default function SubTabs<T extends string>({ tabs, active, onChange, idPrefix, ariaLabel }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
<SegmentedNav tabs={tabs} active={active} onChange={onChange} idPrefix={idPrefix} ariaLabel={ariaLabel} />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildHashSubTab, parseHashSubTab } from './useHashSubTab.js';
const tabs = ['daily', 'overview'] as const;
test('parses valid sub tabs and falls back to the module default', () => {
assert.equal(parseHashSubTab('#electric/overview', 'electric', tabs), 'overview');
assert.equal(parseHashSubTab('#electric/unknown', 'electric', tabs), 'daily');
assert.equal(parseHashSubTab('#hydrogen/overview', 'electric', tabs), 'daily');
});
test('builds a compact default hash and an explicit secondary hash', () => {
assert.equal(buildHashSubTab('electric', 'daily', 'daily'), '#electric');
assert.equal(buildHashSubTab('electric', 'overview', 'daily'), '#electric/overview');
});
+23 -10
View File
@@ -1,5 +1,19 @@
import { useEffect, useState } from 'react';
export function parseHashSubTab<T extends string>(
hash: string,
moduleId: string,
subs: readonly T[],
): T {
const [first, second] = hash.replace(/^#/, '').split('/');
if (first !== moduleId) return subs[0];
return second && (subs as readonly string[]).includes(second) ? second as T : subs[0];
}
export function buildHashSubTab<T extends string>(moduleId: string, sub: T, defaultSub: T): string {
return sub === defaultSub ? `#${moduleId}` : `#${moduleId}/${sub}`;
}
/**
* 把模块内子 tab 状态同步到 URL hash 二级段。
* hash 形如 `#<moduleId>`= 默认 sub)或 `#<moduleId>/<sub>`。
@@ -11,26 +25,25 @@ export function useHashSubTab<T extends string>(
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
window.addEventListener('popstate', onChange);
return () => {
window.removeEventListener('hashchange', onChange);
window.removeEventListener('popstate', onChange);
};
}, [moduleId]);
const setSub = (next: T) => {
if (next === sub) return;
const { pathname, search } = window.location;
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
const newHash = buildHashSubTab(moduleId, next, defaultSub);
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
+20 -8
View File
@@ -32,18 +32,30 @@ export default function MileageModule() {
compactInfo
>
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
<SegmentedNav
tabs={MILEAGE_TABS}
active={activeSubTab}
onChange={setActiveSubTab}
idPrefix="mileage-view"
ariaLabel="里程分析视图"
/>
</div>
<AnimatePresence mode="wait">
<FadeIn key={activeSubTab}>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
<div
id={`mileage-view-panel-${activeSubTab}`}
role="tabpanel"
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
</div>
</FadeIn>
</AnimatePresence>
<RotatingFooterHint />