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 探活
607 lines
29 KiB
JavaScript
607 lines
29 KiB
JavaScript
// 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;
|