infrastructure-operating-information-demo.html 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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 { color-scheme:light; --ink:#172033; --muted:#667085; --line:#d9dee7; --panel:#fff; --page:#f3f5f8;
  9. --accent:#1570ef; --ok:#067647; --watch:#b54708; --danger:#b42318; --idle:#98a2b3; }
  10. * { box-sizing:border-box; }
  11. body { margin:0; min-height:100vh; background:var(--page); color:var(--ink);
  12. font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
  13. header { display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
  14. padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
  15. h1 { margin:0; font-size:18px; letter-spacing:0; }
  16. .meta { display:flex; flex-wrap:wrap; gap:14px; color:var(--muted); }
  17. .meta b { color:var(--ink); font-weight:600; }
  18. button { height:30px; min-width:76px; padding:0 12px; border:1px solid #d0d5dd; border-radius:4px;
  19. background:var(--panel); color:var(--ink); cursor:pointer; font-size:13px; }
  20. button:hover { border-color:var(--accent); color:var(--accent); }
  21. main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); gap:0;
  22. min-height:calc(100vh - 59px); }
  23. aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
  24. section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-height:0; }
  25. h2 { margin:0 0 10px; font-size:14px; font-weight:600; }
  26. h2 + .seg { margin-bottom:16px; }
  27. h2.mid { margin-top:20px; }
  28. .seg { display:flex; gap:6px; }
  29. .seg button.active { border-color:var(--accent); background:#eff6ff; color:var(--accent); font-weight:600; }
  30. .kpis { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
  31. .kpi { border:1px solid var(--line); border-radius:6px; padding:10px 12px; }
  32. .kpi span { display:block; color:var(--muted); font-size:12px; }
  33. .kpi b { display:block; margin-top:4px; font-size:20px; font-weight:600; }
  34. .kpi em { font-style:normal; font-size:12px; color:var(--muted); }
  35. table { width:100%; border-collapse:collapse; }
  36. th { text-align:left; font-weight:600; font-size:12px; color:var(--muted);
  37. padding:6px 8px; border-bottom:1px solid var(--line); }
  38. td { padding:8px; border-bottom:1px solid #eef1f5; vertical-align:middle; }
  39. tr.metric { cursor:pointer; }
  40. tr.metric:hover td { background:#f8fafc; }
  41. tr.metric.sel td { background:#eff6ff; }
  42. .name { font-weight:600; }
  43. .code { display:block; color:var(--muted); font-size:11px; font-weight:400; }
  44. .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; line-height:18px;
  45. border:1px solid currentColor; }
  46. .b-NORMAL, .b-CURRENT { color:var(--ok); }
  47. .b-WATCH, .b-STALE { color:var(--watch); }
  48. .b-OVERLOAD, .b-EXPIRED { color:var(--danger); }
  49. .b-UNKNOWN, .b-EMPTY { color:var(--idle); }
  50. .trend { display:flex; align-items:flex-end; gap:2px; height:22px; }
  51. .trend i { width:7px; background:#c7d7ea; display:block; }
  52. .trend i.hi { background:var(--accent); }
  53. .toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px;
  54. padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
  55. .map { position:relative; background:#eef2f6;
  56. background-image:linear-gradient(#dde3ea 1px,transparent 1px),linear-gradient(90deg,#dde3ea 1px,transparent 1px);
  57. background-size:64px 64px; }
  58. .point { position:absolute; width:12px; height:12px; margin:-6px 0 0 -6px; border-radius:50%;
  59. background:#9fb3c8; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.25); }
  60. .point.flagged { background:var(--danger); }
  61. .point.linked { background:var(--accent); outline:3px solid rgba(21,112,239,.28); }
  62. .plabel { position:absolute; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.93);
  63. border:1px solid var(--line); font-size:11px; white-space:nowrap; color:#344054; }
  64. .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
  65. .caption b { color:var(--ink); }
  66. .legend { position:absolute; left:14px; bottom:14px; padding:6px 10px; border-radius:4px;
  67. background:rgba(255,255,255,.92); border:1px solid var(--line); color:var(--muted); font-size:12px; }
  68. @media (max-width:920px) { main { grid-template-columns:1fr; } .map { min-height:380px; } }
  69. </style>
  70. </head>
  71. <body>
  72. <header>
  73. <h1>基础设施 / 运行信息</h1>
  74. <div class="meta">
  75. <span>范围 <b id="rangeText">--</b></span>
  76. <span>时区 <b id="timezone">Asia/Shanghai</b></span>
  77. <span>新鲜度 <b id="freshness">--</b></span>
  78. <span>更新 <b id="updatedAt">--</b></span>
  79. </div>
  80. <button id="refresh" type="button">手动刷新</button>
  81. </header>
  82. <main>
  83. <aside>
  84. <h2>行业</h2>
  85. <div class="seg" id="industries"></div>
  86. <h2>运行能力</h2>
  87. <div class="kpis" id="kpis"></div>
  88. <h2 class="mid">行业运行指标</h2>
  89. <table>
  90. <thead>
  91. <tr><th>指标</th><th>当前值</th><th>负荷</th><th>新鲜度</th><th>趋势</th></tr>
  92. </thead>
  93. <tbody id="metrics"></tbody>
  94. </table>
  95. </aside>
  96. <section>
  97. <div class="toolbar">
  98. <strong id="mapTitle">中央一张图</strong>
  99. <span class="meta">选中异常指标可联动定位</span>
  100. </div>
  101. <div class="map" id="map">
  102. <div class="legend">灰点=常规点位 · 红点=异常指标关联点位 · 蓝点=当前选中指标关联点位</div>
  103. </div>
  104. <div class="caption" id="caption">未选择指标</div>
  105. </section>
  106. </main>
  107. <script>
  108. const fixture = {
  109. updatedAt: "2026-09-02T09:59:50+08:00",
  110. range: { type: "LAST_24_HOURS", start: "2026-09-01T10:00:00+08:00", end: "2026-09-02T10:00:00+08:00" },
  111. industries: [
  112. {
  113. industry: { code: "GAS", key: "gas", label: "燃气" },
  114. metrics: [
  115. { metricCode: "gas_supply_pressure", metricName: "燃气供应压力", category: "SUPPLY", unit: "MPa",
  116. value: "0.32", capacityValue: "0.40", loadRatio: "0.8000", loadState: "WATCH",
  117. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 30 },
  118. trend: [{ bucket: "2026-08-31", value: "0.29" }, { bucket: "2026-09-01", value: "0.30" }, { bucket: "2026-09-02", value: "0.32" }],
  119. abnormal: false, pointCodes: ["PT-GAS-001"] },
  120. { metricCode: "gas_flow_rate", metricName: "燃气流量", category: "FLOW", unit: "m3/h",
  121. value: "1200.5", capacityValue: "2000", loadRatio: "0.6003", loadState: "NORMAL",
  122. freshness: { status: "STALE", reason: "ONE_UPDATE_CYCLE_MISSED", ageSeconds: 90 },
  123. trend: [{ bucket: "2026-09-01", value: "980" }, { bucket: "2026-09-02", value: "1200.5" }],
  124. abnormal: false, pointCodes: ["PT-GAS-002"] },
  125. { metricCode: "gas_remaining_capacity", metricName: "燃气剩余承载能力", category: "CAPACITY", unit: "m3",
  126. value: "800", capacityValue: "2000", loadRatio: "0.4000", loadState: "NORMAL",
  127. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 12 },
  128. trend: [], abnormal: false, pointCodes: [] }
  129. ]
  130. },
  131. {
  132. industry: { code: "WATER_SUPPLY", key: "waterSupply", label: "供水" },
  133. metrics: [
  134. { metricCode: "water_supply_flow", metricName: "供水流量", category: "SUPPLY", unit: "m3/h",
  135. value: "5000", capacityValue: "4000", loadRatio: "1.2500", loadState: "OVERLOAD",
  136. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 20 },
  137. trend: [{ bucket: "2026-09-01", value: "4200" }, { bucket: "2026-09-02", value: "5000" }],
  138. abnormal: true, pointCodes: ["PT-WS-001", "PT-WS-002"] },
  139. { metricCode: "water_pressure", metricName: "供水压力", category: "PRESSURE", unit: "MPa",
  140. value: "0.45", capacityValue: null, loadRatio: null, loadState: "UNKNOWN",
  141. freshness: { status: "EMPTY", reason: "NO_SOURCE", ageSeconds: null },
  142. trend: [], abnormal: false, pointCodes: ["PT-WS-003"] },
  143. { metricCode: "water_quality_turbidity", metricName: "供水浊度", category: "QUALITY", unit: "NTU",
  144. value: "0.62", capacityValue: "1.00", loadRatio: "0.6200", loadState: "NORMAL",
  145. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 45 },
  146. trend: [{ bucket: "2026-09-01", value: "0.58" }, { bucket: "2026-09-02", value: "0.62" }],
  147. abnormal: false, pointCodes: ["PT-WS-004"] }
  148. ]
  149. },
  150. {
  151. industry: { code: "DRAINAGE", key: "drainage", label: "排水" },
  152. metrics: [
  153. { metricCode: "sewage_cod", metricName: "污水 COD", category: "QUALITY", unit: "mg/L",
  154. value: "18.2", capacityValue: "50", loadRatio: "0.3640", loadState: "NORMAL",
  155. freshness: { status: "EXPIRED", reason: "TWO_UPDATE_CYCLES_MISSED", ageSeconds: 300 },
  156. trend: [{ bucket: "2026-09-01", value: "16.4" }, { bucket: "2026-09-02", value: "18.2" }],
  157. abnormal: true, pointCodes: ["PT-DR-001"] },
  158. { metricCode: "rainwater_flow", metricName: "雨水流量", category: "FLOW", unit: "m3/h",
  159. value: "880", capacityValue: "1000", loadRatio: "0.8800", loadState: "WATCH",
  160. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 18 },
  161. trend: [{ bucket: "2026-09-01", value: "610" }, { bucket: "2026-09-02", value: "880" }],
  162. abnormal: false, pointCodes: ["PT-DR-002"] },
  163. { metricCode: "sewage_pump_capacity", metricName: "污水泵站承载能力", category: "CAPACITY", unit: "m3/h",
  164. value: "3200", capacityValue: "3000", loadRatio: "1.0667", loadState: "OVERLOAD",
  165. freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 25 },
  166. trend: [], abnormal: true, pointCodes: ["PT-DR-003"] }
  167. ]
  168. }
  169. ]
  170. };
  171. const points = {
  172. "PT-GAS-001": { x: 24, y: 30, name: "燃气调压站" },
  173. "PT-GAS-002": { x: 37, y: 46, name: "燃气流量点" },
  174. "PT-WS-001": { x: 55, y: 28, name: "供水主干管 A" },
  175. "PT-WS-002": { x: 66, y: 44, name: "供水主干管 B" },
  176. "PT-WS-003": { x: 48, y: 58, name: "供水压力点" },
  177. "PT-WS-004": { x: 74, y: 62, name: "供水水质点" },
  178. "PT-DR-001": { x: 30, y: 68, name: "污水监测点" },
  179. "PT-DR-002": { x: 82, y: 34, name: "雨水流量点" },
  180. "PT-DR-003": { x: 62, y: 74, name: "污水泵站" }
  181. };
  182. let state = { industry: "WATER_SUPPLY", metricCode: null, lastRefresh: null };
  183. function currentIndustry() {
  184. return fixture.industries.find(item => item.industry.code === state.industry) || fixture.industries[0];
  185. }
  186. function selectedMetric() {
  187. return currentIndustry().metrics.find(m => m.metricCode === state.metricCode) || null;
  188. }
  189. function allFlaggedPointCodes() {
  190. const codes = new Set();
  191. fixture.industries.forEach(block => block.metrics
  192. .filter(m => m.abnormal)
  193. .forEach(m => m.pointCodes.forEach(code => codes.add(code))));
  194. return codes;
  195. }
  196. function renderIndustries() {
  197. document.getElementById("industries").innerHTML = fixture.industries.map(block =>
  198. `<button type="button" data-code="${block.industry.code}" class="${block.industry.code === state.industry ? "active" : ""}">${block.industry.label}</button>`
  199. ).join("");
  200. }
  201. function renderHeader() {
  202. document.getElementById("rangeText").textContent = fixture.range.type;
  203. document.getElementById("timezone").textContent = "Asia/Shanghai";
  204. const expired = currentIndustry().metrics.some(m => m.freshness.status === "EXPIRED");
  205. const stale = currentIndustry().metrics.some(m => m.freshness.status === "STALE");
  206. document.getElementById("freshness").textContent = expired ? "EXPIRED" : stale ? "STALE" : "CURRENT";
  207. document.getElementById("updatedAt").textContent = state.lastRefresh || fixture.updatedAt;
  208. }
  209. function renderKpis() {
  210. const block = currentIndustry();
  211. const loaded = block.metrics.filter(m => m.value !== null);
  212. const overload = block.metrics.filter(m => m.loadState === "OVERLOAD").length;
  213. const abnormal = block.metrics.filter(m => m.abnormal).length;
  214. const ratios = block.metrics.filter(m => m.loadRatio !== null).map(m => Number(m.loadRatio));
  215. const peak = ratios.length ? Math.max.apply(null, ratios).toFixed(4) : "--";
  216. const supply = block.metrics.filter(m => m.category === "SUPPLY" || m.category === "FLOW");
  217. const supplyValue = supply.length ? supply[0].value + " " + supply[0].unit : "--";
  218. document.getElementById("kpis").innerHTML = `
  219. <div class="kpi"><span>供应 / 输送</span><b>${supplyValue}</b><em>${supply.length} 项指标</em></div>
  220. <div class="kpi"><span>最高负荷率</span><b>${peak}</b><em>${overload} 项超载</em></div>
  221. <div class="kpi"><span>可用指标</span><b>${loaded.length}/${block.metrics.length}</b><em>缺失显式为空</em></div>
  222. <div class="kpi"><span>异常指标</span><b>${abnormal}</b><em>可联动定位</em></div>`;
  223. }
  224. function trendBars(points) {
  225. if (!points.length) return '<span class="code">无趋势</span>';
  226. const values = points.map(p => Number(p.value));
  227. const max = Math.max.apply(null, values) || 1;
  228. return '<div class="trend">' + points.map((p, i) =>
  229. `<i class="${i === points.length - 1 ? "hi" : ""}" style="height:${Math.max(3, Number(p.value) / max * 22)}px" title="${p.bucket}: ${p.value}"></i>`
  230. ).join("") + "</div>";
  231. }
  232. function renderMetrics() {
  233. const block = currentIndustry();
  234. if (!block.metrics.length) {
  235. document.getElementById("metrics").innerHTML =
  236. '<tr><td colspan="5">该行业无可用运行指标(EMPTY / NO_METRICS)</td></tr>';
  237. return;
  238. }
  239. document.getElementById("metrics").innerHTML = block.metrics.map(metric => {
  240. const ratio = metric.loadRatio === null ? "--" : metric.loadRatio;
  241. const selected = metric.metricCode === state.metricCode ? " sel" : "";
  242. return `<tr class="metric${selected}" data-code="${metric.metricCode}">
  243. <td><span class="name">${metric.metricName}</span><span class="code">${metric.metricCode} · ${metric.category} · ${metric.unit}</span></td>
  244. <td>${metric.value === null ? "--" : metric.value}</td>
  245. <td>${ratio}<br><span class="badge b-${metric.loadState}">${metric.loadState}</span></td>
  246. <td><span class="badge b-${metric.freshness.status}">${metric.freshness.status}</span></td>
  247. <td>${trendBars(metric.trend)}</td>
  248. </tr>`;
  249. }).join("");
  250. }
  251. function renderMap() {
  252. const flagged = allFlaggedPointCodes();
  253. const chosen = selectedMetric();
  254. const linked = new Set(chosen ? chosen.pointCodes : []);
  255. const map = document.getElementById("map");
  256. map.querySelectorAll(".point,.plabel").forEach(node => node.remove());
  257. Object.keys(points).forEach(code => {
  258. const point = points[code];
  259. const node = document.createElement("div");
  260. node.className = "point" + (linked.has(code) ? " linked" : flagged.has(code) ? " flagged" : "");
  261. node.style.left = point.x + "%";
  262. node.style.top = point.y + "%";
  263. node.title = code + " · " + point.name;
  264. map.appendChild(node);
  265. const label = document.createElement("div");
  266. label.className = "plabel";
  267. label.style.left = "calc(" + point.x + "% + 10px)";
  268. label.style.top = "calc(" + point.y + "% - 9px)";
  269. label.textContent = code;
  270. map.appendChild(label);
  271. });
  272. document.getElementById("mapTitle").textContent = "中央一张图 · " + currentIndustry().industry.label;
  273. const caption = document.getElementById("caption");
  274. if (!chosen) {
  275. caption.innerHTML = "未选择指标,红色点位为当前全部异常指标关联点位";
  276. } else {
  277. caption.innerHTML = "已选 <b>" + chosen.metricName + "</b>(" + chosen.metricCode + ") · 负荷状态 <b>"
  278. + chosen.loadState + "</b> · 新鲜度 <b>" + chosen.freshness.status + "</b> · 关联点位 <b>"
  279. + (chosen.pointCodes.length ? chosen.pointCodes.join("、") : "无") + "</b>";
  280. }
  281. }
  282. function render() {
  283. renderIndustries();
  284. renderHeader();
  285. renderKpis();
  286. renderMetrics();
  287. renderMap();
  288. }
  289. document.getElementById("industries").addEventListener("click", event => {
  290. const button = event.target.closest("button[data-code]");
  291. if (!button) return;
  292. state.industry = button.dataset.code;
  293. state.metricCode = null;
  294. render();
  295. });
  296. document.getElementById("metrics").addEventListener("click", event => {
  297. const row = event.target.closest("tr.metric");
  298. if (!row) return;
  299. state.metricCode = state.metricCode === row.dataset.code ? null : row.dataset.code;
  300. render();
  301. });
  302. document.getElementById("refresh").addEventListener("click", () => {
  303. state.lastRefresh = new Date().toISOString();
  304. state.metricCode = null;
  305. render();
  306. });
  307. render();
  308. </script>
  309. </body>
  310. </html>