Files
oneos-truck-date-prototype/artboards/integration.jsx
kkfluous 97a2f54786
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(integration): 接入 1006 辆真实车辆数据 + 24h 异常派生 + 重点品牌锚定
数据
- data/vehicles-real.js: 从 车辆管理-1167382387645005824.xlsx 抽取 1006 辆,含车牌/VIN/品牌/型号/部门/客户/合同等真实字段
- 接入分布按业务要求: GB32960 在线 700 / JT808 在线 950 / 完全未对接 30
- 业务规则: GB 未对接 226 辆 = 苏龙 197 + 海伯特 29(从帕力安牌中重命名 29 辆)
- DEPARTMENTS 扩展 biz5 / biz6 以匹配 xlsx 部门
- fleet.js 优先使用 RAW_VEHICLES,前 12 辆叠加乍浦港地图坐标

派生状态
- integration.jsx 新增 effectiveStatus(): offline 且 lastSeen ≥ 24h → abnormal (异常)
- ConnPill 增 abnormal 红色态; 重点标记 = 双通道均 abnormal/未对接
- 筛选/KPI 全部基于派生状态实时计算(每 30s 重算)
2026-04-28 15:57:14 +08:00

395 lines
20 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.
// integration.jsx — 数据接入监控
// 后端数据管理总览:所有车辆的 GB32960 / JT808+1078 对接情况、最后接收时间
// 完全未对接的车辆重点标记,便于发现新车未接入或数据停止上报。
const fmtRelative = (ts) => {
if (!ts) return "—";
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 1) return "刚刚";
if (m < 60) return m + " 分钟前";
const h = Math.floor(m / 60);
if (h < 24) return h + " 小时前";
const d = Math.floor(h / 24);
return d + " 天前";
};
const fmtAbsolute = (ts) => {
if (!ts) return "—";
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const fmtDate = (ts) => {
if (!ts) return "—";
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
};
// 24h 未上报阈值
const STALE_THRESHOLD = 24 * 60 * 60 * 1000;
// 派生有效状态offline 且 last_seen > 24h → abnormal异常
const effectiveStatus = (rawStatus, lastSeen) => {
if (rawStatus === "online" || rawStatus === "not_connected") return rawStatus;
// offline
if (lastSeen && Date.now() - lastSeen > STALE_THRESHOLD) return "abnormal";
return "offline";
};
// 是否为"红色重点"状态(需要业务跟进)
const isRedState = (st) => st === "abnormal" || st === "not_connected";
// Status pill
const ConnPill = ({ status, protocol }) => {
const map = {
online: { bg:"var(--accent-soft)", fg:"var(--accent)", bd:"rgba(0,113,67,0.30)", dot:"var(--accent)", l:"在线" },
offline: { bg:"var(--warn-soft)", fg:"var(--warn)", bd:"rgba(181,122,14,0.35)", dot:"var(--warn)", l:"断流 <24h" },
abnormal: { bg:"var(--danger-soft)", fg:"var(--danger)", bd:"rgba(179,48,40,0.40)", dot:"var(--danger)", l:"异常 ≥24h" },
not_connected: { bg:"var(--danger-soft)", fg:"var(--danger)", bd:"rgba(179,48,40,0.40)", dot:"var(--danger)", l:"未对接" },
};
const m = map[status] || map.not_connected;
return (
<span style={{
display:"inline-flex", alignItems:"center", gap:5,
height:21, padding:"0 8px",
fontSize:11, fontWeight:500,
borderRadius:4,
background:m.bg, color:m.fg, border:"1px solid "+m.bd,
whiteSpace:"nowrap",
}}>
<span style={{
width:6, height:6, borderRadius:3, background:m.dot,
boxShadow: status === "online" ? "0 0 4px " + m.dot : "none",
animation: status === "online" ? "pulse 1.6s ease-in-out infinite" : "none",
}}/>
{protocol && <span style={{fontFamily:"var(--font-mono)", fontSize:9, opacity:.7, marginRight:2}}>{protocol}</span>}
{m.l}
</span>
);
};
const KpiCard = ({ label, value, sub, tone, accent }) => {
const toneMap = {
ok: { fg:"var(--accent)", bg:"var(--accent-soft)", bd:"rgba(0,113,67,0.20)" },
warn: { fg:"var(--warn)", bg:"var(--warn-soft)", bd:"rgba(181,122,14,0.25)" },
danger: { fg:"var(--danger)", bg:"var(--danger-soft)", bd:"rgba(179,48,40,0.30)" },
neutral:{ fg:"var(--fg-0)", bg:"var(--bg-2)", bd:"var(--border-1)" },
};
const t = toneMap[tone] || toneMap.neutral;
return (
<div style={{
flex:1, minWidth:160,
padding:"14px 16px",
background:"var(--bg-1)",
border:"1px solid var(--border-1)",
borderRadius:8,
position:"relative",
overflow:"hidden",
}}>
{accent && <div style={{position:"absolute", top:0, left:0, bottom:0, width:3, background:t.fg}}/>}
<div style={{fontSize:11, color:"var(--fg-3)", textTransform:"uppercase", letterSpacing:".1em", fontWeight:500}}>{label}</div>
<div style={{display:"flex", alignItems:"baseline", gap:6, marginTop:6}}>
<span style={{fontFamily:"var(--font-mono)", fontSize:24, fontWeight:600, color:t.fg, fontVariantNumeric:"tabular-nums"}}>{value}</span>
{sub && <span style={{fontSize:11, color:"var(--fg-3)"}}>{sub}</span>}
</div>
</div>
);
};
const ArtboardIntegration = () => {
const allVehicles = (window.VEHICLES || []);
const counts = (window.COUNTS || {});
const [filterMode, setFilterMode] = React.useState("all"); // all | online | offline | not_connected | both_none
const [search, setSearch] = React.useState("");
const [sortBy, setSortBy] = React.useState("priority"); // priority | plate | gbTime | jtTime
const [tick, setTick] = React.useState(0);
// 实时刷新相对时间显示
React.useEffect(() => {
const id = setInterval(() => setTick((x) => x + 1), 30000);
return () => clearInterval(id);
}, []);
// 派生重点标记 + 状态等级(用于排序)
// tick 让 useMemo 在定时器触发时重算时间相关状态
const enriched = React.useMemo(() => allVehicles.map(v => {
const gbEff = effectiveStatus(v.gbStatus, v.gbLastSeen);
const jtEff = effectiveStatus(v.jtStatus, v.jtLastSeen);
// 重点标记:双通道都是红色状态(异常 或 未对接)
const isCritical = isRedState(gbEff) && isRedState(jtEff);
const hasOffline = gbEff === "offline" || jtEff === "offline";
const hasAbnormal = gbEff === "abnormal" || jtEff === "abnormal";
const hasNotConn = gbEff === "not_connected" || jtEff === "not_connected";
const allOnline = gbEff === "online" && jtEff === "online";
let priority = 5;
if (isCritical) priority = 0;
else if (hasNotConn) priority = 1;
else if (hasAbnormal) priority = 2;
else if (hasOffline) priority = 3;
else if (allOnline) priority = 4;
return { ...v, gbEff, jtEff, isCritical, hasOffline, hasAbnormal, hasNotConn, allOnline, priority };
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [allVehicles, tick]);
const filtered = enriched.filter(v => {
if (filterMode === "online" && !(v.gbEff === "online" || v.jtEff === "online")) return false;
if (filterMode === "offline" && !v.hasOffline) return false;
if (filterMode === "abnormal" && !(v.hasAbnormal || v.hasNotConn)) return false;
if (filterMode === "both_none" && !v.isCritical) return false;
if (search) {
const q = search.toLowerCase();
if (!v.plate.toLowerCase().includes(q) &&
!v.vin.toLowerCase().includes(q) &&
!v.brand.toLowerCase().includes(q) &&
!v.model.toLowerCase().includes(q)) return false;
}
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortBy === "priority") return a.priority - b.priority;
if (sortBy === "plate") return a.plate.localeCompare(b.plate);
if (sortBy === "gbTime") return (b.gbLastSeen || 0) - (a.gbLastSeen || 0);
if (sortBy === "jtTime") return (b.jtLastSeen || 0) - (a.jtLastSeen || 0);
return 0;
});
// 派生计数(基于 effectiveStatus
const derivedCounts = React.useMemo(() => {
const c = { gbOnline:0, gbOffline:0, gbAbnormal:0, gbNotConn:0, jtOnline:0, jtOffline:0, jtAbnormal:0, jtNotConn:0, anyOnline:0, hasOffline:0, hasAbnormalOrNC:0, bothRed:0 };
enriched.forEach(v => {
if (v.gbEff === "online") c.gbOnline++;
else if (v.gbEff === "offline") c.gbOffline++;
else if (v.gbEff === "abnormal") c.gbAbnormal++;
else c.gbNotConn++;
if (v.jtEff === "online") c.jtOnline++;
else if (v.jtEff === "offline") c.jtOffline++;
else if (v.jtEff === "abnormal") c.jtAbnormal++;
else c.jtNotConn++;
if (v.gbEff === "online" || v.jtEff === "online") c.anyOnline++;
if (v.hasOffline) c.hasOffline++;
if (v.hasAbnormal || v.hasNotConn) c.hasAbnormalOrNC++;
if (v.isCritical) c.bothRed++;
});
return c;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enriched, tick]);
const filters = [
{ k: "all", l: "全部", c: enriched.length, tone: "neutral" },
{ k: "online", l: "任一在线", c: derivedCounts.anyOnline, tone: "ok" },
{ k: "offline", l: "断流 <24h", c: derivedCounts.hasOffline, tone: "warn" },
{ k: "abnormal", l: "异常 ≥24h", c: derivedCounts.hasAbnormalOrNC, tone: "danger" },
{ k: "both_none", l: "完全未对接", c: derivedCounts.bothRed, tone: "danger" },
];
return (
<div className="app">
<Sidebar active="integration"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={["羚牛车辆数据中心", "数据接入", "监控总览"]}
kpis={[
{ lbl:"总车辆", val: enriched.length },
{ lbl:"GB在线", val: derivedCounts.gbOnline },
{ lbl:"JT在线", val: derivedCounts.jtOnline },
{ lbl:"完全未对接", val: derivedCounts.bothRed, deltaUp:false, delta: derivedCounts.bothRed > 0 ? "需处理" : "—" },
]}
/>
{/* 顶部 banner — 如果有完全未对接车辆,全屏告警条 */}
{derivedCounts.bothRed > 0 && (
<div style={{
padding:"10px 18px",
background:"linear-gradient(90deg, var(--danger-soft), transparent)",
borderBottom:"1px solid rgba(179,48,40,0.25)",
fontSize:12,
display:"flex", alignItems:"center", gap:12, color:"var(--fg-1)",
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>检测到 <span className="strong" style={{color:"var(--danger)", fontFamily:"var(--font-mono)", fontWeight:600}}>{derivedCounts.bothRed}</span> 辆车 GB32960 与 JT808 双协议均处于"未对接 / 24h 未上报"状态,可能为新增/更换车机后未配置上行 — 请尽快在对应业务系统下工单核查。</span>
<button className="btn" style={{marginLeft:"auto", height:24, padding:"0 10px", fontSize:11}} onClick={() => setFilterMode("both_none")}>
查看全部 →
</button>
</div>
)}
{/* KPI 行 */}
<div style={{display:"flex", gap:12, padding:"16px 18px 0"}}>
<KpiCard label="总车辆" value={enriched.length} tone="neutral" accent/>
<KpiCard label="GB32960 接入" value={derivedCounts.gbOnline} sub={`/ ${enriched.length}`} tone="ok" accent/>
<KpiCard label="GB 异常 24h" value={derivedCounts.gbAbnormal + derivedCounts.gbNotConn} tone="danger" accent/>
<KpiCard label="JT808/1078" value={derivedCounts.jtOnline} sub={`/ ${enriched.length}`} tone="ok" accent/>
<KpiCard label="JT 异常 24h" value={derivedCounts.jtAbnormal + derivedCounts.jtNotConn} tone="danger" accent/>
<KpiCard label="完全未对接" value={derivedCounts.bothRed} sub={derivedCounts.bothRed > 0 ? "重点处理" : ""} tone="danger" accent/>
</div>
{/* 工具栏 */}
<div style={{padding:"14px 18px 10px", display:"flex", alignItems:"center", gap:12, flexWrap:"wrap"}}>
<div className="search" style={{maxWidth:280, marginLeft:0}}>
<Icon name="search" size={13}/>
<input placeholder="车牌 / VIN / 品牌 / 型号"
value={search}
onChange={e => setSearch(e.target.value)}/>
</div>
<div style={{display:"flex", gap:6, flexWrap:"wrap"}}>
{filters.map(f => (
<span key={f.k}
onClick={() => setFilterMode(f.k)}
className="chip"
style={{
cursor:"pointer", fontSize:11, height:26, padding:"0 10px",
background: filterMode === f.k
? (f.tone === "danger" ? "var(--danger-soft)" : f.tone === "warn" ? "var(--warn-soft)" : f.tone === "ok" ? "var(--accent-soft)" : "var(--bg-3)")
: "var(--bg-2)",
color: filterMode === f.k
? (f.tone === "danger" ? "var(--danger)" : f.tone === "warn" ? "var(--warn)" : f.tone === "ok" ? "var(--accent)" : "var(--fg-0)")
: "var(--fg-2)",
border: "1px solid " + (filterMode === f.k
? (f.tone === "danger" ? "rgba(179,48,40,0.30)" : f.tone === "warn" ? "rgba(181,122,14,0.30)" : f.tone === "ok" ? "rgba(0,113,67,0.25)" : "var(--border-2)")
: "var(--border-1)"),
fontWeight: filterMode === f.k ? 500 : 400,
}}>
{f.l}
<span className="mono" style={{marginLeft:6, fontSize:10, opacity:.75}}>{f.c}</span>
</span>
))}
</div>
<div style={{marginLeft:"auto", display:"flex", gap:8, alignItems:"center", fontSize:11, color:"var(--fg-3)"}}>
<span>排序</span>
<select value={sortBy} onChange={e => setSortBy(e.target.value)} style={{
height:28, padding:"0 8px", fontSize:11, fontFamily:"inherit",
background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6,
color:"var(--fg-1)", cursor:"pointer",
}}>
<option value="priority">优先级(未对接置顶)</option>
<option value="plate">车牌升序</option>
<option value="gbTime">GB32960 最新</option>
<option value="jtTime">JT 最新</option>
</select>
<button className="btn" style={{height:28, padding:"0 10px", fontSize:11}}>
<Icon name="download" size={12}/>
导出 CSV
</button>
</div>
</div>
{/* 表格 */}
<div className="scroll" style={{flex:1, padding:"0 18px 18px", minHeight:0, overflow:"auto"}}>
<div style={{
background:"var(--bg-1)",
border:"1px solid var(--border-1)",
borderRadius:8, overflow:"hidden",
}}>
<table className="tbl" style={{width:"100%"}}>
<thead>
<tr>
<th style={{width:24}}></th>
<th style={{width:120}}>车牌</th>
<th style={{width:200}}>VIN / 车架号</th>
<th>品牌 / 型号</th>
<th style={{width:130}}>GB32960</th>
<th style={{width:170}}>GB32960 最后接收</th>
<th style={{width:140}}>JT808 / 1078</th>
<th style={{width:170}}>JT 最后接收</th>
<th style={{width:120}}>接入时间</th>
<th style={{width:80}}></th>
</tr>
</thead>
<tbody>
{sorted.map(v => {
const flagged = v.isCritical;
return (
<tr key={v.id} style={{
background: flagged ? "rgba(179,48,40,0.05)" : "transparent",
cursor:"pointer",
}}>
<td style={{padding:"10px 6px 10px 14px", verticalAlign:"middle"}}>
{flagged ? (
<span title="完全未对接 重点处理" style={{
display:"inline-flex", alignItems:"center", justifyContent:"center",
width:20, height:20, borderRadius:10,
background:"var(--danger)", color:"#fff",
}}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/>
</svg>
</span>
) : v.hasOffline ? (
<span title="存在断流通道" style={{display:"inline-block", width:6, height:6, borderRadius:3, background:"var(--warn)", boxShadow:"0 0 4px rgba(181,122,14,0.4)"}}/>
) : null}
</td>
<td className="mono" style={{fontWeight:600, color: flagged ? "var(--danger)" : "var(--fg-0)"}}>
{v.plate}
</td>
<td className="mono" style={{color:"var(--fg-2)", fontSize:11}}>
{v.vin}
</td>
<td>
<div style={{display:"flex", flexDirection:"column", gap:2}}>
<span style={{fontWeight:500, color:"var(--fg-0)", fontSize:12}}>{v.brand}</span>
<span style={{color:"var(--fg-3)", fontSize:11}}>{v.model}</span>
</div>
</td>
<td>
<ConnPill status={v.gbEff} protocol="GB"/>
</td>
<td className="mono" style={{color: v.gbEff === "not_connected" ? "var(--fg-3)" : v.gbEff === "abnormal" ? "var(--danger)" : v.gbEff === "offline" ? "var(--warn)" : "var(--fg-1)"}}>
{v.gbLastSeen ? (
<div style={{display:"flex", flexDirection:"column", gap:1, fontSize:11}}>
<span>{fmtRelative(v.gbLastSeen)}</span>
<span style={{color:"var(--fg-3)", fontSize:10}}>{fmtAbsolute(v.gbLastSeen)}</span>
</div>
) : <span style={{color:"var(--fg-3)"}}>—</span>}
</td>
<td>
<ConnPill status={v.jtEff} protocol="JT"/>
</td>
<td className="mono" style={{color: v.jtEff === "not_connected" ? "var(--fg-3)" : v.jtEff === "abnormal" ? "var(--danger)" : v.jtEff === "offline" ? "var(--warn)" : "var(--fg-1)"}}>
{v.jtLastSeen ? (
<div style={{display:"flex", flexDirection:"column", gap:1, fontSize:11}}>
<span>{fmtRelative(v.jtLastSeen)}</span>
<span style={{color:"var(--fg-3)", fontSize:10}}>{fmtAbsolute(v.jtLastSeen)}</span>
</div>
) : <span style={{color:"var(--fg-3)"}}>—</span>}
</td>
<td className="mono" style={{color:"var(--fg-2)", fontSize:11}}>
{fmtDate(v.onboardAt)}
</td>
<td>
<button className="btn" style={{height:24, padding:"0 8px", fontSize:11}} onClick={() => location.hash = "#/detail"}>
查看
</button>
</td>
</tr>
);
})}
{sorted.length === 0 && (
<tr><td colSpan={10} style={{textAlign:"center", padding:40, color:"var(--fg-3)", fontSize:12}}>没有匹配的车辆</td></tr>
)}
</tbody>
</table>
</div>
<div style={{padding:"10px 4px", display:"flex", justifyContent:"space-between", fontSize:11, color:"var(--fg-3)"}}>
<span>共 <span className="mono strong" style={{color:"var(--fg-1)"}}>{sorted.length}</span> · 30 </span>
<span>处理流程发现未对接 在车联网平台下工单 配置 TBOX/JT 主机参数 回流验证</span>
</div>
</div>
</div>
</div>
);
};
window.ArtboardIntegration = ArtboardIntegration;