Files
oneos-truck-date-prototype/artboards/overview.jsx
kkfluous ed37fe3de5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(overview): KPI 显示 1006 辆/892 在线,按比例缩放在库/租赁/异常
2026-04-28 15:37:29 +08:00

400 lines
23 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-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, online: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++;
if (v.gps === "online") c.online++;
});
return c;
}, [vehicles]);
// Display-scale: 管理员视角下KPI 按实际车队规模放大到 1006 辆 / 892 在线
// (部门视角保持真实数字)
const FLEET_SIZE = 1006;
const FLEET_ONLINE = 892;
const scale = !isDeptScoped && scopedCounts.all > 0 ? FLEET_SIZE / scopedCounts.all : 1;
const sc = (n) => Math.round(n * scale);
const dispTotal = isDeptScoped ? scopedCounts.all : FLEET_SIZE;
const dispOnline = isDeptScoped ? scopedCounts.online : FLEET_ONLINE;
const dispInStock = isDeptScoped ? scopedCounts.inStock : sc(scopedCounts.inStock);
const dispLeasing = isDeptScoped ? scopedCounts.leasing : sc(scopedCounts.leasing);
const dispAbnormal = isDeptScoped ? scopedCounts.abnormal : sc(scopedCounts.abnormal);
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: dispTotal },
{ lbl:"在线", val: dispOnline, delta: dispTotal ? Math.round(dispOnline/dispTotal*100) + "%" : "0%", deltaUp:true },
{ lbl:"在库", val: dispInStock, delta: dispTotal ? Math.round(dispInStock/dispTotal*100) + "%" : "0%" },
{ lbl:"租赁", val: dispLeasing, delta: dispTotal ? Math.round(dispLeasing/dispTotal*100) + "%" : "0%", deltaUp:true },
{ lbl:"异常", val: dispAbnormal, delta: dispAbnormal > 0 ? "+" + dispAbnormal : "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} · {FLEET_SIZE} </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;