feat(integration): 新增数据接入监控页
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 字段:车牌 / 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 图标项
This commit is contained in:
kkfluous
2026-04-28 15:45:59 +08:00
parent ed37fe3de5
commit e38bd8a1d8
5 changed files with 432 additions and 8 deletions

350
artboards/integration.jsx Normal file
View File

@@ -0,0 +1,350 @@
// 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;