monitoring-operating-situation-demo.html 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>运行监测 / 运行概况</title>
  7. <style>
  8. :root { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; }
  9. * { box-sizing:border-box; }
  10. body { margin:0; min-height:100vh; background:var(--page); color:var(--ink); font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
  11. header { display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
  12. h1 { margin:0; font-size:18px; }
  13. .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
  14. .meta b { color:var(--ink); font-weight:600; }
  15. button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
  16. button:hover { border-color:var(--blue); color:var(--blue); }
  17. main { display:grid; grid-template-columns:minmax(360px,440px) minmax(0,1fr); min-height:calc(100vh - 57px); }
  18. aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
  19. section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; }
  20. h2 { margin:0 0 10px; font-size:14px; }
  21. .status-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
  22. .status-card { border:1px solid var(--line); border-radius:5px; padding:8px 10px; }
  23. .status-card span { display:block; color:var(--muted); font-size:11px; }
  24. .status-card b { display:block; margin-top:2px; font-size:18px; }
  25. .t-NORMAL { color:#0f8a4d; } .t-WARNING { color:#f0a500; } .t-ALARM { color:#d0342c; }
  26. .t-EXPIRED { color:#b65c08; } .t-OFFLINE { color:#8a94a0; } .t-UNKNOWN { color:#64748b; }
  27. table { width:100%; border-collapse:collapse; font-size:12px; }
  28. th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
  29. th { color:var(--muted); font-weight:600; }
  30. .toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; padding:10px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
  31. .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; }
  32. .marker { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#9aa9bb; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.3); }
  33. .marker.s-NORMAL { background:#0f8a4d; } .marker.s-WARNING { background:#f0a500; }
  34. .marker.s-ALARM { background:#d0342c; } .marker.s-EXPIRED { background:#b65c08; }
  35. .marker.s-OFFLINE { background:#8a94a0; } .marker.s-UNKNOWN { background:#64748b; }
  36. .marker.hot { outline:3px solid var(--blue); outline-offset:1px; }
  37. .label { position:absolute; font-size:11px; transform:translate(9px,-7px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
  38. .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
  39. .panel { margin-top:14px; border:1px solid var(--line); border-radius:5px; padding:10px 12px; min-height:120px; }
  40. .panel h3 { margin:0 0 8px; font-size:13px; }
  41. .panel ul { margin:0; padding-left:18px; }
  42. .trend { display:flex; align-items:flex-end; gap:6px; height:130px; padding:10px 0; border-bottom:1px solid var(--line); }
  43. .bar { flex:1; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:4px; min-width:18px; }
  44. .bar i { display:block; width:100%; border-radius:3px 3px 0 0; }
  45. .bar.w i { background:#f0a500; } .bar.a i { background:#d0342c; }
  46. .bar small { font-size:10px; color:var(--muted); }
  47. .legend { display:flex; gap:14px; color:var(--muted); font-size:12px; }
  48. .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; }
  49. @media (max-width:940px){ main{grid-template-columns:1fr;} .map{min-height:320px;} }
  50. </style>
  51. </head>
  52. <body>
  53. <header>
  54. <h1>运行监测 / 运行概况</h1>
  55. <div class="meta"><span>设备 <b id="devices">0</b></span><span>异常设备 <b id="abnormal">0</b></span><span>新鲜度 <b id="freshness">--</b></span><span>窗口 <b id="window">--</b></span></div>
  56. </header>
  57. <main>
  58. <aside>
  59. <h2>监测参数状态分布</h2>
  60. <div class="status-grid" id="statusGrid"></div>
  61. <h2>预警 / 报警趋势(每小时)</h2>
  62. <div class="legend"><span><i style="background:#f0a500"></i>预警</span><span><i style="background:#d0342c"></i>报警</span></div>
  63. <div class="trend" id="trend"></div>
  64. <h2>行业覆盖</h2>
  65. <table><thead><tr><th>行业</th><th>设备</th><th>异常</th></tr></thead>
  66. <tbody id="coverageRows"></tbody></table>
  67. <h2 style="margin-top:14px">异常设备(点击联动中央一张图 / 设备详情)</h2>
  68. <table><thead><tr><th>设备</th><th>类型</th><th>状态</th></tr></thead>
  69. <tbody id="abnormalRows"></tbody></table>
  70. <div class="panel" id="detail"><h3>详情</h3><p style="color:var(--muted)">点击异常设备查看详情;地图上对应标记会高亮。</p></div>
  71. </aside>
  72. <section>
  73. <div class="toolbar">
  74. <strong>中央一张图</strong>
  75. <span>
  76. <button id="toggle" type="button">D-GAS-3 报警/正常切换</button>
  77. <button id="refresh" type="button">手动刷新</button>
  78. </span>
  79. </div>
  80. <div class="map" id="map"></div>
  81. <div class="caption" id="caption">异常设备点位(越界坐标设备不出现在地图上)</div>
  82. </section>
  83. </main>
  84. <script>
  85. const STATUS = ["NORMAL", "WARNING", "ALARM", "EXPIRED", "OFFLINE", "UNKNOWN"];
  86. const TYPE_NAME = { GAS_SENSOR: "燃气浓度计", WATER_SUPPLY_SENSOR: "供水压力计", SEWAGE_ENV: "污水环境监测" };
  87. const INDUSTRY_NAME = { GAS: "燃气", WATER_SUPPLY: "供水", DRAINAGE: "排水" };
  88. const ABNORMAL = new Set(["WARNING", "ALARM", "EXPIRED", "OFFLINE"]);
  89. const fixtures = () => ({
  90. window: { start: "2026-09-02T00:00:00+08:00", end: "2026-09-02T02:00:00+08:00" },
  91. devices: [
  92. { code: "D-GAS-1", name: "燃气浓度计-001", industry: "GAS", type: "GAS_SENSOR", status: "NORMAL", region: "城区A", lon: 110.130, lat: 28.440, reasons: [] },
  93. { code: "D-GAS-2", name: "燃气浓度计-002", industry: "GAS", type: "GAS_SENSOR", status: "WARNING", region: "城区A", lon: 110.131, lat: 28.441, reasons: ["流量波动"] },
  94. { code: "D-GAS-3", name: "燃气浓度计-003", industry: "GAS", type: "GAS_SENSOR", status: "ALARM", region: "城区A", lon: 110.132, lat: 28.442, reasons: ["浓度超限"] },
  95. { code: "D-WS-1", name: "供水压力计-001", industry: "WATER_SUPPLY", type: "WATER_SUPPLY_SENSOR", status: "EXPIRED", region: "城区B", lon: 110.150, lat: 28.460, reasons: ["采样过期"] },
  96. { code: "D-WS-2", name: "供水压力计-002", industry: "WATER_SUPPLY", type: "WATER_SUPPLY_SENSOR", status: "OFFLINE", region: "城区B", lon: 110.151, lat: 28.461, reasons: ["离线"] },
  97. { code: "D-DR-1", name: "污水环境监测仪-001", industry: "DRAINAGE", type: "SEWAGE_ENV", status: "NORMAL", region: "城区C", lon: 110.170, lat: 28.480, reasons: [] },
  98. { code: "D-EXCL-GAS", name: "越界坐标燃气计", industry: "GAS", type: "GAS_SENSOR", status: "ALARM", region: "城区A", lon: 181, lat: 28.443, reasons: ["坐标越界"] }
  99. ],
  100. warnings: [
  101. { no: "W-GAS-1", time: "00:30", industry: "GAS" },
  102. { no: "W-GAS-2", time: "01:15", industry: "GAS" },
  103. { no: "W-WS-1", time: "01:45", industry: "WATER_SUPPLY" }
  104. ],
  105. alarms: [
  106. { no: "A-GAS-1", time: "00:40", industry: "GAS" },
  107. { no: "A-DR-1", time: "01:10", industry: "DRAINAGE" }
  108. ]
  109. });
  110. let data = fixtures();
  111. const buckets = [0, 1, 2].map(h => ({ hour: "0" + h + ":00", warning: 0, alarm: 0 }));
  112. function rebuildTrends() {
  113. buckets.forEach(b => (b.warning = 0, b.alarm = 0));
  114. data.warnings.forEach(w => buckets[+w.time.slice(0, 2)].warning++);
  115. data.alarms.forEach(a => buckets[+a.time.slice(0, 2)].alarm++);
  116. }
  117. function render() {
  118. const abnormal = data.devices.filter(d => ABNORMAL.has(d.status)).sort((a, b) => a.code.localeCompare(b.code));
  119. const counts = {}; STATUS.forEach(s => counts[s] = 0);
  120. const byIndustry = {};
  121. data.devices.forEach(d => {
  122. counts[d.status]++;
  123. const row = byIndustry[d.industry] = byIndustry[d.industry] || { device: 0, abnormal: 0 };
  124. row.device++;
  125. if (ABNORMAL.has(d.status)) row.abnormal++;
  126. });
  127. const renderableAbnormal = abnormal.filter(d => Math.abs(d.lon) <= 180 && Math.abs(d.lat) <= 90);
  128. document.getElementById("devices").textContent = data.devices.length;
  129. document.getElementById("abnormal").textContent = abnormal.length;
  130. document.getElementById("freshness").textContent = counts.EXPIRED ? "STALE" : (data.devices.length ? "CURRENT" : "EMPTY");
  131. document.getElementById("window").textContent = data.window.start + " ~ " + data.window.end;
  132. document.getElementById("statusGrid").innerHTML = STATUS.map(s =>
  133. `<div class="status-card"><span>${s}</span><b class="t-${s}">${counts[s]}</b></div>`).join("");
  134. const maxCount = Math.max(1, ...buckets.flatMap(b => [b.warning, b.alarm]));
  135. document.getElementById("trend").innerHTML = buckets.map(b => `
  136. <div class="bar w"><i style="height:${b.warning / maxCount * 88}px"></i></div>
  137. <div class="bar a"><i style="height:${b.alarm / maxCount * 88}px"></i></div>
  138. <small>${b.hour}</small>`).join("");
  139. document.getElementById("coverageRows").innerHTML = Object.keys(byIndustry).sort().map(k =>
  140. `<tr><td>${INDUSTRY_NAME[k]}</td><td>${byIndustry[k].device}</td><td>${byIndustry[k].abnormal}</td></tr>`).join("");
  141. document.getElementById("abnormalRows").innerHTML = abnormal.map((d, index) =>
  142. `<tr><td><a href="#" data-i="${index}">${d.code}</a></td><td>${TYPE_NAME[d.type]}</td><td class="t-${d.status}">${d.status}</td></tr>`).join("");
  143. const map = document.getElementById("map");
  144. map.innerHTML = "";
  145. const lons = renderableAbnormal.map(d => d.lon), lats = renderableAbnormal.map(d => d.lat);
  146. const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
  147. const xs = d => 12 + (d.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
  148. const ys = d => 12 + (maxLat - d.lat) / Math.max(0.001, maxLat - minLat) * 76;
  149. renderableAbnormal.forEach(d => {
  150. const el = document.createElement("div");
  151. el.className = "marker s-" + d.status;
  152. el.style.left = xs(d) + "%"; el.style.top = ys(d) + "%";
  153. el.title = d.code + " / " + d.status;
  154. map.appendChild(el);
  155. const l = document.createElement("div");
  156. l.className = "label"; l.style.left = xs(d) + "%"; l.style.top = ys(d) + "%";
  157. l.textContent = d.code;
  158. map.appendChild(l);
  159. });
  160. document.getElementById("caption").textContent =
  161. `异常 ${abnormal.length} 台 · 地图可定位 ${renderableAbnormal.length} 台 · 越界坐标 ${abnormal.length - renderableAbnormal.length} 台`;
  162. document.querySelectorAll("#abnormalRows a").forEach(a =>
  163. a.addEventListener("click", ev => { ev.preventDefault(); select(abnormal[+a.dataset.i], map); }));
  164. }
  165. function select(device, map) {
  166. const box = document.getElementById("detail");
  167. box.innerHTML = `<h3>异常设备 ${device.code}</h3><ul>
  168. <li>名称:${device.name} 区域:${device.region} 行业:${INDUSTRY_NAME[device.industry]}</li>
  169. <li>类型:${TYPE_NAME[device.type]} 状态:${device.status}</li>
  170. <li>原因:${device.reasons.join("、") || "无"}</li>
  171. </ul>`;
  172. map.querySelectorAll(".marker").forEach((m, index) => {
  173. const hot = m.title.startsWith(device.code + " ");
  174. m.classList.toggle("hot", hot);
  175. if (hot) {
  176. m.scrollIntoView({ block: "nearest", inline: "nearest" });
  177. }
  178. });
  179. }
  180. document.getElementById("toggle").addEventListener("click", () => {
  181. const d = data.devices.find(x => x.code === "D-GAS-3");
  182. d.status = d.status === "ALARM" ? "NORMAL" : "ALARM";
  183. render();
  184. });
  185. document.getElementById("refresh").addEventListener("click", () => {
  186. document.getElementById("window").textContent =
  187. new Date().toLocaleString("zh-CN").slice(0, 19) + "+08:00";
  188. render();
  189. });
  190. rebuildTrends();
  191. render();
  192. </script>
  193. </body>
  194. </html>