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

385
artboards/overview.jsx Normal file
View File

@@ -0,0 +1,385 @@
// artboard-overview.jsx — Asset-management overview
// Filter by: 资产状态 / 部门 / 归属
// Card shows: 车牌 + VIN + 城市 + 部门 + 客户 + 资产状态
// Detail shows: 资产档案 + 业务关系 + 实时车况 + 保养预警 (no driver)
const AssetStatusChip = ({ status }) => {
const map = {
in_stock: { label: "在库", bg: "var(--accent-soft)", fg: "var(--accent)", dot: "ok" },
leasing: { label: "租赁" , bg: "rgba(46,140,140,0.15)",fg: "var(--info)", dot: "info" },
abnormal: { label: "异常", bg: "var(--danger-soft)", fg: "var(--danger)", dot: "danger" },
};
const m = map[status] || map.in_stock;
return (
<span className="chip" style={{background:m.bg, color:m.fg, border:"1px solid " + m.fg + "33", fontSize:10, padding:"2px 7px"}}>
<span className={"dot " + m.dot} style={{width:5, height:5}}/> {m.label}
</span>
);
};
const OwnChip = ({ own }) => (
<span className="chip" style={{
fontSize:10, padding:"1px 6px",
background: own === "self" ? "rgba(31,139,76,0.10)" : "rgba(122,140,46,0.12)",
color: own === "self" ? "var(--accent)" : "#7A8C2E",
border: "1px solid " + (own === "self" ? "rgba(31,139,76,0.25)" : "rgba(122,140,46,0.25)"),
}}>{own === "self" ? "自有" : "外租"}</span>
);
const DeptDot = ({ dept }) => {
const d = (window.DEPARTMENTS || []).find(x => x.id === dept);
if (!d) return null;
return (
<span style={{display:"inline-flex", alignItems:"center", gap:4, fontSize:11}}>
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block"}}/>
<span style={{color:"var(--fg-1)"}}>{d.name}</span>
</span>
);
};
const ArtboardOverview = () => {
const allVehicles = (window.VEHICLES || []);
const { role } = (typeof window.useCurrentRole === "function") ? window.useCurrentRole() : { role: null };
// Apply role-based scope before user-facing filters
const vehicles = React.useMemo(() => {
if (!role || role.scope === "all" || role.scope === "ops" || role.scope === "finance") return allVehicles;
if (role.scope === "dept") return allVehicles.filter(v => v.dept === role.deptId);
return allVehicles;
}, [role, allVehicles]);
const counts = (window.COUNTS || {});
const deps = (window.DEPARTMENTS || []);
const isDeptScoped = role && role.scope === "dept";
// Scoped counts so KPIs match what the role can actually see
const scopedCounts = React.useMemo(() => {
const c = { all: vehicles.length, inStock:0, leasing:0, abnormal:0, self:0, lease:0 };
vehicles.forEach(v => {
if (v.asset === "in_stock") c.inStock++;
else if (v.asset === "leasing") c.leasing++;
else if (v.asset === "abnormal") c.abnormal++;
if (v.own === "self") c.self++; else c.lease++;
});
return c;
}, [vehicles]);
const [selected, setSelected] = React.useState(vehicles[8]?.id || vehicles[0]?.id);
React.useEffect(() => {
if (vehicles.length && !vehicles.find(x => x.id === selected)) {
setSelected(vehicles[0].id);
}
}, [vehicles, selected]);
const [filterAsset, setFilterAsset] = React.useState("all"); // all | in_stock | leasing | abnormal
const [filterDept, setFilterDept] = React.useState("all");
const [filterOwn, setFilterOwn] = React.useState("all");
const [search, setSearch] = React.useState("");
const filtered = vehicles.filter(v => {
if (filterAsset !== "all" && v.asset !== filterAsset) return false;
if (filterDept !== "all" && v.dept !== filterDept) return false;
if (filterOwn !== "all" && v.own !== filterOwn) return false;
if (search && !v.plate.includes(search) && !v.vin.includes(search)) return false;
return true;
});
const v = vehicles.find(x => x.id === selected) || vehicles[0];
if (!v) return null;
return (
<div className="app">
<Sidebar active="map"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={isDeptScoped ? ["羚牛车辆数据中心", "资产管理", role.name.replace(/.*·/,"")] : ["羚牛车辆数据中心", "资产管理", "总览"]}
kpis={[
{ lbl: isDeptScoped ? "本部门车辆" : "总车辆", val: scopedCounts.all },
{ lbl:"在库", val: scopedCounts.inStock, delta: scopedCounts.all ? Math.round(scopedCounts.inStock/scopedCounts.all*100) + "%" : "0%" },
{ lbl:"租赁", val: scopedCounts.leasing, delta: scopedCounts.all ? Math.round(scopedCounts.leasing/scopedCounts.all*100) + "%" : "0%", deltaUp:true },
{ lbl:"异常", val: scopedCounts.abnormal, delta: scopedCounts.abnormal > 0 ? "+" + scopedCounts.abnormal : "0", deltaUp:false },
]}
/>
{isDeptScoped && (
<div style={{
padding:"7px 16px", background:"var(--accent-soft)",
borderBottom:"1px solid var(--border-1)", fontSize:11,
display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)"
}}>
<span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/>
<span><span className="strong">数据权限</span>当前以 <span className="strong">{role.name}</span> 身份登录仅可见本部门 {scopedCounts.all} 辆车 · 全公司共 {counts.all} </span>
<span className="muted" style={{marginLeft:"auto"}}>切换身份请使用右下角 Tweaks · 登录身份</span>
</div>
)}
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr 360px", gap:0, minHeight:0}}>
{/* Left: fleet list with asset filters */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div style={{padding:"12px 14px 8px"}}>
<div className="between" style={{marginBottom:8}}>
<span className="eyebrow">车辆 · {filtered.length}/{scopedCounts.all}</span>
<span className="muted" style={{fontSize:11, cursor:"pointer"}}><Icon name="filter" size={11} style={{verticalAlign:"middle"}}/> 高级</span>
</div>
<div className="search" style={{height:28}}>
<Icon name="search" size={12}/>
<input placeholder="车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)}/>
</div>
{/* Asset status filter */}
<div style={{marginTop:8}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>资产状态</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
{[
{k:"all", l:"全部", c: scopedCounts.all},
{k:"in_stock", l:"在库", c: scopedCounts.inStock},
{k:"leasing", l:"租赁" , c: scopedCounts.leasing},
{k:"abnormal", l:"异常", c: scopedCounts.abnormal},
].map(o => (
<span key={o.k}
className={"chip" + (filterAsset === o.k ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterAsset(o.k)}>
{o.l} <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>
</span>
))}
</div>
</div>
{/* Ownership */}
<div style={{marginTop:6}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>归属</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
{[
{k:"all", l:"全部"},
{k:"self", l:"自有", c: scopedCounts.self},
{k:"lease", l:"外租", c: scopedCounts.lease},
].map(o => (
<span key={o.k}
className={"chip" + (filterOwn === o.k ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterOwn(o.k)}>
{o.l}{o.c != null && <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>}
</span>
))}
</div>
</div>
{/* Department — hidden when role is dept-scoped (only one dept visible) */}
{!isDeptScoped && (
<div style={{marginTop:6}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>业务部门</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
<span className={"chip" + (filterDept === "all" ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterDept("all")}>全部</span>
{deps.map(d => (
<span key={d.id}
className={"chip" + (filterDept === d.id ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterDept(d.id)}>
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block", marginRight:4, verticalAlign:"middle"}}/>
{d.name}
<span className="muted mono" style={{marginLeft:3, fontSize:10}}>{counts.byDept?.[d.id] || 0}</span>
</span>
))}
</div>
</div>
)}
</div>
<div className="scroll" style={{flex:1, padding:"4px 0"}}>
{filtered.map(x => (
<div key={x.id} onClick={() => setSelected(x.id)}
style={{
padding:"10px 14px", borderLeft: "2px solid " + (x.id === selected ? "var(--accent)" : "transparent"),
background: x.id === selected ? "var(--accent-soft)" : "transparent",
cursor:"pointer", borderBottom:"1px solid var(--border-1)"
}}>
<div className="between">
<span className="mono strong" style={{fontSize:13, color:"var(--fg-0)"}}>{x.plate}</span>
<AssetStatusChip status={x.asset}/>
</div>
<div className="muted mono" style={{fontSize:10, marginTop:3, opacity:.8}}>{x.vin}</div>
<div style={{display:"flex", gap:8, marginTop:5, alignItems:"center", flexWrap:"wrap"}}>
<DeptDot dept={x.dept}/>
<OwnChip own={x.own}/>
{x.gps === "offline" && <span className="muted" style={{fontSize:10}}> GPS离线</span>}
</div>
<div className="muted" style={{fontSize:10, marginTop:4}}>
<Icon name="pin" size={9} style={{verticalAlign:"middle", marginRight:3, opacity:.6}}/>
{x.city}
{x.customer !== "—" && (<><span style={{margin:"0 4px"}}>·</span>{x.customer}</>)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="muted" style={{padding:"40px 16px", textAlign:"center", fontSize:12}}>没有匹配的车辆</div>
)}
</div>
</div>
{/* Map center */}
<div style={{position:"relative", minWidth:0, minHeight:0}}>
<FleetMap selectedId={selected} onSelect={(x)=>setSelected(x.id)} />
<div style={{position:"absolute", top:12, right:12, display:"flex", flexDirection:"column", gap:6}}>
{["layers","plus","close","sat","pin"].map((n,i)=>(
<div key={i} className="icon-btn" style={{width:32, height:32, background:"var(--bg-1)", border:"1px solid var(--border-1)"}}>
<Icon name={n === "close" ? "expand" : n} size={14}/>
</div>
))}
</div>
{/* Legend by asset */}
<div style={{position:"absolute", bottom:12, left:12, padding:"8px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, display:"flex", gap:14, fontSize:10}}>
<span className="mid gap-1"><span className="dot ok"/> 在库/正常</span>
<span className="mid gap-1"><span className="dot info"/> 租赁</span>
<span className="mid gap-1"><span className="dot warn"/> 待整备</span>
<span className="mid gap-1"><span className="dot danger"/> 异常</span>
<span className="mid gap-1"><span className="dot idle"/> GPS离线</span>
</div>
<div style={{position:"absolute", top:12, left:12, padding:"6px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)", boxShadow:"var(--shadow-1)"}}>
<span style={{display:"inline-flex", alignItems:"center", gap:4}}><span className="dot ok pulse"/> LIVE</span>
<span className="muted">|</span>
<span>嘉兴市·平湖</span>
<span className="muted">|</span>
<span>14:32:08</span>
</div>
</div>
{/* Right: vehicle asset detail panel */}
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div style={{padding:"14px 16px 12px", borderBottom:"1px solid var(--border-1)"}}>
<div className="between">
<div style={{minWidth:0, flex:1}}>
<div className="eyebrow">{v.deptName} · {v.deptLead}</div>
<div className="mono strong" style={{fontSize:18, fontWeight:600, marginTop:4}}>{v.plate}</div>
<div className="muted mono" style={{fontSize:10, marginTop:2}}>{v.vin}</div>
</div>
<div className="col gap-1" style={{alignItems:"flex-end"}}>
<AssetStatusChip status={v.asset}/>
<OwnChip own={v.own}/>
</div>
</div>
<div className="mid gap-2" style={{marginTop:8, fontSize:10}}>
<span className="muted">车辆等级</span>
<span className="strong">{v.grade}</span>
<span className="muted">·</span>
<span className="muted">状态时长</span>
<span className="mono strong">{v.statusDays}</span>
{v.fleetCode && <>
<span className="muted">·</span>
<span className="muted">编号</span>
<span className="mono strong">{v.fleetCode}</span>
</>}
</div>
</div>
<div className="scroll" style={{flex:1}}>
{/* 业务关系 */}
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>业务关系</div>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">业务部门</span>
<span className="mid gap-1"><span style={{width:6,height:6,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
</div>
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
<div className="between"><span className="muted">客户</span><span className="strong" style={{textAlign:"right"}}>{v.customer}</span></div>
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.ownCompany}</span></div>
{v.own === "lease" && <div className="between"><span className="muted">租赁公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.company}</span></div>}
{v.contractNo && <div className="between"><span className="muted">合同编号</span><span className="mono" style={{fontSize:10}}>{v.contractNo}</span></div>}
</div>
</div>
{/* 实时车况 — 财务身份不可见 */}
{role && role.scope === "finance" ? (
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>实时车况</div>
<div style={{padding:"14px 12px", background:"var(--bg-2)", border:"1px dashed var(--border-1)", borderRadius:4, textAlign:"center"}}>
<div style={{fontSize:18, opacity:0.4, marginBottom:6}}>🔒</div>
<div style={{fontSize:11, color:"var(--fg-2)"}}>实时车况数据已隐藏</div>
<div style={{fontSize:10, color:"var(--fg-3)", marginTop:3}}>财务身份仅可见资产 · 业务关系 · 合同</div>
</div>
</div>
) : (
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="between" style={{marginBottom:10}}>
<span className="eyebrow">实时车况</span>
<span className="chip" style={{fontSize:9, padding:"1px 6px"}}>
<span className={"dot " + (v.gps === "online" ? "ok" : "idle")} style={{width:5,height:5}}/> GPS{v.gps === "online" ? "在线" : "离线"}
</span>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
<Gauge value={v.speed/120} label={v.speed} sub="km/h" color="var(--info)"/>
<Gauge value={v.soc/100} label={v.soc + "%"} sub="电量" color={v.soc < 20 ? "var(--danger)" : "var(--accent)"}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginTop:10, fontSize:11}}>
<div className="col gap-1">
<span className="muted">氢气压力</span>
<span className="mono strong">{v.h2} MPa</span>
</div>
<div className="col gap-1">
<span className="muted">续航</span>
<span className="mono strong">{v.range} km</span>
</div>
<div className="col gap-1">
<span className="muted">电机温度</span>
<span className="mono strong" style={{color: v.motorTemp > 90 ? "var(--danger)" : "var(--fg-0)"}}>{v.motorTemp}°C</span>
</div>
<div className="col gap-1">
<span className="muted">停车场</span>
<span className="strong" style={{fontSize:10}}>{v.parking}</span>
</div>
</div>
</div>
)}
{/* 里程 & 保养 */}
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>里程与保养</div>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">累计里程</span><span className="mono strong">{v.totalKm.toLocaleString()} km</span></div>
<div className="between"><span className="muted">上次保养</span><span className="mono">{v.lastMaintDays}天前 · {v.lastMaintKm.toLocaleString()}km</span></div>
<div>
<div className="between" style={{marginBottom:4}}>
<span className="muted">下次保养</span>
<span className={"mono strong"} style={{color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--fg-0)"}}>
剩余 {v.kmToMaint.toLocaleString()} km
</span>
</div>
<div className="bar" style={{height:4}}>
<i style={{
width: Math.min(100, ((10000 - v.kmToMaint) / 10000) * 100) + "%",
background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)",
}}/>
</div>
</div>
{v.handoverKm != null && (
<div className="between"><span className="muted">交车里程</span><span className="mono">{v.handoverKm.toLocaleString()} km</span></div>
)}
{v.returnKm != null && (
<div className="between"><span className="muted">还车里程</span><span className="mono">{v.returnKm.toLocaleString()} km</span></div>
)}
</div>
</div>
{v.asset === "abnormal" && (
<div style={{padding:"14px 16px"}}>
<div className="eyebrow" style={{marginBottom:8}}>异常处理</div>
<div style={{padding:"8px 10px", background:"var(--danger-soft)", border:"1px solid oklch(0.68 0.220 25 / 0.4)", borderRadius:4, fontSize:11}}>
<div className="between">
<span className="strong">资产状态异常</span>
<span className="mono muted">{v.statusDays}</span>
</div>
<div className="muted" style={{marginTop:3}}>停车场标记为异常 · 待业务部门核查</div>
</div>
</div>
)}
<div style={{padding:"12px 16px", display:"flex", gap:6}}>
<button className="btn primary" style={{flex:1}} onClick={() => location.hash = "#/detail"}><Icon name="route" size={13}/> 详情</button>
<button className="btn" style={{flex:1}} onClick={() => location.hash = "#/playback"}><Icon name="history" size={13}/> 轨迹</button>
<button className="btn icon"><Icon name="bell" size={13}/></button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardOverview = ArtboardOverview;