feat: preserve BI sub tab history
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user