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

419 lines
24 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.
// 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;