|
|
@@ -0,0 +1,329 @@
|
|
|
+<!doctype html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="utf-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
+ <title>基础设施 / 运行信息</title>
|
|
|
+ <style>
|
|
|
+ :root { color-scheme:light; --ink:#172033; --muted:#667085; --line:#d9dee7; --panel:#fff; --page:#f3f5f8;
|
|
|
+ --accent:#1570ef; --ok:#067647; --watch:#b54708; --danger:#b42318; --idle:#98a2b3; }
|
|
|
+ * { box-sizing:border-box; }
|
|
|
+ body { margin:0; min-height:100vh; background:var(--page); color:var(--ink);
|
|
|
+ font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
|
|
|
+ header { display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
|
|
|
+ padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
|
|
|
+ h1 { margin:0; font-size:18px; letter-spacing:0; }
|
|
|
+ .meta { display:flex; flex-wrap:wrap; gap:14px; color:var(--muted); }
|
|
|
+ .meta b { color:var(--ink); font-weight:600; }
|
|
|
+ button { height:30px; min-width:76px; padding:0 12px; border:1px solid #d0d5dd; border-radius:4px;
|
|
|
+ background:var(--panel); color:var(--ink); cursor:pointer; font-size:13px; }
|
|
|
+ button:hover { border-color:var(--accent); color:var(--accent); }
|
|
|
+ main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); gap:0;
|
|
|
+ min-height:calc(100vh - 59px); }
|
|
|
+ aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
|
|
|
+ section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-height:0; }
|
|
|
+ h2 { margin:0 0 10px; font-size:14px; font-weight:600; }
|
|
|
+ h2 + .seg { margin-bottom:16px; }
|
|
|
+ h2.mid { margin-top:20px; }
|
|
|
+ .seg { display:flex; gap:6px; }
|
|
|
+ .seg button.active { border-color:var(--accent); background:#eff6ff; color:var(--accent); font-weight:600; }
|
|
|
+ .kpis { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
|
|
+ .kpi { border:1px solid var(--line); border-radius:6px; padding:10px 12px; }
|
|
|
+ .kpi span { display:block; color:var(--muted); font-size:12px; }
|
|
|
+ .kpi b { display:block; margin-top:4px; font-size:20px; font-weight:600; }
|
|
|
+ .kpi em { font-style:normal; font-size:12px; color:var(--muted); }
|
|
|
+ table { width:100%; border-collapse:collapse; }
|
|
|
+ th { text-align:left; font-weight:600; font-size:12px; color:var(--muted);
|
|
|
+ padding:6px 8px; border-bottom:1px solid var(--line); }
|
|
|
+ td { padding:8px; border-bottom:1px solid #eef1f5; vertical-align:middle; }
|
|
|
+ tr.metric { cursor:pointer; }
|
|
|
+ tr.metric:hover td { background:#f8fafc; }
|
|
|
+ tr.metric.sel td { background:#eff6ff; }
|
|
|
+ .name { font-weight:600; }
|
|
|
+ .code { display:block; color:var(--muted); font-size:11px; font-weight:400; }
|
|
|
+ .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; line-height:18px;
|
|
|
+ border:1px solid currentColor; }
|
|
|
+ .b-NORMAL, .b-CURRENT { color:var(--ok); }
|
|
|
+ .b-WATCH, .b-STALE { color:var(--watch); }
|
|
|
+ .b-OVERLOAD, .b-EXPIRED { color:var(--danger); }
|
|
|
+ .b-UNKNOWN, .b-EMPTY { color:var(--idle); }
|
|
|
+ .trend { display:flex; align-items:flex-end; gap:2px; height:22px; }
|
|
|
+ .trend i { width:7px; background:#c7d7ea; display:block; }
|
|
|
+ .trend i.hi { background:var(--accent); }
|
|
|
+ .toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px;
|
|
|
+ padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
|
|
|
+ .map { position:relative; background:#eef2f6;
|
|
|
+ background-image:linear-gradient(#dde3ea 1px,transparent 1px),linear-gradient(90deg,#dde3ea 1px,transparent 1px);
|
|
|
+ background-size:64px 64px; }
|
|
|
+ .point { position:absolute; width:12px; height:12px; margin:-6px 0 0 -6px; border-radius:50%;
|
|
|
+ background:#9fb3c8; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.25); }
|
|
|
+ .point.flagged { background:var(--danger); }
|
|
|
+ .point.linked { background:var(--accent); outline:3px solid rgba(21,112,239,.28); }
|
|
|
+ .plabel { position:absolute; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.93);
|
|
|
+ border:1px solid var(--line); font-size:11px; white-space:nowrap; color:#344054; }
|
|
|
+ .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
|
|
|
+ .caption b { color:var(--ink); }
|
|
|
+ .legend { position:absolute; left:14px; bottom:14px; padding:6px 10px; border-radius:4px;
|
|
|
+ background:rgba(255,255,255,.92); border:1px solid var(--line); color:var(--muted); font-size:12px; }
|
|
|
+ @media (max-width:920px) { main { grid-template-columns:1fr; } .map { min-height:380px; } }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<header>
|
|
|
+ <h1>基础设施 / 运行信息</h1>
|
|
|
+ <div class="meta">
|
|
|
+ <span>范围 <b id="rangeText">--</b></span>
|
|
|
+ <span>时区 <b id="timezone">Asia/Shanghai</b></span>
|
|
|
+ <span>新鲜度 <b id="freshness">--</b></span>
|
|
|
+ <span>更新 <b id="updatedAt">--</b></span>
|
|
|
+ </div>
|
|
|
+ <button id="refresh" type="button">手动刷新</button>
|
|
|
+</header>
|
|
|
+
|
|
|
+<main>
|
|
|
+ <aside>
|
|
|
+ <h2>行业</h2>
|
|
|
+ <div class="seg" id="industries"></div>
|
|
|
+ <h2>运行能力</h2>
|
|
|
+ <div class="kpis" id="kpis"></div>
|
|
|
+ <h2 class="mid">行业运行指标</h2>
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr><th>指标</th><th>当前值</th><th>负荷</th><th>新鲜度</th><th>趋势</th></tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="metrics"></tbody>
|
|
|
+ </table>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <section>
|
|
|
+ <div class="toolbar">
|
|
|
+ <strong id="mapTitle">中央一张图</strong>
|
|
|
+ <span class="meta">选中异常指标可联动定位</span>
|
|
|
+ </div>
|
|
|
+ <div class="map" id="map">
|
|
|
+ <div class="legend">灰点=常规点位 · 红点=异常指标关联点位 · 蓝点=当前选中指标关联点位</div>
|
|
|
+ </div>
|
|
|
+ <div class="caption" id="caption">未选择指标</div>
|
|
|
+ </section>
|
|
|
+</main>
|
|
|
+
|
|
|
+<script>
|
|
|
+ const fixture = {
|
|
|
+ updatedAt: "2026-09-02T09:59:50+08:00",
|
|
|
+ range: { type: "LAST_24_HOURS", start: "2026-09-01T10:00:00+08:00", end: "2026-09-02T10:00:00+08:00" },
|
|
|
+ industries: [
|
|
|
+ {
|
|
|
+ industry: { code: "GAS", key: "gas", label: "燃气" },
|
|
|
+ metrics: [
|
|
|
+ { metricCode: "gas_supply_pressure", metricName: "燃气供应压力", category: "SUPPLY", unit: "MPa",
|
|
|
+ value: "0.32", capacityValue: "0.40", loadRatio: "0.8000", loadState: "WATCH",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 30 },
|
|
|
+ trend: [{ bucket: "2026-08-31", value: "0.29" }, { bucket: "2026-09-01", value: "0.30" }, { bucket: "2026-09-02", value: "0.32" }],
|
|
|
+ abnormal: false, pointCodes: ["PT-GAS-001"] },
|
|
|
+ { metricCode: "gas_flow_rate", metricName: "燃气流量", category: "FLOW", unit: "m3/h",
|
|
|
+ value: "1200.5", capacityValue: "2000", loadRatio: "0.6003", loadState: "NORMAL",
|
|
|
+ freshness: { status: "STALE", reason: "ONE_UPDATE_CYCLE_MISSED", ageSeconds: 90 },
|
|
|
+ trend: [{ bucket: "2026-09-01", value: "980" }, { bucket: "2026-09-02", value: "1200.5" }],
|
|
|
+ abnormal: false, pointCodes: ["PT-GAS-002"] },
|
|
|
+ { metricCode: "gas_remaining_capacity", metricName: "燃气剩余承载能力", category: "CAPACITY", unit: "m3",
|
|
|
+ value: "800", capacityValue: "2000", loadRatio: "0.4000", loadState: "NORMAL",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 12 },
|
|
|
+ trend: [], abnormal: false, pointCodes: [] }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ industry: { code: "WATER_SUPPLY", key: "waterSupply", label: "供水" },
|
|
|
+ metrics: [
|
|
|
+ { metricCode: "water_supply_flow", metricName: "供水流量", category: "SUPPLY", unit: "m3/h",
|
|
|
+ value: "5000", capacityValue: "4000", loadRatio: "1.2500", loadState: "OVERLOAD",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 20 },
|
|
|
+ trend: [{ bucket: "2026-09-01", value: "4200" }, { bucket: "2026-09-02", value: "5000" }],
|
|
|
+ abnormal: true, pointCodes: ["PT-WS-001", "PT-WS-002"] },
|
|
|
+ { metricCode: "water_pressure", metricName: "供水压力", category: "PRESSURE", unit: "MPa",
|
|
|
+ value: "0.45", capacityValue: null, loadRatio: null, loadState: "UNKNOWN",
|
|
|
+ freshness: { status: "EMPTY", reason: "NO_SOURCE", ageSeconds: null },
|
|
|
+ trend: [], abnormal: false, pointCodes: ["PT-WS-003"] },
|
|
|
+ { metricCode: "water_quality_turbidity", metricName: "供水浊度", category: "QUALITY", unit: "NTU",
|
|
|
+ value: "0.62", capacityValue: "1.00", loadRatio: "0.6200", loadState: "NORMAL",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 45 },
|
|
|
+ trend: [{ bucket: "2026-09-01", value: "0.58" }, { bucket: "2026-09-02", value: "0.62" }],
|
|
|
+ abnormal: false, pointCodes: ["PT-WS-004"] }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ industry: { code: "DRAINAGE", key: "drainage", label: "排水" },
|
|
|
+ metrics: [
|
|
|
+ { metricCode: "sewage_cod", metricName: "污水 COD", category: "QUALITY", unit: "mg/L",
|
|
|
+ value: "18.2", capacityValue: "50", loadRatio: "0.3640", loadState: "NORMAL",
|
|
|
+ freshness: { status: "EXPIRED", reason: "TWO_UPDATE_CYCLES_MISSED", ageSeconds: 300 },
|
|
|
+ trend: [{ bucket: "2026-09-01", value: "16.4" }, { bucket: "2026-09-02", value: "18.2" }],
|
|
|
+ abnormal: true, pointCodes: ["PT-DR-001"] },
|
|
|
+ { metricCode: "rainwater_flow", metricName: "雨水流量", category: "FLOW", unit: "m3/h",
|
|
|
+ value: "880", capacityValue: "1000", loadRatio: "0.8800", loadState: "WATCH",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 18 },
|
|
|
+ trend: [{ bucket: "2026-09-01", value: "610" }, { bucket: "2026-09-02", value: "880" }],
|
|
|
+ abnormal: false, pointCodes: ["PT-DR-002"] },
|
|
|
+ { metricCode: "sewage_pump_capacity", metricName: "污水泵站承载能力", category: "CAPACITY", unit: "m3/h",
|
|
|
+ value: "3200", capacityValue: "3000", loadRatio: "1.0667", loadState: "OVERLOAD",
|
|
|
+ freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 25 },
|
|
|
+ trend: [], abnormal: true, pointCodes: ["PT-DR-003"] }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ const points = {
|
|
|
+ "PT-GAS-001": { x: 24, y: 30, name: "燃气调压站" },
|
|
|
+ "PT-GAS-002": { x: 37, y: 46, name: "燃气流量点" },
|
|
|
+ "PT-WS-001": { x: 55, y: 28, name: "供水主干管 A" },
|
|
|
+ "PT-WS-002": { x: 66, y: 44, name: "供水主干管 B" },
|
|
|
+ "PT-WS-003": { x: 48, y: 58, name: "供水压力点" },
|
|
|
+ "PT-WS-004": { x: 74, y: 62, name: "供水水质点" },
|
|
|
+ "PT-DR-001": { x: 30, y: 68, name: "污水监测点" },
|
|
|
+ "PT-DR-002": { x: 82, y: 34, name: "雨水流量点" },
|
|
|
+ "PT-DR-003": { x: 62, y: 74, name: "污水泵站" }
|
|
|
+ };
|
|
|
+
|
|
|
+ let state = { industry: "WATER_SUPPLY", metricCode: null, lastRefresh: null };
|
|
|
+
|
|
|
+ function currentIndustry() {
|
|
|
+ return fixture.industries.find(item => item.industry.code === state.industry) || fixture.industries[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ function selectedMetric() {
|
|
|
+ return currentIndustry().metrics.find(m => m.metricCode === state.metricCode) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function allFlaggedPointCodes() {
|
|
|
+ const codes = new Set();
|
|
|
+ fixture.industries.forEach(block => block.metrics
|
|
|
+ .filter(m => m.abnormal)
|
|
|
+ .forEach(m => m.pointCodes.forEach(code => codes.add(code))));
|
|
|
+ return codes;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderIndustries() {
|
|
|
+ document.getElementById("industries").innerHTML = fixture.industries.map(block =>
|
|
|
+ `<button type="button" data-code="${block.industry.code}" class="${block.industry.code === state.industry ? "active" : ""}">${block.industry.label}</button>`
|
|
|
+ ).join("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderHeader() {
|
|
|
+ document.getElementById("rangeText").textContent = fixture.range.type;
|
|
|
+ document.getElementById("timezone").textContent = "Asia/Shanghai";
|
|
|
+ const expired = currentIndustry().metrics.some(m => m.freshness.status === "EXPIRED");
|
|
|
+ const stale = currentIndustry().metrics.some(m => m.freshness.status === "STALE");
|
|
|
+ document.getElementById("freshness").textContent = expired ? "EXPIRED" : stale ? "STALE" : "CURRENT";
|
|
|
+ document.getElementById("updatedAt").textContent = state.lastRefresh || fixture.updatedAt;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderKpis() {
|
|
|
+ const block = currentIndustry();
|
|
|
+ const loaded = block.metrics.filter(m => m.value !== null);
|
|
|
+ const overload = block.metrics.filter(m => m.loadState === "OVERLOAD").length;
|
|
|
+ const abnormal = block.metrics.filter(m => m.abnormal).length;
|
|
|
+ const ratios = block.metrics.filter(m => m.loadRatio !== null).map(m => Number(m.loadRatio));
|
|
|
+ const peak = ratios.length ? Math.max.apply(null, ratios).toFixed(4) : "--";
|
|
|
+ const supply = block.metrics.filter(m => m.category === "SUPPLY" || m.category === "FLOW");
|
|
|
+ const supplyValue = supply.length ? supply[0].value + " " + supply[0].unit : "--";
|
|
|
+ document.getElementById("kpis").innerHTML = `
|
|
|
+ <div class="kpi"><span>供应 / 输送</span><b>${supplyValue}</b><em>${supply.length} 项指标</em></div>
|
|
|
+ <div class="kpi"><span>最高负荷率</span><b>${peak}</b><em>${overload} 项超载</em></div>
|
|
|
+ <div class="kpi"><span>可用指标</span><b>${loaded.length}/${block.metrics.length}</b><em>缺失显式为空</em></div>
|
|
|
+ <div class="kpi"><span>异常指标</span><b>${abnormal}</b><em>可联动定位</em></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function trendBars(points) {
|
|
|
+ if (!points.length) return '<span class="code">无趋势</span>';
|
|
|
+ const values = points.map(p => Number(p.value));
|
|
|
+ const max = Math.max.apply(null, values) || 1;
|
|
|
+ return '<div class="trend">' + points.map((p, i) =>
|
|
|
+ `<i class="${i === points.length - 1 ? "hi" : ""}" style="height:${Math.max(3, Number(p.value) / max * 22)}px" title="${p.bucket}: ${p.value}"></i>`
|
|
|
+ ).join("") + "</div>";
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMetrics() {
|
|
|
+ const block = currentIndustry();
|
|
|
+ if (!block.metrics.length) {
|
|
|
+ document.getElementById("metrics").innerHTML =
|
|
|
+ '<tr><td colspan="5">该行业无可用运行指标(EMPTY / NO_METRICS)</td></tr>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ document.getElementById("metrics").innerHTML = block.metrics.map(metric => {
|
|
|
+ const ratio = metric.loadRatio === null ? "--" : metric.loadRatio;
|
|
|
+ const selected = metric.metricCode === state.metricCode ? " sel" : "";
|
|
|
+ return `<tr class="metric${selected}" data-code="${metric.metricCode}">
|
|
|
+ <td><span class="name">${metric.metricName}</span><span class="code">${metric.metricCode} · ${metric.category} · ${metric.unit}</span></td>
|
|
|
+ <td>${metric.value === null ? "--" : metric.value}</td>
|
|
|
+ <td>${ratio}<br><span class="badge b-${metric.loadState}">${metric.loadState}</span></td>
|
|
|
+ <td><span class="badge b-${metric.freshness.status}">${metric.freshness.status}</span></td>
|
|
|
+ <td>${trendBars(metric.trend)}</td>
|
|
|
+ </tr>`;
|
|
|
+ }).join("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMap() {
|
|
|
+ const flagged = allFlaggedPointCodes();
|
|
|
+ const chosen = selectedMetric();
|
|
|
+ const linked = new Set(chosen ? chosen.pointCodes : []);
|
|
|
+ const map = document.getElementById("map");
|
|
|
+ map.querySelectorAll(".point,.plabel").forEach(node => node.remove());
|
|
|
+ Object.keys(points).forEach(code => {
|
|
|
+ const point = points[code];
|
|
|
+ const node = document.createElement("div");
|
|
|
+ node.className = "point" + (linked.has(code) ? " linked" : flagged.has(code) ? " flagged" : "");
|
|
|
+ node.style.left = point.x + "%";
|
|
|
+ node.style.top = point.y + "%";
|
|
|
+ node.title = code + " · " + point.name;
|
|
|
+ map.appendChild(node);
|
|
|
+ const label = document.createElement("div");
|
|
|
+ label.className = "plabel";
|
|
|
+ label.style.left = "calc(" + point.x + "% + 10px)";
|
|
|
+ label.style.top = "calc(" + point.y + "% - 9px)";
|
|
|
+ label.textContent = code;
|
|
|
+ map.appendChild(label);
|
|
|
+ });
|
|
|
+ document.getElementById("mapTitle").textContent = "中央一张图 · " + currentIndustry().industry.label;
|
|
|
+ const caption = document.getElementById("caption");
|
|
|
+ if (!chosen) {
|
|
|
+ caption.innerHTML = "未选择指标,红色点位为当前全部异常指标关联点位";
|
|
|
+ } else {
|
|
|
+ caption.innerHTML = "已选 <b>" + chosen.metricName + "</b>(" + chosen.metricCode + ") · 负荷状态 <b>"
|
|
|
+ + chosen.loadState + "</b> · 新鲜度 <b>" + chosen.freshness.status + "</b> · 关联点位 <b>"
|
|
|
+ + (chosen.pointCodes.length ? chosen.pointCodes.join("、") : "无") + "</b>";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function render() {
|
|
|
+ renderIndustries();
|
|
|
+ renderHeader();
|
|
|
+ renderKpis();
|
|
|
+ renderMetrics();
|
|
|
+ renderMap();
|
|
|
+ }
|
|
|
+
|
|
|
+ document.getElementById("industries").addEventListener("click", event => {
|
|
|
+ const button = event.target.closest("button[data-code]");
|
|
|
+ if (!button) return;
|
|
|
+ state.industry = button.dataset.code;
|
|
|
+ state.metricCode = null;
|
|
|
+ render();
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById("metrics").addEventListener("click", event => {
|
|
|
+ const row = event.target.closest("tr.metric");
|
|
|
+ if (!row) return;
|
|
|
+ state.metricCode = state.metricCode === row.dataset.code ? null : row.dataset.code;
|
|
|
+ render();
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById("refresh").addEventListener("click", () => {
|
|
|
+ state.lastRefresh = new Date().toISOString();
|
|
|
+ state.metricCode = null;
|
|
|
+ render();
|
|
|
+ });
|
|
|
+
|
|
|
+ render();
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|