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

418
artboards/playback.jsx Normal file
View File

@@ -0,0 +1,418 @@
// 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;