init: 羚牛车辆数据中心原型 + 部署配置
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:
kkfluous
2026-04-28 15:12:32 +08:00
commit b2d0016a0d
59 changed files with 6938 additions and 0 deletions

606
artboards/history.jsx Normal file
View File

@@ -0,0 +1,606 @@
// artboard-history.jsx — 数据检索 (data search studio)
// Flow: ① 选车辆+时段 → ② 选数据项目 → ③ 选展示方式 → ④ 渲染结果
const DATA_GROUPS = [
{
id: "vehicle", label: "车辆运行", icon: "car", color: "var(--info)",
items: [
{id:"speed", l:"速度", u:"km/h", src:"TBOX", freq:"10s"},
{id:"odometer", l:"累计里程", u:"km", src:"TBOX", freq:"60s"},
{id:"trip_km", l:"行程里程", u:"km", src:"TBOX", freq:"事件"},
{id:"engine_run", l:"运行时长", u:"s", src:"TBOX", freq:"60s"},
{id:"gear", l:"档位", u:"-", src:"TBOX", freq:"10s"},
{id:"steer", l:"方向盘转角", u:"°", src:"CAN", freq:"100ms"},
],
},
{
id: "energy", label: "氢电系统", icon: "h2", color: "var(--accent)",
items: [
{id:"soc", l:"动力电池 SOC", u:"%", src:"BMS", freq:"10s"},
{id:"batt_volt", l:"电池总压", u:"V", src:"BMS", freq:"10s"},
{id:"batt_curr", l:"电池电流", u:"A", src:"BMS", freq:"10s"},
{id:"batt_temp", l:"电池温度", u:"℃", src:"BMS", freq:"10s"},
{id:"h2_pressure",l:"H₂ 压力", u:"MPa", src:"FCU", freq:"10s"},
{id:"h2_flow", l:"H₂ 流量", u:"g/s", src:"FCU", freq:"10s"},
{id:"fc_power", l:"电堆输出功率", u:"kW", src:"FCU", freq:"10s"},
{id:"fc_temp", l:"电堆温度", u:"℃", src:"FCU", freq:"10s"},
],
},
{
id: "chassis", label: "底盘 & 安全", icon: "shield", color: "var(--warn)",
items: [
{id:"tire_p_fl", l:"胎压 左前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_fr", l:"胎压 右前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rl", l:"胎压 左后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rr", l:"胎压 右后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"brake", l:"制动信号", u:"0/1", src:"CAN", freq:"100ms"},
{id:"airbag", l:"安全气囊状态", u:"0/1", src:"CAN", freq:"事件"},
{id:"abs", l:"ABS 状态", u:"0/1", src:"CAN", freq:"事件"},
],
},
{
id: "location", label: "定位 & 通讯", icon: "pin", color: "var(--info)",
items: [
{id:"gps_lat", l:"GPS 纬度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_lng", l:"GPS 经度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_alt", l:"海拔", u:"m", src:"JT808", freq:"10s"},
{id:"signal", l:"信号强度", u:"dBm", src:"TBOX", freq:"60s"},
{id:"network", l:"网络类型", u:"-", src:"TBOX", freq:"60s"},
],
},
{
id: "driving", label: "驾驶行为", icon: "speed", color: "var(--accent)",
items: [
{id:"hard_acc", l:"急加速次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"hard_brake", l:"急刹车次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"sharp_turn", l:"急转弯次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"overspeed", l:"超速时长", u:"s/h", src:"TBOX", freq:"事件"},
{id:"score", l:"驾驶评分", u:"分", src:"算法", freq:"日"},
],
},
];
const VIEW_MODES = [
{id:"line", l:"曲线图", ic:"chart", d:"时间序列趋势"},
{id:"area", l:"面积图", ic:"pulse", d:"叠加趋势对比"},
{id:"bar", l:"柱状图", ic:"layers", d:"分时统计"},
{id:"table", l:"数据表", ic:"list", d:"按时间戳列表"},
{id:"heat", l:"热力日历", ic:"history", d:"按日聚合"},
{id:"summary",l:"统计摘要", ic:"gauge", d:"min/max/avg/p95"},
];
const QUICK_RANGES = [
{id:"1h", l:"近1小时"}, {id:"6h", l:"近6小时"}, {id:"24h", l:"近24小时"},
{id:"7d", l:"近7日"}, {id:"30d", l:"近30日"}, {id:"custom", l:"自定义"},
];
const ArtboardHistory = () => {
// Wizard step: 1 选范围, 2 选项目, 3 展示
const [step, setStep] = React.useState(3);
const [vehicle] = React.useState({plate:"浙F03980F", vin:"LJ2A...8814", dept:"业务一部"});
const [range, setRange] = React.useState("24h");
const [dateFrom] = React.useState("2026-04-27 14:02");
const [dateTo] = React.useState("2026-04-28 14:02");
// Selected data items (ids)
const [picked, setPicked] = React.useState(new Set(["speed","soc","h2_pressure"]));
const [activeGroup, setActiveGroup] = React.useState("vehicle");
// Visualization mode
const [view, setView] = React.useState("line");
const togglePick = (id) => {
const next = new Set(picked);
if (next.has(id)) next.delete(id); else next.add(id);
setPicked(next);
};
const pickedItems = DATA_GROUPS.flatMap(g => g.items.filter(it => picked.has(it.id)).map(it => ({...it, group:g})));
return (
<div className="app">
<Sidebar active="history"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar crumbs={["数据检索", `${vehicle.plate}`]} kpis={[]} showSearch={false}/>
{/* Step indicator */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:24, alignItems:"center"}}>
{[
{n:1, l:"选择范围"},
{n:2, l:"选择数据项目"},
{n:3, l:"选择展示方式"},
].map((s,i,arr)=>(
<React.Fragment key={s.n}>
<div className="mid gap-2" style={{cursor:"pointer", opacity: step >= s.n ? 1 : 0.5}} onClick={() => setStep(s.n)}>
<span style={{
width:22, height:22, borderRadius:11,
background: step >= s.n ? "var(--accent)" : "var(--bg-3)",
color: step >= s.n ? "#fff" : "var(--fg-3)",
display:"grid", placeItems:"center",
fontSize:11, fontWeight:600, fontFamily:"var(--font-mono)",
}}>{s.n}</span>
<span className={step === s.n ? "strong" : ""} style={{fontSize:12}}>{s.l}</span>
</div>
{i < arr.length - 1 && <span style={{flex:0, width:32, height:1, background:"var(--border-2)"}}/>}
</React.Fragment>
))}
<div style={{marginLeft:"auto", display:"flex", gap:6, alignItems:"center"}}>
<span className="muted" style={{fontSize:11}}>已选 <span className="strong" style={{color:"var(--accent)"}}>{picked.size}</span> </span>
<span style={{width:1, height:18, background:"var(--border-1)"}}/>
<button className="btn" onClick={() => location.hash = "#/compare"}><Icon name="layers" size={12}/> 多车对比</button>
<button className="btn"><Icon name="download" size={12}/> 导出 CSV</button>
<button className="btn"><Icon name="bookmark" size={12}/> 保存查询</button>
</div>
</div>
{/* Main */}
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr", minHeight:0}}>
{/* LEFT: range + data items selector */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
{/* Vehicle + time */}
<div style={{padding:14, borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:8}}>查询对象</div>
<div className="mid gap-2" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6}}>
<Icon name="car" size={14} style={{color:"var(--accent)"}}/>
<div style={{flex:1}}>
<div className="mono strong" style={{fontSize:12}}>{vehicle.plate}</div>
<div className="muted" style={{fontSize:10}}>{vehicle.vin} · {vehicle.dept}</div>
</div>
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
</div>
<div className="eyebrow" style={{marginTop:14, marginBottom:8}}>时间范围</div>
<div style={{display:"grid", gridTemplateColumns:"repeat(3, 1fr)", gap:4, marginBottom:8}}>
{QUICK_RANGES.map(r => (
<span key={r.id}
onClick={() => setRange(r.id)}
className={"chip " + (range === r.id ? "accent" : "")}
style={{justifyContent:"center", cursor:"pointer", fontSize:10, padding:"4px 0"}}>
{r.l}
</span>
))}
</div>
<div style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
<div className="muted" style={{fontSize:9, marginBottom:2}}> </div>
<div>{dateFrom}</div>
<div>{dateTo}</div>
</div>
</div>
{/* Data item picker */}
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="cube" size={13}/>
<span className="title">数据项目</span>
<span className="chip accent" style={{marginLeft:"auto"}}>已选 {picked.size}</span>
</div>
<div style={{display:"flex", borderBottom:"1px solid var(--border-1)", padding:"6px 10px", gap:4, flexWrap:"wrap", background:"var(--bg-2)"}}>
{DATA_GROUPS.map(g => (
<span key={g.id}
onClick={() => setActiveGroup(g.id)}
className={"chip " + (activeGroup === g.id ? "accent" : "")}
style={{cursor:"pointer", fontSize:10, padding:"3px 8px"}}>
<Icon name={g.icon} size={10}/> {g.label}
<span className="mono" style={{marginLeft:4, opacity:.7}}>
{g.items.filter(it => picked.has(it.id)).length || g.items.length}
</span>
</span>
))}
</div>
<div className="scroll" style={{flex:1}}>
{DATA_GROUPS.filter(g => g.id === activeGroup).map(g => (
<div key={g.id}>
{g.items.map(it => {
const on = picked.has(it.id);
return (
<div key={it.id}
onClick={() => togglePick(it.id)}
style={{
display:"flex", alignItems:"center", gap:10,
padding:"9px 14px", borderBottom:"1px solid var(--border-1)",
cursor:"pointer",
background: on ? "var(--accent-soft)" : "transparent",
}}>
<span style={{
width:14, height:14, borderRadius:3,
border: "1.5px solid " + (on ? "var(--accent)" : "var(--border-2)"),
background: on ? "var(--accent)" : "transparent",
display:"grid", placeItems:"center",
flexShrink:0,
}}>
{on && <svg width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<div style={{flex:1, minWidth:0}}>
<div className="between">
<span className="strong" style={{fontSize:12}}>{it.l}</span>
<span className="mono muted" style={{fontSize:10}}>{it.u}</span>
</div>
<div className="mid gap-1" style={{marginTop:2}}>
<span style={{fontSize:9, padding:"1px 5px", borderRadius:2, background:"var(--bg-2)", border:"1px solid var(--border-1)", color: g.color, fontFamily:"var(--font-mono)"}}>{it.src}</span>
<span className="muted mono" style={{fontSize:9}}>· {it.freq}</span>
</div>
</div>
</div>
);
})}
</div>
))}
</div>
<div style={{padding:"10px 14px", borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
<button className="btn" style={{flex:1}} onClick={() => setPicked(new Set())}>清空</button>
<button className="btn primary" style={{flex:2}}>
<Icon name="search" size={12}/> 查询 {picked.size}
</button>
</div>
</div>
{/* RIGHT: visualization */}
<div style={{display:"flex", flexDirection:"column", minHeight:0, background:"var(--bg-2)"}}>
{/* View-mode picker */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, alignItems:"center", flexWrap:"wrap"}}>
<span className="muted" style={{fontSize:11, marginRight:4}}>展示</span>
{VIEW_MODES.map(v => (
<span key={v.id}
onClick={() => setView(v.id)}
style={{
display:"flex", alignItems:"center", gap:6,
padding:"5px 10px", borderRadius:5, cursor:"pointer", fontSize:11,
background: view === v.id ? "var(--accent-soft)" : "transparent",
border: "1px solid " + (view === v.id ? "var(--accent)" : "var(--border-1)"),
color: view === v.id ? "var(--accent)" : "var(--fg-1)",
}}>
<Icon name={v.ic} size={12}/>
<span>{v.l}</span>
</span>
))}
<div style={{marginLeft:"auto"}}>
<span className="muted" style={{fontSize:11}}>采样</span>
<span className="chip">原始</span>
<span className="chip" style={{marginLeft:4}}>1分钟</span>
<span className="chip accent" style={{marginLeft:4}}>5分钟</span>
<span className="chip" style={{marginLeft:4}}>1小时</span>
</div>
</div>
{/* Selected items pills */}
<div style={{padding:"8px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, flexWrap:"wrap", alignItems:"center"}}>
{pickedItems.length === 0 && <span className="muted" style={{fontSize:11}}>请从左侧选择数据项</span>}
{pickedItems.map(it => (
<span key={it.id} className="chip" style={{
fontSize:10, padding:"3px 8px",
background:"var(--bg-2)",
borderColor: it.group.color, color: it.group.color,
}}>
<span style={{width:6, height:6, borderRadius:3, background: it.group.color}}/>
{it.l}
<span className="muted" style={{fontSize:9, marginLeft:2}}>{it.u}</span>
<span style={{cursor:"pointer", marginLeft:2}} onClick={() => togglePick(it.id)}>×</span>
</span>
))}
</div>
{/* Render area */}
<div className="scroll" style={{flex:1, padding:16}}>
{view === "line" && <LineView items={pickedItems}/>}
{view === "area" && <AreaView items={pickedItems}/>}
{view === "bar" && <BarView items={pickedItems}/>}
{view === "table" && <TableView items={pickedItems}/>}
{view === "heat" && <HeatView items={pickedItems}/>}
{view === "summary" && <SummaryView items={pickedItems}/>}
</div>
</div>
</div>
</div>
</div>
);
};
// ── Visualizations ───────────────────────────────────────────
const colorFor = (it, fallback) => {
if (it.id === "speed" || it.id.startsWith("hard_") || it.id === "h2_flow") return "var(--info)";
if (it.id === "soc" || it.id.startsWith("batt") || it.id === "fc_power") return "var(--accent)";
if (it.id.startsWith("h2_") || it.id.startsWith("fc_temp")) return "var(--warn)";
if (it.id.startsWith("tire_")) return "var(--danger)";
return it.group?.color || fallback || "var(--accent)";
};
const synth = (id, n=120) => {
// Deterministic per-id pseudo-random series
const seed = id.split("").reduce((s,c) => s + c.charCodeAt(0), 0);
const out = [];
for (let i=0;i<n;i++) {
const v = Math.sin((i+seed) * 0.18) * 0.4 + Math.cos((i+seed*0.7) * 0.07) * 0.3 + 0.5
+ Math.sin((i+seed*1.3) * 0.5) * 0.08;
out.push(Math.max(0.02, Math.min(0.98, v)));
}
return out;
};
const LineView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l}</span>
<span className="muted mono" style={{fontSize:10, marginLeft:4}}>{it.u}</span>
<div className="actions">
<span className="mono muted" style={{fontSize:10}}>{it.src} · {it.freq}</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
<span className="chip"><Icon name="expand" size={9}/></span>
</div>
</div>
<div style={{padding:"14px 16px"}}>
<Sparkline data={synth(it.id, 240)} h={120} color={colorFor(it)} fill axis/>
<TimeAxis/>
</div>
</div>
))}
</div>
);
};
const AreaView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="pulse" size={12}/>
<span className="title">{items.length} 项叠加</span>
<div className="actions">
{items.map(it => (
<span key={it.id} className="mid gap-1" style={{fontSize:10}}>
<span style={{width:8, height:8, borderRadius:2, background: colorFor(it)}}/>
{it.l}
</span>
))}
</div>
</div>
<div style={{padding:"14px 16px", position:"relative"}}>
<svg width="100%" height="320" viewBox="0 0 800 320" preserveAspectRatio="none" style={{display:"block"}}>
{[0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={300*p+10} x2="800" y2={300*p+10} stroke="var(--border-1)" strokeWidth="1"/>
))}
{items.map((it,idx) => {
const data = synth(it.id, 240);
const path = data.map((v,i) => `${(i/(data.length-1))*800},${310 - v*300}`).join(" L");
return (
<g key={it.id}>
<path d={`M ${path} L 800,310 L 0,310 Z`} fill={colorFor(it)} opacity={0.12}/>
<path d={`M ${path}`} fill="none" stroke={colorFor(it)} strokeWidth="1.6"/>
</g>
);
})}
</svg>
<TimeAxis/>
</div>
</div>
);
};
const BarView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
const buckets = ["00","02","04","06","08","10","12","14","16","18","20","22"];
return (
<div className="col gap-3">
{items.map(it => {
const data = synth(it.id, buckets.length).map(v => v * 100);
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} <span className="muted" style={{fontSize:10, marginLeft:4}}>· 2 小时聚合</span></span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<svg width="100%" height="120" viewBox="0 0 720 120" preserveAspectRatio="none" style={{display:"block"}}>
{data.map((v,i) => {
const x = i * (720/data.length) + 8;
const w = (720/data.length) - 16;
const h = (v/100) * 100;
return (
<g key={i}>
<rect x={x} y={110-h} width={w} height={h} fill={colorFor(it)} opacity="0.85" rx="2"/>
<text x={x+w/2} y="118" textAnchor="middle" fontSize="9" fill="var(--fg-3)" fontFamily="var(--font-mono)">{buckets[i]}</text>
</g>
);
})}
</svg>
</div>
</div>
);
})}
</div>
);
};
const TableView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
// 30 rows of timestamps
const rows = Array.from({length:40}, (_,i) => {
const d = new Date(2026, 3, 28, 14, 0, 0);
d.setMinutes(d.getMinutes() - i * 5);
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:00`;
const vals = items.map(it => synth(it.id, 240)[i % 240]);
return { ts, vals };
});
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="list" size={12}/>
<span className="title">数据列表</span>
<span className="muted" style={{fontSize:11, marginLeft:6}}> 1,728 · 显示 40</span>
<div className="actions">
<span className="chip"><Icon name="filter" size={9}/> 筛选</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
</div>
</div>
<div style={{maxHeight:520, overflowY:"auto"}}>
<table className="tbl">
<thead>
<tr>
<th style={{width:160, position:"sticky", top:0, background:"var(--bg-1)"}}>时间戳</th>
{items.map(it => (
<th key={it.id} style={{textAlign:"right", position:"sticky", top:0, background:"var(--bg-1)"}}>
<div>{it.l}</div>
<div className="muted mono" style={{fontSize:9, fontWeight:400}}>{it.u} · {it.src}</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r,i) => (
<tr key={i}>
<td className="mono muted" style={{fontSize:11}}>{r.ts}</td>
{r.vals.map((v,j) => {
const it = items[j];
let display;
if (it.id === "speed") display = (v*80).toFixed(1);
else if (it.id === "soc") display = (20 + v*70).toFixed(1);
else if (it.id.startsWith("h2_pressure")) display = (3.5 + v*1.3).toFixed(2);
else if (it.id.startsWith("tire_")) display = (2.7 + v*0.6).toFixed(2);
else if (it.id.startsWith("batt_temp")) display = (24 + v*22).toFixed(1);
else if (it.id === "odometer") display = (124820 + v*4).toFixed(1);
else display = (v*100).toFixed(2);
return <td key={j} className="mono" style={{textAlign:"right", color: colorFor(it)}}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const HeatView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => {
const cells = synth(it.id, 30); // 30 days
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} · 30 日热力</span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<div style={{display:"grid", gridTemplateColumns:"repeat(30, 1fr)", gap:3}}>
{cells.map((v,i) => (
<div key={i} title={`${i+1} 日 · ${(v*100).toFixed(0)}`}
style={{
aspectRatio:"1",
borderRadius:3,
background: colorFor(it),
opacity: 0.15 + v*0.85,
}}/>
))}
</div>
<div className="between muted mono" style={{marginTop:8, fontSize:10}}>
<span>30 日前</span><span>今日</span>
</div>
</div>
</div>
);
})}
</div>
);
};
const SummaryView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div style={{display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(280px, 1fr))", gap:12}}>
{items.map(it => {
const data = synth(it.id, 240).map(v => v*100);
const min = Math.min(...data), max = Math.max(...data);
const avg = data.reduce((s,v)=>s+v,0)/data.length;
const sorted = [...data].sort((a,b)=>a-b);
const p95 = sorted[Math.floor(sorted.length*0.95)];
const p50 = sorted[Math.floor(sorted.length*0.50)];
// Scale to plausible units
const fmt = (v) => {
if (it.id === "speed") return (v*0.8).toFixed(1);
if (it.id === "soc") return (20 + v*0.7).toFixed(1);
if (it.id.startsWith("h2_pressure")) return (3.5 + v*0.013).toFixed(2);
return v.toFixed(1);
};
return (
<div key={it.id} className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<div>
<div className="strong" style={{fontSize:13}}>{it.l}</div>
<div className="muted mono" style={{fontSize:10}}>{it.u} · {it.src}</div>
</div>
<span style={{width:10, height:10, borderRadius:5, background: colorFor(it)}}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
{[
{l:"平均", v:fmt(avg), c:colorFor(it)},
{l:"中位", v:fmt(p50), c:"var(--fg-1)"},
{l:"最大", v:fmt(max), c:"var(--danger)"},
{l:"最小", v:fmt(min), c:"var(--ok)"},
{l:"P95", v:fmt(p95), c:"var(--warn)"},
{l:"样本", v:"1,728", c:"var(--fg-2)"},
].map((s,i) => (
<div key={i} style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:10}}>{s.l}</div>
<div className="mono strong" style={{fontSize:14, color: s.c, marginTop:2}}>{s.v}</div>
</div>
))}
</div>
<div style={{marginTop:10}}>
<Sparkline data={synth(it.id, 80)} h={32} color={colorFor(it)} fill/>
</div>
</div>
);
})}
</div>
);
};
// Sparkline + axis helpers
const Sparkline = ({ data, h=80, color="var(--accent)", fill=false, axis=false }) => {
const w = 800;
const max = Math.max(...data, 0.01);
const path = data.map((v,i) => `${(i/(data.length-1))*w},${h - (v/max) * (h-12) - 6}`).join(" L");
return (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{display:"block"}}>
{axis && [0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={(h-12)*p+6} x2={w} y2={(h-12)*p+6} stroke="var(--border-1)" strokeWidth="1"/>
))}
{fill && <path d={`M ${path} L ${w},${h} L 0,${h} Z`} fill={color} opacity="0.16"/>}
<path d={`M ${path}`} fill="none" stroke={color} strokeWidth="1.6"/>
</svg>
);
};
const TimeAxis = () => (
<div className="muted mono" style={{display:"flex", justifyContent:"space-between", marginTop:6, fontSize:10}}>
{["00:00","04:00","08:00","12:00","16:00","20:00","24:00"].map(t => <span key={t}>{t}</span>)}
</div>
);
const EmptyHint = () => (
<div style={{padding:"60px 20px", textAlign:"center", color:"var(--fg-3)"}}>
<Icon name="cube" size={48} style={{opacity:.3}}/>
<div style={{marginTop:14, fontSize:13}}>从左侧选择数据项目以开始检索</div>
<div className="muted" style={{fontSize:11, marginTop:4}}>支持速度 / SOC / H₂ 压力 / 胎压 / 驾驶行为等 30+ 字段</div>
</div>
);
window.ArtboardHistory = ArtboardHistory;