| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- <!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 { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; }
- * { 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; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
- h1 { margin:0; font-size:18px; }
- .tabs { display:inline-flex; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
- .tabs button { border:0; border-right:1px solid var(--line); padding:8px 16px; background:#f4f7fa; }
- .tabs button.on { background:var(--blue); color:#fff; }
- .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
- .meta b { color:var(--ink); font-weight:600; }
- button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
- select { border:1px solid #b9c8d5; border-radius:4px; padding:6px 8px; background:#fff; }
- main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); min-height:calc(100vh - 57px); }
- 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-width:0; }
- h2 { margin:0 0 10px; font-size:14px; }
- .kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
- .kpi { border:1px solid var(--line); border-radius:5px; padding:8px 10px; }
- .kpi span { display:block; color:var(--muted); font-size:11px; }
- .kpi b { display:block; margin-top:2px; font-size:18px; }
- table { width:100%; border-collapse:collapse; font-size:12px; }
- th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
- th { color:var(--muted); font-weight:600; }
- .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); }
- .chart { position:relative; padding:10px 18px; background:#fff; overflow:auto; }
- .columns { display:flex; align-items:flex-end; gap:10px; height:220px; border-bottom:1px solid var(--line); }
- .col { flex:1; min-width:44px; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:5px; cursor:pointer; }
- .col .bars { display:flex; align-items:flex-end; gap:3px; width:100%; height:150px; }
- .col .bars i { display:block; width:100%; border-radius:3px 3px 0 0; }
- .col .bars .ev { background:var(--blue); opacity:.85; }
- .col .bars .hd { background:#0f8a4d; }
- .col small { font-size:11px; color:var(--muted); }
- .col .eff { font-size:11px; color:#b65c08; }
- .col.selected .bars { outline:2px solid var(--ink); }
- .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; min-height:150px; }
- .marker { position:absolute; width:13px; height:13px; margin:-6px 0 0 -6px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
- .marker.hot { outline:3px solid var(--ink); outline-offset:1px; }
- .label { position:absolute; font-size:11px; transform:translate(8px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
- .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
- .empty { color:var(--muted); padding:12px; border:1px dashed var(--line); border-radius:5px; margin-top:10px; }
- .comparison { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; }
- .comparison .kpi b.mom-up { color:#0f8a4d; } .comparison .kpi b.mom-down { color:#d0342c; }
- @media (max-width:940px){ main{grid-template-columns:1fr;} }
- </style>
- </head>
- <body>
- <header>
- <h1>预警处置 / 历史分析</h1>
- <div class="tabs">
- <button id="tabWARNING" class="on" type="button">预警</button>
- <button id="tabALARM" type="button">报警</button>
- </div>
- <div class="meta"><span>事件族 <b id="familyName">预警</b></span><span>范围 <b id="rangeText">--</b></span><span>更新 <b id="updated">--</b></span></div>
- </header>
- <main>
- <aside>
- <h2>筛选与摘要</h2>
- <div class="comparison">
- <label style="color:var(--muted);font-size:12px">等级
- <select id="fLevel"><option value="">全部</option><option value="1">1 级</option><option value="2">2 级</option><option value="3">3 级</option><option value="4">4 级</option></select>
- </label>
- <label style="color:var(--muted);font-size:12px">行业
- <select id="fIndustry"><option value="">全部</option><option value="GAS">燃气</option><option value="WATER_SUPPLY">供水</option><option value="DRAINAGE">排水</option></select>
- </label>
- <label style="color:var(--muted);font-size:12px">天数
- <select id="fDays"><option value="3">近 3 天</option><option value="5" selected>近 5 天</option><option value="7">近 7 天</option></select>
- </label>
- </div>
- <div class="toolbar" style="padding:0 0 12px; border-bottom:none">
- <button id="apply" type="button">应用</button>
- <button id="refresh" type="button">手动刷新</button>
- </div>
- <div class="kpis">
- <div class="kpi"><span>事件总数</span><b id="totalEvents">0</b></div>
- <div class="kpi"><span>已处置(结案)</span><b id="totalHandled">0</b></div>
- <div class="kpi"><span>处置效率</span><b id="totalEff">0%</b></div>
- </div>
- <h2>同比/环比摘要</h2>
- <div class="comparison">
- <div class="kpi"><span>近段 vs 前段(事件)</span><b id="momEvents" class="mom-up">--</b></div>
- <div class="kpi"><span>近段 vs 前段(效率)</span><b id="momEff" class="mom-up">--</b></div>
- </div>
- <h2>每日明细</h2>
- <table><thead><tr><th>日期</th><th>事件</th><th>处置</th><th>效率</th></tr></thead>
- <tbody id="dayRows"></tbody></table>
- <div class="empty" id="empty" style="display:none">所选时间段内没有事件(空时间段状态)。</div>
- </aside>
- <section>
- <div class="toolbar">
- <strong>趋势(蓝=事件数 绿=已处置数)</strong>
- <span style="color:var(--muted)">点击某一列:筛选该时间桶并联动下方中央一张图</span>
- </div>
- <div class="chart"><div class="columns" id="columns"></div></div>
- <div class="toolbar"><strong>中央一张图(当前选中桶的设备)</strong><span id="bucketLabel" style="color:var(--muted)">未选中</span></div>
- <div class="map" id="map"></div>
- <div class="caption" id="caption">事件按 hourly 分桶落入 Asia/Shanghai;点击列查看该桶设备点位</div>
- </section>
- </main>
- <script>
- const INDUSTRY = { GAS_SENSOR: "GAS", WATER_SUPPLY_SENSOR: "WATER_SUPPLY", SEWAGE_ENV: "DRAINAGE" };
- const HANDLED = 6;
- const days = day => {
- const d = new Date(Date.UTC(2026, 8, day));
- return d.toISOString().slice(0, 10);
- };
- // Multi-day fixture, deterministic. Each event: day, hour, level, type, work order state (null = 无工单).
- const buildFixture = () => {
- const warnings = [], alarms = [];
- const specs = [
- { day: 1, hour: 8, level: 1, type: "GAS_SENSOR", precision: "w" },
- { day: 1, hour: 12, level: 2, type: "WATER_SUPPLY_SENSOR", precision: "w" },
- { day: 1, hour: 20, level: 3, type: "SEWAGE_ENV", precision: "a" },
- { day: 2, hour: 7, level: 1, type: "GAS_SENSOR", precision: "w" },
- { day: 2, hour: 10, level: 2, type: "GAS_SENSOR", precision: "a" },
- { day: 2, hour: 15, level: 4, type: "WATER_SUPPLY_SENSOR", precision: "w" },
- { day: 3, hour: 9, level: 1, type: "GAS_SENSOR", precision: "w" },
- { day: 3, hour: 9, level: 1, type: "GAS_SENSOR", precision: "a" },
- { day: 3, hour: 18, level: 2, type: "SEWAGE_ENV", precision: "w" },
- { day: 4, hour: 6, level: 3, type: "WATER_SUPPLY_SENSOR", precision: "w" },
- { day: 4, hour: 13, level: 1, type: "GAS_SENSOR", precision: "a" },
- { day: 5, hour: 8, level: 2, type: "SEWAGE_ENV", precision: "w" },
- { day: 5, hour: 11, level: 1, type: "GAS_SENSOR", precision: "w" }
- ];
- specs.forEach((s, i) => {
- const device = `D-${s.type === "GAS_SENSOR" ? "GAS" : s.type === "WATER_SUPPLY_SENSOR" ? "WS" : "DR"}-${i + 1}`;
- const event = {
- no: (s.precision === "w" ? "H" : "A") + String(i + 1).padStart(3, "0"),
- level: s.level, hour: s.hour, day: days(s.day), type: s.type,
- device, lon: 110.13 + i * 0.004, lat: 28.44 + (i % 4) * 0.01,
- wo: i % 3 === 0 ? HANDLED : (i % 3 === 1 ? 4 : null)
- };
- (s.precision === "w" ? warnings : alarms).push(event);
- });
- return { warnings, alarms };
- };
- const fixture = buildFixture();
- let family = "WARNING";
- const filters = { level: "", industry: "", days: 5 };
- function events() { return family === "WARNING" ? fixture.warnings : fixture.alarms; }
- function render() {
- const startDay = "2026-09-" + String(Math.max(1, 6 - filters.days)).padStart(2, "0");
- const endDay = "2026-09-06";
- const inWindow = e => e.day >= startDay && e.day <= endDay;
- const list = events().filter(e =>
- inWindow(e) &&
- (!filters.level || String(e.level) === filters.level) &&
- (!filters.industry || INDUSTRY[e.type] === filters.industry));
- const daily = {};
- let totalEvents = 0, totalHandled = 0;
- const buckets = {};
- list.forEach(e => {
- const day = e.day, key = e.day + "T" + String(e.hour).padStart(2, "0") + ":00:00+08:00";
- totalEvents++;
- if (e.wo === HANDLED) totalHandled++;
- (daily[day] = daily[day] || { ev: 0, hd: 0, eff: 0, devices: new Set() });
- daily[day].ev++;
- if (e.wo === HANDLED) daily[day].hd++;
- daily[day].devices.add(e.device);
- (buckets[key] = buckets[key] || []).push(e);
- });
- document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
- document.getElementById("rangeText").textContent = startDay + " ~ " + endDay;
- document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
- document.getElementById("totalEvents").textContent = totalEvents;
- document.getElementById("totalHandled").textContent = totalHandled;
- document.getElementById("totalEff").textContent =
- totalEvents ? (totalHandled / totalEvents * 100).toFixed(1) + "%" : "--";
- document.getElementById("empty").style.display = totalEvents ? "none" : "block";
- // MoM-style comparison: last half vs previous half of the window.
- const half = Math.floor(list.length / 2);
- if (list.length >= 2) {
- const evA = list.slice(0, half).length, evB = list.length - half;
- const hdA = list.slice(0, half).filter(e => e.wo === HANDLED).length;
- const hdB = list.slice(half).filter(e => e.wo === HANDLED).length;
- const rateA = evA ? hdA / evA : 0, rateB = evB ? hdB / evB : 0;
- setMom("momEvents", evB - evA, evA);
- setMom("momEff", rateB - rateA, rateA);
- } else {
- document.getElementById("momEvents").textContent = "--";
- document.getElementById("momEff").textContent = "--";
- }
- document.getElementById("dayRows").innerHTML = Object.keys(daily).sort().map(d => {
- const row = daily[d];
- return `<tr><td>${d}</td><td>${row.ev}</td><td>${row.hd}</td><td>${row.ev ? (row.hd / row.ev * 100).toFixed(0) + "%" : "--"}</td></tr>`;
- }).join("");
- const maxEv = Math.max(1, ...Object.values(daily).map(d => d.ev));
- document.getElementById("columns").innerHTML = Object.keys(daily).sort().map(d => {
- const row = daily[d];
- const pct = row.ev ? (row.hd / row.ev * 100).toFixed(0) : 0;
- return `<div class="col" data-day="${d}"><span class="eff">${pct}%</span><div class="bars">
- <i class="ev" style="height:${row.ev / maxEv * 130}px"></i>
- <i class="hd" style="height:${row.hd / maxEv * 130}px"></i>
- </div><small>${d.slice(5)}</small></div>`;
- }).join("");
- document.querySelectorAll(".col").forEach(col =>
- col.addEventListener("click", () => selectBucket(daily[col.dataset.day], col.dataset.day)));
- }
- function setMom(id, delta, base) {
- const el = document.getElementById(id);
- if (!base) { el.textContent = delta > 0 ? "+" + delta : String(delta); return; }
- const pct = delta / base * 100;
- el.textContent = (pct >= 0 ? "+" : "") + pct.toFixed(1) + "%";
- el.className = pct > 0 ? "mom-up" : pct < 0 ? "mom-down" : "";
- }
- function selectBucket(day, label) {
- const map = document.getElementById("map");
- map.innerHTML = "";
- const selected = events().filter(e => e.day === label);
- document.getElementById("bucketLabel").textContent = label + " 选中(" + selected.length + " 个事件)";
- if (!selected.length) { document.getElementById("caption").textContent = "该桶无事件"; return; }
- const lons = selected.map(e => e.lon), lats = selected.map(e => e.lat);
- const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
- const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
- const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
- selected.forEach(e => {
- const el = document.createElement("div");
- el.className = "marker hot";
- el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
- map.appendChild(el);
- const l = document.createElement("div");
- l.className = "label"; l.style.left = xs(e) + "%"; l.style.top = ys(e) + "%";
- l.textContent = e.no + " · " + e.device;
- map.appendChild(l);
- });
- document.getElementById("caption").textContent = `${label} 桶内 ${selected.length} 个事件的点位(联动中央一张图)`;
- }
- document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
- document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
- function setFamily(next) {
- family = next;
- document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
- document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
- render();
- }
- document.getElementById("apply").addEventListener("click", () => {
- filters.level = document.getElementById("fLevel").value;
- filters.industry = document.getElementById("fIndustry").value;
- filters.days = Number(document.getElementById("fDays").value);
- render();
- });
- document.getElementById("refresh").addEventListener("click", render);
- render();
- </script>
- </body>
- </html>
|