From fdef0a940acccbb477bb56c66cb543503bc347fc Mon Sep 17 00:00:00 2001 From: lnljyang <506960565@qq.com> Date: Thu, 14 May 2026 17:33:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E8=8F=9C=E5=8D=95=20?= =?UTF-8?q?=E9=80=9A=E8=BF=87url=E5=8C=BA=E5=88=86=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 161 ++++++++++++++++++++------ src/components/Shell.tsx | 34 +++--- src/modules/energy/ElectricModule.tsx | 23 ++++ src/modules/energy/EnergyModule.tsx | 86 -------------- src/modules/energy/EtcModule.tsx | 11 ++ src/modules/energy/HydrogenModule.tsx | 23 ++++ src/modules/energy/SubTabs.tsx | 39 +++++++ src/modules/energy/useHashSubTab.ts | 38 ++++++ 8 files changed, 271 insertions(+), 144 deletions(-) create mode 100644 src/modules/energy/ElectricModule.tsx delete mode 100644 src/modules/energy/EnergyModule.tsx create mode 100644 src/modules/energy/EtcModule.tsx create mode 100644 src/modules/energy/HydrogenModule.tsx create mode 100644 src/modules/energy/SubTabs.tsx create mode 100644 src/modules/energy/useHashSubTab.ts diff --git a/src/App.tsx b/src/App.tsx index 383ca98..3a42fb0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,60 +1,141 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Truck, Route, Activity, Zap } from 'lucide-react'; -import { Shell, type ModuleConfig } from './components/Shell'; -import AssetsModule from './modules/assets/AssetsModule'; -import MileageModule from './modules/mileage/MileageModule'; -import SchedulingModule from './modules/scheduling/SchedulingModule'; -import EnergyModule from './modules/energy/EnergyModule'; -import EleImportPage from './modules/ele/EleImportPage'; -import FeedbackAdminPage from './modules/admin/FeedbackAdminPage'; -import AuthProvider from './auth/AuthProvider'; -import { useAuth } from './auth/useAuth'; -import UnauthorizedPage from './auth/UnauthorizedPage'; -import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles'; +import { useEffect, useMemo, useState } from "react"; +import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react"; +import { Shell, type ModuleConfig } from "./components/Shell"; +import AssetsModule from "./modules/assets/AssetsModule"; +import MileageModule from "./modules/mileage/MileageModule"; +import SchedulingModule from "./modules/scheduling/SchedulingModule"; +import HydrogenModule from "./modules/energy/HydrogenModule"; +import ElectricModule from "./modules/energy/ElectricModule"; +import EtcModule from "./modules/energy/EtcModule"; +import EleImportPage from "./modules/ele/EleImportPage"; +import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage"; +import AuthProvider from "./auth/AuthProvider"; +import { useAuth } from "./auth/useAuth"; +import UnauthorizedPage from "./auth/UnauthorizedPage"; +import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles"; -const BASE_MODULES: ModuleConfig[] = [ - { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, - { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, -]; +const ASSETS_MODULE: ModuleConfig = { + id: "assets", + label: "资产管理", + icon: Truck, + component: AssetsModule, +}; -const ENERGY_MODULE: ModuleConfig = { - id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule, +const MILEAGE_MODULE: ModuleConfig = { + id: "mileage", + label: "里程管理", + icon: Route, + component: MileageModule, }; 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 = { + "/": { 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 { - if (typeof window === 'undefined') return ''; + if (typeof window === "undefined") return ""; const path = window.location.pathname; const hash = window.location.hash; - if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import'; - if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback'; - return ''; + if ( + path === "/ele/import" || + 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() { const { isLoading, isAuthenticated, error, user } = useAuth(); const [routeKey, setRouteKey] = useState(getRouteKey); + const [pathSet, setPathSet] = useState(getPathSet); - // 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效 + // 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效 useEffect(() => { - const update = () => setRouteKey(getRouteKey()); - window.addEventListener('hashchange', update); - window.addEventListener('popstate', update); + const update = () => { + setRouteKey(getRouteKey()); + setPathSet(getPathSet()); + }; + window.addEventListener("hashchange", update); + window.addEventListener("popstate", update); return () => { - window.removeEventListener('hashchange', update); - window.removeEventListener('popstate', update); + window.removeEventListener("hashchange", update); + window.removeEventListener("popstate", update); }; }, []); - const modules = useMemo(() => { - const result = [...BASE_MODULES]; - if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE); + const modules = useMemo(() => { + if (pathSet === "energy") { + return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE]; + } + const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE]; if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE); return result; - }, [user?.roles]); + }, [pathSet, user?.roles]); if (isLoading) { return ( @@ -72,10 +153,16 @@ function AuthGate() { } // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 - if (routeKey === 'ele/import') return ; - if (routeKey === 'admin/feedback') return ; + if (routeKey === "ele/import") return ; + if (routeKey === "admin/feedback") return ; - return ; + // /energy 整组按能源权限控制 + if (pathSet === "energy" && !canAccessEnergy(user?.roles)) { + return ; + } + + // key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule + return ; } export default function App() { diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 33137ef..863be5f 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -10,29 +10,20 @@ export interface ModuleConfig { component: ComponentType; } -/** path 到模块 id 的映射 */ -const PATH_MAP: Record = { - '/vehicle': 'assets', - '/assets': 'assets', - '/mileage': 'mileage', - '/scheduling': 'scheduling', - '/energy': 'energy', -}; +/** hash 一级段(`#` 或 `#/` 都只取 id) */ +function getHashHead(): string { + return window.location.hash.slice(1).split('/')[0]; +} function getInitialModule(modules: ModuleConfig[]): string { - // 优先看 hash - const hash = window.location.hash.slice(1); - 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; - // 默认第一个 + const head = getHashHead(); + if (modules.some((m) => m.id === head)) return head; return modules[0]?.id ?? ''; } function getHashModule(modules: ModuleConfig[]): string { - const hash = window.location.hash.slice(1); - return modules.some((m) => m.id === hash) ? hash : ''; + const head = getHashHead(); + return modules.some((m) => m.id === head) ? head : ''; } export function Shell({ modules }: { modules: ModuleConfig[] }) { @@ -48,16 +39,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { }, [modules]); useEffect(() => { - // 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录, - // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出 - if (window.location.hash.slice(1) !== activeModule) { + // 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录, + // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。 + // 注意只比对一级段,避免把子模块写入的 `#/` 二级段抹掉。 + if (getHashHead() !== activeModule) { const { pathname, search } = window.location; window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`); } }, [activeModule]); const switchModule = (id: string) => { - if (window.location.hash.slice(1) === id) return; + if (getHashHead() === id) return; const { pathname, search } = window.location; window.history.replaceState(null, '', `${pathname}${search}#${id}`); setActiveModule(id); diff --git a/src/modules/energy/ElectricModule.tsx b/src/modules/energy/ElectricModule.tsx new file mode 100644 index 0000000..d629473 --- /dev/null +++ b/src/modules/energy/ElectricModule.tsx @@ -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('electric', SUB_IDS); + return ( +
+
+ + +
+
+ ); +} diff --git a/src/modules/energy/EnergyModule.tsx b/src/modules/energy/EnergyModule.tsx deleted file mode 100644 index fb68a16..0000000 --- a/src/modules/energy/EnergyModule.tsx +++ /dev/null @@ -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('hydrogen'); - const [hydroSub, setHydroSub] = useState('daily'); - const [electricSub, setElectricSub] = useState('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 ( -
-
- - {/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */} - {/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */} -
-
- {/* 顶部 tab:氢能 / 电能 / ETC */} -
- {TABS.map(tab => { - const Icon = tab.icon; - const active = activeTab === tab.key; - return ( - - ); - })} -
- {/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */} - {showSubTabs && ( -
- {SUB_TABS.map(({ id, label, icon: Icon }) => { - const active = currentSub === id; - return ( - - ); - })} -
- )} -
-
- - {activeTab === 'hydrogen' && } - {activeTab === 'electric' && } - {activeTab === 'etc' && } -
-
- ); -} diff --git a/src/modules/energy/EtcModule.tsx b/src/modules/energy/EtcModule.tsx new file mode 100644 index 0000000..626bf92 --- /dev/null +++ b/src/modules/energy/EtcModule.tsx @@ -0,0 +1,11 @@ +import ETCView from './ETCView'; + +export default function EtcModule() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/modules/energy/HydrogenModule.tsx b/src/modules/energy/HydrogenModule.tsx new file mode 100644 index 0000000..bb3cdbe --- /dev/null +++ b/src/modules/energy/HydrogenModule.tsx @@ -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('hydrogen', SUB_IDS); + return ( +
+
+ + +
+
+ ); +} diff --git a/src/modules/energy/SubTabs.tsx b/src/modules/energy/SubTabs.tsx new file mode 100644 index 0000000..c323171 --- /dev/null +++ b/src/modules/energy/SubTabs.tsx @@ -0,0 +1,39 @@ +import type { ComponentType } from 'react'; + +interface SubTab { + id: T; + label: string; + icon: ComponentType<{ size?: number; className?: string }>; +} + +interface Props { + tabs: readonly SubTab[]; + active: T; + onChange: (id: T) => void; +} + +export default function SubTabs({ tabs, active, onChange }: Props) { + return ( +
+
+
+ {tabs.map(({ id, label, icon: Icon }) => { + const isActive = active === id; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/modules/energy/useHashSubTab.ts b/src/modules/energy/useHashSubTab.ts new file mode 100644 index 0000000..2a74049 --- /dev/null +++ b/src/modules/energy/useHashSubTab.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; + +/** + * 把模块内子 tab 状态同步到 URL hash 二级段。 + * hash 形如 `#`(= 默认 sub)或 `#/`。 + * 默认值不写入 hash,刷新页面可恢复。 + */ +export function useHashSubTab( + 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(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]; +}