init: 羚牛车辆数据中心原型 + 部署配置
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
- 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 探活
This commit is contained in:
257
app.jsx
Normal file
257
app.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user