Compare commits
1 Commits
dev/local-
...
dev-ljyang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdef0a940a |
161
src/App.tsx
161
src/App.tsx
@@ -1,60 +1,141 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from "./modules/assets/AssetsModule";
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from "./modules/mileage/MileageModule";
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import SchedulingModule from "./modules/scheduling/SchedulingModule";
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
import HydrogenModule from "./modules/energy/HydrogenModule";
|
||||||
import EleImportPage from './modules/ele/EleImportPage';
|
import ElectricModule from "./modules/energy/ElectricModule";
|
||||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
import EtcModule from "./modules/energy/EtcModule";
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import EleImportPage from "./modules/ele/EleImportPage";
|
||||||
import { useAuth } from './auth/useAuth';
|
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage";
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
import { useAuth } from "./auth/useAuth";
|
||||||
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
|
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const ASSETS_MODULE: ModuleConfig = {
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
id: "assets",
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
label: "资产管理",
|
||||||
];
|
icon: Truck,
|
||||||
|
component: AssetsModule,
|
||||||
|
};
|
||||||
|
|
||||||
const ENERGY_MODULE: ModuleConfig = {
|
const MILEAGE_MODULE: ModuleConfig = {
|
||||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
id: "mileage",
|
||||||
|
label: "里程管理",
|
||||||
|
icon: Route,
|
||||||
|
component: MileageModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
id: "scheduling",
|
||||||
|
label: "智能调度",
|
||||||
|
icon: Activity,
|
||||||
|
component: SchedulingModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HYDROGEN_MODULE: ModuleConfig = {
|
||||||
|
id: "hydrogen",
|
||||||
|
label: "氢能",
|
||||||
|
icon: Fuel,
|
||||||
|
component: HydrogenModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELECTRIC_MODULE: ModuleConfig = {
|
||||||
|
id: "electric",
|
||||||
|
label: "电能",
|
||||||
|
icon: BatteryCharging,
|
||||||
|
component: ElectricModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ETC_MODULE: ModuleConfig = {
|
||||||
|
id: "etc",
|
||||||
|
label: "ETC",
|
||||||
|
icon: Receipt,
|
||||||
|
component: EtcModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||||
|
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||||
|
*/
|
||||||
|
function normalizePath() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const { pathname, hash, search } = window.location;
|
||||||
|
|
||||||
|
// 主路径 & 隐藏后台页保持不变
|
||||||
|
if (pathname === "/asset" || pathname === "/energy") return;
|
||||||
|
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||||
|
|
||||||
|
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||||
|
"/": { path: "/asset" },
|
||||||
|
"/vehicle": { path: "/asset", hash: "assets" },
|
||||||
|
"/assets": { path: "/asset", hash: "assets" },
|
||||||
|
"/mileage": { path: "/asset", hash: "mileage" },
|
||||||
|
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||||
|
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||||
|
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||||
|
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePath();
|
||||||
|
|
||||||
|
type PathSet = "asset" | "energy";
|
||||||
|
|
||||||
|
function getPathSet(): PathSet {
|
||||||
|
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||||
|
}
|
||||||
|
|
||||||
function getRouteKey(): string {
|
function getRouteKey(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === "undefined") return "";
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
if (
|
||||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
path === "/ele/import" ||
|
||||||
return '';
|
hash === "#/ele/import" ||
|
||||||
|
hash === "#ele/import"
|
||||||
|
)
|
||||||
|
return "ele/import";
|
||||||
|
if (
|
||||||
|
path === "/admin/feedback" ||
|
||||||
|
hash === "#/admin/feedback" ||
|
||||||
|
hash === "#admin/feedback"
|
||||||
|
)
|
||||||
|
return "admin/feedback";
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||||
|
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||||
|
|
||||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setRouteKey(getRouteKey());
|
const update = () => {
|
||||||
window.addEventListener('hashchange', update);
|
setRouteKey(getRouteKey());
|
||||||
window.addEventListener('popstate', update);
|
setPathSet(getPathSet());
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", update);
|
||||||
|
window.addEventListener("popstate", update);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', update);
|
window.removeEventListener("hashchange", update);
|
||||||
window.removeEventListener('popstate', update);
|
window.removeEventListener("popstate", update);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
const modules = useMemo<ModuleConfig[]>(() => {
|
||||||
const result = [...BASE_MODULES];
|
if (pathSet === "energy") {
|
||||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||||
|
}
|
||||||
|
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
|
||||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||||
return result;
|
return result;
|
||||||
}, [user?.roles]);
|
}, [pathSet, user?.roles]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -72,10 +153,16 @@ function AuthGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
if (routeKey === "ele/import") return <EleImportPage />;
|
||||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
if (routeKey === "admin/feedback") return <FeedbackAdminPage />;
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
// /energy 整组按能源权限控制
|
||||||
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||||
|
return <Shell key={pathSet} modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
@@ -10,29 +10,20 @@ export interface ModuleConfig {
|
|||||||
component: ComponentType;
|
component: ComponentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** path 到模块 id 的映射 */
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
const PATH_MAP: Record<string, string> = {
|
function getHashHead(): string {
|
||||||
'/vehicle': 'assets',
|
return window.location.hash.slice(1).split('/')[0];
|
||||||
'/assets': 'assets',
|
}
|
||||||
'/mileage': 'mileage',
|
|
||||||
'/scheduling': 'scheduling',
|
|
||||||
'/energy': 'energy',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
// 优先看 hash
|
const head = getHashHead();
|
||||||
const hash = window.location.hash.slice(1);
|
if (modules.some((m) => m.id === head)) return head;
|
||||||
if (modules.some((m) => m.id === hash)) return hash;
|
|
||||||
// 再看 pathname
|
|
||||||
const pathModule = PATH_MAP[window.location.pathname];
|
|
||||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
||||||
// 默认第一个
|
|
||||||
return modules[0]?.id ?? '';
|
return modules[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashModule(modules: ModuleConfig[]): string {
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
const hash = window.location.hash.slice(1);
|
const head = getHashHead();
|
||||||
return modules.some((m) => m.id === hash) ? hash : '';
|
return modules.some((m) => m.id === head) ? head : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
@@ -48,16 +39,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [modules]);
|
}, [modules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||||
if (window.location.hash.slice(1) !== activeModule) {
|
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||||
|
if (getHashHead() !== activeModule) {
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||||
}
|
}
|
||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (window.location.hash.slice(1) === id) return;
|
if (getHashHead() === id) return;
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
|
|||||||
23
src/modules/energy/ElectricModule.tsx
Normal file
23
src/modules/energy/ElectricModule.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function ElectricModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|
||||||
import ETCView from './ETCView';
|
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
|
||||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
|
||||||
|
|
||||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
|
||||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
|
||||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
|
||||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnergyModule() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
|
||||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
|
||||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
|
||||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
|
||||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
|
||||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
|
||||||
|
|
||||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
|
||||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
|
||||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
|
||||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const active = activeTab === tab.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
|
||||||
{active && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
|
||||||
{showSubTabs && (
|
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = currentSub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
|
||||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
|
||||||
{activeTab === 'etc' && <ETCView />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
11
src/modules/energy/EtcModule.tsx
Normal file
11
src/modules/energy/EtcModule.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import ETCView from './ETCView';
|
||||||
|
|
||||||
|
export default function EtcModule() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<ETCView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/modules/energy/HydrogenModule.tsx
Normal file
23
src/modules/energy/HydrogenModule.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function HydrogenModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<HydrogenView sub={sub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/modules/energy/SubTabs.tsx
Normal file
39
src/modules/energy/SubTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
interface SubTab<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
tabs: readonly SubTab<T>[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-1 flex gap-1">
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = active === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||||
|
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/modules/energy/useHashSubTab.ts
Normal file
38
src/modules/energy/useHashSubTab.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||||
|
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||||
|
* 默认值不写入 hash,刷新页面可恢复。
|
||||||
|
*/
|
||||||
|
export function useHashSubTab<T extends string>(
|
||||||
|
moduleId: string,
|
||||||
|
subs: readonly T[],
|
||||||
|
): [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 [sub, setSubState] = useState<T>(parse);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setSubState(parse());
|
||||||
|
window.addEventListener('hashchange', onChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onChange);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
const setSub = (next: T) => {
|
||||||
|
const { pathname, search } = window.location;
|
||||||
|
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
||||||
|
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
||||||
|
setSubState(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [sub, setSub];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user