Files
oneos-truck-date-prototype/artboards/mobile.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

718 lines
39 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.
// 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:0214:44", v: "浙F07179F", k: "32.4 km", h: "0.84 kg", st: "ok" },
{ d: "04-28", t: "10:1111:03", v: "浙F07179F", k: "48.2 km", h: "1.21 kg", st: "ok" },
{ d: "04-28", t: "08:3009:18", v: "浙F08638F", k: "29.8 km", h: "0.76 kg", st: "warn" },
{ d: "04-27", t: "17:4218:25", v: "浙F07179F", k: "36.1 km", h: "0.92 kg", st: "ok" },
{ d: "04-27", t: "14:0815:01", v: "浙F02002F", k: "44.5 km", h: "1.13 kg", st: "danger" },
{ d: "04-27", t: "09:2210: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;