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 图标项
351 lines
18 KiB
JavaScript
351 lines
18 KiB
JavaScript
// 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;
|