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

View File

@@ -6,8 +6,9 @@ const ROUTES = [
{ path: "detail", icon: "car", label: "车辆详情", crumbs: ["羚牛车辆数据中心", "实时监控", "单车详情"], component: "ArtboardDetail" },
{ path: "history", icon: "history", label: "历史查询", crumbs: ["羚牛车辆数据中心", "数据分析", "历史查询"], component: "ArtboardHistory" },
{ path: "playback", icon: "route", label: "轨迹回放", crumbs: ["羚牛车辆数据中心", "数据分析", "轨迹回放"], component: "ArtboardPlayback" },
{ path: "alarm", icon: "bell", label: "事件规则", crumbs: ["羚牛车辆数据中心", "事件中心", "规则编排"], component: "ArtboardAlarm" },
{ path: "inbox", icon: "inbox", label: "通知中心", crumbs: ["羚牛车辆数据中心", "事件中心", "通知中心"], component: "ArtboardInbox" },
{ path: "alarm", icon: "bell", label: "事件规则", crumbs: ["羚牛车辆数据中心", "事件中心", "规则编排"], component: "ArtboardAlarm" },
{ path: "inbox", icon: "inbox", label: "通知中心", crumbs: ["羚牛车辆数据中心", "事件中心", "通知中心"], component: "ArtboardInbox" },
{ path: "integration", icon: "plug", label: "数据接入监控", crumbs: ["羚牛车辆数据中心", "数据接入", "监控总览"], component: "ArtboardIntegration" },
];
const SUB_ROUTES = [

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;

View File

@@ -1,12 +1,13 @@
// chrome.jsx — Sidebar, Topbar shared chrome (route-aware)
const SIDEBAR_ITEMS = [
{ id: "overview", icon: "map", label: "实时地图" },
{ id: "detail", icon: "car", label: "车辆详情" },
{ id: "history", icon: "history", label: "历史查询" },
{ id: "playback", icon: "route", label: "轨迹回放" },
{ id: "alarm", icon: "bell", label: "事件规则" },
{ id: "inbox", icon: "inbox", label: "通知中心" },
{ id: "overview", icon: "map", label: "实时地图" },
{ id: "detail", icon: "car", label: "车辆详情" },
{ id: "history", icon: "history", label: "历史查询" },
{ id: "playback", icon: "route", label: "轨迹回放" },
{ id: "alarm", icon: "bell", label: "事件规则" },
{ id: "inbox", icon: "inbox", label: "通知中心" },
{ id: "integration", icon: "plug", label: "数据接入监控" },
];
const SIDEBAR_SUB = [
{ id: "esg", icon: "chart", label: "ESG·碳减排" },

View File

@@ -133,6 +133,60 @@ const _enrich = (v, i) => {
const h2 = v.status === "danger" ? 0.8 : (v.soc / 100 * 5.6 + 0.2).toFixed(1);
const motorTemp = v.status === "danger" ? 102 : 58 + Math.floor(r()*15);
// ── Brand & model — 真实国内氢能车型样本 ──
const MODELS = [
{ brand: "上汽大通", model: "MIFA-H 氢燃料 MPV" },
{ brand: "上汽大通", model: "EUNIQ 7 氢电版" },
{ brand: "现代", model: "NEXO" },
{ brand: "丰田", model: "MIRAI 第二代" },
{ brand: "格罗夫", model: "格罗夫氢能 SUV" },
{ brand: "海马汽车", model: "7X-H" },
{ brand: "红旗", model: "H5 FCV" },
{ brand: "长安深蓝", model: "SL03 氢燃料版" },
{ brand: "飞驰科技", model: "FCB80 氢燃料客车" },
{ brand: "宇通客车", model: "ZK6105FCEVG3" },
];
const m = MODELS[Math.floor(r() * MODELS.length)];
// ── Integration / 对接情况 ──
// src: T = TBOX(GB/T 32960), J = JT808/1078, B = both
// 状态online (recent) / offline (had data, now stale) / not_connected (从未对接)
// 5% 完全未对接(重点标记) · 12% TBOX 断流 · 8% JT 断流
const integFlag = r();
let gbStatus, jtStatus;
if (i >= 12 && integFlag < 0.05) {
// 5% 全部未对接(重点标记)
gbStatus = "not_connected";
jtStatus = "not_connected";
} else {
if (v.src === "T" || v.src === "B") {
gbStatus = (r() < 0.12) ? "offline" : "online";
} else {
gbStatus = (r() < 0.30) ? "offline" : "not_connected";
}
if (v.src === "J" || v.src === "B") {
jtStatus = (r() < 0.08) ? "offline" : "online";
} else {
jtStatus = (r() < 0.20) ? "offline" : "not_connected";
}
}
// 时间戳生成
const now = Date.now();
const minute = 60 * 1000, hour = 60 * minute, day = 24 * hour;
const tsFor = (st, baseSeed) => {
if (st === "not_connected") return null;
if (st === "online") return now - Math.floor(baseSeed * 5 * minute) - 2000; // 2s ~ 5min
// offline
return now - Math.floor(baseSeed * 7 * day) - 30 * minute; // 30min ~ 7d ago
};
const gbLastSeen = tsFor(gbStatus, r());
const jtLastSeen = tsFor(jtStatus, r());
// 接入时间首次对接日期6 个月内随机)
const onboardDays = Math.floor(r() * 180) + 7;
const onboardAt = now - onboardDays * day;
return {
...v,
plate: v.id,
@@ -148,6 +202,12 @@ const _enrich = (v, i) => {
// Hydrogen-specific
h2Pressure: parseFloat(h2),
range: Math.round(v.soc * 6.2),
// Brand / model
brand: m.brand, model: m.model,
// Integration / 对接情况
gbStatus, gbLastSeen,
jtStatus, jtLastSeen,
onboardAt,
};
};
@@ -175,6 +235,17 @@ const COUNTS = {
acc[d.id] = VEHICLES.filter(v => v.dept === d.id).length;
return acc;
}, {}),
// Integration / 数据接入
gbOnline: VEHICLES.filter(v => v.gbStatus === "online").length,
gbOffline: VEHICLES.filter(v => v.gbStatus === "offline").length,
gbNotConn: VEHICLES.filter(v => v.gbStatus === "not_connected").length,
jtOnline: VEHICLES.filter(v => v.jtStatus === "online").length,
jtOffline: VEHICLES.filter(v => v.jtStatus === "offline").length,
jtNotConn: VEHICLES.filter(v => v.jtStatus === "not_connected").length,
// 完全未对接(重点标记)
bothNone: VEHICLES.filter(v => v.gbStatus === "not_connected" && v.jtStatus === "not_connected").length,
// 任一在线
anyOnline: VEHICLES.filter(v => v.gbStatus === "online" || v.jtStatus === "online").length,
};
// ── User roles for permission demo ────────────────────────

View File

@@ -52,6 +52,7 @@
<script type="text/babel" src="artboards/playback.jsx"></script>
<script type="text/babel" src="artboards/alarm.jsx"></script>
<script type="text/babel" src="artboards/inbox.jsx"></script>
<script type="text/babel" src="artboards/integration.jsx"></script>
<script type="text/babel" src="artboards/esg.jsx"></script>
<script type="text/babel" src="artboards/variant-light.jsx"></script>
<script type="text/babel" src="artboards/variant-dense.jsx"></script>