All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
13 KiB
JavaScript
259 lines
13 KiB
JavaScript
// app.jsx — SPA router + responsive shell for OneOS数据中台
|
||
// hash routes: #/, #/overview, #/detail, #/history, #/playback, #/alarm, #/inbox, #/esg, #/canvas
|
||
|
||
const ROUTES = [
|
||
{ path: "overview", icon: "map", label: "实时地图", crumbs: ["OneOS数据中台", "实时监控", "总览"], component: "ArtboardOverview" },
|
||
{ path: "detail", icon: "car", label: "车辆详情", crumbs: ["OneOS数据中台", "实时监控", "单车详情"], component: "ArtboardDetail" },
|
||
{ path: "history", icon: "history", label: "历史查询", crumbs: ["OneOS数据中台", "数据分析", "历史查询"], component: "ArtboardHistory" },
|
||
{ path: "playback", icon: "route", label: "轨迹回放", crumbs: ["OneOS数据中台", "数据分析", "轨迹回放"], component: "ArtboardPlayback" },
|
||
{ path: "alarm", icon: "bell", label: "事件规则", crumbs: ["OneOS数据中台", "事件中心", "规则编排"], component: "ArtboardAlarm" },
|
||
{ path: "inbox", icon: "inbox", label: "通知中心", crumbs: ["OneOS数据中台", "事件中心", "通知中心"], component: "ArtboardInbox" },
|
||
{ path: "integration", icon: "plug", label: "数据接入监控", crumbs: ["OneOS数据中台", "数据接入", "监控总览"], component: "ArtboardIntegration" },
|
||
];
|
||
|
||
const SUB_ROUTES = [
|
||
{ path: "esg", icon: "chart", label: "ESG·碳减排", crumbs: ["OneOS数据中台", "运营分析", "ESG驾驶舱"], component: "ArtboardESG" },
|
||
{ path: "canvas", icon: "settings", label: "设计画板", crumbs: ["OneOS数据中台", "设计画板"], component: "DesignCanvasMode" },
|
||
];
|
||
|
||
const ALL_ROUTES = [...ROUTES, ...SUB_ROUTES];
|
||
const DEFAULT_ROUTE = "overview";
|
||
|
||
// ── Hash router hook ────────────────────────────────────────
|
||
const useHashRoute = () => {
|
||
const parse = () => {
|
||
const h = (window.location.hash || "").replace(/^#\/?/, "").split("?")[0];
|
||
return h || DEFAULT_ROUTE;
|
||
};
|
||
const [route, setRoute] = React.useState(parse());
|
||
React.useEffect(() => {
|
||
const onHash = () => setRoute(parse());
|
||
window.addEventListener('hashchange', onHash);
|
||
return () => window.removeEventListener('hashchange', onHash);
|
||
}, []);
|
||
const navigate = (p) => { window.location.hash = "#/" + p; };
|
||
return [route, navigate];
|
||
};
|
||
|
||
// ── Viewport hook ───────────────────────────────────────────
|
||
const useIsMobile = () => {
|
||
const [m, setM] = React.useState(() => window.innerWidth < 900);
|
||
React.useEffect(() => {
|
||
const on = () => setM(window.innerWidth < 900);
|
||
window.addEventListener('resize', on);
|
||
return () => window.removeEventListener('resize', on);
|
||
}, []);
|
||
return m;
|
||
};
|
||
|
||
// ── Responsive sidebar (desktop rail / mobile drawer) ───────
|
||
const RouterSidebar = ({ active, onNavigate, isMobile, drawerOpen, onCloseDrawer }) => {
|
||
const renderItem = (i) => (
|
||
<div
|
||
key={i.path}
|
||
className={"nav-item" + (i.path === active ? " active" : "")}
|
||
title={i.label}
|
||
onClick={() => { onNavigate(i.path); onCloseDrawer && onCloseDrawer(); }}
|
||
style={isMobile ? {width:"100%", height:44, display:"flex", justifyContent:"flex-start", padding:"0 16px", gap:14, borderRadius:8} : {}}
|
||
>
|
||
<Icon name={i.icon} size={18}/>
|
||
{isMobile && <span style={{fontSize:14}}>{i.label}</span>}
|
||
</div>
|
||
);
|
||
|
||
if (isMobile) {
|
||
return (
|
||
<>
|
||
{drawerOpen && (
|
||
<div onClick={onCloseDrawer} style={{
|
||
position:"fixed", inset:0, background:"oklch(0 0 0 / 0.4)", zIndex:50, backdropFilter:"blur(2px)"
|
||
}}/>
|
||
)}
|
||
<div className="sidebar mobile" style={{
|
||
position:"fixed", left:0, top:0, bottom:0, width:260, zIndex:51,
|
||
background:"var(--bg-1)", borderRight:"1px solid var(--border-1)",
|
||
padding:"16px 12px", alignItems:"stretch", gap:2,
|
||
transform: drawerOpen ? "translateX(0)" : "translateX(-100%)",
|
||
transition:"transform 220ms cubic-bezier(.4,0,.2,1)",
|
||
}}>
|
||
<div style={{display:"flex", alignItems:"center", gap:10, padding:"4px 8px 16px"}}>
|
||
<img src="assets/logo_light.svg" alt="羚牛" style={{height:28, display:"block"}}/>
|
||
<div style={{borderLeft:"1px solid var(--border-1)", paddingLeft:10}}>
|
||
<div style={{fontWeight:600, fontSize:13, color:"var(--fg-0)"}}>车辆数据中心</div>
|
||
<div style={{fontSize:10, color:"var(--fg-3)", letterSpacing:"0.04em"}}>氢能乘用车队</div>
|
||
</div>
|
||
</div>
|
||
{ROUTES.map(renderItem)}
|
||
<div className="sidebar-divider" style={{margin:"12px 8px"}}/>
|
||
{SUB_ROUTES.map(renderItem)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="sidebar">
|
||
<div className="logo" title="羚牛 · Lingniu" onClick={() => onNavigate(DEFAULT_ROUTE)} style={{
|
||
cursor:"pointer",
|
||
background:"#FFFFFF",
|
||
border:"1px solid var(--border-1)",
|
||
boxShadow:"0 1px 2px rgba(47,40,40,.06)",
|
||
overflow:"hidden",
|
||
padding:0,
|
||
}}>
|
||
<img src="assets/logo_light.svg" alt="羚牛"
|
||
style={{height:24, width:"auto", maxWidth:"none", marginLeft:-3, display:"block", pointerEvents:"none"}}/>
|
||
</div>
|
||
{ROUTES.map(renderItem)}
|
||
<div className="sidebar-divider"/>
|
||
{SUB_ROUTES.map(renderItem)}
|
||
<div style={{flex:1}}/>
|
||
<div className="avatar" title="张工">ZG</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── Page wrapper: provides full-bleed canvas + page transition ──
|
||
const Page = ({ children, route }) => (
|
||
<div key={route} style={{
|
||
width:"100%", height:"100%", position:"relative",
|
||
animation:"pageFade 180ms cubic-bezier(.2,0,.2,1)",
|
||
}}>
|
||
{children}
|
||
</div>
|
||
);
|
||
|
||
// ── Mobile topbar (replaces desktop topbar on small screens) ──
|
||
const MobileTopbar = ({ title, onMenu, onSearch }) => (
|
||
<div style={{
|
||
height:52, flex:"0 0 52px", display:"flex", alignItems:"center", padding:"0 12px", gap:8,
|
||
background:"var(--bg-1)", borderBottom:"1px solid var(--border-1)", zIndex:2,
|
||
}}>
|
||
<button onClick={onMenu} style={{
|
||
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
|
||
background:"transparent", border:"none", color:"var(--fg-1)", cursor:"pointer",
|
||
}} aria-label="菜单">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
<div style={{fontWeight:600, fontSize:15, color:"var(--fg-0)", flex:1}}>{title}</div>
|
||
<button style={{
|
||
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
|
||
background:"transparent", border:"none", color:"var(--fg-2)",
|
||
}} aria-label="搜索"><Icon name="search" size={16}/></button>
|
||
<button style={{
|
||
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
|
||
background:"transparent", border:"none", color:"var(--fg-2)", position:"relative",
|
||
}} aria-label="通知"><Icon name="bell" size={16}/><span className="dot" style={{position:"absolute", top:8, right:8}}/></button>
|
||
</div>
|
||
);
|
||
|
||
// ── Canvas mode (preserves the original design board) ────────
|
||
const DesignCanvasMode = () => {
|
||
const W = 1440, H = 900;
|
||
return (
|
||
<div style={{width:"100%", height:"100%", overflow:"hidden", background:"var(--bg-0)"}}>
|
||
<DesignCanvas>
|
||
<DCSection id="primary" title="① 主总览 · 实时监控" subtitle="地图主导布局 · 三栏:车辆列表 / 地图 / 详情面板">
|
||
<DCArtboard id="overview" label="A · 主总览(深色驾驶舱)" width={W} height={H}><ArtboardOverview/></DCArtboard>
|
||
<DCArtboard id="dense" label="B · 信息密集驾驶舱(KPI网格)" width={W} height={H}><ArtboardDense/></DCArtboard>
|
||
<DCArtboard id="light" label="C · 浅色变体(极简)" width={W} height={H}><ArtboardLightVariant/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="detail" title="② 单车详情">
|
||
<DCArtboard id="detail" label="单车详情页" width={W} height={H}><ArtboardDetail/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="history" title="③ 历史信息查询">
|
||
<DCArtboard id="history" label="历史查询" width={W} height={H}><ArtboardHistory/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="playback" title="④ 轨迹回放">
|
||
<DCArtboard id="playback" label="轨迹回放" width={W} height={H}><ArtboardPlayback/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="alarm" title="⑤ 事件规则引擎">
|
||
<DCArtboard id="alarm" label="规则编排" width={W} height={H}><ArtboardAlarm/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="esg" title="ESG · 碳减排驾驶舱">
|
||
<DCArtboard id="esg" label="ESG·碳减排(全国)" width={1440} height={900}><ArtboardESG/></DCArtboard>
|
||
</DCSection>
|
||
<DCSection id="inbox" title="⑥ 通知中心">
|
||
<DCArtboard id="inbox" label="通知中心" width={W} height={H}><ArtboardInbox/></DCArtboard>
|
||
</DCSection>
|
||
</DesignCanvas>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── Component lookup (deferred to render time so window globals are ready) ──
|
||
const RESOLVE = (name) => window[name];
|
||
|
||
// ── Main router app ─────────────────────────────────────────
|
||
const RouterApp = () => {
|
||
const [route, navigate] = useHashRoute();
|
||
const isMobile = useIsMobile();
|
||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||
|
||
const meta = ALL_ROUTES.find(r => r.path === route) || ALL_ROUTES[0];
|
||
const Cmp = RESOLVE(meta.component) || (() => <div style={{padding:40}}>页面 {route} 不存在</div>);
|
||
|
||
// close drawer on route change
|
||
React.useEffect(() => { setDrawerOpen(false); }, [route]);
|
||
|
||
// canvas mode = full-bleed, no chrome
|
||
if (route === "canvas") {
|
||
return (
|
||
<div className="app" style={{flexDirection: isMobile ? "column" : "row"}} data-screen-label={meta.label}>
|
||
<RouterSidebar active={route} onNavigate={navigate} isMobile={isMobile} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
|
||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, background:"var(--bg-0)"}}>
|
||
{isMobile && <MobileTopbar title={meta.label} onMenu={() => setDrawerOpen(true)}/>}
|
||
<div style={{flex:1, position:"relative", overflow:"hidden"}}>
|
||
<Page route={route}><DesignCanvasMode/></Page>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Pages render with their own internal chrome (overview etc include sidebar+topbar inside)
|
||
// To avoid double chrome, pages get rendered as full-page content — and we DON'T add an outer shell here.
|
||
// Instead, we inject a route-aware Sidebar+Topbar via the `chrome` system.
|
||
// SIMPLER: pages own their own .app .sidebar .topbar via the artboard.
|
||
// We need to intercept their sidebar to make it clickable.
|
||
// So we use a wrapper that overrides chrome by passing context.
|
||
|
||
// On mobile: render purpose-built MobileRouter with native single-column layouts
|
||
if (isMobile && window.MobileRouter && route !== "canvas") {
|
||
return (
|
||
<RouteContext.Provider value={{ route, navigate, isMobile, openDrawer: () => setDrawerOpen(true) }}>
|
||
<div data-screen-label={meta.label} style={{width:"100%", height:"100%", position:"relative", overflow:"hidden"}}>
|
||
<window.MobileRouter route={route}/>
|
||
<RouterSidebar active={route} onNavigate={navigate} isMobile={true} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
|
||
</div>
|
||
</RouteContext.Provider>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<RouteContext.Provider value={{ route, navigate, isMobile, openDrawer: () => setDrawerOpen(true) }}>
|
||
<div data-screen-label={meta.label} style={{width:"100%", height:"100%", position:"relative"}}>
|
||
<Page route={route}>
|
||
<Cmp/>
|
||
</Page>
|
||
{isMobile && (
|
||
<RouterSidebar active={route} onNavigate={navigate} isMobile={true} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
|
||
)}
|
||
</div>
|
||
</RouteContext.Provider>
|
||
);
|
||
};
|
||
|
||
// ── Context for child artboards to read route & nav ─────────
|
||
const RouteContext = React.createContext({ route: DEFAULT_ROUTE, navigate: () => {}, isMobile: false, openDrawer: () => {} });
|
||
const useRoute = () => React.useContext(RouteContext);
|
||
|
||
window.RouterApp = RouterApp;
|
||
window.useRoute = useRoute;
|
||
window.RouteContext = RouteContext;
|
||
window.ROUTES = ROUTES;
|
||
window.SUB_ROUTES = SUB_ROUTES;
|
||
window.ALL_ROUTES = ALL_ROUTES;
|
||
window.MobileTopbar = MobileTopbar;
|
||
window.RouterSidebar = RouterSidebar;
|