| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- <!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(360px,450px) 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:17px; }
- .basis { border-left:3px solid var(--blue); background:#f5f8fc; padding:8px 10px; color:var(--muted); margin-bottom:14px; font-size:12px; }
- 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; }
- .statebars { display:grid; gap:5px; margin-bottom:14px; }
- .staterow { display:grid; grid-template-columns:78px 1fr 34px; gap:8px; align-items:center; cursor:pointer; }
- .staterow .bar { height:12px; background:#e6ecf2; border-radius:3px; overflow:hidden; }
- .staterow .bar i { display:block; height:100%; background:var(--blue); }
- .staterow.on .bar i { background:#0f8a4d; }
- .staterow.on { 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 { display:flex; align-items:flex-end; gap:8px; height:150px; padding:10px 18px; background:#fff; border-bottom:1px solid var(--line); }
- .chart .col { flex:1; min-width:22px; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:4px; }
- .chart .col i { display:block; width:100%; background:#b65c08; border-radius:3px 3px 0 0; }
- .chart .col small { font-size:10px; color:var(--muted); }
- .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:160px; }
- .marker { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
- .label { position:absolute; font-size:11px; transform:translate(9px,-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; }
- @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>效率 KPI</h2>
- <div class="kpis">
- <div class="kpi"><span>事件总数</span><b id="totalEvents">0</b></div>
- <div class="kpi"><span>已结案</span><b id="handledCount">0</b></div>
- <div class="kpi"><span>处理中(1-5)</span><b id="inProgress">0</b></div>
- <div class="kpi"><span>已取消(7)</span><b id="cancelled">0</b></div>
- <div class="kpi"><span>无关联工单</span><b id="missing">0</b></div>
- <div class="kpi"><span>处置效率</span><b id="efficiency">0%</b></div>
- </div>
- <div class="basis">处置效率口径:已结案工单数 ÷ 事件总数(basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS)。本页不做预警真伪或缺报/误报判断。</div>
- <h2>工单状态分布(七态不合并,点击筛选)</h2>
- <div class="statebars" id="stateBars"></div>
- <h2>平均处置耗时</h2>
- <div class="kpis">
- <div class="kpi"><span>平均耗时(小时)</span><b id="avgDuration">--</b></div>
- <div class="kpi"><span>样本数</span><b id="avgSamples">0</b></div>
- <div class="kpi"><span>统计范围</span><b>已结案</b></div>
- </div>
- <h2 style="margin-top:14px">事件列表</h2>
- <table><thead><tr><th>事件</th><th>设备</th><th>等级</th><th>工单状态</th><th>耗时(h)</th></tr></thead>
- <tbody id="eventRows"></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" id="chart"></div>
- <div class="toolbar">
- <strong>中央一张图</strong>
- <span id="filterLabel" style="color:var(--muted)">未筛选</span>
- <button id="clearFilter" type="button">清除状态筛选</button>
- <button id="refresh" type="button">手动刷新</button>
- </div>
- <div class="map" id="map"></div>
- <div class="caption" id="caption">点击左侧状态条筛选事件与地图点位</div>
- </section>
- </main>
- <script>
- const STATES = [
- { code: 1, name: "待派单" }, { code: 2, name: "待接单" }, { code: 3, name: "已接单" },
- { code: 4, name: "处理中" }, { code: 5, name: "待验收" }, { code: 6, name: "已结案" },
- { code: 7, name: "已取消" }
- ];
- const makeFixture = (prefix, specs) => specs.map((s, i) => ({
- no: prefix + String(i + 1).padStart(3, "0"),
- device: `D-${s.ind}-${i + 1}`,
- level: s.level, hour: s.hour, state: s.state, duration: s.duration,
- lon: 110.13 + i * 0.005, lat: 28.44 + (i % 5) * 0.008
- }));
- const fixture = {
- WARNING: makeFixture("EW-", [
- { ind: "GAS", level: 1, hour: 8, state: 1, duration: null },
- { ind: "GAS", level: 2, hour: 8, state: 6, duration: 2.0 },
- { ind: "WS", level: 2, hour: 9, state: 4, duration: null },
- { ind: "DR", level: 3, hour: 9, state: 5, duration: null },
- { ind: "WS", level: 4, hour: 10, state: 6, duration: 1.5 },
- { ind: "GAS", level: 1, hour: 10, state: 7, duration: null },
- { ind: "DR", level: 2, hour: 11, state: null, duration: null },
- { ind: "DR", level: 1, hour: 11, state: null, duration: null },
- { ind: "GAS", level: 1, hour: 11, state: 6, duration: 3.25 }
- ]),
- ALARM: makeFixture("EA-", [
- { ind: "GAS", level: 1, hour: 8, state: 6, duration: 4.0 },
- { ind: "WS", level: 2, hour: 10, state: 4, duration: null },
- { ind: "DR", level: 1, hour: 11, state: null, duration: null }
- ])
- };
- let family = "WARNING";
- let stateFilter = null;
- function render() {
- const all = fixture[family];
- const scoped = all;
- const stateCounts = {};
- STATES.forEach(s => stateCounts[s.code] = 0);
- let linked = 0, missing = 0;
- scoped.forEach(e => {
- if (e.state === null) missing++;
- else { linked++; stateCounts[e.state]++; }
- });
- const handled = stateCounts[6];
- const inProgress = [1, 2, 3, 4, 5].reduce((n, c) => n + stateCounts[c], 0);
- const durations = scoped.filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
- const avg = durations.length ? (durations.reduce((a, b) => a + b, 0) / durations.length) : null;
- const filtered = stateFilter === null
- ? scoped
- : stateFilter === "NONE" ? scoped.filter(e => e.state === null)
- : scoped.filter(e => e.state === stateFilter);
- document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
- document.getElementById("rangeText").textContent = "2026-09-02 08:00 ~ 12:00 (+08:00)";
- document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
- document.getElementById("totalEvents").textContent = scoped.length;
- document.getElementById("handledCount").textContent = handled;
- document.getElementById("inProgress").textContent = inProgress;
- document.getElementById("cancelled").textContent = stateCounts[7];
- document.getElementById("missing").textContent = missing;
- document.getElementById("efficiency").textContent =
- scoped.length ? (handled / scoped.length * 100).toFixed(1) + "%" : "0.0%";
- document.getElementById("avgDuration").textContent = avg === null ? "--" : avg.toFixed(2);
- document.getElementById("avgSamples").textContent = durations.length;
- const maxState = Math.max(1, ...Object.values(stateCounts));
- document.getElementById("stateBars").innerHTML = STATES.map(s => `
- <div class="staterow ${stateFilter === s.code ? "on" : ""}" data-code="${s.code}">
- <span>${s.code} ${s.name}</span>
- <span class="bar"><i style="width:${stateCounts[s.code] / maxState * 100}%"></i></span>
- <span>${stateCounts[s.code]}</span>
- </div>`).join("");
- document.querySelectorAll(".staterow").forEach(row =>
- row.addEventListener("click", () => {
- const code = Number(row.dataset.code);
- stateFilter = stateFilter === code ? null : code;
- render();
- }));
- document.getElementById("empty").style.display = filtered.length ? "none" : "block";
- document.getElementById("eventRows").innerHTML = filtered.map(e => `
- <tr>
- <td>${e.no}</td><td>${e.device}</td>
- <td>${e.level}</td>
- <td>${e.state === null ? '<span style="color:#64748b">无关联工单</span>' : STATES.find(s => s.code === e.state).name}</td>
- <td>${e.duration === null ? "--" : e.duration.toFixed(2)}</td>
- </tr>`).join("");
- const hours = {};
- scoped.forEach(e => {
- const bucket = "0" + e.hour + ":00";
- (hours[bucket] = hours[bucket] || []).push(e);
- });
- const bucketKeys = Object.keys(hours).sort();
- const maxAvg = Math.max(0.5, ...bucketKeys.map(k => {
- const ds = hours[k].filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
- return ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : 0;
- }));
- document.getElementById("chart").innerHTML = bucketKeys.map(k => {
- const ds = hours[k].filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
- const value = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : 0;
- return `<div class="col"><small>${value ? value.toFixed(1) + "h" : "--"}</small>
- <i style="height:${value / maxAvg * 110}px"></i><small>${k}</small></div>`;
- }).join("");
- const map = document.getElementById("map");
- map.innerHTML = "";
- document.getElementById("filterLabel").textContent =
- stateFilter === null ? "未筛选"
- : stateFilter === "NONE" ? "无关联工单"
- : STATES.find(s => s.code === stateFilter).name;
- if (filtered.length) {
- const lons = filtered.map(e => e.lon), lats = filtered.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;
- filtered.forEach(e => {
- const el = document.createElement("div");
- el.className = "marker";
- el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
- el.title = e.no;
- 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;
- map.appendChild(l);
- });
- }
- document.getElementById("caption").textContent =
- `${filtered.length} 个事件点位(状态筛选:${document.getElementById("filterLabel").textContent})`;
- }
- document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
- document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
- function setFamily(next) {
- family = next;
- stateFilter = null;
- document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
- document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
- render();
- }
- document.getElementById("clearFilter").addEventListener("click", () => { stateFilter = null; render(); });
- document.getElementById("refresh").addEventListener("click", render);
- render();
- </script>
- </body>
- </html>
|