Files
oneos-truck-date-prototype/app.jsx
kkfluous b2d0016a0d
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
init: 羚牛车辆数据中心原型 + 部署配置
- React 18 + Babel-in-browser SPA 原型,覆盖 8 个画板:
  实时地图 / 车辆详情 / 历史查询 / 轨迹回放 / 事件规则 / 通知中心 / ESG 碳减排 / 移动端
- 设计系统:IBM Plex Sans + JetBrains Mono,亮/暗双主题,羚牛绿 #007143
- 数据模型:12 + 40 辆车,TBOX (T) / JT808+1078 (JT) / 双源 (B)
- 部署:nginx 静态托管,Dockerfile + woodpecker.yml + docker-compose.yml
- 镜像:harbor.lnh2e.com/lingniu-v1/ln-vdc:<branch>-<VERSION>
- 容器端口 80,宿主映射 8112,含 /healthz 探活
2026-04-28 15:12:32 +08:00

258 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.jsx — SPA router + responsive shell for 羚牛车辆数据中心
// hash routes: #/, #/overview, #/detail, #/history, #/playback, #/alarm, #/inbox, #/esg, #/canvas
const ROUTES = [
{ path: "overview", icon: "map", label: "实时地图", crumbs: ["羚牛车辆数据中心", "实时监控", "总览"], component: "ArtboardOverview" },
{ path: "detail", icon: "car", label: "车辆详情", crumbs: ["羚牛车辆数据中心", "实时监控", "单车详情"], component: "ArtboardDetail" },
{ path: "history", icon: "history", label: "历史查询", crumbs: ["羚牛车辆数据中心", "数据分析", "历史查询"], component: "ArtboardHistory" },
{ path: "playback", icon: "route", label: "轨迹回放", crumbs: ["羚牛车辆数据中心", "数据分析", "轨迹回放"], component: "ArtboardPlayback" },
{ path: "alarm", icon: "bell", label: "事件规则", crumbs: ["羚牛车辆数据中心", "事件中心", "规则编排"], component: "ArtboardAlarm" },
{ path: "inbox", icon: "inbox", label: "通知中心", crumbs: ["羚牛车辆数据中心", "事件中心", "通知中心"], component: "ArtboardInbox" },
];
const SUB_ROUTES = [
{ path: "esg", icon: "chart", label: "ESG·碳减排", crumbs: ["羚牛车辆数据中心", "运营分析", "ESG驾驶舱"], component: "ArtboardESG" },
{ path: "canvas", icon: "settings", label: "设计画板", crumbs: ["羚牛车辆数据中心", "设计画板"], 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;