Files
oneos-truck-date-prototype/app.jsx
kkfluous e38bd8a1d8
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(integration): 新增数据接入监控页
- 字段:车牌 / VIN / 品牌+型号 / GB32960 状态+最后接收 / JT808 状态+最后接收 / 接入时间
- 状态:在线 / 断流 / 未对接,三色 pill + 脉冲点
- 重点标记:双协议均未对接的车辆 → 行红底 + 警示图标 + 顶部 banner
- 工具栏:搜索(车牌/VIN/品牌/型号)+ 5 维筛选 + 4 维排序 + CSV 导出
- KPI:总车辆 / GB 在线 / GB 断流 / JT 在线 / JT 断流 / 完全未对接
- 数据:fleet.js 增 brand/model/gbStatus/gbLastSeen/jtStatus/jtLastSeen/onboardAt
- 路由 #/integration · sidebar 增 plug 图标项
2026-04-28 15:45:59 +08:00

259 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" },
{ path: "integration", icon: "plug", label: "数据接入监控", crumbs: ["羚牛车辆数据中心", "数据接入", "监控总览"], component: "ArtboardIntegration" },
];
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;