Files
oneos-truck-date-prototype/artboards/integration.jsx
kkfluous e38bd8a1d8
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(integration): 新增数据接入监控页
- 字段:车牌 / VIN / 品牌+型号 / GB32960 状态+最后接收 / JT808 状态+最后接收 / 接入时间
- 状态:在线 / 断流 / 未对接,三色 pill + 脉冲点
- 重点标记:双协议均未对接的车辆 → 行红底 + 警示图标 + 顶部 banner
- 工具栏:搜索(车牌/VIN/品牌/型号)+ 5 维筛选 + 4 维排序 + CSV 导出
- KPI:总车辆 / GB 在线 / GB 断流 / JT 在线 / JT 断流 / 完全未对接
- 数据:fleet.js 增 brand/model/gbStatus/gbLastSeen/jtStatus/jtLastSeen/onboardAt
- 路由 #/integration · sidebar 增 plug 图标项
2026-04-28 15:45:59 +08:00

351 lines
18 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())}`;
};
// 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:"断流" },
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);
}, []);
// 派生重点标记 + 状态等级(用于排序)
const enriched = React.useMemo(() => allVehicles.map(v => {
const isCritical = v.gbStatus === "not_connected" && v.jtStatus === "not_connected";
const hasOffline = v.gbStatus === "offline" || v.jtStatus === "offline";
const allOnline = v.gbStatus === "online" && v.jtStatus === "online";
let priority = 4;
if (isCritical) priority = 0;
else if (v.gbStatus === "not_connected" || v.jtStatus === "not_connected") priority = 1;
else if (hasOffline) priority = 2;
else if (allOnline) priority = 3;
return { ...v, isCritical, hasOffline, allOnline, priority };
}), [allVehicles]);
const filtered = enriched.filter(v => {
if (filterMode === "online" && !(v.gbStatus === "online" || v.jtStatus === "online")) return false;
if (filterMode === "offline" && !v.hasOffline) return false;
if (filterMode === "not_connected" && !(v.gbStatus === "not_connected" || v.jtStatus === "not_connected")) 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;
});
const filters = [
{ k: "all", l: "全部", c: enriched.length, tone: "neutral" },
{ k: "online", l: "任一在线", c: enriched.filter(v => v.gbStatus === "online" || v.jtStatus === "online").length, tone: "ok" },
{ k: "offline", l: "断流", c: enriched.filter(v => v.hasOffline).length, tone: "warn" },
{ k: "not_connected", l: "未对接", c: enriched.filter(v => v.gbStatus === "not_connected" || v.jtStatus === "not_connected").length, tone: "danger" },
{ k: "both_none", l: "完全未对接", c: enriched.filter(v => v.isCritical).length, 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: counts.all },
{ lbl:"GB在线", val: counts.gbOnline },
{ lbl:"JT在线", val: counts.jtOnline },
{ lbl:"重点未对接", val: counts.bothNone, deltaUp:false, delta: counts.bothNone > 0 ? "需处理" : "—" },
]}
/>
{/* 顶部 banner — 如果有完全未对接车辆,全屏告警条 */}
{counts.bothNone > 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}}>{counts.bothNone}</span> GB32960 JT808 / </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={counts.all} tone="neutral" accent/>
<KpiCard label="GB32960 接入" value={counts.gbOnline} sub={`/ ${counts.all}`} tone="ok" accent/>
<KpiCard label="GB32960 断流" value={counts.gbOffline} tone="warn" accent/>
<KpiCard label="JT808/1078" value={counts.jtOnline} sub={`/ ${counts.all}`} tone="ok" accent/>
<KpiCard label="JT 断流" value={counts.jtOffline} tone="warn" accent/>
<KpiCard label="完全未对接" value={counts.bothNone} sub={counts.bothNone > 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.gbStatus} protocol="GB"/>
</td>
<td className="mono" style={{color: v.gbStatus === "not_connected" ? "var(--fg-3)" : v.gbStatus === "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.jtStatus} protocol="JT"/>
</td>
<td className="mono" style={{color: v.jtStatus === "not_connected" ? "var(--fg-3)" : v.jtStatus === "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;