| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- <!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>
|