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 探活
419 lines
24 KiB
JavaScript
419 lines
24 KiB
JavaScript
// artboard-playback.jsx — Trajectory playback with synchronized data
|
||
|
||
// ── Calendar dropdown ─────────────────────────────────────
|
||
const Calendar = ({ selected, onSelect, onClose }) => {
|
||
// Anchor: April 2026 — month-view picker
|
||
const [viewMonth, setViewMonth] = React.useState(() => {
|
||
const [y, m] = selected.split("-").map(Number);
|
||
return { y, m: m - 1 }; // 0-indexed
|
||
});
|
||
|
||
React.useEffect(() => {
|
||
const onDoc = (e) => { if (!e.target.closest("[data-cal]")) onClose(); };
|
||
document.addEventListener("mousedown", onDoc);
|
||
return () => document.removeEventListener("mousedown", onDoc);
|
||
}, [onClose]);
|
||
|
||
const monthName = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"][viewMonth.m];
|
||
const firstDay = new Date(viewMonth.y, viewMonth.m, 1).getDay(); // 0=Sun
|
||
const daysInMth = new Date(viewMonth.y, viewMonth.m + 1, 0).getDate();
|
||
const today = "2026-04-28";
|
||
|
||
// Mock days with trips (heat indicator)
|
||
const tripDays = {
|
||
"2026-04-21": 5, "2026-04-22": 6, "2026-04-23": 4, "2026-04-24": 7, "2026-04-25": 3,
|
||
"2026-04-26": 0, "2026-04-27": 5, "2026-04-28": 6, "2026-04-15": 4, "2026-04-16": 6,
|
||
"2026-04-08": 3, "2026-04-09": 5, "2026-04-10": 4,
|
||
};
|
||
|
||
const cells = [];
|
||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||
for (let d = 1; d <= daysInMth; d++) cells.push(d);
|
||
while (cells.length % 7) cells.push(null);
|
||
|
||
const fmt = (d) => `${viewMonth.y}-${String(viewMonth.m + 1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
|
||
|
||
return (
|
||
<div data-cal style={{
|
||
position:"absolute", top:36, left:0, zIndex:100, width:280,
|
||
background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8,
|
||
boxShadow:"0 12px 32px rgba(0,0,0,.18)", padding:14,
|
||
}}>
|
||
{/* Month nav */}
|
||
<div className="between" style={{marginBottom:10}}>
|
||
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 0 ? m.y-1 : m.y, m: m.m === 0 ? 11 : m.m-1 }))}>
|
||
<Icon name="chevron" size={12} style={{transform:"rotate(180deg)"}}/>
|
||
</button>
|
||
<span className="strong" style={{fontSize:13}}>{viewMonth.y} 年 {monthName}</span>
|
||
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 11 ? m.y+1 : m.y, m: m.m === 11 ? 0 : m.m+1 }))}>
|
||
<Icon name="chevron" size={12}/>
|
||
</button>
|
||
</div>
|
||
{/* Weekdays */}
|
||
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2, marginBottom:4}}>
|
||
{["日","一","二","三","四","五","六"].map(w => (
|
||
<div key={w} className="muted" style={{fontSize:10, textAlign:"center", padding:"4px 0"}}>{w}</div>
|
||
))}
|
||
</div>
|
||
{/* Day cells */}
|
||
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2}}>
|
||
{cells.map((d, i) => {
|
||
if (!d) return <div key={i} style={{height:30}}/>;
|
||
const ds = fmt(d);
|
||
const trips = tripDays[ds] || 0;
|
||
const isSel = ds === selected;
|
||
const isToday = ds === today;
|
||
const heat = trips === 0 ? null : trips < 3 ? "var(--accent-soft)" : trips < 5 ? "rgba(0,113,67,.30)" : "var(--accent)";
|
||
return (
|
||
<div key={i}
|
||
onClick={() => onSelect(ds)}
|
||
style={{
|
||
height:30, borderRadius:5, cursor:"pointer",
|
||
display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center",
|
||
fontSize:11, position:"relative",
|
||
background: isSel ? "var(--accent)" : "transparent",
|
||
color: isSel ? "#fff" : trips === 0 ? "var(--fg-3)" : "var(--fg-1)",
|
||
fontWeight: isSel || isToday ? 600 : 400,
|
||
border: isToday && !isSel ? "1px solid var(--accent)" : "1px solid transparent",
|
||
}}
|
||
onMouseEnter={(e) => !isSel && (e.currentTarget.style.background = "var(--bg-2)")}
|
||
onMouseLeave={(e) => !isSel && (e.currentTarget.style.background = "transparent")}>
|
||
<span className="mono">{d}</span>
|
||
{trips > 0 && !isSel && (
|
||
<span style={{position:"absolute", bottom:3, width:4, height:4, borderRadius:2, background:heat}}/>
|
||
)}
|
||
{trips > 0 && isSel && (
|
||
<span style={{position:"absolute", bottom:2, fontSize:8, opacity:.85}}>{trips}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* Footer legend */}
|
||
<div className="between" style={{marginTop:10, paddingTop:10, borderTop:"1px solid var(--border-1)", fontSize:10}}>
|
||
<span className="muted">行程频次</span>
|
||
<div className="mid gap-2">
|
||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent-soft)"}}/><span className="muted">少</span></span>
|
||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"rgba(0,113,67,.30)"}}/><span className="muted">中</span></span>
|
||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/><span className="muted">高</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ArtboardPlayback = () => {
|
||
const [t, setT] = React.useState(42); // 0-100
|
||
const [speed, setSpeed] = React.useState(2);
|
||
const [playing, setPlaying] = React.useState(true);
|
||
|
||
// Date / time-range selection
|
||
const [dateStr, setDateStr] = React.useState("2026-04-28");
|
||
const [timeFrom, setTimeFrom] = React.useState("14:02");
|
||
const [timeTo, setTimeTo] = React.useState("14:44");
|
||
const [calOpen, setCalOpen] = React.useState(false);
|
||
|
||
// List of trips on the selected date — each is a candidate range
|
||
const trips = [
|
||
{id:"T-001", from:"08:14", to:"09:42", km:42.6, evt:1, route:"总站 → 港务局", active:false},
|
||
{id:"T-002", from:"10:08", to:"11:35", km:38.1, evt:0, route:"港务局 → 维保中心", active:false},
|
||
{id:"T-003", from:"12:45", to:"13:38", km:21.4, evt:2, route:"维保中心 → 总站", active:false},
|
||
{id:"T-004", from:"14:02", to:"14:44", km:32.4, evt:3, route:"总站 → 乍浦港 #2", active:true },
|
||
{id:"T-005", from:"15:10", to:"16:32", km:48.9, evt:1, route:"乍浦港 → 嘉兴南", active:false},
|
||
{id:"T-006", from:"17:48", to:"19:05", km:55.2, evt:2, route:"嘉兴南 → 总站", active:false},
|
||
];
|
||
|
||
// Compute marker pos along a path
|
||
const path = "M 200 540 L 280 480 L 360 420 L 440 380 L 520 340 L 620 320 L 720 320 L 820 340 L 900 380 L 980 440";
|
||
const points = [
|
||
[200,540],[280,480],[360,420],[440,380],[520,340],[620,320],[720,320],[820,340],[900,380],[980,440]
|
||
];
|
||
const idx = Math.min(points.length - 1, Math.floor((t/100) * (points.length - 1)));
|
||
const next = Math.min(points.length - 1, idx + 1);
|
||
const frac = (t/100) * (points.length - 1) - idx;
|
||
const px = points[idx][0] + (points[next][0] - points[idx][0]) * frac;
|
||
const py = points[idx][1] + (points[next][1] - points[idx][1]) * frac;
|
||
|
||
React.useEffect(() => {
|
||
if (!playing) return;
|
||
const id = setInterval(() => setT(prev => (prev + 0.4 * speed) % 100), 80);
|
||
return () => clearInterval(id);
|
||
}, [playing, speed]);
|
||
|
||
const events = [
|
||
{at:8, type:"start", lbl:"出发·总站"},
|
||
{at:22, type:"warn", lbl:"急加速"},
|
||
{at:38, type:"stop", lbl:"信号停车"},
|
||
{at:55, type:"warn", lbl:"超速"},
|
||
{at:74, type:"stop", lbl:"补能站"},
|
||
{at:92, type:"end", lbl:"到达·机场"},
|
||
];
|
||
|
||
return (
|
||
<div className="app">
|
||
<Sidebar active="route"/>
|
||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||
<Topbar crumbs={["轨迹回放", `浙F07179F · ${dateStr.slice(5)} ${timeFrom} → ${timeTo}`]} kpis={[]} showSearch={false}/>
|
||
|
||
{/* Date / time-range selector bar */}
|
||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:14, alignItems:"center", flexWrap:"wrap"}}>
|
||
<div className="mid gap-2">
|
||
<Icon name="history" size={14} style={{color:"var(--accent)"}}/>
|
||
<span className="muted" style={{fontSize:11}}>日期</span>
|
||
</div>
|
||
|
||
{/* Date pill with calendar dropdown */}
|
||
<div style={{position:"relative"}}>
|
||
<button
|
||
onClick={() => setCalOpen(!calOpen)}
|
||
className="btn"
|
||
style={{height:30, padding:"0 12px", display:"flex", alignItems:"center", gap:8, background:"var(--bg-2)", borderColor: calOpen ? "var(--accent)" : "var(--border-1)"}}>
|
||
<Icon name="bookmark" size={12}/>
|
||
<span className="mono strong" style={{fontSize:12}}>{dateStr}</span>
|
||
<span className="muted" style={{fontSize:10, marginLeft:4}}>周二</span>
|
||
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
|
||
</button>
|
||
{calOpen && (
|
||
<Calendar selected={dateStr} onSelect={(d) => { setDateStr(d); setCalOpen(false); }} onClose={() => setCalOpen(false)}/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quick-range presets */}
|
||
<div className="row gap-1">
|
||
{[
|
||
{l:"今日", v:"today"},
|
||
{l:"昨日", v:"yesterday"},
|
||
{l:"近7日", v:"7d"},
|
||
{l:"近30日",v:"30d"},
|
||
{l:"自定义",v:"custom"},
|
||
].map((p,i) => (
|
||
<span key={i} className={"chip " + (p.v === "custom" ? "accent" : "")}
|
||
style={{cursor:"pointer", fontSize:10}}
|
||
onClick={() => {
|
||
const today = new Date("2026-04-28");
|
||
if (p.v === "today") setDateStr("2026-04-28");
|
||
else if (p.v === "yesterday") setDateStr("2026-04-27");
|
||
else if (p.v === "7d") setDateStr("2026-04-22");
|
||
else if (p.v === "30d") setDateStr("2026-03-29");
|
||
}}>{p.l}</span>
|
||
))}
|
||
</div>
|
||
|
||
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
|
||
|
||
{/* Time range */}
|
||
<div className="mid gap-2">
|
||
<span className="muted" style={{fontSize:11}}>时段</span>
|
||
<div style={{display:"flex", alignItems:"center", gap:0, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, height:30, padding:"0 4px"}}>
|
||
<input type="text" value={timeFrom} onChange={e => setTimeFrom(e.target.value)}
|
||
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
|
||
<span className="muted mono" style={{fontSize:11}}>→</span>
|
||
<input type="text" value={timeTo} onChange={e => setTimeTo(e.target.value)}
|
||
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Trip count badge */}
|
||
<span className="chip" style={{fontSize:10}}>
|
||
<span className="muted">当日行程</span>
|
||
<span className="mono strong" style={{marginLeft:6, color:"var(--accent)"}}>{trips.length}</span>
|
||
</span>
|
||
|
||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||
<button className="btn"><Icon name="refresh" size={13}/> 刷新</button>
|
||
<button className="btn primary"><Icon name="search" size={13}/> 查询轨迹</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Toolbar */}
|
||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", display:"flex", gap:12, alignItems:"center", background:"var(--bg-1)"}}>
|
||
<span className="chip accent">浙F07179F ×</span>
|
||
<span className="chip">+ 添加车辆对比</span>
|
||
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
|
||
<span className="muted" style={{fontSize:11}}>显示</span>
|
||
<span className="chip accent">轨迹</span>
|
||
<span className="chip accent">事件</span>
|
||
<span className="chip">热力</span>
|
||
<span className="chip">停留点</span>
|
||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||
<button className="btn"><Icon name="download" size={13}/> 导出</button>
|
||
<button className="btn"><Icon name="bookmark" size={13}/> 保存</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
|
||
<div style={{display:"flex", flexDirection:"column", minWidth:0}}>
|
||
{/* Map */}
|
||
<div style={{flex:1, position:"relative", minHeight:0}}>
|
||
<FleetMap
|
||
vehicles={[]}
|
||
highlightPath={path}
|
||
playbackPoint={{x: px, y: py}}
|
||
/>
|
||
{/* Event markers on map */}
|
||
<svg viewBox="0 0 1240 800" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"
|
||
style={{position:"absolute", inset:0, pointerEvents:"none"}}>
|
||
{events.map((e,i)=>{
|
||
const ei = Math.min(points.length - 1, Math.floor((e.at/100) * (points.length - 1)));
|
||
const ef = (e.at/100) * (points.length - 1) - ei;
|
||
const en = Math.min(points.length - 1, ei + 1);
|
||
const x = points[ei][0] + (points[en][0]-points[ei][0])*ef;
|
||
const y = points[ei][1] + (points[en][1]-points[ei][1])*ef;
|
||
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
|
||
return (
|
||
<g key={i} transform={`translate(${x} ${y})`}>
|
||
<circle r="6" fill={c} opacity="0.25"/>
|
||
<circle r="3" fill={c} stroke="var(--bg-0)" strokeWidth="1"/>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
{/* Live readout */}
|
||
<div style={{position:"absolute", top:14, left:14, padding:"10px 14px", background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", gap:18, boxShadow:"0 4px 16px rgba(0,0,0,.12)"}}>
|
||
<div><div className="muted" style={{fontSize:10}}>时间</div><div className="strong">14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</div></div>
|
||
<div><div className="muted" style={{fontSize:10}}>速度</div><div className="strong" style={{color:"var(--info)"}}>{Math.floor(40 + Math.sin(t*0.1)*30)} km/h</div></div>
|
||
<div><div className="muted" style={{fontSize:10}}>SOC</div><div className="strong" style={{color:"var(--accent)"}}>{Math.floor(78 - t*0.18)}%</div></div>
|
||
<div><div className="muted" style={{fontSize:10}}>H₂</div><div className="strong">{(4.2 - t*0.012).toFixed(2)} MPa</div></div>
|
||
<div><div className="muted" style={{fontSize:10}}>累计</div><div className="strong">{(t*0.32).toFixed(1)} km</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Player + multi-curve */}
|
||
<div style={{borderTop:"1px solid var(--border-1)", background:"var(--bg-1)"}}>
|
||
{/* Synced data curves */}
|
||
<div style={{padding:"10px 16px 4px", position:"relative"}}>
|
||
<div style={{display:"flex", gap:14, fontSize:10, marginBottom:4}}>
|
||
<span className="mid gap-1"><span className="dot" style={{background:"var(--info)"}}/> 速度 km/h</span>
|
||
<span className="mid gap-1"><span className="dot" style={{background:"var(--accent)"}}/> SOC %</span>
|
||
<span className="mid gap-1"><span className="dot" style={{background:"var(--warn)"}}/> H₂压力 MPa</span>
|
||
</div>
|
||
<div style={{position:"relative", height:80}}>
|
||
<div style={{position:"absolute", inset:0}}><LineChart data={genSpeed()} w={920} h={80} color="var(--info)" axis/></div>
|
||
<div style={{position:"absolute", inset:0}}><LineChart data={genSoc()} w={920} h={80} color="var(--accent)" fill={false}/></div>
|
||
<div style={{position:"absolute", inset:0}}><LineChart data={genH2().map(v=>v*15)} w={920} h={80} color="var(--warn)" fill={false}/></div>
|
||
{/* playhead */}
|
||
<div style={{position:"absolute", top:0, bottom:0, left: (t)+"%", width:1, background:"var(--accent)", boxShadow:"0 0 8px var(--accent-glow)"}}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timeline + controls */}
|
||
<div style={{padding:"6px 16px 14px"}}>
|
||
<div style={{position:"relative", height:36, background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||
{/* event markers */}
|
||
{events.map((e,i)=>(
|
||
<div key={i} style={{position:"absolute", left: e.at+"%", top:0, bottom:0, width:2,
|
||
background: e.type==="warn"?"var(--warn)":e.type==="stop"?"var(--fg-2)":"var(--accent)"}}
|
||
title={e.lbl}/>
|
||
))}
|
||
{/* progress fill */}
|
||
<div style={{position:"absolute", left:0, top:0, bottom:0, width: t+"%", background:"var(--accent-soft)"}}/>
|
||
{/* playhead */}
|
||
<div style={{position:"absolute", left: t+"%", top:-4, bottom:-4, width:2, background:"var(--accent)", boxShadow:"0 0 12px var(--accent-glow)"}}/>
|
||
<div style={{position:"absolute", left:`calc(${t}% - 5px)`, top:"50%", marginTop:-5, width:10, height:10, borderRadius:5, background:"var(--accent)", border:"2px solid var(--bg-0)"}}/>
|
||
{/* time labels */}
|
||
<div style={{position:"absolute", inset:"auto 0 -16px", display:"flex", justifyContent:"space-between", padding:"0 4px", fontSize:9}} className="muted mono">
|
||
{["14:02","14:08","14:15","14:23","14:30","14:36","14:44"].map(x=><span key={x}>{x}</span>)}
|
||
</div>
|
||
</div>
|
||
<div className="between" style={{marginTop:24}}>
|
||
<div className="mid gap-2">
|
||
<button className="btn icon"><Icon name="prev" size={13}/></button>
|
||
<button className="btn icon primary" onClick={()=>setPlaying(!playing)}>
|
||
<Icon name={playing?"pause":"play"} size={13}/>
|
||
</button>
|
||
<button className="btn icon"><Icon name="next" size={13}/></button>
|
||
<span className="muted mono" style={{marginLeft:8, fontSize:11}}>14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} / 14:44:00</span>
|
||
</div>
|
||
<div className="mid gap-1">
|
||
<span className="muted" style={{fontSize:11, marginRight:6}}>倍速</span>
|
||
{[0.5, 1, 2, 4, 8, 16].map(s=>(
|
||
<span key={s} className={"chip " + (s === speed ? "accent" : "")}
|
||
style={{cursor:"pointer"}} onClick={()=>setSpeed(s)}>{s}×</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Side: trips + events + summary */}
|
||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||
{/* Trips of selected day */}
|
||
<div style={{borderBottom:"1px solid var(--border-1)"}}>
|
||
<div className="panel-head">
|
||
<Icon name="route" size={13}/>
|
||
<span className="title">当日行程</span>
|
||
<span className="chip" style={{marginLeft:"auto"}}>{trips.length}</span>
|
||
</div>
|
||
<div style={{maxHeight:180, overflowY:"auto"}}>
|
||
{trips.map((tr,i)=>(
|
||
<div key={i} style={{
|
||
display:"flex", gap:10, padding:"8px 14px", cursor:"pointer",
|
||
borderLeft: tr.active ? "2px solid var(--accent)" : "2px solid transparent",
|
||
background: tr.active ? "var(--accent-soft)" : "transparent",
|
||
borderBottom:"1px solid var(--border-1)",
|
||
}}>
|
||
<div style={{flex:1, minWidth:0}}>
|
||
<div className="between">
|
||
<span className="mono strong" style={{fontSize:11}}>{tr.from} → {tr.to}</span>
|
||
<span className="mono" style={{fontSize:10, color: tr.active ? "var(--accent)" : "var(--fg-2)"}}>{tr.km} km</span>
|
||
</div>
|
||
<div className="between" style={{marginTop:3}}>
|
||
<span className="muted" style={{fontSize:10, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{tr.route}</span>
|
||
{tr.evt > 0 && <span className="chip" style={{fontSize:9, padding:"1px 5px", color:"var(--warn)", background:"var(--warn-soft)", borderColor:"transparent"}}>{tr.evt}事件</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Events */}
|
||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||
<Icon name="timeline" size={13}/>
|
||
<span className="title">事件时间线</span>
|
||
<span className="chip" style={{marginLeft:"auto"}}>{events.length}</span>
|
||
</div>
|
||
<div className="scroll" style={{flex:1, padding:"10px 0"}}>
|
||
{events.map((e,i)=>{
|
||
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
|
||
return (
|
||
<div key={i} style={{display:"flex", gap:10, padding:"10px 16px", cursor:"pointer", borderLeft: Math.abs(t - e.at) < 3 ? "2px solid var(--accent)" : "2px solid transparent", background: Math.abs(t - e.at) < 3 ? "var(--accent-soft)" : "transparent"}} onClick={()=>setT(e.at)}>
|
||
<div style={{position:"relative", width:14, paddingTop:3}}>
|
||
<span className="dot" style={{background:c, width:8, height:8, borderRadius:4}}/>
|
||
{i < events.length - 1 && <span style={{position:"absolute", top:14, bottom:-22, left:3, width:2, background:"var(--border-1)"}}/>}
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<div className="between">
|
||
<span className="strong" style={{fontSize:12}}>{e.lbl}</span>
|
||
<span className="mono muted" style={{fontSize:10}}>14:{String(Math.floor(e.at*0.42 + 2)).padStart(2,"0")}</span>
|
||
</div>
|
||
<div className="muted" style={{fontSize:11, marginTop:2}}>
|
||
{e.type==="warn" ? "速度 +18 km/h · 持续 2.4s" :
|
||
e.type==="stop" ? "停留 1分12秒" :
|
||
e.type==="start" ? "里程 0 km" : "里程 32.4 km · 平均 49 km/h"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div style={{padding:14, borderTop:"1px solid var(--border-1)"}}>
|
||
<div className="eyebrow" style={{marginBottom:8}}>本次行程</div>
|
||
<div className="col gap-2" style={{fontSize:11}}>
|
||
<div className="between"><span className="muted">里程</span><span className="mono strong">32.4 km</span></div>
|
||
<div className="between"><span className="muted">时长</span><span className="mono strong">42 分钟</span></div>
|
||
<div className="between"><span className="muted">平均速度</span><span className="mono strong">46 km/h</span></div>
|
||
<div className="between"><span className="muted">最高速度</span><span className="mono strong" style={{color:"var(--warn)"}}>89 km/h</span></div>
|
||
<div className="between"><span className="muted">能耗</span><span className="mono strong">5.8 kWh / 0.32 kg H₂</span></div>
|
||
<div className="between"><span className="muted">评分</span><span className="mono strong" style={{color:"var(--accent)"}}>87 / 100</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
window.ArtboardPlayback = ArtboardPlayback;
|