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

150
components/charts.jsx Normal file
View File

@@ -0,0 +1,150 @@
// charts.jsx — minimal SVG charts for the cockpit
// Sparkline / line chart
const LineChart = ({ data, w = 300, h = 80, color = "var(--accent)", fill = true, axis = false, showDots = false, baseline = null }) => {
const min = Math.min(...data), max = Math.max(...data);
const span = max - min || 1;
const stepX = w / (data.length - 1);
const padY = 6;
const y = v => padY + (h - padY * 2) * (1 - (v - min) / span);
const pts = data.map((v, i) => [i * stepX, y(v)]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0] + " " + p[1]).join(" ");
const areaPath = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
<defs>
<linearGradient id={`g-${color.replace(/[^a-z]/gi,'')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.35"/>
<stop offset="1" stopColor={color} stopOpacity="0"/>
</linearGradient>
</defs>
{axis && (
<>
<line x1="0" y1={h-1} x2={w} y2={h-1} stroke="var(--border-1)" strokeWidth="1"/>
{[0.25, 0.5, 0.75].map(p => (
<line key={p} x1="0" y1={h*p} x2={w} y2={h*p} stroke="var(--border-1)" strokeWidth="0.5" strokeDasharray="2 3"/>
))}
</>
)}
{baseline != null && (
<line x1="0" y1={y(baseline)} x2={w} y2={y(baseline)} stroke="var(--warn)" strokeWidth="1" strokeDasharray="3 3" opacity="0.6"/>
)}
{fill && <path d={areaPath} fill={`url(#g-${color.replace(/[^a-z]/gi,'')})`}/>}
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
{showDots && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r="2" fill={color}/>
))}
</svg>
);
};
const Bars = ({ data, w = 300, h = 80, color = "var(--accent)", labels = null }) => {
const max = Math.max(...data) || 1;
const slot = w / data.length;
const bw = Math.max(slot * 0.55, 3);
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
{data.map((v, i) => {
const bh = (h - 14) * (v / max);
return (
<g key={i}>
<rect x={i*slot + (slot-bw)/2} y={h - 14 - bh} width={bw} height={bh} fill={color} rx="1.5" opacity={0.85}/>
{labels && <text x={i*slot + slot/2} y={h-3} textAnchor="middle" fill="var(--fg-3)" fontSize="9" fontFamily="var(--font-mono)">{labels[i]}</text>}
</g>
);
})}
</svg>
);
};
// Donut for pie share
const Donut = ({ size = 80, value = 0.7, color = "var(--accent)", track = "var(--bg-3)", thick = 8, label }) => {
const r = size/2 - thick/2;
const c = 2*Math.PI*r;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={track} strokeWidth={thick}/>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={thick}
strokeDasharray={`${c*value} ${c}`} strokeDashoffset={c*0.25}
transform={`rotate(-90 ${size/2} ${size/2})`}
strokeLinecap="round"/>
{label && <text x={size/2} y={size/2+1} textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>}
</svg>
);
};
// Radial gauge (for speed / soc)
const Gauge = ({ value = 0.6, size = 100, color = "var(--accent)", label, sub }) => {
const r = size/2 - 8;
const c = Math.PI * r; // half circle
return (
<svg width={size} height={size*0.65} viewBox={`0 0 ${size} ${size*0.65}`}>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke="var(--bg-3)" strokeWidth="6" strokeLinecap="round"/>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${c*value} ${c}`}/>
<text x={size/2} y={size/2 - 4} textAnchor="middle" fontSize="18" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>
{sub && <text x={size/2} y={size/2 + 10} textAnchor="middle" fontSize="9" fill="var(--fg-3)" letterSpacing="0.1em">{sub}</text>}
</svg>
);
};
// Stacked horizontal bar (battery + h2)
const Stack = ({ segs, w = 200, h = 8 }) => {
const total = segs.reduce((a, s) => a + s.v, 0) || 1;
let x = 0;
return (
<svg width={w} height={h} style={{display: "block"}}>
{segs.map((s, i) => {
const sw = w * (s.v/total);
const r = <rect key={i} x={x} y="0" width={sw} height={h} fill={s.color || "var(--accent)"}/>;
x += sw;
return r;
})}
</svg>
);
};
// Sample data generators (deterministic)
const seed = (s) => () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const genSpeed = () => {
const r = seed(12);
const out = [];
let v = 50;
for (let i = 0; i < 60; i++) {
v += (r() - 0.5) * 12;
v = Math.max(0, Math.min(95, v));
out.push(v);
}
return out;
};
const genSoc = () => {
const out = [];
let v = 92;
for (let i = 0; i < 60; i++) {
v -= 0.08 + Math.random()*0.4;
out.push(Math.max(15, v));
}
return out;
};
const genH2 = () => {
const out = [];
let v = 4.8;
for (let i = 0; i < 60; i++) {
v -= 0.005 + Math.random()*0.04;
out.push(Math.max(0.5, v));
}
return out;
};
window.LineChart = LineChart;
window.Bars = Bars;
window.Donut = Donut;
window.Gauge = Gauge;
window.Stack = Stack;
window.genSpeed = genSpeed;
window.genSoc = genSoc;
window.genH2 = genH2;