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:
717
artboards/mobile.jsx
Normal file
717
artboards/mobile.jsx
Normal file
@@ -0,0 +1,717 @@
|
||||
// mobile.jsx — Native mobile layouts for each route
|
||||
// Renders a single-column, gesture-friendly version of each page
|
||||
|
||||
// ── Shared mobile chrome ───────────────────────────────────
|
||||
const MAppBar = ({ title, subtitle, onMenu, right }) => (
|
||||
<div style={{
|
||||
height: 52, flex: "0 0 52px", display: "flex", alignItems: "center",
|
||||
padding: "0 8px 0 4px", gap: 8, position: "relative", zIndex: 5,
|
||||
background: "var(--bg-1)", borderBottom: "1px solid var(--border-1)",
|
||||
}}>
|
||||
<button onClick={onMenu} aria-label="菜单" style={{
|
||||
width: 40, height: 40, display: "grid", placeItems: "center",
|
||||
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer",
|
||||
}}>
|
||||
<svg width="20" height="20" 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={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: "var(--fg-0)", lineHeight: 1.2 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: 11, color: "var(--fg-3)", lineHeight: 1.2 }}>{subtitle}</div>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MIconBtn = ({ icon, badge, onClick }) => (
|
||||
<button onClick={onClick} style={{
|
||||
width: 40, height: 40, display: "grid", placeItems: "center",
|
||||
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer", position: "relative",
|
||||
}}>
|
||||
<Icon name={icon} size={17}/>
|
||||
{badge && <span style={{ position: "absolute", top: 8, right: 8, minWidth: 14, height: 14, padding: "0 4px", background: "var(--danger)", color: "#fff", fontSize: 9, fontWeight: 600, borderRadius: 7, display: "grid", placeItems: "center", lineHeight: 1 }}>{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const MTabBar = ({ active, onChange }) => {
|
||||
const tabs = [
|
||||
{ id: "overview", icon: "map", label: "总览" },
|
||||
{ id: "history", icon: "history", label: "查询" },
|
||||
{ id: "playback", icon: "route", label: "回放" },
|
||||
{ id: "inbox", icon: "bell", label: "通知" },
|
||||
{ id: "esg", icon: "chart", label: "ESG" },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
flex: "0 0 56px", height: 56, display: "grid", gridTemplateColumns: `repeat(${tabs.length}, 1fr)`,
|
||||
background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => onChange(t.id)} style={{
|
||||
background: "transparent", border: "none", display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center", gap: 2, cursor: "pointer",
|
||||
color: active === t.id ? "var(--accent)" : "var(--fg-3)",
|
||||
}}>
|
||||
<Icon name={t.icon} size={18}/>
|
||||
<span style={{ fontSize: 10, fontWeight: active === t.id ? 600 : 400 }}>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile shell wrapper ───────────────────────────────────
|
||||
const MobileShell = ({ title, subtitle, right, children, hideTabBar }) => {
|
||||
const ctx = window.useRoute();
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "flex", flexDirection: "column",
|
||||
background: "var(--bg-0)", color: "var(--fg-1)", overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
<MAppBar title={title} subtitle={subtitle} onMenu={ctx.openDrawer} right={right}/>
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }}>
|
||||
{children}
|
||||
</div>
|
||||
{!hideTabBar && <MTabBar active={ctx.route} onChange={ctx.navigate}/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 1. Mobile Overview: hero map + bottom sheet vehicle list ──
|
||||
const MobileOverview = () => {
|
||||
const [selected, setSelected] = React.useState("浙F08638F");
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
const [filter, setFilter] = React.useState("all");
|
||||
const v = (window.VEHICLES || []).find(x => x.id === selected) || {};
|
||||
const vehicles = window.VEHICLES || [];
|
||||
const counts = { all: vehicles.length, ok: vehicles.filter(x=>x.status==="ok").length, warn: vehicles.filter(x=>x.status==="warn").length, danger: vehicles.filter(x=>x.status==="danger").length };
|
||||
const filtered = filter === "all" ? vehicles : vehicles.filter(x => x.status === filter);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
title="实时监控"
|
||||
subtitle="487/512 在线 · 312 行驶中"
|
||||
right={<><MIconBtn icon="search"/><MIconBtn icon="bell" badge="3" onClick={() => window.useRoute().navigate("inbox")}/></>}
|
||||
>
|
||||
{/* Map fills, sheet floats */}
|
||||
<div style={{ position: "absolute", inset: 0 }}>
|
||||
<FleetMap selected={selected} onSelect={setSelected}/>
|
||||
</div>
|
||||
|
||||
{/* KPI strip floating on map */}
|
||||
<div style={{ position: "absolute", top: 12, left: 12, right: 12, display: "flex", gap: 8, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{[
|
||||
{ l: "在线率", v: "95.1%", c: "var(--ok)" },
|
||||
{ l: "告警", v: "8", c: "var(--danger)" },
|
||||
{ l: "今日里程", v: "24.7K km", c: "var(--fg-1)" },
|
||||
{ l: "平均能耗", v: "1.16", c: "var(--info)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{
|
||||
flex: "0 0 auto", padding: "8px 12px", background: "var(--bg-1)",
|
||||
border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: "var(--fg-3)" }}>{k.l}</div>
|
||||
<div className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: k.c }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating action: locate */}
|
||||
<button style={{
|
||||
position: "absolute", right: 14, bottom: sheetOpen ? "70%" : 130,
|
||||
width: 44, height: 44, borderRadius: 22, border: "1px solid var(--border-1)",
|
||||
background: "var(--bg-1)", color: "var(--accent)", display: "grid", placeItems: "center",
|
||||
boxShadow: "var(--shadow-2)", cursor: "pointer", transition: "bottom 280ms cubic-bezier(.3,0,.2,1)",
|
||||
}}>
|
||||
<Icon name="pin" size={18}/>
|
||||
</button>
|
||||
|
||||
{/* Bottom sheet */}
|
||||
<div style={{
|
||||
position: "absolute", left: 0, right: 0, bottom: 0,
|
||||
height: sheetOpen ? "70%" : 120, background: "var(--bg-1)",
|
||||
borderTop: "1px solid var(--border-1)", borderRadius: "16px 16px 0 0",
|
||||
boxShadow: "0 -8px 24px -8px rgba(0,0,0,0.16)",
|
||||
transition: "height 320ms cubic-bezier(.3,0,.2,1)",
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
}}>
|
||||
{/* Drag handle + selected vehicle quick card */}
|
||||
<div onClick={() => setSheetOpen(s => !s)} style={{ padding: "8px 16px 6px", cursor: "pointer" }}>
|
||||
<div style={{ width: 36, height: 4, background: "var(--border-2)", borderRadius: 2, margin: "0 auto 8px" }}/>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span className={"dot " + v.status}/>
|
||||
<span className="mono strong" style={{ fontSize: 14 }}>{v.id}</span>
|
||||
<SourceBadge src={v.src}/>
|
||||
<span className="muted" style={{ fontSize: 11 }}>{v.deptName}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 13, color: v.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{v.soc}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sheetOpen ? (
|
||||
// Mini quick stats when collapsed
|
||||
<div style={{ padding: "0 16px 12px", display: "flex", gap: 16, fontSize: 11 }}>
|
||||
<div className="col"><span className="muted">速度</span><span className="mono strong">{v.speed} km/h</span></div>
|
||||
<div className="col"><span className="muted">续航</span><span className="mono strong">{Math.round((v.soc||0)*6.2)} km</span></div>
|
||||
<div className="col"><span className="muted">温度</span><span className="mono strong">{v.status==="danger"?"102":"68"}°C</span></div>
|
||||
<button onClick={() => window.useRoute().navigate("detail")} className="btn primary sm" style={{ marginLeft: "auto", alignSelf: "center" }}>详情</button>
|
||||
</div>
|
||||
) : (
|
||||
// Full list when expanded
|
||||
<>
|
||||
<div style={{ padding: "0 16px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{[
|
||||
{id:"all", label:`全部 ${counts.all}`, c:""},
|
||||
{id:"ok", label:`行驶 ${counts.ok}`, c:"ok"},
|
||||
{id:"warn", label:`异常 ${counts.warn}`, c:"warn"},
|
||||
{id:"danger", label:`故障 ${counts.danger}`, c:"danger"},
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={() => setFilter(t.id)} className={"chip " + (filter===t.id ? "accent" : t.c)} style={{ flex: "0 0 auto", cursor: "pointer", padding: "6px 12px", fontSize: 12 }}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filtered.map(x => (
|
||||
<div key={x.id} onClick={() => { setSelected(x.id); }} style={{
|
||||
padding: "12px 16px", borderBottom: "1px solid var(--border-1)",
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
background: x.id === selected ? "var(--accent-soft)" : "transparent", cursor: "pointer",
|
||||
}}>
|
||||
<span className={"dot " + x.status}/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{x.id}</span>
|
||||
<SourceBadge src={x.src}/>
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{x.deptName} · {x.speed} km/h</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 13, color: x.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{x.soc}%</div>
|
||||
<div style={{ width: 50, height: 3, background: "var(--bg-3)", borderRadius: 2, marginTop: 4 }}>
|
||||
<div style={{ width: x.soc + "%", height: "100%", background: x.soc<20?"var(--danger)":x.soc<40?"var(--warn)":"var(--accent)", borderRadius: 2 }}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 2. Mobile Detail ────────────────────────────────────────
|
||||
const MobileDetail = () => {
|
||||
const vehicles = window.VEHICLES || [];
|
||||
const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0] || {};
|
||||
return (
|
||||
<MobileShell title={v.plate || v.id} subtitle={`${v.deptName || ''} · ${v.customer || ''}`} right={<MIconBtn icon="pin"/>}>
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12, paddingBottom: 24 }}>
|
||||
{/* Hero status card */}
|
||||
<div className="panel" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div className="between" style={{ marginBottom: 12 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok")}>
|
||||
<span className={"dot " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok") + " pulse"}/>
|
||||
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库 · 运营中"}
|
||||
</span>
|
||||
<span className="chip" style={{fontSize:10, background: v.own === "self" ? "rgba(31,139,76,.10)" : "rgba(122,140,46,.12)", color: v.own === "self" ? "var(--accent)" : "#7A8C2E"}}>{v.own === "self" ? "自有" : "外租"}</span>
|
||||
</div>
|
||||
<SourceBadge src={v.src}/>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<MStat label="车速" value={v.speed} unit="km/h" big/>
|
||||
<MStat label="续航" value={v.range} unit="km" big/>
|
||||
<MStat label="电池SOC" value={v.soc} unit="%" color={v.soc < 20 ? "var(--danger)" : "var(--ok)"}/>
|
||||
<MStat label="氢气压力" value={v.h2} unit="MPa" color="var(--info)"/>
|
||||
<MStat label="电机温度" value={v.motorTemp} unit="°C"/>
|
||||
<MStat label="累计里程" value={(v.totalKm/1000).toFixed(1)} unit="k km"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MSection title="业务关系">
|
||||
<div className="panel" style={{padding: 14}}>
|
||||
<div className="col gap-2" style={{fontSize:12}}>
|
||||
<div className="between"><span className="muted">业务部门</span>
|
||||
<span className="mid gap-1"><span style={{width:7,height:7,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
|
||||
</div>
|
||||
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
|
||||
<div className="between"><span className="muted">客户</span><span style={{textAlign:"right", maxWidth:160}}>{v.customer}</span></div>
|
||||
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:11,textAlign:"right"}}>{v.ownCompany}</span></div>
|
||||
{v.contractNo && <div className="between"><span className="muted">合同</span><span className="mono" style={{fontSize:11}}>{v.contractNo}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="氢电系统">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<MMini label="燃料电池" value="42.5 kW" sub="输出功率"/>
|
||||
<MMini label="动力电池" value={v.soc + "%"} sub="SOC"/>
|
||||
<MMini label="H₂消耗" value="0.84 kg" sub="本次行程"/>
|
||||
<MMini label="电机扭矩" value="180 N·m" sub="实时"/>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="胎压与温度">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{[
|
||||
{ l: "左前", p: "0.24", t: "32" },
|
||||
{ l: "右前", p: "0.24", t: "33" },
|
||||
{ l: "左后", p: "0.23", t: "31" },
|
||||
{ l: "右后", p: v.asset === "abnormal" ? "0.16" : "0.24", t: "38", warn: v.asset === "abnormal" },
|
||||
].map((tire, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 12, border: tire.warn ? "1px solid var(--danger)" : "1px solid var(--border-1)" }}>
|
||||
<div className="between"><span className="muted" style={{ fontSize: 11 }}>{tire.l}</span>{tire.warn && <span className="chip danger" style={{ fontSize: 9 }}>低压</span>}</div>
|
||||
<div className="mono strong" style={{ fontSize: 18, color: tire.warn ? "var(--danger)" : "var(--fg-0)" }}>{tire.p}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> MPa</span></div>
|
||||
<div className="mono muted" style={{ fontSize: 11 }}>{tire.t}°C</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="保养预警">
|
||||
<div className="panel" style={{padding: 14}}>
|
||||
<div className="between" style={{marginBottom:8}}>
|
||||
<span className="muted" style={{fontSize:12}}>距下次保养</span>
|
||||
<span className="mono strong" style={{fontSize:16, color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}>{v.kmToMaint.toLocaleString()} km</span>
|
||||
</div>
|
||||
<div className="bar" style={{height:5}}>
|
||||
<i style={{width: ((10000 - v.kmToMaint)/10000*100) + "%", background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}/>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:8}}>上次保养 {v.lastMaintDays} 天前 · {v.lastMaintKm.toLocaleString()} km</div>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="数据通道">
|
||||
<div className="col gap-2" style={{ fontSize: 12 }}>
|
||||
{[
|
||||
{ l: "TBOX (3296/2016)", st: "ok", info: "5s 上报 · 信号 -68dBm" },
|
||||
{ l: "JT808 部标", st: "ok", info: "实时 · 北京·朝阳" },
|
||||
{ l: "JT1078 视频", st: "ok", info: "4 路 · 720P" },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
|
||||
<div>
|
||||
<div className="strong" style={{ fontSize: 13 }}>{c.l}</div>
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{c.info}</div>
|
||||
</div>
|
||||
<span className="dot ok"/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
|
||||
<button onClick={() => window.useRoute().navigate("playback")} className="btn primary" style={{ height: 44 }}><Icon name="route" size={14}/> 轨迹回放</button>
|
||||
<button onClick={() => window.useRoute().navigate("history")} className="btn" style={{ height: 44 }}><Icon name="history" size={14}/> 历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const MStat = ({ label, value, unit, color, big }) => (
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 11 }}>{label}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: big ? 26 : 18, color: color || "var(--fg-0)", lineHeight: 1.1, marginTop: 2 }}>
|
||||
{value}<span style={{ fontSize: big ? 12 : 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MMini = ({ label, value, sub }) => (
|
||||
<div style={{ padding: 10, background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{label}</div>
|
||||
<div className="mono strong" style={{ fontSize: 15, marginTop: 2 }}>{value}</div>
|
||||
{sub && <div className="muted" style={{ fontSize: 10, marginTop: 1 }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MSection = ({ title, children, action }) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="between" style={{ marginBottom: 8, padding: "0 4px" }}>
|
||||
<span className="eyebrow" style={{ fontSize: 11 }}>{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 3. Mobile History ───────────────────────────────────────
|
||||
const MobileHistory = () => {
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
const trips = [
|
||||
{ d: "04-28", t: "14:02–14:44", v: "浙F07179F", k: "32.4 km", h: "0.84 kg", st: "ok" },
|
||||
{ d: "04-28", t: "10:11–11:03", v: "浙F07179F", k: "48.2 km", h: "1.21 kg", st: "ok" },
|
||||
{ d: "04-28", t: "08:30–09:18", v: "浙F08638F", k: "29.8 km", h: "0.76 kg", st: "warn" },
|
||||
{ d: "04-27", t: "17:42–18:25", v: "浙F07179F", k: "36.1 km", h: "0.92 kg", st: "ok" },
|
||||
{ d: "04-27", t: "14:08–15:01", v: "浙F02002F", k: "44.5 km", h: "1.13 kg", st: "danger" },
|
||||
{ d: "04-27", t: "09:22–10:14", v: "浙F07179F", k: "39.7 km", h: "1.01 kg", st: "ok" },
|
||||
];
|
||||
return (
|
||||
<MobileShell title="历史查询" subtitle="近 7 日 · 187 条记录" right={<MIconBtn icon="filter" onClick={()=>setShowFilter(s=>!s)}/>}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* Search */}
|
||||
<div style={{ padding: "12px 12px 0" }}>
|
||||
<div className="search" style={{ height: 40 }}>
|
||||
<Icon name="search" size={14}/>
|
||||
<input placeholder="车牌 / VIN / 部门 / 客户" style={{ fontSize: 14 }}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter chips - horizontal scroll */}
|
||||
<div style={{ padding: "10px 12px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0 }}>
|
||||
<span className="chip accent" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>近 7 日</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>编组A</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>全部车型</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>有告警</span>
|
||||
</div>
|
||||
|
||||
{/* KPI summary */}
|
||||
<div style={{ padding: "0 12px 12px", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8, flexShrink: 0 }}>
|
||||
{[
|
||||
{ l: "总里程", v: "1,847", u: "km", c: "var(--fg-0)" },
|
||||
{ l: "氢耗", v: "47.2", u: "kg", c: "var(--info)" },
|
||||
{ l: "减碳", v: "118", u: "kg", c: "var(--ok)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16, color: k.c }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> {k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trip list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0 12px 16px" }}>
|
||||
{trips.map((t, i) => {
|
||||
const cls = t.st === "danger" ? "danger" : t.st === "warn" ? "warn" : "ok";
|
||||
return (
|
||||
<div key={i} className="panel" style={{ padding: 12, marginBottom: 8 }}>
|
||||
<div className="between" style={{ marginBottom: 6 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{t.v}</span>
|
||||
<span className={"chip " + cls} style={{ fontSize: 9 }}>{t.st === "danger" ? "故障" : t.st === "warn" ? "告警" : "正常"}</span>
|
||||
</div>
|
||||
<span className="muted mono" style={{ fontSize: 11 }}>{t.d} {t.t}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 12 }}>
|
||||
<div><span className="muted">里程 </span><span className="mono strong">{t.k}</span></div>
|
||||
<div><span className="muted">氢耗 </span><span className="mono strong">{t.h}</span></div>
|
||||
<button onClick={() => window.useRoute().navigate("playback")} className="btn sm ghost" style={{ marginLeft: "auto", padding: "4px 10px", fontSize: 11 }}>回放</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 4. Mobile Playback ──────────────────────────────────────
|
||||
const MobilePlayback = () => {
|
||||
const [t, setT] = React.useState(38);
|
||||
const [playing, setPlaying] = React.useState(true);
|
||||
const [speed, setSpeed] = React.useState(2);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!playing) return;
|
||||
const id = setInterval(() => setT(v => (v + speed * 0.6) % 100), 200);
|
||||
return () => clearInterval(id);
|
||||
}, [playing, speed]);
|
||||
|
||||
return (
|
||||
<MobileShell title="轨迹回放" subtitle="浙F07179F · 04-28 14:02 → 14:44">
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column" }}>
|
||||
{/* Map area */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<FleetMap selected="浙F07179F" playbackPoint={{ x: 280 + t * 4, y: 260 + Math.sin(t/15) * 80 }}/>
|
||||
{/* Floating speed badge */}
|
||||
<div style={{ position: "absolute", top: 12, left: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>当前速度</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16 }}>{Math.round(40 + Math.sin(t/8)*20)} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>km/h</span></div>
|
||||
</div>
|
||||
<div style={{ position: "absolute", top: 12, right: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>SOC</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16, color: "var(--ok)" }}>{Math.round(78 - t * 0.15)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom playback panel */}
|
||||
<div style={{
|
||||
flex: "0 0 auto", background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
|
||||
padding: "12px 14px 16px", boxShadow: "0 -4px 16px -4px rgba(0,0,0,0.08)",
|
||||
}}>
|
||||
{/* Time + scrub */}
|
||||
<div className="between" style={{ marginBottom: 8 }}>
|
||||
<span className="mono strong" style={{ fontSize: 14 }}>14:{String(Math.floor(t * 0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</span>
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>已 {Math.round(t*0.42)} 分 / 共 42 分</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={t} onChange={e => setT(+e.target.value)} style={{
|
||||
width: "100%", height: 4, accentColor: "var(--accent)", marginBottom: 12,
|
||||
}}/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button onClick={() => setT(0)} style={ctrlBtn}><Icon name="route" size={14}/></button>
|
||||
<button onClick={() => setT(v => Math.max(0, v - 10))} style={ctrlBtn}>« 10s</button>
|
||||
<button onClick={() => setPlaying(p => !p)} style={{ ...ctrlBtn, width: 56, height: 44, background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>{playing ? "⏸" : "▶"}</button>
|
||||
<button onClick={() => setT(v => Math.min(100, v + 10))} style={ctrlBtn}>10s »</button>
|
||||
<select value={speed} onChange={e => setSpeed(+e.target.value)} style={{ ...ctrlBtn, padding: "0 10px" }}>
|
||||
{[0.5, 1, 2, 4, 8, 16].map(s => <option key={s} value={s}>{s}×</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mini chart timeline events */}
|
||||
<div style={{ marginTop: 12, position: "relative", height: 24, background: "var(--bg-2)", borderRadius: 4 }}>
|
||||
{[12, 38, 65, 88].map((p, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: p+"%", top: 0, bottom: 0, width: 2, background: i===2?"var(--danger)":"var(--warn)" }}/>
|
||||
))}
|
||||
<div style={{ position: "absolute", left: t+"%", top: -4, bottom: -4, width: 2, background: "var(--accent)", boxShadow: "0 0 8px var(--accent)" }}/>
|
||||
</div>
|
||||
<div className="between" style={{ marginTop: 4, fontSize: 10 }}>
|
||||
<span className="muted">事件</span>
|
||||
<span className="mono muted">急刹×1 · 超速×2 · 故障×1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ctrlBtn = {
|
||||
flex: 1, height: 40, display: "grid", placeItems: "center",
|
||||
background: "var(--bg-2)", border: "1px solid var(--border-1)",
|
||||
color: "var(--fg-1)", borderRadius: 8, fontSize: 12, fontFamily: "var(--font-mono)", cursor: "pointer",
|
||||
};
|
||||
|
||||
// ── 5. Mobile Alarm Rules (list-based) ──────────────────────
|
||||
const MobileAlarm = () => {
|
||||
const rules = [
|
||||
{ n: "电池SOC严重不足", st: "on", trig: 12, cond: "SOC < 15% 持续 30s", p: "P0" },
|
||||
{ n: "右后胎压低", st: "on", trig: 8, cond: "压力 < 0.20 MPa", p: "P0" },
|
||||
{ n: "超速预警", st: "on", trig: 47, cond: "速度 > 限速 + 5 km/h", p: "P1" },
|
||||
{ n: "H₂压力异常下降", st: "on", trig: 5, cond: "5min 内下降 > 1.0 MPa", p: "P0" },
|
||||
{ n: "电堆过温保护", st: "on", trig: 3, cond: "温度 > 90°C", p: "P0" },
|
||||
{ n: "急加速密集", st: "on", trig: 31, cond: "5min 内 ≥ 3 次", p: "P2" },
|
||||
{ n: "疲劳驾驶", st: "off", trig: 0, cond: "连续驾驶 > 4h", p: "P1" },
|
||||
];
|
||||
return (
|
||||
<MobileShell title="事件规则" subtitle={`${rules.filter(r=>r.st==="on").length} / ${rules.length} 启用`} right={<MIconBtn icon="plus"/>}>
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginBottom: 12 }}>
|
||||
{[
|
||||
{ l: "P0 紧急", v: 4, c: "var(--danger)" },
|
||||
{ l: "P1 警告", v: 2, c: "var(--warn)" },
|
||||
{ l: "P2 提示", v: 1, c: "var(--info)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong" style={{ fontSize: 18, color: k.c }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rules.map((r, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 14, marginBottom: 8 }}>
|
||||
<div className="between" style={{ marginBottom: 6 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (r.p==="P0"?"danger":r.p==="P1"?"warn":"info")} style={{ fontSize: 9 }}>{r.p}</span>
|
||||
<span className="strong" style={{ fontSize: 14 }}>{r.n}</span>
|
||||
</div>
|
||||
<MSwitch on={r.st==="on"}/>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: 11, marginBottom: 6 }}>{r.cond}</div>
|
||||
<div className="between" style={{ fontSize: 11 }}>
|
||||
<span className="muted">7日触发 <span className="mono strong" style={{ color: "var(--fg-1)" }}>{r.trig}</span> 次</span>
|
||||
<span className="muted">短信 · App推送 · 邮件</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const MSwitch = ({ on }) => (
|
||||
<div style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2,
|
||||
background: on ? "var(--accent)" : "var(--bg-3)", transition: "background 200ms",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 8, background: "#fff",
|
||||
transform: on ? "translateX(16px)" : "translateX(0)",
|
||||
transition: "transform 200ms cubic-bezier(.3,0,.2,1)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 6. Mobile Inbox ─────────────────────────────────────────
|
||||
const MobileInbox = () => {
|
||||
const [filter, setFilter] = React.useState("all");
|
||||
const alerts = [
|
||||
{p:"P0", n:"电池SOC严重不足", v:"浙F08638F", t:"刚刚", det:"SOC 9% < 阈值 15% · 持续 4分20秒", st:"new"},
|
||||
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3分钟前", det:"0.16 MPa · 阈值 0.20 MPa", st:"new"},
|
||||
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12分钟前", det:"实测 89 km/h · 限速 80 km/h · 持续 12s", st:"new"},
|
||||
{p:"P1", n:"H₂压力异常下降", v:"浙F07179F", t:"32分钟前", det:"5分钟内下降 1.2 MPa", st:"ack"},
|
||||
{p:"P0", n:"电堆过温保护", v:"浙F00598F", t:"1小时前", det:"电堆温度 95°C · 阈值 90°C", st:"resolved"},
|
||||
{p:"P2", n:"急加速密集", v:"浙F02608F", t:"2小时前", det:"5分钟内 3 次急加速", st:"resolved"},
|
||||
{p:"P1", n:"偏离规划路线", v:"浙F00278F", t:"3小时前", det:"偏离 1.2 km · 持续 6 分钟", st:"resolved"},
|
||||
];
|
||||
const filtered = filter === "all" ? alerts : filter === "new" ? alerts.filter(a=>a.st==="new") : alerts.filter(a=>a.p===filter);
|
||||
return (
|
||||
<MobileShell title="通知中心" subtitle="3 未处理 · 24 今日">
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "10px 12px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0, borderBottom: "1px solid var(--border-1)" }}>
|
||||
{[
|
||||
{id:"all", l:"全部 24"},
|
||||
{id:"new", l:"未处理 3"},
|
||||
{id:"P0", l:"P0 · 2"},
|
||||
{id:"P1", l:"P1 · 8"},
|
||||
{id:"P2", l:"P2 · 14"},
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={()=>setFilter(t.id)} className={"chip " + (filter===t.id?"accent":"")} style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12, cursor: "pointer" }}>{t.l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filtered.map((a, i) => {
|
||||
const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)";
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display: "flex", gap: 12, padding: "14px 14px",
|
||||
borderBottom: "1px solid var(--border-1)",
|
||||
background: a.st === "new" ? "var(--accent-soft)" : "transparent",
|
||||
}}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 18, background: a.st === "new" ? c : "var(--bg-3)", opacity: a.st==="resolved"?0.4:1, display: "grid", placeItems: "center", color: "#fff", boxShadow: a.st === "new" ? `0 0 12px ${c}` : "none" }}>
|
||||
<Icon name="bell" size={14}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"info")} style={{ fontSize: 9, padding: "2px 6px" }}>{a.p}</span>
|
||||
<span className="strong" style={{ fontSize: 13 }}>{a.n}</span>
|
||||
</div>
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>{a.t}</span>
|
||||
</div>
|
||||
<div className="mono muted" style={{ fontSize: 11, marginTop: 2 }}>{a.v}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{a.det}</div>
|
||||
{a.st === "new" && (
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 10 }}>
|
||||
<button onClick={()=>window.useRoute().navigate("playback")} className="btn sm primary" style={{ fontSize: 11 }}><Icon name="route" size={11}/> 轨迹</button>
|
||||
<button className="btn sm" style={{ fontSize: 11 }}>确认</button>
|
||||
<button className="btn sm ghost" style={{ fontSize: 11 }}>忽略</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 7. Mobile ESG ────────────────────────────────────────────
|
||||
const MobileESG = () => {
|
||||
return (
|
||||
<MobileShell title="ESG · 碳减排" subtitle="羚牛 ESG Link">
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
|
||||
{/* Hero stat */}
|
||||
<div className="panel" style={{ padding: 16, marginBottom: 12, background: "linear-gradient(135deg, #007143, #00A35F)", color: "#fff", border: "none" }}>
|
||||
<div style={{ fontSize: 11, opacity: 0.85 }}>本年度累计减碳</div>
|
||||
<div className="mono tnum" style={{ fontSize: 36, fontWeight: 700, lineHeight: 1.1, margin: "4px 0" }}>1,847.2<span style={{ fontSize: 14, fontWeight: 400, opacity: 0.85, marginLeft: 6 }}>tCO₂e</span></div>
|
||||
<div style={{ fontSize: 11, opacity: 0.85 }}>较去年同期 ▲ 32.4%</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 12 }}>
|
||||
{[
|
||||
{ l: "氢能消耗", v: "47.2", u: "万 m³", c: "var(--info)" },
|
||||
{ l: "里程", v: "1.84", u: "万 km", c: "var(--fg-0)" },
|
||||
{ l: "碳交易收益", v: "18.78", u: "万元", c: "var(--accent)" },
|
||||
{ l: "覆盖城市", v: "23", u: "个", c: "var(--fg-0)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 12 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 18, color: k.c, marginTop: 2 }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MSection title="月度减碳趋势">
|
||||
<div className="panel" style={{ padding: 14 }}>
|
||||
<svg viewBox="0 0 320 120" width="100%" height="100" style={{ overflow: "visible" }}>
|
||||
{[180,220,255,235,290,270,310,345,320,355,380,420].map((v, i, arr) => {
|
||||
const x = 12 + i * 26;
|
||||
const h = (v / 420) * 90;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x-9} y={108-h} width="18" height={h} fill="var(--accent)" rx="2" opacity={i===11?1:0.65}/>
|
||||
{i%3===0 && <text x={x} y="118" fontSize="9" fill="var(--fg-3)" textAnchor="middle">{i+1}月</text>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="车辆减碳排行 Top 5">
|
||||
<div className="col gap-1">
|
||||
{[
|
||||
{ p:"浙F·8A03F", v: 24.38 },
|
||||
{ p:"浙F·2C57G", v: 22.15 },
|
||||
{ p:"浙F·9D14B", v: 19.84 },
|
||||
{ p:"浙F·6E72H", v: 17.21 },
|
||||
{ p:"浙F·1B49K", v: 15.67 },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<span className="mid gap-2">
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-3)", width: 16 }}>#{i+1}</span>
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{r.p}</span>
|
||||
</span>
|
||||
<span className="mono strong" style={{ fontSize: 13, color: "var(--accent)" }}>{r.v} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>kg</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile route map ────────────────────────────────────────
|
||||
const MOBILE_PAGES = {
|
||||
overview: MobileOverview,
|
||||
detail: MobileDetail,
|
||||
history: MobileHistory,
|
||||
playback: MobilePlayback,
|
||||
alarm: MobileAlarm,
|
||||
inbox: MobileInbox,
|
||||
esg: MobileESG,
|
||||
};
|
||||
|
||||
const MobileRouter = ({ route }) => {
|
||||
const Cmp = MOBILE_PAGES[route];
|
||||
if (!Cmp) {
|
||||
return (
|
||||
<MobileShell title="设计画板" subtitle="该页面仅桌面端可用">
|
||||
<div style={{ padding: 24, textAlign: "center", color: "var(--fg-2)" }}>
|
||||
<Icon name="settings" size={32}/>
|
||||
<div style={{ marginTop: 12, fontSize: 14 }}>设计画板模式</div>
|
||||
<div style={{ fontSize: 12, color: "var(--fg-3)", marginTop: 4 }}>请在桌面端访问以查看完整设计稿</div>
|
||||
<button onClick={() => window.useRoute().navigate("overview")} className="btn primary" style={{ marginTop: 16, height: 40, padding: "0 20px" }}>返回主页</button>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
return <Cmp/>;
|
||||
};
|
||||
|
||||
window.MobileRouter = MobileRouter;
|
||||
window.MobileShell = MobileShell;
|
||||
Reference in New Issue
Block a user