Prechádzať zdrojové kódy

2.63.0 前端与 v2.9.2 一致: (A) Vue 采用 v2.9.2 旧页样式表 src/windscada/ui/app.css 原样(产物 CSS 1.9KB->55.7KB, 字体/字号/配色同源); (B) 用 v2.9.2 自带 build.py 出原样经典页 release/web/detail_v2.html(238,644B, 含语言包, 无占位符) 并在工作台加入口; 远端验证 /web/detail_v2.html 200 /web/index.html 200 / 200 /api/fleet 200

zhouyang.xie 1 týždeň pred
rodič
commit
7580cd6e68

+ 1 - 1
VERSION

@@ -1 +1 @@
-2.62.1
+2.63.0

+ 4 - 1
app_common/app_common_guanlan/version.py

@@ -24,7 +24,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.62.1'         # ★ 版本只改这里
+VERSION = '2.63.0'         # ★ 版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -54,6 +54,9 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.63.0', date='2026-10-03', level='minor',
+         title='前端与 v2.9.2 一致:Vue 采用旧页样式表 + 提供 v2.9.2 原样经典页',
+         note='① 用户令 2026-10-03:「前端必须与 `app_guanlang_v2.9.2` 版本的页面保持一致(布局、样式、字体、字号、图文表)」。两次并行落地,分别解决"像"与"就是":② **A 样式同源(Vue 侧)**:从 v2.9.2 包内 `src/windscada/ui/app.css`(23,199 B)**原样**落盘为 `app_frontEnd/web/src/styles/legacy_app.css` 并在 `main.ts` 末尾引入(作为最终口径)⇒ Vue 产物的 CSS 由 1.9 KB 变为 55.7 KB,工作台各页签继承 v2.9.2 的字体/字号/配色/表格与卡片样式(类名此前已由对拍工具逐字核过)。③ **B 原样经典页(保证逐像素一致)**:把 v2.9.2 包内 `src/` 与 `configs/` 解出,用**它自己的** `src/windscada/ui/build.py` 出页(shell.html + app.css + app.js + charts.js + **语言包**,zh),产物 238,644 B,校验无遗留占位符;放入 `release/web/detail_v2.html`(静态伺服 ⇒ **零路由改动**即可访问),工作台「门户栏目」页签加入口「经典版页面」。数据面复用现有 `/api/*`(网关照旧转 Java 后端),实测 `/api/fleet` 200 ⇒ 经典页可直接工作。④ 远端验证(106.120.102.238):`/web/detail_v2.html` 200 · 238,644 B ✓;`/web/index.html` 200 ✓;`/` 200 ✓;`/api/fleet` 200 ✓。⑤ 说明:Vue 侧为"样式同源 + 类名一致",理论上高度接近;原样经典页则是**同一份源码**,可作逐像素基准与回退视图。'),
     dict(version='2.62.1', date='2026-10-03', level='patch',
          title='「门户栏目」收敛为在线服务两项;「架构与资料」内嵌四节整页展开',
          note='① 收尾两项(承 2.62.0 门户四节内嵌):(a)「门户栏目」页签只保留**在线服务**两项(仿真与回放 → `/sim/`、系统状态 → `/ops`),系统架构/方法/经验发现/案例·液压 已迁为「架构与资料」页签,避免重复入口;`pt.note` 文案同步改口径(中英成对)。(b)「架构与资料」的内嵌区由 `max-height: calc(100vh - 260px); overflow:auto` 改为 `max-height: none; overflow: visible` ⇒ **整页展开**,观感回到门户原样(不再套一层内滚动)。② 远端已验证(106.120.102.238):新产物 `index-CK3ivS0h.js` + `index-97FhsCXN.css` 上线,核验其在远端伺服的压缩内容含 `ps-embed` ✓、`max-height:none` ✓、四节锚点 ✓;探活 `/` 200 · `/web/index.html` 200 · `/api/fleet` 200 · `/api/recalc` 200 ✓。'),

+ 6 - 5
app_frontEnd/web/src/main.ts

@@ -1,5 +1,6 @@
-import { createApp } from 'vue'
-import App from './App.vue'
-import './styles.css'
-
-createApp(App).mount('#app')
+import { createApp } from 'vue'
+import App from './App.vue'
+import './styles.css'
+
+createApp(App).mount('#app')
+import './styles/legacy_app.css'

+ 322 - 0
app_frontEnd/web/src/styles/legacy_app.css

@@ -0,0 +1,322 @@
+/* 由 P18 脚本从 `app_guanlang_v2.9.2` 包内 `src/windscada/ui/app.css` **原样**落盘 ——
+   目的:Vue 版的样式/字体/字号/配色与 v2.9.2 旧页同源(用户令 2026-10-03)。 */
+/* 观澜 新前端样式表 —— 设计基准 = docs/design/guanlan_v2.html
+ * (2026-09-03 用户: "你帮我整体设计一下吧"; 纸底/印章/长卷那版已否, 留档 docs/design/_explored/)
+ *
+ * 六条决定:
+ *   01 问题先于全貌   打开先看该管的几台; 全场状态压成 38 格一条带子放第二屏
+ *   02 两条轴两枚chip 设备状态 (彩色) × 证据级 (单色) 并排 —— 两者不可互替, 这是产品的真正特色
+ *   03 限定语不可分离 每个结论下的限定行在同一张卡, 带自己的标签
+ *   04 一种强调色     海蓝只用于「可操作」; 红黄灰绿是语义不是装饰
+ *   05 System font stack only; no external font request.
+ *   06 不动           无入场动画, 只有悬停与选中
+ *
+ * 单主题 (显式底色)。深色版留作后续 —— token 已分层, 届时只改 :root。
+ */
+
+:root{
+  --ground:#F5F7F8; --panel:#FFFFFF; --ink:#1B2430; --muted:#5C6B78; --line:#DDE3E8; --hair:#C9D1D8;
+  --accent:#1F6F8B; --accent-deep:#175A71; --accent-soft:#BFDCE6; --wash:#E8F2F6;
+  --critical:#C8372D; --warning:#D48A1E; --unknown:#8A96A0; --good:#2E8B6A; --excellent:#8FCBB5;
+  /* 迁移期: 旧 JS 里的变量名指到新语义 */
+  --bad:var(--critical); --warn:var(--warning); --ok:var(--good); --info:var(--accent);
+  --sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif;
+  --mono:"SF Mono",Menlo,Consolas,monospace;
+  --r:10px;
+}
+*{box-sizing:border-box}
+html{background:var(--ground)}
+body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
+body[data-lang=zh]{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Inter,ui-sans-serif,system-ui,sans-serif}
+a{color:var(--accent-deep);text-decoration:none}
+a:hover{text-decoration:underline}
+.wrap{max-width:1200px;margin:0 auto;padding:0 28px 72px}
+:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.num{font-variant-numeric:tabular-nums}
+.small{font-size:12.5px} .muted{color:var(--muted)}
+h1,h2,h3{margin:0;font-weight:600;letter-spacing:-.005em;text-wrap:balance}
+h2{font-size:15px} h3{font-size:14px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+
+/* ── 顶 ── */
+header.top{display:flex;align-items:center;gap:14px;padding:18px 0 12px}
+.mark{display:inline-flex;align-items:center;gap:9px}
+.mark svg{width:26px;height:26px;color:var(--accent)}
+.brand{font-weight:600;font-size:18px;letter-spacing:-.01em}
+.brand small{font-weight:400;color:var(--muted);margin-left:8px;font-size:13px}
+.tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border:1px solid var(--hair);border-radius:4px;padding:2px 7px}
+header.top .right{margin-left:auto;display:flex;gap:8px;align-items:center}
+select,input,button{font:inherit;font-size:13px;color:var(--ink)}
+select,input{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 10px}
+input::placeholder{color:var(--muted)}
+.toggle{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:var(--panel)}
+.toggle button{border:0;background:transparent;padding:6px 12px;cursor:pointer;color:var(--muted)}
+.toggle button.on{background:var(--ink);color:#fff}
+
+/* ── 页签: 平铺 (用户 2026-08-28 裁定), 当前项一条下划线 ── */
+nav.tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);flex-wrap:wrap}
+nav.tabs button{font-size:13.5px;border:0;background:transparent;color:var(--muted);padding:9px 12px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
+nav.tabs button.on{color:var(--accent-deep);border-bottom-color:var(--accent);font-weight:500}
+
+/* ── 节 ── */
+section{margin-top:26px}
+.sechead{display:flex;align-items:baseline;gap:12px;margin-bottom:10px}
+.sechead .meta{color:var(--muted);font-size:12.5px}
+.sechead .legend{margin-left:auto}
+.legend{display:flex;gap:14px;font-size:12px;color:var(--muted)}
+.legend i{display:inline-block;width:9px;height:9px;border-radius:2px;vertical-align:-1px;margin-right:5px}
+.eyebrow{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:22px 0 8px}
+
+/* ── 按钮 ── */
+.btn{padding:7px 16px;border-radius:7px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:500;cursor:pointer}
+.btn:hover{background:var(--accent-deep);border-color:var(--accent-deep)}
+.btn.quiet{background:transparent;color:var(--accent-deep)}
+.btn:disabled{opacity:.5;cursor:not-allowed}
+
+/* ── 面板 / 表 ── */
+.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px}
+.mb{margin-bottom:12px}
+.tblwrap{overflow-x:auto}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th{font-weight:500;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);font-size:12px;letter-spacing:.02em;white-space:nowrap}
+td{padding:7px 10px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums;vertical-align:top}
+tr:last-child td{border-bottom:0}
+td.r,th.r{text-align:right}
+
+/* ── KPI: 左边条 = 语义 ── */
+.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
+.kpi{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 11px;position:relative}
+.kpi::before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:3px;border-radius:0 2px 2px 0;background:var(--accent)}
+.kpi.warn::before{background:var(--warning)} .kpi.bad::before{background:var(--critical)} .kpi.ok::before{background:var(--good)}
+.kpi b{display:block;font-size:24px;font-weight:600;letter-spacing:-.02em;line-height:1.15;font-variant-numeric:tabular-nums}
+.kpi b small{font-size:13px;font-weight:500;color:var(--muted);margin-left:2px}
+.kpi span{display:block;font-size:12px;color:var(--muted);margin-top:4px}
+
+/* ── chip: 设备状态 (彩色) ── */
+.chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:500;padding:2px 8px;border-radius:5px;white-space:nowrap;line-height:1.5}
+.chip.bad{background:rgba(200,55,45,.10);color:var(--critical)}
+.chip.warn{background:rgba(212,138,30,.14);color:#9C6410}
+.chip.unk{background:rgba(138,150,160,.16);color:#4F5B66}
+.chip.ok{background:rgba(46,139,106,.12);color:#22705A}
+.chip.ex{background:rgba(143,203,181,.22);color:#2E7A62}
+
+/* ── tier: 证据级 (单色) · rv: 审级 ── */
+.evid{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
+.tier{font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:4px;border:1px solid var(--hair);color:var(--muted);white-space:nowrap}
+.tier.confirmed{background:var(--ink);border-color:var(--ink);color:#fff}
+.tier.near{border-color:var(--ink);color:var(--ink)}
+.tier.candidate{border-color:var(--muted);color:var(--muted)}
+.tier.reference{}
+.tier.insufficient{border-style:dashed}
+.tier.withdrawn{text-decoration:line-through}
+.rv{font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
+.rv::before{content:"·";margin-right:3px;color:var(--hair)}
+
+/* ── 关注卡: 左边条 = 设备状态 ── */
+.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px}
+.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 12px 18px;position:relative;transition:box-shadow .15s}
+.card::before{content:"";position:absolute;left:0;top:12px;bottom:12px;width:3px;border-radius:0 2px 2px 0;background:var(--sev,var(--hair))}
+.card.lit{box-shadow:0 0 0 2px var(--accent-soft)}
+.card .head{display:flex;align-items:baseline;gap:10px}
+.card .unit{font-weight:600;font-size:19px;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
+.card .sys{color:var(--muted);font-size:13px}
+.card .head .chip{margin-left:auto}
+.card .why{margin:8px 0 9px;font-size:13.5px}
+.caveat{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:7px;margin-top:2px;line-height:1.5}
+.caveat b{font-family:var(--mono);font-weight:500;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-right:7px}
+
+/* ── 迷你趋势: 越早越淡 (JS 按序设 opacity), 末点实色 ── */
+.spark{height:28px;display:flex;align-items:flex-end;gap:2px;margin:9px 0 8px}
+.spark i{flex:1;display:block;background:var(--accent);border-radius:1px 1px 0 0}
+
+/* ── 全场状态条: 一格一台 ── */
+.strip{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 8px}
+.cells{display:grid;grid-template-columns:repeat(var(--n,38),1fr);gap:4px}
+.cell{aspect-ratio:1;border-radius:3px;background:var(--excellent);transition:transform .1s}
+.cell.hot{cursor:pointer} .cell.hot:hover{transform:translateY(-2px)}
+.cell.bad{background:var(--critical)} .cell.warn{background:var(--warning)} .cell.unk{background:var(--unknown)} .cell.ok{background:var(--good)}
+.cell.lit{outline:2px solid var(--ink);outline-offset:1px}
+.nums{display:grid;grid-template-columns:repeat(var(--n,38),1fr);gap:4px;margin-top:5px;font-family:var(--mono);font-size:9.5px;color:var(--muted);text-align:center}
+.nums span.hot{color:var(--ink);font-weight:500}
+.quiet{color:var(--muted);font-size:13px;margin-top:10px}
+.quiet b{color:var(--ink);font-weight:500}
+
+/* ── 系统入口 ── */
+.systems{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px}
+.sysc{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;display:grid;gap:7px}
+.sysc .t{display:flex;justify-content:space-between;align-items:baseline}
+.sysc .t b{font-weight:500}
+.sysc .t a{font-size:12px}
+.bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--line)}
+.bar i{display:block;height:100%}
+.sysc .cnt{display:flex;gap:10px;font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}
+.sysc .cnt i{display:inline-block;width:7px;height:7px;border-radius:2px;margin-right:4px}
+
+/* ── 骨架 (载入中) ── */
+.skel{height:12px;border-radius:6px;background:linear-gradient(90deg,var(--line),var(--panel),var(--line));background-size:200% 100%;animation:sk 1.2s linear infinite}
+@keyframes sk{to{background-position:-200% 0}}
+
+/* 原文数据 (英文模式下按原文标注显示) */
+.verbatim{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
+code{font-family:var(--mono);font-size:11.5px}
+
+/* ── 问答: 类别 pill / 常用问题行 / 核验徽章 ── */
+.pill{font-size:13px;padding:6px 14px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
+.pill.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:500}
+.qitem{font-size:13px;cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-top:8px;background:var(--panel)}
+.qitem:hover{border-color:var(--accent-soft);background:var(--wash)}
+.badge{display:inline-block;font-size:11.5px;padding:1px 8px;border-radius:5px;border:1px solid var(--hair);color:var(--muted)}
+.badge.ok{border-color:var(--good);color:var(--good)} .badge.warn{border-color:var(--warning);color:#9C6410}
+
+/* ── 汇报定制: 一页纸预览 ── */
+.paper{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:26px 30px;margin-top:14px}
+.paper-hd{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:12px;margin-bottom:16px;border-bottom:2px solid var(--ink)}
+.paper-ti{font-weight:600;font-size:17px;letter-spacing:-.01em}
+.paper-ft{display:flex;justify-content:space-between;gap:16px;font-size:11.5px;color:var(--muted);padding-top:10px;margin-top:16px;border-top:1px solid var(--line);flex-wrap:wrap}
+.paper .panel{border-radius:8px}
+svg.dt text{font-family:var(--sans)}
+@media print{.noprint,nav.tabs,header.top,footer{display:none!important}.wrap{padding:0;max-width:none}.paper{border:0;padding:0}body{background:#fff}}
+
+/* ── 双列网格 (故障/电量页): 窄屏单列, 宽屏两列; span2 占满 ── */
+.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(480px,1fr));gap:12px}
+.grid2 .span2{grid-column:1/-1}
+i.bar{display:inline-block;height:10px;border-radius:2px;vertical-align:middle;margin-right:6px;opacity:.85}
+
+/* ── 振动融合: 状态|证据 双联 chip, 传动链小格, 矩阵表 ── */
+.duo{display:inline-flex;align-items:stretch;border:1px solid var(--c,var(--hair));border-radius:5px;overflow:hidden;font-size:11.5px;line-height:1.5;max-width:100%}
+.duo .st{padding:1px 7px;background:var(--c,var(--hair));color:#fff;font-weight:500;white-space:nowrap}
+.duo .ev{padding:1px 7px;color:var(--ink);background:var(--panel);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
+.chain{display:inline-flex;gap:2px;vertical-align:middle}
+.chain i{display:inline-block;width:11px;height:11px;border-radius:2px;border:1px solid var(--hair);background:var(--panel)}
+.chain i.on{border-color:var(--accent)}
+table.fmx td{vertical-align:top;padding:6px 8px}
+table.fmx th div{line-height:1.3}
+
+/* ── 逐台/单问题浮层 (静态站上 /turbine /problem 是 404, 页内兜住) ── */
+.popm{position:fixed;inset:0;z-index:9999;background:rgba(27,36,48,.55);overflow:auto;padding:24px 16px}
+.popc{max-width:1120px;margin:0 auto;background:var(--ground);border:1px solid var(--line);border-radius:var(--r);padding:16px 18px}
+.poph{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line)}
+.poph h3{font-size:16px}
+
+
+/* 2026-09-03: reading hierarchy, work-order details and responsive layout. */
+:root{--r:12px;--shadow:0 3px 16px rgba(23,51,68,.035)}   /* 2026-09-06 用户裁: 去中段 --ground/--line 覆盖, 令牌与门户一致 */
+.wrap{max-width:1360px;padding:0 32px 64px}
+header.top{padding:24px 0 20px;gap:24px}
+.brand{font-size:21px;letter-spacing:-.035em}
+.brand small{letter-spacing:0;font-size:12px}
+.mark{gap:12px}
+nav.tabs{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;padding:6px;background:#EAF0F3;border:1px solid var(--line);border-radius:12px}
+nav.tabs button{margin:0;padding:10px 6px;min-height:40px;border:0;border-radius:7px;font-size:12px;line-height:1.3}
+nav.tabs button:hover{color:var(--accent-deep);background:#F5F9FA}
+nav.tabs button.on{color:var(--accent-deep);background:var(--panel);box-shadow:0 1px 5px #17334412;font-weight:600}
+.page-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:28px 0 16px}
+.page-head h1{font-size:28px;line-height:1.2;letter-spacing:-.035em}
+.window-badge{font-size:11px;font-weight:500;white-space:nowrap;color:var(--accent-deep);border:1px solid #CDDEE6;background:#ECF4F7;padding:5px 10px;border-radius:6px}
+.page-head+section{margin-top:0}
+.sechead{flex-wrap:wrap;row-gap:6px;margin-bottom:14px}
+.sechead h2{font-size:18px;letter-spacing:-.025em}
+.legend{flex-wrap:wrap;row-gap:5px}
+.panel,.card,.kpi,.strip,.sysc{box-shadow:var(--shadow)}
+.panel{padding:18px}
+.panel>h3{font-size:15px;line-height:1.45;margin-bottom:8px}
+.panel,.grid2>*{min-width:0}
+.kpis{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
+.kpi{padding:18px 20px;min-height:108px}
+.kpi::before{top:18px;bottom:18px}
+.kpi b{font-size:29px;line-height:1.1}
+.kpi b small{margin-left:5px;font-size:12px}
+.kpi span{font-size:12px;margin-top:9px;line-height:1.45;max-width:36ch}
+.cards{grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:16px}
+.card{padding:20px 22px 18px}
+.card .head{align-items:start;flex-wrap:wrap;gap:8px}
+.card .unit{font-size:23px;line-height:1.15;white-space:nowrap}
+.card .sys{flex:1;min-width:90px;line-height:1.45}
+.card .head .chip{margin-left:auto}
+.card .evid{margin:12px 0 8px}
+.card .caveat{font-size:12px;line-height:1.65;padding-top:12px}
+.evidence-detail{margin-top:14px;background:#F5F8FA;border:1px solid #E5ECF0;border-radius:7px;padding:9px 12px}
+.evidence-detail summary{cursor:pointer;color:var(--accent-deep);font-size:12px;font-weight:500}
+.card .why{line-height:1.7;margin:10px 0 3px}
+.sysc{padding:16px;gap:12px}
+.sysc[role=button]{cursor:pointer}
+.sysc[role=button]:hover{border-color:#9DBECA;background:#FBFDFE}
+.grid2{grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:16px}
+.tblwrap{max-width:100%;border-radius:7px;overscroll-behavior-x:contain}
+th{background:#F4F7F9;font-size:11px;font-weight:600;letter-spacing:.025em;padding:10px 12px}
+td{padding:11px 12px;line-height:1.6}
+tr:hover>td{background:#F7FAFC}
+td.r,th.r{white-space:nowrap}
+.strip{overflow-x:auto;padding:18px}
+.strip .cells,.strip .nums{min-width:850px}
+footer{border-top:1px solid var(--line);padding-top:18px;line-height:1.7;font-size:11px!important}
+.popm{padding:24px;background:rgba(17,35,47,.58);backdrop-filter:blur(3px)}
+.popc{max-width:1240px;padding:0 0 8px;border-radius:16px;box-shadow:0 24px 80px #0E263640}
+.poph{position:sticky;top:-24px;z-index:2;align-items:center;padding:20px 24px;margin:0 0 20px;background:var(--panel);border-radius:16px 16px 0 0;box-shadow:0 2px 8px #17334405}
+.poph h3{font-size:23px;line-height:1.3;letter-spacing:-.025em}
+.poph .btn{flex-shrink:0;min-height:36px;border-color:var(--line);background:var(--ground)}
+.popc>.panel,.popc>p{margin:16px 20px}
+.popc>.panel{padding:20px}
+.popc>.panel>.head{align-items:center!important;margin-bottom:10px;font-size:17px}
+.popc>.panel>.small{max-width:105ch;line-height:1.7}
+.popc .grid2>.panel{padding:16px;box-shadow:none;background:#FBFCFD}
+.order-block{margin-top:24px;border-top:1px solid var(--line);padding-top:18px}
+.order-head{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:12px}
+.order-head>b{font-size:14px}
+.action-counts{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
+.action-count{display:inline-flex;gap:7px;align-items:center;background:var(--wash);color:var(--accent-deep);border:1px solid #D5E7EF;border-radius:5px;padding:3px 8px;font-size:11px}
+.action-count b{font-variant-numeric:tabular-nums;font-weight:600}
+.order-table{table-layout:fixed;min-width:780px}
+.order-table .order-date{width:112px;white-space:nowrap}
+.order-table td{overflow-wrap:anywhere;padding-top:14px;padding-bottom:14px}
+.order-table td.order-date{overflow-wrap:normal;font-size:12px;color:var(--muted)}
+.popup-verbatim-note{color:var(--muted);font-size:11px;line-height:1.7;padding:0 4px}
+.verbatim{border-bottom:1px dotted #B9C6CF}
+@media(min-width:1440px){.kpis{grid-template-columns:repeat(6,minmax(0,1fr))}}
+@media(max-width:900px){.wrap{padding:0 20px 48px}.brand small{display:block;margin:3px 0 0}.cards{grid-template-columns:1fr}.sechead .legend{margin-left:0;width:100%}.popm{padding:12px}.poph{top:-12px}.systems{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:600px){
+ .wrap{padding:0 14px 40px}header.top{flex-wrap:wrap;gap:14px;padding:18px 0}.brand{font-size:20px}header.top .right{margin-left:0;width:100%;gap:8px}header.top select{flex:1;min-width:0}
+ nav.tabs{grid-template-columns:repeat(2,minmax(0,1fr));gap:3px;padding:5px}nav.tabs button{font-size:11.5px;min-height:36px;text-align:left;padding:8px 10px}
+ .page-head{margin:22px 0 14px;align-items:flex-start}.page-head h1{font-size:23px}.window-badge{font-size:10px;padding:4px 7px}
+ .kpis{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.kpi{padding:14px;min-height:116px}.kpi b{font-size:24px}.kpi span{font-size:11px}
+ .systems{grid-template-columns:1fr}.panel{padding:14px}.card{padding:16px}.card .head .sys{font-size:12px}.card .unit{font-size:21px}
+ .popm{padding:8px}.popc{border-radius:12px}.poph{top:-8px;padding:15px 14px;border-radius:12px 12px 0 0;margin-bottom:12px}.poph h3{font-size:19px}.popc>.panel,.popc>p{margin:12px 10px}.popc>.panel{padding:14px}.popc .grid2>.panel{padding:12px}
+ .order-head{gap:8px}.paper{padding:18px 14px}.paper-hd{gap:12px;flex-wrap:wrap}
+}
+@media print{.evidence-detail{display:block}.poph{position:static}nav.tabs,header.top,.page-head .window-badge{display:none!important}.card,.panel,.kpi{box-shadow:none}}
+
+/* Singapore bay theme: a restrained Marina Bay silhouette, garden canopies, turbines and wind-flow traces. */
+/* 2026-09-06 用户裁: 删末块令牌覆盖, 与门户单文件浅色令牌 (#F5F7F8/#1B2430/#1F6F8B/#DDE3E8) 一致; 基线 detail_style_baseline */
+body{background:radial-gradient(ellipse at 95% 0,rgba(41,201,187,.075),transparent 40%),var(--ground)}
+header.top{position:relative;isolation:isolate;overflow:hidden;min-height:128px;margin:22px 0 12px;padding:28px 30px;border:1px solid #1F526A;border-radius:18px;background:radial-gradient(ellipse at 68% 120%,#08788788,transparent 60%),linear-gradient(115deg,#092438,#103E55);box-shadow:0 10px 32px #0A344416}
+header.top::before{content:"";position:absolute;z-index:-1;inset:0;opacity:.16;background-image:linear-gradient(#73B4C425 1px,transparent 1px),linear-gradient(90deg,#73B4C425 1px,transparent 1px);background-size:28px 28px;pointer-events:none}
+.wind-field{position:absolute;width:420px;height:130px;left:35%;bottom:0;color:#61DBCE;opacity:.4;pointer-events:none;z-index:-1}
+header.top .mark{gap:15px}header.top .mark>svg{width:36px;height:36px;color:#65E1D0;padding:5px;border:1px solid #66CCBE55;border-radius:10px;background:#73E4CE0D}
+header.top .brand{font-size:27px;color:#F4FFFF;letter-spacing:-.035em}
+header.top .brand small{display:block;margin:3px 0 0;font-size:11px;letter-spacing:.035em;color:#B3D6DF}
+header.top .right{position:relative;padding:9px 11px;border:1px solid #759EAD44;border-radius:10px;background:#092A3EBF}
+header.top .right label{color:#B9D7E1}header.top select{background:#153E52;color:#E5FAFF;border-color:#3A6576}
+header.top .toggle{background:#E9F5F7;border-color:#527887}
+nav.tabs{background:#E4EFF2;border-color:#D1E3E8;gap:5px}
+nav.tabs button.on{color:#fff;background:linear-gradient(110deg,#087788,#006975);box-shadow:0 3px 8px #08778820}
+nav.tabs button:hover:not(.on){background:#F7FFFF;color:#00636B}
+.page-head h1{position:relative;padding-left:14px}
+.page-head h1::before{content:"";position:absolute;left:0;top:4px;bottom:2px;width:4px;border-radius:3px;background:linear-gradient(#07949C,#46C7AB)}
+.window-badge{background:#E5F5F3;border-color:#BDDCD8;color:#00636B}
+.kpi{background:linear-gradient(140deg,#fff 65%,#EEF9F8)}
+.evidence-detail{background:#F0F8F9;border-color:#DAECEE}
+.panel,.card,.sysc{border-color:#D7E5EB}.sysc[role=button]:hover{border-color:#69AAA9}
+a:hover{color:#008591}
+:focus-visible{outline-color:#05A2AD;outline-width:3px}
+@media(max-width:900px){header.top{padding:24px;gap:16px}.wind-field{left:34%;width:280px;opacity:.25}}
+@media(max-width:600px){header.top{margin-top:14px;padding:20px 17px;min-height:156px;border-radius:14px;gap:18px}header.top .brand{font-size:25px}header.top .brand small{font-size:10px}header.top .right{padding:8px;width:100%}.wind-field{left:auto;right:-50px;bottom:46px;width:230px;opacity:.22}header.top .mark{gap:12px}}
+@media print{body{background:#fff}.page-head h1::before{display:none}.kpi{background:#fff}a{color:inherit}}
+
+.source-record{margin-top:5px;font-size:10px;color:var(--muted);font-weight:400;line-height:1.6}
+.source-record summary{cursor:pointer;display:list-item;color:var(--accent-deep)}
+.source-record[open]{background:var(--ground);border:1px solid var(--line);border-radius:6px;padding:7px}
+.source-record>div{margin-top:6px;font-size:12px;overflow-wrap:anywhere}
+
+/* 返回门户 / 重新选择机组·测点 (2026-09-06 用户裁: 补上再冻结) */
+header.top .right .back-portal,header.top .right .reselect{display:inline-block;margin-right:8px;padding:6px 11px;border:1px solid #759EAD66;border-radius:8px;background:#0E3446;color:#E5FAFF;font-size:12.5px;text-decoration:none;cursor:pointer;line-height:1.3}
+header.top .right .back-portal:hover,header.top .right .reselect:hover{background:#155069;border-color:#9BC6D3}

+ 2 - 0
app_frontEnd/web/src/views/Portal.vue

@@ -14,6 +14,8 @@ const PORTAL_ORIGIN = `${location.protocol}//${location.hostname}:28086`
 const ENTRIES: Entry[] = [
   { id: 'sim', label: '仿真', href: '/sim/', note: '仿真与回放(仿真台 18791)', kind: 'svc' },
   { id: 'admin', label: '状态', href: '/ops', note: '系统状态(运维台)', kind: 'svc' },
+  { id: 'classic', label: '经典', href: '/web/detail_v2.html',
+    note: '经典版页面(v2.9.2 原文原样:布局/样式/字体/图文表一致)', kind: 'svc' },
 ]
 </script>
 

+ 1 - 1
docs/src/数据要求说明_观澜_2.62.1.md → docs/src/数据要求说明_观澜_2.63.0.md

@@ -1,4 +1,4 @@
-# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.62.1
+# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.63.0
 
 ## 1 文档说明
 

+ 9 - 9
docs/src/系统设计说明_观澜_2.62.1.md → docs/src/系统设计说明_观澜_2.63.0.md

@@ -1,4 +1,4 @@
-# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.62.1
+# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.63.0
 
 ## 1 文档说明
 
@@ -6,19 +6,19 @@
 
 ### 1.1 目的与范围
 
-本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.62.1;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
+本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.63.0;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
 
 本文覆盖十五个设计面:总体架构与分层、目录结构与路径真源、数据接入与重算链、判级与算法、时间窗口径、服务与前端、本体与知识层、本机模型接入、运维控制台与重算编排、安装与服务化与版本管理、质量保证、安全与离线边界、可移植性与资源占用、多场适用性与换场迁移、已知边界与未实现。
 
-本文不重复需求条目本身(那是《需求分析_观澜_2.62.1.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
+本文不重复需求条目本身(那是《需求分析_观澜_2.63.0.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
 
 ### 1.2 读者与用法
 
 现场运维与检修人员可看第 3 章、第 7 章、第 8 章、第 11 章,了解自己能点到的页面背后读的是什么口径;场站管理人员与换场交付人员可看第 6 章、第 7 章、第 16 章、第 17 章,了解判级与可靠性指标的口径与边界,以及换场时要重新标定哪些参数;研发与交付人员应通读全文,重点是第 4 章、第 5 章、第 12 章、第 13 章;验收方可直接按第 13 章的质量门与附录 A 的编写依据逐条复核。
 
-### 1.3 与《需求分析_观澜_2.62.1.docx》的对应关系
+### 1.3 与《需求分析_观澜_2.63.0.docx》的对应关系
 
-需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.62.1.docx》的数据侧口径去向见 16.6 节。
+需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.63.0.docx》的数据侧口径去向见 16.6 节。
 
 | 本文章节 | 需求分析对应章 | 对应关系说明 |
 |---|---|---|
@@ -41,7 +41,7 @@
 
 ### 1.4 口径与依据
 
-本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.62.1;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.62.1.zip。
+本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.63.0;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.63.0.zip。
 
 本文遵循四条写作口径:含"窗"且确实指时间窗口的,一律写全"时间窗"(天气窗、作业窗、预览窗、观测窗属领域词,保持原样);影响的是风电机组时写"影响机组"或"影响机组数";使用英文简写时必须写成"中文(英文简写)"形式,如平均无故障间隔(MTBF)、平均停机间隔(MTBO)、单次停机时长(MDT);全文用简体中文与半角数字与单位。
 
@@ -863,7 +863,7 @@ Windows 安装入口是 install.bat 与 install.ps1,步骤号写死在输出
 
 ### 12.3 安装记录与版本三守卫
 
-安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.62.1,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
+安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.63.0,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
 
 | 字段 | 含义 |
 |---|---|
@@ -1224,7 +1224,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 ### 16.6 与同批交付文档的对应关系
 
-本章对应《需求分析_观澜_2.62.1.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.62.1.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
+本章对应《需求分析_观澜_2.63.0.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.63.0.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
 
 ***
 
@@ -1290,7 +1290,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 | 章节 | 来源文件 |
 |---|---|
-| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.59.0 条目)、docs/需求分析_观澜_2.62.1.docx、scripts/delivery_docs_figures.py |
+| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.59.0 条目)、docs/需求分析_观澜_2.63.0.docx、scripts/delivery_docs_figures.py |
 | 2 设计目标与原则 | docs/系统设计说明.md(四条设计铁律)、app_ontology/.../ontology/mcp_server.py 头部、src/windscada/subsys/fusion.py 头部、src/windscada/perf/reliability.py |
 | 3 总体架构 | configs/serve.json、scripts/guanlan_gateway.py、guanlan.py(组件启动表)、run/pids.json、docs/系统设计说明.md(产物全景) |
 | 4 目录结构与路径真源 | src/paths.py、configs/registry.yaml、docs/系统设计说明.md(路径约定与统一记录)、本次目录实测 |

+ 20 - 19
docs/src/需求分析_观澜_2.62.1.md → docs/src/需求分析_观澜_2.63.0.md

@@ -1,4 +1,4 @@
-# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.62.1
+# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.63.0
 
 ## 1 文档说明
 
@@ -6,7 +6,7 @@
 
 ### 1.1 目的
 
-本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.62.1.docx》《数据要求说明_观澜_2.62.1.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
+本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.63.0.docx》《数据要求说明_观澜_2.63.0.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
 
 本文的写作口径是**只写能取证的事实**:每个数字、每条结论都能指到仓库里的某个文件、某次实跑输出或某条版本记录;查不到、取不到的一律写明「未取证」或「未到位」,不做推测性补全。因此文中会出现少量「未取证」的说明句,那是刻意留下的诚实边界,不是遗漏。
 
@@ -18,15 +18,15 @@
 
 ### 1.3 版本对应关系
 
-系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.62.1,版本记录(HISTORY)共 106 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
+系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.63.0,版本记录(HISTORY)共 107 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
 
 表 1-1 版本与文档的对应关系
 
 | 项 | 值 | 取证方式 |
 |---|---|---|
-| 系统版本 | 2.62.1 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.62.1」 |
-| 交付包名 | app_guanlang_v2.62.1.zip | src/version.py 的 package_name();同一条 check 输出 |
-| 本文版本 | 2.62.1(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
+| 系统版本 | 2.63.0 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.63.0」 |
+| 交付包名 | app_guanlang_v2.63.0.zip | src/version.py 的 package_name();同一条 check 输出 |
+| 本文版本 | 2.63.0(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
 | 版本史条目数 | 13 条(最新一条即本次文档交付) | src/version.py 的 HISTORY;docs/版本记录.md |
 | 版本号规则 | v大版本号.中版本号.小版本号;大改方案或架构、中改非核心功能、小为消缺 | src/version.py 的 BUMP_RULE 与 LEVEL_MEANING;docs/系统设计说明.md §15 |
 | 版本级别机器判据 | level_of(旧, 新) 返回 major 或 minor 或 patch 或 same | src/version.py 的 level_of() |
@@ -119,10 +119,11 @@
 
 ### 3.2 需求演进时间轴
 
-表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 106 条)
+表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 107 条)
 
 | 版本 | 日期 | 用户令要点 | 变化级别 |
 |---|---|---|---|
+| 2.63.0 | 2026-10-03 | 前端与 v2.9.2 一致:Vue 采用旧页样式表 + 提供 v2.9.2 原样经典页 | 中(minor) |
 | 2.62.1 | 2026-10-03 | 「门户栏目」收敛为在线服务两项;「架构与资料」内嵌四节整页展开 | 小(patch) |
 | 2.62.0 | 2026-10-03 | 门户四节原样内嵌进工作台+ 修 Workbench 模板分支未命中 | 中(minor) |
 | 2.61.0 | 2026-10-03 | 工作台补「门户栏目」页签;记档一次失败的网关路由尝试 | 中(minor) |
@@ -230,7 +231,7 @@
 | 0.4.0 | 2026-09-17 | 打包默认不含输入数据与产物与日志与临时文件;无窗口启动;统一配置与日志;页面归口;服务化 | 旧编号(legacy) |
 | 0.2.0 | 2026-09-01 | 含产物的旧交付基线,历史上用作「包内无生成端」那批产物的补救源 | 旧编号(legacy) |
 
-需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 106 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的104个版本)。按级别统计,这104个版本里「中」级变化58条(2.62.0、2.61.0、2.60.0、2.59.0、2.58.0、2.57.0、2.56.0、2.55.0、2.54.0、2.53.0、2.52.0、2.51.0、2.50.0、2.49.0、2.48.0、2.47.0、2.46.0、2.45.0、2.44.0、2.43.0、2.42.0、2.41.0、2.40.0、2.39.0、2.38.0、2.37.0、2.36.0、2.35.0、2.34.0、2.33.0、2.32.0、2.31.0、2.30.0、2.29.0、2.28.0、2.27.0、2.26.0、2.25.0、2.24.0、2.23.0、2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化46条(2.62.1、2.60.1、2.57.2、2.57.1、2.56.1、2.54.1、2.53.2、2.53.1、2.52.1、2.51.1、2.47.1、2.42.1、2.40.1、2.36.2、2.36.1、2.30.1、2.29.1、2.28.1、2.24.2、2.24.1、2.23.3、2.23.2、2.23.1、2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
+需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 107 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的105个版本)。按级别统计,这105个版本里「中」级变化59条(2.63.0、2.62.0、2.61.0、2.60.0、2.59.0、2.58.0、2.57.0、2.56.0、2.55.0、2.54.0、2.53.0、2.52.0、2.51.0、2.50.0、2.49.0、2.48.0、2.47.0、2.46.0、2.45.0、2.44.0、2.43.0、2.42.0、2.41.0、2.40.0、2.39.0、2.38.0、2.37.0、2.36.0、2.35.0、2.34.0、2.33.0、2.32.0、2.31.0、2.30.0、2.29.0、2.28.0、2.27.0、2.26.0、2.25.0、2.24.0、2.23.0、2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化46条(2.62.1、2.60.1、2.57.2、2.57.1、2.56.1、2.54.1、2.53.2、2.53.1、2.52.1、2.51.1、2.47.1、2.42.1、2.40.1、2.36.2、2.36.1、2.30.1、2.29.1、2.28.1、2.24.2、2.24.1、2.23.3、2.23.2、2.23.1、2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
 
 ![图 1-1 观澜需求演进时间轴(依据 src/version.py 的 HISTORY)](figures/fig-req-01-需求演进时间轴.png)
 
@@ -249,7 +250,7 @@
 | 所有的计算均要形成观澜的源代码 | 逐族补生成端:变桨面、在升闭环、三层基线、融合面 handoff、总览页、由台账生成 claim | 反向呼应审计成立 3,548 件、不成立 0 件、未归类 0 件 |
 | 运行期一律不从交付包补齐 | 第 ⑤ 步由「补齐随包件」改为「反向呼应审计」,只报账不搬运 | rebuild_all --dry-run 计划中 ⑤ 步命令为 products_reverse_audit.py --check |
 | 清除产物不留备份 | products_state.py --off --yes 改真删除;--on 与门户恢复按钮移除 | 控制台按钮语义与 409 前置校验;docs/系统设计说明.md §7 |
-| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.62.1」 |
+| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.63.0」 |
 | 观澜改为监听所有 IP | serve.json 增 public_host 且出厂为 0.0.0.0,仅门户网关用它,组件仍绑本机 | check 实跑显示 6 个端口全部运行中;README 第二节写明无鉴权风险与回退口径 |
 | 描述口径:时间窗、影响机组、中文(英文简写) | 界面文案与报告构建器统一改写;i18n_en 键同步 | docs/系统设计说明.md §17.1 与 §17.2;语言包 845 条前后端成对 |
 | 整理需求与设计与数据接入各写一份 Word 文档 | docs/src 下 markdown 源件;渲染器输出带域目录的 docx;插图生成器生成 14 张图,数字全部从真件取 | HISTORY 2.10.0 条目;本版三份文档 |
@@ -465,7 +466,7 @@
 | NFR-09 | 安全只读:不写回现场系统、不改原始件、不产生控制指令;对外暴露时须自行在网侧限来源 | 原始件按只读输入对待,落位冲突默认拒绝;对外只有一个网关入口;启动日志每次提醒页面无鉴权 | docs/输入数据放置指导_v0.1.md §3;README_先读我.MD 第二节 |
 | NFR-10 | 可维护性:单一真源,改一处不必改多处;关键口径有机器守卫 | 版本号只有一行真源并由三处守卫盯住;路径、配置、日志、语言各有唯一取用口与审计器 | docs/系统设计说明.md §15.2 与 §11.1;docs/系统设计说明.md §3.1 |
 | NFR-11 | 无人值守与自愈:长跑的服务要能被托管并自动拉起 | 服务体每 15 秒巡检并拉起掉线组件;崩溃重启策略;远程部署一律用服务,不用会话前台进程 | docs/系统设计说明.md §14.1 与 §14.4;README_先读我.MD 第二节 |
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.62.1.docx》第 8 章与第 12 章 |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.63.0.docx》第 8 章与第 12 章 |
 
 ### 6.2 性能与耗时实测
 
@@ -501,7 +502,7 @@
 
 | 真源 | 管什么 | 守卫与实跑结果 |
 |---|---|---|
-| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.62.1 且记录表与代码一致 |
+| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.63.0 且记录表与代码一致 |
 | src/paths.py | 一切路径解析的基准与助手 | 配置审计器检查不手拼路径;实跑无不一致 |
 | configs 目录与登记表 | 端口、模型档、场配置、页面归口、配置登记 | 配置审计实跑已知缺口与白名单 14 条、提示 7 条 |
 | src/logfile.py | 日志目录、命名、行格式、保留策略 | 日志审计实跑无不一致、提示 87 条 |
@@ -512,7 +513,7 @@
 
 ### 7.1 数据族与功能映射
 
-系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.62.1.docx》。
+系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.63.0.docx》。
 
 表 7-1 数据族到功能的映射(样本场实测(2026-09-22))
 
@@ -545,7 +546,7 @@
 
 ### 7.3 与数据要求说明的分工
 
-本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.62.1.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
+本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.63.0.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
 
 ## 8 页面与信息架构需求
 
@@ -627,7 +628,7 @@
 | 运行环境与依赖 | 10 | Python 版本不低于 3.11(实测 3.12.10);九个第三方依赖逐个导入 | 全 OK |
 | 静态质量门 | 4 | 源码可编译 229 个文件;语言包 845 条成对;入口引用闭合 48 条;入口脚本编码守则 | 全 OK |
 | 子进程口径 | 3 | 捕获输出可用;无窗口启动输出进日志;无窗口位已设,标志位 0x8000200 | 全 OK |
-| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.62.1,包名 app_guanlang_v2.62.1.zip);卸载入口两个都在位 | 全 OK |
+| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.63.0,包名 app_guanlang_v2.63.0.zip);卸载入口两个都在位 | 全 OK |
 | 制品与台账审计 | 5 | 反向呼应 3,548 件成立;页面归口检查 21 项;配置统一;日志统一;输入数据放置体检 26,503 件结构合规 | 全 OK |
 | 产物与发布件在位 | 10 | 标准仓、本体对象库、findings、事实契约、门户、仿真合页服务与资料包、三维资产、仿真回放资产、治理清单交付件 | 全 OK |
 | 原始件目录 | 1 | 原始件目录存在(无数据时此项不影响页面) | 全 OK |
@@ -753,7 +754,7 @@
 
 本章有两个词要先定义。「本场」指**当前被选中的场**,由配置选定,不必然等于本文取数的样本风电场;「换场」指从一场切到另一场运行的整套动作(改配置、放数据、重跑、按检查表复验),不是把两场的数据混在一棵树里。本章沿用的占位符与附录 A 一致:`<场>`、`<场站>`、`<机型>` 按实际风电场替换。
 
-本章需求与《数据要求说明_观澜_2.62.1.docx》《系统设计说明_观澜_2.62.1.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
+本章需求与《数据要求说明_观澜_2.63.0.docx》《系统设计说明_观澜_2.63.0.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
 
 取证边界要如实写一句:FR-44 至 FR-51 与 NFR-12 有既有的场配置层、摄入层与场级字段语义登记作依据;FR-52 的跨场横向对比是本次新增的口径要求,其实跑记录与机器守卫随后续版本补齐——本文不谎称已经通过。
 
@@ -832,7 +833,7 @@
 
 | 编号 | 需求 | 判据 | 依据文件 |
 |---|---|---|---|
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.62.1.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.63.0.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
 
 ### 11.9 换场验收需求
 
@@ -975,8 +976,8 @@
 | 场定义与机型与物理约束参数的写法样例 | app_ETL/configs/farms/<场>.yaml |
 | 机型—场站数据契约与字段单位真源 | app_ETL/configs/contracts/<机型>_<场>.yaml 与 app_ETL/configs/canonical/dictionary.yaml |
 | 场级字段语义登记与其它厂商导出形态的对齐 | app_ETL/configs/farms/<场>/field_semantic_registry.yaml |
-| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.62.1.md |
-| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.62.1.md |
+| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.63.0.md |
+| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.63.0.md |
 
 表中 `<场>`、`<场站>`、`<机型>` 为占位符,按实际风电场替换。
 
@@ -1019,4 +1020,4 @@
 | 换场 | — | 从一场切到另一场运行的整套动作:改配置、放数据、重跑、按检查表复验 | 本文 §11.9 |
 | 缺族降级 | — | 某族数据缺失时按缺件如实标注并降低该族功能,不造数、不用他场数字顶替 | 本文 FR-49 |
 | 按场标定 | — | 把阈值、时间窗锚点、限电窗、温度档带宽等场相关参数按本场取值 | 本文 FR-50 |
-| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.62.1.md |
+| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.63.0.md |

BIN
docs/数据要求说明_观澜_2.62.1.docx


BIN
docs/数据要求说明_观澜_2.63.0.docx


+ 6 - 5
docs/版本记录.md

@@ -7,7 +7,7 @@
 <!-- VERSION-LOG:BEGIN -->
 ## 版本号规则(用户令 2026-09-17)
 
-编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.62.1`。
+编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.63.0`。
 
 | 级别 | 含义 |
 |---|---|
@@ -17,12 +17,13 @@
 
 **怎么改**:改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级
 
-**打包文件名** = `app_guanlang_v2.62.1.zip`(规则:`app_guanlang_v<版本号>.zip`)。
+**打包文件名** = `app_guanlang_v2.63.0.zip`(规则:`app_guanlang_v<版本号>.zip`)。
 
-版本号只写在 `src/version.py` 一行(`VERSION = '2.62.1'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
+版本号只写在 `src/version.py` 一行(`VERSION = '2.63.0'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
 
 | 版本 | 日期 | 级别 | 主要变更 | 说明 |
 |---|---|---|---|---|
+| `v2.63.0` | 2026-10-03 | 中 | 前端与 v2.9.2 一致:Vue 采用旧页样式表 + 提供 v2.9.2 原样经典页 | ① 用户令 2026-10-03:「前端必须与 `app_guanlang_v2.9.2` 版本的页面保持一致(布局、样式、字体、字号、图文表)」。两次并行落地,分别解决"像"与"就是":② **A 样式同源(Vue 侧)**:从 v2.9.2 包内 `src/windscada/ui/app.css`(23,199 B)**原样**落盘为 `app_frontEnd/web/src/styles/legacy_app.css` 并在 `main.ts` 末尾引入(作为最终口径)⇒ Vue 产物的 CSS 由 1.9 KB 变为 55.7 KB,工作台各页签继承 v2.9.2 的字体/字号/配色/表格与卡片样式(类名此前已由对拍工具逐字核过)。③ **B 原样经典页(保证逐像素一致)**:把 v2.9.2 包内 `src/` 与 `configs/` 解出,用**它自己的** `src/windscada/ui/build.py` 出页(shell.html + app.css + app.js + charts.js + **语言包**,zh),产物 238,644 B,校验无遗留占位符;放入 `release/web/detail_v2.html`(静态伺服 ⇒ **零路由改动**即可访问),工作台「门户栏目」页签加入口「经典版页面」。数据面复用现有 `/api/*`(网关照旧转 Java 后端),实测 `/api/fleet` 200 ⇒ 经典页可直接工作。④ 远端验证(106.120.102.238):`/web/detail_v2.html` 200 · 238,644 B ✓;`/web/index.html` 200 ✓;`/` 200 ✓;`/api/fleet` 200 ✓。⑤ 说明:Vue 侧为"样式同源 + 类名一致",理论上高度接近;原样经典页则是**同一份源码**,可作逐像素基准与回退视图。 |
 | `v2.62.1` | 2026-10-03 | 小 | 「门户栏目」收敛为在线服务两项;「架构与资料」内嵌四节整页展开 | ① 收尾两项(承 2.62.0 门户四节内嵌):(a)「门户栏目」页签只保留**在线服务**两项(仿真与回放 → `/sim/`、系统状态 → `/ops`),系统架构/方法/经验发现/案例·液压 已迁为「架构与资料」页签,避免重复入口;`pt.note` 文案同步改口径(中英成对)。(b)「架构与资料」的内嵌区由 `max-height: calc(100vh - 260px); overflow:auto` 改为 `max-height: none; overflow: visible` ⇒ **整页展开**,观感回到门户原样(不再套一层内滚动)。② 远端已验证(106.120.102.238):新产物 `index-CK3ivS0h.js` + `index-97FhsCXN.css` 上线,核验其在远端伺服的压缩内容含 `ps-embed` ✓、`max-height:none` ✓、四节锚点 ✓;探活 `/` 200 · `/web/index.html` 200 · `/api/fleet` 200 · `/api/recalc` 200 ✓。 |
 | `v2.62.0` | 2026-10-03 | 中 | 门户四节原样内嵌进工作台(样式与 v2.9.2 同源)+ 修 Workbench 模板分支未命中 | ① 用户令 2026-10-03(限当日 17:00 前):把门户的 系统架构/方法/经验发现/案例·液压 四节内容迁进工作台,**页面样式、文图颜色必须与 `app_guanlang_v2.9.2` 一致**。② 做法(保证"同源"而非"近似"):脚本直接从 **`app_guanlang_v2.9.2.zip` 包内的 `release/portal.html`** 抽出· 门户 CSS(33.4 KB,8 个 `<style>` 块原样)与 **四节 HTML 原文**(3,043 / 2,965 / 4,928 / 6,193 B,内联图片无需外链),写入 `app_frontEnd/web/src/lib/portalSections.ts`;新增 `views/PortalSections.vue`:四个子选项卡(系统架构/方法/经验发现/案例·液压)+ `v-html` 内嵌 + 门户 CSS 原样 ⇒ 样式/颜色/文图与 v2.9.2 门户逐像素同源。③ 页签:`lib/tabs.ts` 增补 `portal_sec`(「架构与资料」),`i18n/{zh,en}.ts` 各增文案,`Workbench.vue` 接线。④ **实逮并修的一处隐患**:`Workbench.vue` 的模板分支缩进是 **2 空格**,而我前三次(P14 documents/recalc、P15 portal、P16 portal_sec)补分支时锚点写成 6 空格 ⇒ **三次都静默未命中**,那几个页签一直落到"尚未迁移"占位。本轮修正锚点、一次补齐 4 个分支;重建后自检 bundle 含 `ps-embed` / `case_hydraulic` / `delivery_docs` ✓。⑤ 远端部署与验证(106.120.102.238,静态件即时生效):远端 `index.html` 引用新包 `index-etN9Sux4.js`(193.4 KB),其内容含 `ps-embed` 与 `case_hydraulic` ✓;探活 `/` 200 · `/web/index.html` 200 · `/api/fleet` 200 · `/api/delivery_docs` 200 ✓。 |
 | `v2.61.0` | 2026-10-03 | 中 | 工作台补「门户栏目」页签(对齐重构前门户 11 个栏目);记档一次失败的网关路由尝试 | ① 用户反馈「`/` 与重构前有差异」——差异查明:重构前门户有 **11 个栏目**(总览 · 系统架构 · 方法 · 经验发现 · 案例·液压 · 振动·CMS · 仿真与回放 · 交付文档 · 系统状态 · 数据重算 + 仿真台入口),入口改 Vue 后工作台只覆盖 4 个(总览/振动·CMS/交付文档/数据重算)。② 补齐:新增工作台页签「门户栏目」(`views/Portal.vue`)列出其余 6 项 —— 系统架构/方法/经验发现/案例·液压 指向**门户存档页**(由 Python 网关 28086 伺服,链接用运行时 host 拼 `http://<host>:28086/#<anchor>`,实测该地址 200 · 20,186,279 B ✓);仿真与回放 → `/sim/`(仿真台 18791);系统状态 → `/ops`(运维台)。③ **一次失败的尝试(如实记档)**:我先用网关 `application.yml` 加 `Path=/portal/**` 路由并配了网关侧控制器想直接伺服存档页,结果 **Java 网关启动失败**(yml 路由插入位置/格式不当)⇒ 入口一度中断;已 `git checkout` 回滚 yml、把回滚后的 yml 部署到远端 classpath(`target/classes/application.yml`——Spring Boot 读的是它,不是 `src/main/resources`),重启 `guanlan` 服务后入口恢复(`/` 200 · `X-Portal-Source: vue` · `/api/fleet` 200 · `/ops` 200)。教训:改网关路由必须**按 YAML 结构**改并先本地解析校验,不能文本插入;且两端 yml 位置不同(源 vs classpath)。④ 附:后端新增的 `PortalArchiveController` 保留(在 28120 上提供 `/portal`,不参与入口链路,无副作用)。 |
@@ -132,8 +133,8 @@
 
 ## 本次交付
 
-* 版本:`v2.62.1`(观澜·如东样板 v2)
-* 交付包:`app_guanlang_v2.62.1.zip`
+* 版本:`v2.63.0`(观澜·如东样板 v2)
+* 交付包:`app_guanlang_v2.63.0.zip`
 * 服务名:`guanlan`(Windows 服务 / systemd 单元)
 * 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`
 <!-- VERSION-LOG:END -->

BIN
docs/系统设计说明_观澜_2.62.1.docx → docs/系统设计说明_观澜_2.63.0.docx


BIN
docs/需求分析_观澜_2.62.1.docx → docs/需求分析_观澜_2.63.0.docx


+ 1387 - 0
release/detail_v2.html

@@ -0,0 +1,1387 @@
+<!doctype html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>观澜 · 风电数据分析与决策支持</title>
+<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSJHdWFubGFuIHdpbmQgaW50ZWxsaWdlbmNlIj4KICA8cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSIxNCIgZmlsbD0iIzA3MGIxMSIvPgogIDxjaXJjbGUgY3g9IjQ4IiBjeT0iMTYiIHI9IjEyIiBmaWxsPSIjNDNjNmQ2IiBvcGFjaXR5PSIwLjE4Ii8+CiAgPHBhdGggZD0iTTEyIDQ2YzgtNiAxNy03IDI3LTMgNSAyIDkgMiAxMy0xIiBmaWxsPSJub25lIiBzdHJva2U9IiM0M2M2ZDYiIHN0cm9rZS13aWR0aD0iMy4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9IjAuODIiLz4KICA8cGF0aCBkPSJNMzIgMTd2MzAiIHN0cm9rZT0iI2UyZWFmMyIgc3Ryb2tlLXdpZHRoPSIzLjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjMyIiBjeT0iMjUiIHI9IjQuMiIgZmlsbD0iI2UyZWFmMyIvPgogIDxwYXRoIGQ9Ik0zMiAyNSAyMCAxN00zMiAyNWwxNC02TTMyIDI1bDcgMTUiIHN0cm9rZT0iIzQzYzZkNiIgc3Ryb2tlLXdpZHRoPSIzLjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxwYXRoIGQ9Ik00OSA0NWg0TTExIDUyaDQyIiBzdHJva2U9IiNjZjkyMzYiIHN0cm9rZS13aWR0aD0iMi4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9IjAuNzUiLz4KPC9zdmc+Cg==" type="image/svg+xml">
+<style>%CSS%</style>
+</head>
+<body data-lang="zh">
+<div class="wrap">
+  <header class="top" id="top"></header>
+  <nav class="tabs" id="tabs"></nav>
+  <main id="root"><div class="panel"><div class="skel" style="width:38%"></div><div class="skel" style="width:72%;margin-top:9px"></div><div class="skel" style="width:56%;margin-top:9px"></div></div></main>
+  <footer id="foot" class="small muted" style="margin-top:36px"></footer>
+</div>
+<script>window.__L=%LANGPACK%;</script>
+<script>%CHARTS%</script>
+<script>%JS%</script>
+<script>
+/* 观澜 图元 —— 从经典页 CHART_JS 搬出的 7 个 SVG 图元, 文案全部走语言包。
+ * 用法: const CH = window.__charts({ t, fmt, esc, dv, EN });  (app.js 里调用, 同一份 ctx)
+ * 输入形状与经典 /api/fleet 一致 (faults.quad / faults.monthly / control.energy / rel.表 / faults.pareto_n)。
+ * 设计: 海蓝 = 主, 红 = 需注意, 灰 = 外部/本底; 无动画。
+ */
+window.__charts = function (ctx) {
+  'use strict';
+  const { t, fmt, esc, dv, EN } = ctx;
+  // ★SVG 里不能用 dv(): 它返回 HTML <span>, SVG 不认这个元素, 原文标注会静默失效 (英文点检实逮)。
+  //   图元里的数据字符串走**纯文本**通道, 由 SVG 元素自身带 class="verbatim" 承担标注与计数。
+  const CJK = /[㐀-鿿]/;
+  const isV = (x) => EN && CJK.test(String(x == null ? '' : x));
+  const plain = (x) => { x = ctx.displayText ? ctx.displayText(x) : (x == null ? '' : String(x)); if (isV(x) && ctx.verb) ctx.verb(); return x; };
+  const ttl = (x) => { const label = plain(x); return `<title${isV(label) ? ' class="verbatim"' : ''}>${esc(label)}</title>`; };
+  const svg = (W, H, body) => `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto" role="img">${body}</svg>`;
+  const txt = (x, y, s, o = {}) => `<text x="${(+x).toFixed(1)}" y="${(+y).toFixed(1)}" font-size="${o.fs || 9}" fill="${o.fill || 'var(--muted)'}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''}${o.op ? ` opacity="${o.op}"` : ''}${o.w ? ` font-weight="${o.w}"` : ''}${o.tr ? ` transform="${o.tr}"` : ''}${o.cls ? ` class="${o.cls}"` : ''}>${s}</text>`;
+  // 数据字符串进 SVG 文本: 转义 + (英文模式下含中文则) 标注
+  const dtxt = (x, y, raw, o = {}) => { const p = plain(raw); return txt(x, y, esc(p), isV(p) ? Object.assign({}, o, { cls: 'verbatim' }) : o); };
+  const LOSS = { '限电·绑定': 'loss.curtail_bound', '限电命令·未绑定': 'loss.curtail_unbound', '停机(调度令)': 'loss.dispatch', '停机': 'loss.stop', '正常发电': 'loss.normal', '低风待机': 'loss.lowwind' };
+  const lossName = (k) => LOSS[k] ? t(LOSS[k]) : plain(k);
+
+  // 故障四象限: 条数 × 单次中位时长 (对数); 分界 = 条数中位 × 600s (一个 SCADA 采样格, 物理刻度)
+  function quadChart(items) {
+    const it = (items || []).filter(x => x && x.n > 0 && x.med > 0);
+    if (it.length < 3) return `<p class="small muted">${t('ch.quad.few')}</p>`;
+    const W = 720, H = 330, ML = 58, MR = 16, MB = 44, MT = 16;
+    const lx = x => Math.log10(x), xs = it.map(d => lx(d.n)), ys = it.map(d => lx(d.med));
+    const x0 = Math.min(...xs) - .15, x1 = Math.max(...xs) + .15, y0 = Math.min(...ys) - .15, y1 = Math.max(...ys) + .15;
+    const PX = v => ML + (lx(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (lx(v) - y0) / (y1 - y0) * (H - MT - MB);
+    const srt = it.map(d => d.n).sort((a, b) => a - b), nmid = srt[Math.floor(srt.length / 2)];
+    const cx = PX(nmid), cy = PY(600), mt = Math.max(...it.map(d => d.nt));
+    const pts = it.map(d => { const r = 3 + Math.sqrt(d.nt / mt) * 7, hot = d.n >= nmid && d.med >= 600, c = hot ? 'var(--critical)' : 'var(--accent)';
+      return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity=".5" stroke="${c}" stroke-width="1">${ttl(`${d.code} ${plain(d.k)}\n` + t('ch.quad.tip', { n: d.n, med: d.med, h: d.h, nt: d.nt }))}</circle>`; }).join('');
+    const lab = it.filter(d => d.n >= nmid || d.med >= 600).slice(0, 8).map(d => txt(PX(d.n) + 9, PY(d.med) + 3, esc(d.code))).join('');
+    const qn = (x, y, a, b) => txt(x, y, a, { fs: 10, op: .85 }) + txt(x, y + 12, b, { fs: 8.5, op: .6 });
+    const tick = v => v >= 3600 ? (v / 3600) + 'h' : (v >= 60 ? (v / 60) + 'min' : v + 's');
+    const yt = [10, 60, 600, 3600, 36000].filter(v => lx(v) >= y0 && lx(v) <= y1).map(v => `<line x1="${ML}" y1="${PY(v).toFixed(1)}" x2="${W - MR}" y2="${PY(v).toFixed(1)}" stroke="var(--line)" stroke-opacity=".4"/>` + txt(ML - 6, PY(v) + 3, tick(v), { anchor: 'end' })).join('');
+    const xt = [1, 10, 100, 1000, 10000].filter(v => lx(v) >= x0 && lx(v) <= x1).map(v => txt(PX(v), H - MB + 14, v, { anchor: 'middle' })).join('');
+    const mid = MT + (H - MB - MT) / 2;
+    return svg(W, H, `${yt}${xt}
+      <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
+      <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
+      ${qn(ML + 6, MT + 12, t('ch.quad.q1'), t('ch.quad.q1s'))}${qn(W - MR - 130, MT + 12, t('ch.quad.q2'), t('ch.quad.q2s'))}
+      ${qn(ML + 6, H - MB - 18, t('ch.quad.q3'), t('ch.quad.q3s'))}${qn(W - MR - 130, H - MB - 18, t('ch.quad.q4'), t('ch.quad.q4s'))}
+      ${pts}${lab}
+      ${txt(ML + (W - MR - ML) / 2, H - 6, t('ch.quad.xlab'), { fs: 9.5, anchor: 'middle' })}
+      ${txt(14, mid, t('ch.quad.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
+  }
+
+  // 月度故障率: 柱 = 日均 (可比); 残月灰虚线柱; 标总条数 (原始事实)
+  function rateChart(mo) {
+    const ms = mo.months || [], vs = mo.vals || [], cov = mo.cov || ms.map(() => 1), pd = mo.per_day || [];
+    const W = 720, H = 250, ML = 48, MR = 14, MB = 52, MT = 18, n = ms.length || 1;
+    const mx = Math.max(...pd.filter(x => x != null), 1), bw = (W - ML - MR) / n;
+    let bars = '', lab = '';
+    ms.forEach((m, i) => { const v = pd[i], part = cov[i] < .5; if (v == null) return;
+      const hh = v / mx * (H - MT - MB), bx = ML + i * bw + bw * .16, by = H - MB - hh, cxm = bx + bw * .34;
+      bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="${part ? 'var(--muted)' : 'var(--accent)'}" fill-opacity="${part ? .32 : .8}" stroke="${part ? 'var(--warning)' : 'none'}" stroke-dasharray="${part ? '3 2' : ''}"><title>${t('ch.rate.tip', { m, v, n: vs[i], cov: (cov[i] * 100).toFixed(0) })}</title></rect>`;
+      lab += txt(cxm, by - 4, v, { anchor: 'middle' }) + txt(cxm, H - MB + 13, t('ch.rate.month', { m: m.slice(5) }), { anchor: 'middle' })
+        + txt(cxm, H - MB + 25, t('ch.rate.n', { n: vs[i] }), { fs: 8, op: .65, anchor: 'middle' })
+        + (part ? txt(cxm, H - MB + 37, t('ch.rate.days', { d: Math.round(cov[i] * 30) }), { fs: 8, fill: 'var(--warning)', anchor: 'middle' }) : ''); });
+    const yt = [0, .5, 1].map(f => txt(ML - 6, H - MB - f * (H - MT - MB) + 3, Math.round(mx * f), { anchor: 'end' })).join('');
+    const mid = MT + (H - MB - MT) / 2;
+    return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
+      ${bars}${lab}${yt}${txt(14, mid, t('ch.rate.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
+  }
+
+  // 能量瀑布: 理论可发 → 逐段扣损 (按损失降序) → 实际上网
+  function waterfall(e) {
+    if (!e || !e.theo) return '';
+    const W = 720, H = 300, ML = 8, MR = 8, MT = 30, MB = 64, items = (e.items || []).filter(x => x.loss > 0);
+    const cols = items.length + 2, cw = (W - ML - MR) / cols, sc = (H - MT - MB) / e.theo;
+    let run = e.theo, h = '', i = 0;
+    const bar = (idx, y, hh, fill, op) => `<rect x="${(ML + idx * cw + cw * .14).toFixed(1)}" y="${y.toFixed(1)}" width="${(cw * .72).toFixed(1)}" height="${Math.max(hh, 1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
+    const lab = (idx, y, nm, v, c) => txt(ML + idx * cw + cw * .5, y - 6, v, { fs: 10, fill: c || 'var(--ink)', anchor: 'middle', w: 600 }) + txt(ML + idx * cw + cw * .5, H - MB + 14, nm, { fs: 8.5, anchor: 'middle' });
+    h += bar(0, H - MB - e.theo * sc, e.theo * sc, 'var(--accent)', .45) + lab(0, H - MB - e.theo * sc, t('ch.wf.theo'), fmt(e.theo));
+    items.forEach(x => { i++; const top = H - MB - run * sc, hh = x.loss * sc; run -= x.loss; const nm = lossName(x.k);
+      h += bar(i, top, hh, 'var(--critical)', .55) + `<line x1="${(ML + (i - 1) * cw + cw * .86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML + i * cw + cw * .14).toFixed(1)}" y2="${top.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity=".6"/>`
+        + lab(i, top, esc(nm.length > 12 ? nm.slice(0, 11) + '…' : nm), '−' + fmt(x.loss), 'var(--critical)')
+        + ttl(t('ch.wf.tip', { nm, loss: fmt(x.loss), pct: (x.loss / e.theo * 100).toFixed(1), h: fmt(x.h) })); });
+    h += bar(cols - 1, H - MB - e.act * sc, e.act * sc, 'var(--good)', .6) + lab(cols - 1, H - MB - e.act * sc, t('ch.wf.act'), fmt(e.act), 'var(--good)');
+    return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/>${txt(ML, 14, 'MWh', { fs: 9.5 })}${h}`);
+  }
+
+  // 可靠性 × 可维修性象限: x = MTBF (对数), y = MDT, 面积 = 累计停机
+  function ramChart(tb, partName) {
+    const it = (tb || []).filter(x => x.MTBF_h && x.MDT_h);
+    if (it.length < 3) return `<p class="small muted">${t('ch.ram.few')}</p>`;
+    const W = 720, H = 340, ML = 56, MR = 112, MB = 48, MT = 16;
+    const lg = v => Math.log10(v), xs = it.map(d => lg(d.MTBF_h)), ys = it.map(d => d.MDT_h);
+    const x0 = Math.min(...xs) - .08, x1 = Math.max(...xs) + .08, y0 = 0, y1 = Math.max(...ys) * 1.12;
+    const PX = v => ML + (lg(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (v - y0) / (y1 - y0) * (H - MT - MB);
+    const md = a => { const z = a.slice().sort((p, q) => p - q); return z[Math.floor(z.length / 2)]; };
+    const mMT = md(it.map(d => d.MTBF_h)), mMD = md(ys), cx = PX(mMT), cy = PY(mMD), mh = Math.max(...it.map(d => d.停机时长h));
+    const nm = d => plain(partName ? partName(d.部件) : d.部件);
+    const pts = it.map(d => { const r = 4 + Math.sqrt(d.停机时长h / mh) * 13, ext = d.外部, hot = d.MTBF_h < mMT && d.MDT_h >= mMD, c = ext ? 'var(--muted)' : (hot ? 'var(--critical)' : 'var(--accent)');
+      return `<circle cx="${PX(d.MTBF_h).toFixed(1)}" cy="${PY(d.MDT_h).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity="${ext ? .3 : .42}" stroke="${c}" stroke-width="1.2" stroke-dasharray="${ext ? '3 2' : ''}">${ttl(t('ch.ram.tip', { p: nm(d), mtbf: d.MTBF_h, mdt: d.MDT_h, h: d.停机时长h, n: d.事件数, u: d.影响台数 }) + (ext ? t('ch.ram.tip_ext') : ''))}</circle>`
+        + dtxt(PX(d.MTBF_h) + r + 4, PY(d.MDT_h) + 3.5, plain(nm(d)).slice(0, EN ? 14 : 7), { fs: 9.5, fill: ext ? 'var(--muted)' : 'currentColor' }); }).join('');
+    const qn = (x, y, a, s1, s2) => txt(x, y, s1, { fs: 10, anchor: a, op: .9 }) + txt(x, y + 12, s2, { fs: 8.5, anchor: a, op: .62 });
+    const xt = [1000, 2000, 5000, 10000, 20000].filter(v => lg(v) >= x0 && lg(v) <= x1).map(v => `<line x1="${PX(v).toFixed(1)}" y1="${MT}" x2="${PX(v).toFixed(1)}" y2="${H - MB}" stroke="var(--line)" stroke-opacity=".3"/>` + txt(PX(v), H - MB + 14, v >= 1000 ? (v / 1000) + 'k' : v, { anchor: 'middle' })).join('');
+    const yt = [0, 5, 10, 15, 20].filter(v => v <= y1).map(v => txt(ML - 6, PY(v) + 3, v, { anchor: 'end' })).join('');
+    const mid = MT + (H - MB - MT) / 2;
+    return svg(W, H, `${xt}${yt}<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
+      <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
+      <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
+      ${qn(ML + 6, MT + 12, 'start', t('ch.ram.q1'), t('ch.ram.q1s'))}${qn(W - MR - 6, MT + 12, 'end', t('ch.ram.q2'), t('ch.ram.q2s'))}
+      ${qn(ML + 6, H - MB - 16, 'start', t('ch.ram.q3'), t('ch.ram.q3s'))}${qn(W - MR - 6, H - MB - 16, 'end', t('ch.ram.q4'), t('ch.ram.q4s'))}
+      ${pts}${txt(ML + (W - MR - ML) / 2, H - 8, t('ch.ram.xlab'), { fs: 9.5, anchor: 'middle' })}
+      ${txt(13, mid, t('ch.ram.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 13 ${mid.toFixed(1)})` })}
+      <circle cx="${W - MR + 16}" cy="${MT + 40}" r="5" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>${txt(W - MR + 26, MT + 44, t('ch.ram.equip'))}
+      <circle cx="${W - MR + 16}" cy="${MT + 58}" r="5" fill="var(--muted)" fill-opacity=".3" stroke="var(--muted)" stroke-dasharray="3 2"/>${txt(W - MR + 26, MT + 62, t('ch.ram.ext'))}
+      ${txt(W - MR + 6, MT + 84, t('ch.ram.area'), { fs: 8.5 })}${txt(W - MR + 6, MT + 98, t('ch.ram.median'), { fs: 8.5 })}`);
+  }
+
+  // 归因覆盖条: 已归因 / 未归类 / 无记录
+  function attribBar(tb, tot) {
+    const nore = (tb || []).find(x => x.部件 === '无记录停机'), unk = nore ? nore.停机时长h : 0;
+    const un2 = (tb || []).filter(x => x.部件 === '未归类').reduce((a, b) => a + b.停机时长h, 0), ok = tot - unk - un2, W = 720, H = 54;
+    const seg = (x, w, c, op) => `<rect x="${x.toFixed(1)}" y="14" width="${Math.max(w, 1).toFixed(1)}" height="22" rx="3" fill="${c}" fill-opacity="${op}"/>`;
+    const w1 = ok / tot * W, w2 = un2 / tot * W, w3 = unk / tot * W;
+    return svg(W, H, `${seg(0, w1, 'var(--accent)', .55)}${seg(w1, w2, 'var(--warning)', .5)}${seg(w1 + w2, w3, 'var(--critical)', .5)}
+      ${txt(4, 10, t('ch.attrib.ok', { h: Math.round(ok), p: (ok / tot * 100).toFixed(0) }))}
+      ${txt(w1 + w2 + w3 - 2, 10, t('ch.attrib.none', { h: Math.round(unk), p: (unk / tot * 100).toFixed(0) }), { fill: 'var(--critical)', anchor: 'end' })}
+      ${txt(w1 + w2 / 2, 50, t('ch.attrib.uncls', { h: Math.round(un2) }), { fill: 'var(--warning)', anchor: 'middle' })}`);
+  }
+
+  // 帕累托: 降序条 + 累计折线 + 80% 线
+  function paretoChart(items, unit, note) {
+    const it = (items || []).filter(x => x && x.v > 0);
+    if (!it.length) return `<p class="small muted">${t('ch.pareto.none')}</p>`;
+    const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14, n = it.length, tot = it.reduce((a, x) => a + x.v, 0), mx = Math.max(...it.map(x => x.v)), bw = (W - ML - MR) / n;
+    const short = k => { k = plain(k); const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
+    let cum = 0, bars = '', line = [], dots = '', k80 = null;
+    it.forEach((x, i) => { const hh = x.v / mx * (H - MT - MB), bx = ML + i * bw + bw * .14, by = H - MB - hh;
+      bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .72).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? .85 : .45}">${ttl(`${plain(x.k)}: ${fmt(x.v)}${unit}`)}</rect>`;
+      cum += x.v; const pct = cum / tot; if (k80 === null && pct >= .8) k80 = i + 1;
+      const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB); line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
+      dots += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="var(--warning)">${ttl(t('ch.pareto.cumtip', { k: plain(x.k), p: (pct * 100).toFixed(0) }))}</circle>`; });
+    const y80 = MT + .2 * (H - MT - MB);
+    const xt = it.map((x, i) => dtxt(ML + i * bw + bw / 2, H - MB + 13, short(x.k), { anchor: 'middle' })).join('');
+    const yt = [0, .5, 1].map(f => txt(ML - 5, H - MB - f * (H - MT - MB) + 3, fmt(Math.round(mx * f)), { anchor: 'end' })).join('') + [0, .5, 1].map(f => txt(W - MR + 5, MT + (1 - f) * (H - MT - MB) + 3, Math.round(f * 100) + '%', { fill: 'var(--warning)' })).join('');
+    const top3 = it.slice(0, 3).map(x => `<b>${isV(short(x.k)) ? `<span class="verbatim">${esc(plain(short(x.k)))}</span>` : esc(short(x.k))}</b> ${dv(plain(x.k).replace(/^[0-9]+ */, '').slice(0, 18))}`).join(' · ');
+    return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>${bars}
+      <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W - MR}" y2="${y80.toFixed(1)}" stroke="var(--warning)" stroke-dasharray="4 3" stroke-opacity=".7"/>
+      ${txt(W - MR - 3, y80 - 4, t('ch.pareto.cum80'), { fill: 'var(--warning)', anchor: 'end' })}
+      <polyline points="${line.join(' ')}" fill="none" stroke="var(--warning)" stroke-width="1.5"/>${dots}${xt}${yt}
+      ${txt(ML + (W - MR - ML) / 2, H - 3, t('ch.pareto.xlab'), { fs: 8.5, anchor: 'middle' })}`)
+      + `<p class="small muted" style="margin-top:5px;line-height:1.6">${k80 ? `<b>${t('ch.pareto.key', { k: k80 })}</b> (${t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })})` : t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })}<br>${t('ch.pareto.top3')}${top3}${note ? '<br>' + note : ''}</p>`;
+  }
+
+  // 损失责任分账: 交易与调度侧 / 设备侧 / 运行侧
+  function blameBar(e) {
+    const cat = k => (k.indexOf('限电') >= 0 || k.indexOf('调度') >= 0) ? 0 : (k === '停机' ? 1 : 2);
+    const NM = [t('ch.blame.n1'), t('ch.blame.n2'), t('ch.blame.n3')], NOTE = [t('ch.blame.d1'), t('ch.blame.d2'), t('ch.blame.d3')], CL = ['var(--accent)', 'var(--critical)', 'var(--muted)'];
+    const g = [0, 0, 0]; (e.items || []).forEach(x => { g[cat(x.k)] += x.loss; });
+    const tot = g.reduce((a, b) => a + b, 0) || 1, W = 500, H = 44; let x = 0, seg = '';
+    g.forEach((v, i) => { const w = v / tot * W;
+      seg += `<rect x="${x.toFixed(1)}" y="12" width="${Math.max(w, 1).toFixed(1)}" height="26" rx="3" fill="${CL[i]}" fill-opacity=".55"><title>${NM[i]}: ${fmt(Math.round(v))} MWh</title></rect>` + (w > 46 ? txt(x + w / 2, 29, (v / tot * 100).toFixed(0) + '%', { fs: 10.5, fill: 'currentColor', anchor: 'middle', w: 600 }) : ''); x += w; });
+    const leg = g.map((v, i) => `<div style="display:flex;align-items:baseline;gap:7px;margin-top:5px"><i style="flex:none;width:9px;height:9px;border-radius:2px;background:${CL[i]};opacity:.55"></i><span style="font-weight:600">${NM[i]}</span><span class="num">${fmt(Math.round(v))} MWh</span><span class="small muted">${NOTE[i]}</span></div>`).join('');
+    return svg(W, H, `${txt(0, 8, t('ch.blame.title', { tot: fmt(Math.round(tot)) }))}${seg}`) + leg;
+  }
+
+
+  // 多线月度图 (季节性): 至多 4 条序列, 同一 y 轴; 断点不连线
+  function multiline(f) {
+    const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
+    const W = 620, H = 250, ML = 52, MB = 34, cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
+    const all = []; (f.series || []).forEach(s => s.vals.forEach(v => { if (v != null && v === v) all.push(v); })); (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
+    if (!all.length) return '';
+    let lo = f.zero === false ? Math.min(...all) : Math.min(0, ...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; } const pad = (hi - lo) * .08; hi += pad; if (f.zero === false) lo -= pad;
+    const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - 14);
+    const line = (vals, c) => { let segs = [], cur = []; vals.forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
+      return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="1.8"/>`).join(''); };
+    let body = (f.series || []).map((s, k) => line(s.vals, cols[k % 4])).join('');
+    const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
+    for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
+    let yt = ''; for (let k = 0; k < 5; k++) { const v = lo + (hi - lo) * k / 4; yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
+    (f.thresholds || []).forEach(th => { if (!th || th.v == null) return; body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
+    if (f.xlab) body += dtxt((ML + W) / 2, H - 1, f.xlab, { fs: 10, anchor: 'middle' });
+    const leg = `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + (f.series || []).map((s, k) => `<span><i style="background:${cols[k % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>`;
+    return svg(W, H, `${yt}${body}${xt}`) + leg;
+  }
+
+
+  // 封顶参数落位: 两轴 (满发 kW / 转速 rpm) 上各自的离散分组, 圆面积 ∝ 台数
+  function capAxis(P, W9) {
+    const W = 500, H = 158, ML = 54, MR = 34;
+    const row = (y, spec, label) => { const gs = Object.entries(spec.分组 || {}).map(([k, v]) => ({ v: parseFloat(k), n: v, raw: k })); if (!gs.length) return '';
+      const lo = Math.min(...gs.map(g => g.v)), hi = Math.max(...gs.map(g => g.v)), pad = (hi - lo) * .22 || 1, x0 = lo - pad, x1 = hi + pad, PX = v => ML + (v - x0) / (x1 - x0) * (W - ML - MR), mn = Math.max(...gs.map(g => g.n)), med = spec['中位'];
+      return txt(ML - 6, y + 3, label, { fs: 9.5, anchor: 'end' }) + `<line x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}" stroke="var(--line)"/><line x1="${PX(med).toFixed(1)}" y1="${y - 24}" x2="${PX(med).toFixed(1)}" y2="${y + 18}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".55"/>`
+        + txt(PX(med), y + 28, t('ch.cap.median', { v: med }), { fs: 8.5, anchor: 'middle' })
+        + gs.map(g => { const r = 5 + Math.sqrt(g.n / mn) * 9; return `<circle cx="${PX(g.v).toFixed(1)}" cy="${y}" r="${r.toFixed(1)}" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>` + txt(PX(g.v), y - r - 5, esc(g.raw), { anchor: 'middle', fill: 'currentColor' }) + txt(PX(g.v), y + 3.5, g.n, { anchor: 'middle', fill: 'var(--panel)', w: 600 }); }).join(''); };
+    return svg(W, H, `${row(44, P || {}, t('ch.cap.p'))}${row(120, W9 || {}, t('ch.cap.w'))}${txt(W - MR, 12, t('ch.cap.legend'), { fs: 8.5, anchor: 'end' })}`);
+  }
+  // 桨距同档差: 离群台的平均偏 vs 全场同档差中位
+  function betaBar(b, unitNo) {
+    const outs = b.离群 || [], med = b['全场同档差中位'] || 0, W = 500, H = 34 + outs.length * 26, ML = 52, MR = 58;
+    const mx = Math.max(med, ...outs.map(o => o.平均偏)) * 1.18 || 1, PX = v => ML + v / mx * (W - ML - MR);
+    const rows = outs.map((o, i) => { const y = 30 + i * 26; return txt(ML - 6, y + 10, esc(unitNo(o.t)), { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 1}" width="${(PX(o.平均偏) - ML).toFixed(1)}" height="13" rx="2" fill="var(--warning)" fill-opacity=".62"/>` + txt(PX(o.平均偏) + 5, y + 11, o.平均偏 + '°', { fill: 'var(--warning)' }); }).join('');
+    return svg(W, H, `<line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(med), 12, t('ch.beta.median', { v: med }), { fs: 8.5, fill: 'var(--good)' })}<line x1="${ML}" y1="18" x2="${ML}" y2="${H - 4}" stroke="var(--line)"/>${rows}`);
+  }
+  // 尺子巡检: 各轴 极差/σ 对 38 台正态抽样期望 3.7σ
+  function rulerBar(rl) {
+    const ks = Object.entries(rl || {}), W = 500, H = 26 + ks.length * 30, ML = 96, MR = 76, NORM = 3.7;
+    const mxr = Math.max(NORM * 1.35, ...ks.map(([, v]) => v.极差 / v.σ)), PX = v => ML + v / mxr * (W - ML - MR);
+    const rows = ks.map(([k, v], i) => { const y = 22 + i * 30, ratio = v.极差 / v.σ, wide = ratio > NORM;
+      return dtxt(ML - 6, y + 11, k, { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 2}" width="${(PX(ratio) - ML).toFixed(1)}" height="12" rx="2" fill="${wide ? 'var(--warning)' : 'var(--accent)'}" fill-opacity=".55"/>` + txt(PX(ratio) + 5, y + 11, t('ch.ruler.row', { r: ratio.toFixed(1), s: v.σ })); }).join('');
+    return svg(W, H, `<line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(NORM), 10, t('ch.ruler.norm'), { fs: 8.5, fill: 'var(--good)', anchor: 'middle' })}${rows}`);
+  }
+
+
+  // ── 通用图元 fig(f): 覆盖 /api/turbine · /api/problem 的 5 种 kind ──
+  //   band 本台 vs 全场中位 + 四分位带 · bars 月度条 · xy 两线 · multiline 多线
+  //   vib/vibbar 是经典页的传动链 SVG 与倍中位条, 这里退化为文本 + 表格 (语义不丢)
+  function fig(f) {
+    const k = f.kind;
+    if (k === 'vib') return `<p class="small">${isV(f.text || f.comp) ? `<span class="verbatim">${esc(plain(f.text || f.comp))}</span>` : esc(f.text || f.comp || '')}</p>`;
+    if (k === 'vibbar') {
+      const rows = f.rows || []; if (!rows.length) return '';
+      const sig = v => { const a = Math.abs(v); return a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : a >= 1 ? v.toFixed(2) : a >= .01 ? v.toFixed(3) : Number(v).toExponential(1); };
+      const byM = {}; rows.forEach(r => { (byM[r.指标] = byM[r.指标] || []).push(r); });
+      let h = `<p class="small muted">${t('fig.vibbar_note')}</p>`;
+      for (const [mk, rs] of Object.entries(byM)) {
+        h += `<div class="tblwrap" style="margin-top:8px"><b class="small muted">${dv(mk)}</b><table style="margin-top:2px"><tr><th>${t('fig.point')}</th><th class="r">${t('fig.x_median')}</th><th class="r">${t('fig.this_unit')}</th><th class="r">${t('fig.fleet_median')}</th><th class="r">z</th><th class="r">${t('fig.trend')}</th></tr>`
+          + rs.map(r => { const col = Math.abs(r.z) >= 3 ? 'var(--critical)' : Math.abs(r.z) >= 2 ? 'var(--warning)' : 'var(--good)';
+            return `<tr><td class="small">${dv(r.测点)}</td><td class="r" style="color:${col};font-weight:600">${r.倍中位 != null ? r.倍中位 + '×' : '—'}</td><td class="r small">${sig(r.本台)}</td><td class="r small muted">${sig(r.全场中位)}</td><td class="r small" style="color:${Math.abs(r.z) >= 3 ? 'var(--critical)' : 'var(--muted)'}">${r.z}</td><td class="r small" style="color:${r.六窗比 > 1.15 ? 'var(--warning)' : 'var(--muted)'}">${r.六窗比 != null ? r.六窗比 + '×' : '—'}</td></tr>`; }).join('') + `</table></div>`;
+      }
+      return h;
+    }
+    const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
+    const W = 620, H = 250, ML = 52, MB = 34, MT = 14;
+    const all = [];
+    if (k === 'band') [f.self, f.med, f.q1, f.q3].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
+    else if (k === 'bars') { all.push(0); (f.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }); }
+    else if (k === 'xy') [f.self, f.med].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
+    else (f.series || []).forEach(s => (s.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }));
+    (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
+    if (!all.length) return '';
+    let lo = Math.min(...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; }
+    const pad = (hi - lo) * .08; lo -= pad; hi += pad;
+    const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - MT);
+    const line = (vals, c, w) => { let segs = [], cur = []; (vals || []).forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
+      return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="${w || 1.8}"/>`).join(''); };
+    let body = '';
+    if (k === 'band' && f.q1 && f.q3) { const up = [], dn = [];
+      f.q3.forEach((v, i) => { if (v != null) up.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); });
+      for (let i = f.q1.length - 1; i >= 0; i--) if (f.q1[i] != null) dn.push(`${X(i).toFixed(1)},${Y(f.q1[i]).toFixed(1)}`);
+      if (up.length && dn.length) body += `<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="var(--unknown)" fill-opacity=".18"/>`; }
+    if (k === 'bars') { const bw = Math.max(3, (W - ML - 20) / n * .6), y0 = Y(Math.max(lo, 0));
+      body += (f.vals || []).map((v, i) => (v == null || v !== v) ? '' : `<rect x="${(X(i) - bw / 2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(y0 - Y(v), .8).toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity=".75"/>`).join(''); }
+    if (f.med) body += line(f.med, 'var(--muted)', 1.7);
+    if (f.self) body += line(f.self, 'var(--critical)', 2.1) + (f.self || []).map((v, i) => (v == null || v !== v) ? '' : `<circle cx="${X(i).toFixed(1)}" cy="${Y(v).toFixed(1)}" r="2.4" fill="var(--critical)"/>`).join('');
+    const cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
+    (f.series || []).forEach((s, i) => { body += line(s.vals, cols[i % 4]); });
+    (f.thresholds || []).forEach(th => { if (th == null || th.v == null) return;
+      body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
+    const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
+    for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
+    let yt = ''; for (let i = 0; i < 5; i++) { const v = lo + (hi - lo) * i / 4;
+      yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
+    const leg = k === 'band' ? txt(W - 190, 12, t('fig.this_unit'), { fill: 'var(--critical)', fs: 10 }) + txt(W - 110, 12, t('fig.fleet_median'), { fs: 10 }) + txt(W - 40, 12, t('fig.quartile'), { fs: 10 })
+      : ((f.series || []).length && (f.series || []).length <= 2 ? f.series.map((s, i) => txt(W - 12 - (f.series.length - 1 - i) * 118, 12, '— ' + esc(plain(s.name)), { fill: cols[i % 4], fs: 10, anchor: 'end' })).join('') : '');
+    const legHtml = ((f.series || []).length > 2) ? `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + f.series.map((s, i) => `<span><i style="background:${cols[i % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>` : '';
+    return svg(W, H, `${yt}${body}${xt}${leg}`) + legHtml;
+  }
+
+  return { quadChart, rateChart, waterfall, ramChart, attribBar, paretoChart, blameBar, lossName, multiline, capAxis, betaBar, rulerBar, fig };
+};
+
+</script>
+<script>
+/* 观澜 新前端 —— 真正的 .js 文件, 不再是 Python 三引号串 (反斜杠那类坑到此为止)。
+ *
+ * 语言: 构建期注入 window.__L (已过 lang.check 硬闸)。这里没有"翻译"这一步 ——
+ *       文案按 key 取, 缺 key 就显示 ⟦key⟧ 并 console.error, 响亮不静默。
+ * 数据: 同一套 /api/*。英文模式带 lang=en 让 API 用 name_en 等权威译名投影;
+ *       正文使用标准词库与逐条译文;未覆盖正文保持可见,来源原文可主动展开。
+ * 设计: docs/design/guanlan_v2.html 六条决定。
+ */
+(function () {
+  'use strict';
+  const L = window.__L || {};
+  const LANG = document.documentElement.lang || 'zh';
+  const EN = LANG === 'en';
+  const RECORD_EN = L.__record_en || {};
+  const REVIEW_REQUIRED = 'Review required';
+  // 中文显示层术语映射: 数据里的内部词 (如「引用同族, 见族主台」就在振动线 handoff 与本体里,
+  // 且 populate.py 等 3 处拿它做匹配) 只在**显示时**换成现场说法, 数据与匹配逻辑一概不动。
+  // 表与后端同源 = configs/terms/display_map.yaml, 经语言包 __display_zh 注入 (已按长度降序)。
+  const DISPLAY_ZH = L.__display_zh || [];
+  const displayText = value => {
+    const raw = String(value == null ? '' : value);
+    let s = EN ? (RECORD_EN[raw] || raw) : raw;
+    if (EN && /\[translation pending\]/i.test(s)) s = s.replace(/\[translation pending\]/ig, REVIEW_REQUIRED);
+    if (!EN) for (const [a, b] of DISPLAY_ZH) if (s.indexOf(a) >= 0) s = s.split(a).join(b);
+    return s;
+  };
+  const CJK = /[㐀-鿿 -〿＀-￯]/;
+
+  function t(k, kw) {
+    let s = L[k];
+    if (s == null) { console.error('lang miss:', k); return '⟦' + k + '⟧'; }
+    if (kw) for (const [a, b] of Object.entries(kw)) s = s.split('{' + a + '}').join(String(b));
+    return s;
+  }
+  const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+  // 数据文本按标准词库精确翻译;未覆盖文本保持可见并计数,不能靠隐藏通过英文检查。
+  let HIDDEN = 0;
+  function dt(s) {
+    s = displayText(s);
+    if (!EN) return s;
+    if (CJK.test(s) || s.includes('[translation pending]')) HIDDEN++; // Keep unresolved text visible so the English audit cannot pass by hiding it.
+    return s;
+  }
+
+  // ── API 枚举 → 语言包 key (API 的枚举值是中文, 有限集合; 这里是唯一的映射点) ──
+  const ST = { '危险': 'bad', '报警': 'warn', '不可判': 'unk', '良好': 'ok', '优秀': 'ex' };
+  const stateKey = value => ST[value] || ({critical:'bad', alarm:'warn', undeterminable:'unk', indeterminate:'unk', 'not evaluable':'unk', good:'ok', excellent:'ex'}[String(value || '').toLowerCase()]);
+  const ST_RANK = { bad: 0, warn: 1, unk: 2, ok: 3, ex: 4 };
+  const TIER = { '定论': 'confirmed', '准定论·预警': 'near', '候选·新发': 'candidate_new', '候选': 'candidate',
+                 '候选·记基线': 'candidate_baseline', '参考': 'reference', 'INSUFFICIENT': 'insufficient',
+                 '正常': 'normal', '撤回': 'withdrawn' };
+  const TIER_CLS = { candidate_new: 'candidate', candidate_baseline: 'candidate', normal: 'reference' };
+  const STEP = { '证据': 'evidence', '机制': 'mechanism', '判级': 'rating', '排期': 'schedule', '动作': 'action', '验收': 'acceptance' };
+  const SYS9 = ['叶片与叶根', '变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网'];
+  const SYS_KEY = { '叶片与叶根': 'blade', '变桨': 'pitch', '偏航': 'yaw', '齿轮箱': 'gearbox', '发电机': 'generator',
+                    '主轴承': 'main_bearing', '变流器': 'converter', '塔架与基础': 'tower', '主控与传感网': 'control',
+                    '主轴': 'main_bearing', '其他': 'other' };   // fus.事件 用「主轴」作键 (英文点检逮: 标题空白)
+  // 系统名查不到时按**原文标注**而非隐藏 —— 隐藏会让标题整个空掉
+  // 反查表: **正名优先, 别名只补空位**。用 Object.fromEntries 直接反转会让后写的别名覆盖正名
+  // (「主轴」在「主轴承」之后 ⇒ main_bearing 反查成「主轴」, 而数据键是「主轴承」⇒ 详情页取不到数据,
+  //  显示"全场 1 台 / 四项全 0"; 2026-09-08 xzy 测试报告逮)。
+  const SYS_ZH = Object.fromEntries(SYS9.map(zh => [SYS_KEY[zh], zh]));
+  Object.entries(SYS_KEY).forEach(([zh, key]) => { if (!(key in SYS_ZH)) SYS_ZH[key] = zh; });
+  const sysSlug = value => SYS_KEY[value] || value;
+  const sysFromSlug = value => SYS_ZH[value] || value;
+  const sysName = (value) => { const key = SYS_KEY[value] || value; return SYS_ZH[key] ? t('sys.' + key) : dv(value); };
+  const WIN_PRESETS = [['近30日', 'win.30d'], ['近90日', 'win.90d'], ['2026年', 'win.2026'], ['2026H1', 'win.2026H1'], ['2025H2', 'win.2025H2'], ['全程', 'win.all']];
+  const WIN_SLUG = { '近30日': '30d', '近90日': '90d', '2026年': '2026', '2026H1': '2026H1', '2025H2': '2025H2', '全程': 'all' };
+  const WIN_ZH = Object.fromEntries(Object.entries(WIN_SLUG).map(([zh, slug]) => [slug, zh]));
+  const winSlug = value => WIN_SLUG[value] || value;
+  const winFromSlug = value => WIN_ZH[value] || value;
+  const winName = (w) => { const p = WIN_PRESETS.find(x => x[0] === w); return p ? t(p[1]) : w; };
+  const TABS = ['overview', 'energy', 'component', 'vibration', 'generation', 'fault', 'decision', 'assistant', 'report', 'system'];
+
+  // ── 状态 ──
+  const Q = new URLSearchParams(location.search);
+  // 快照模式 (window.__WINS 由 snapshot.py 注入): 只列已烤的窗, 默认取第一个已烤窗 —— 给一个必然空白的选项比少给几个更糟
+  const SNAP = typeof window.__SNAP__ !== 'undefined', BAKED = SNAP ? (window.__WINS || []) : null;
+  let WIN = winFromSlug(Q.get('win') || (BAKED ? (BAKED[0] || '2026年') : '2026年'));
+  if (BAKED && !BAKED.includes(WIN)) WIN = BAKED[0] || WIN;
+  let TAB = (location.hash.match(/tab=([a-z]+)/) || [])[1] || 'overview';
+  let D = null;
+
+  function stateChip(zh, cls) { const k = stateKey(zh); return k ? `<span class="chip ${k} ${cls || ''}">${t(stKey(k))}</span>` : (zh ? `<span class="chip">${esc(dt(zh))}</span>` : ''); }
+  function tierChip(zh) { const k = TIER[zh]; if (!k) return ''; return `<span class="tier ${TIER_CLS[k] || k}" title="${esc(t('tier.' + k))}">${t('tier.' + k + '.s')}</span>`; }
+  // 枚举短名 ex ↔ 语言包 key st.excellent (CSS class 用短名, 语言包用全名; 首次浏览器点检即逮 ⟦st.ex⟧)
+  const stKey = (k) => 'st.' + (k === 'ex' ? 'excellent' : k);
+  const unitNo = (wtg) => String(wtg).replace('WTG', '').replace(/^0/, '') + '#';
+  const fmt = (n, d = 0) => (n == null || isNaN(n)) ? '—' : Number(n).toLocaleString(EN ? 'en-US' : 'zh-CN', { maximumFractionDigits: d, minimumFractionDigits: d });
+
+  // ── 顶 + 页签 ──
+  function renderTop(d) {
+    const months = BAKED ? [] : ((d && d.all_months) || []);
+    const isCustom = /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/.test(WIN);
+    const custOpt = isCustom ? `<option value="${esc(WIN)}" selected>${t('win.custom_sel', { a: WIN.split('~')[0], b: WIN.split('~')[1] })}</option>` : '';
+    const opts = `<optgroup label="${t('win.presets')}">` + WIN_PRESETS.filter(([w]) => !BAKED || BAKED.includes(w)).map(([w]) => `<option value="${winSlug(w)}" ${w === WIN ? 'selected' : ''}>${winName(w)}</option>`).join('') + `</optgroup>`
+      + (custOpt ? `<optgroup label="${t('win.custom')}">${custOpt}</optgroup>` : '')
+      + (months.length ? `<optgroup label="${t('win.months')}">` + months.slice().reverse().map(m => `<option value="${m}" ${m === WIN ? 'selected' : ''}>${m}</option>`).join('') + `</optgroup>` : '');
+    // 静态快照下 ?lang= 不起作用 (点了只重载同一文件) —— 用 __PEER 指向对方语言的真实文件。
+    // 没有 peer 就**不给**对方按钮: 一个点了没反应的切换比没有切换更糟。
+    const PEER = (typeof window.__PEER === 'string' && window.__PEER) ? window.__PEER : null;
+    //  当前语言的按钮指向**自身** (只带 hash), 对方语言才指向 peer —— 两个都指对方的话
+    //  点哪个都跳走, 高亮那个也点不住 (云上实逮)。
+    const href = (l) => { const self = (l === 'en') === EN;
+      return SNAP ? (self ? location.hash || '#' : PEER + location.hash)
+                  : `?lang=${l}&win=${encodeURIComponent(winSlug(WIN))}${location.hash}`; };
+    //  静态快照且没有对方语言 ⇒ 整个切换器收起。只剩一个按钮的"切换器"没有意义, 还让人以为能切。
+    const showToggle = !SNAP || !!PEER;
+    const fleetCount = d ? (d.kpi && d.kpi.全场) : null;
+    const fleetUnitLabel = EN && Number(fleetCount) !== 1 ? t('ui.units') : t('ui.unit');
+    document.getElementById('top').innerHTML = `
+      <svg class="wind-field" viewBox="0 0 420 130" fill="none" aria-hidden="true">
+        <g stroke="currentColor" stroke-width="1.2"><path d="M0 108Q100 84 200 108T420 106M0 118Q100 96 200 118T420 116" opacity=".45"/>
+        <path d="M115 48v62m0-62 4-39-4-5-4 44m4 0-33 23 2 5 31-28m0 0 35 18 5-2-40-16M230 34v74m0-74 4-27-4-5-4 32m4 0-35 23 2 5 33-28m0 0 37 19 5-2-42-17M332 56v54m0-54 3-27-3-5-3 32m3 0-25 19 1 4 24-23m0 0 28 15 4-2-32-13"/>
+        <path d="M38 35h24m-12-8h28M165 82h30m-15-9h28M355 32h31m-15-8h30" opacity=".5"/>
+        <path d="M44 100 115 88 230 77 332 88 392 71" stroke-dasharray="3 5" opacity=".5"/></g>
+        <g stroke="currentColor" stroke-width="1" opacity=".8">
+          <path d="M20 96V58h9v38m9 0V57h9v39m9 0V56h9v40M12 55 72 51 68 58 19 61Z"/>
+          <path d="M378 100V71m0 0-14-11m14 11 15-11m-29 0q14 8 29 0m-25-5q10 5 21 0M403 105V82m0 0-10-8m10 8 11-8m-21 0q10 5 21 0"/>
+        </g>
+        <g fill="currentColor"><circle cx="115" cy="88" r="3"/><circle cx="230" cy="77" r="3"/><circle cx="332" cy="88" r="3"/><circle cx="392" cy="71" r="3"/></g>
+      </svg>
+      <span class="mark"><svg viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
+        <path d="M3 8.5c3-3 6-3 9 0s6 3 9 0"/><path d="M3 14.5c3-3 6-3 9 0s6 3 9 0"/><path d="M3 20.5c3-3 6-3 9 0s6 3 9 0"/></svg>
+        <span class="brand">${t('app.brand')}<small>${t('app.subtitle')} · ${d ? fmt(fleetCount) : '—'} ${fleetUnitLabel}</small></span></span>
+      <div class="right">
+        <a class="back-portal" id="backPortal" href="${portalHref()}" title="${t('ui.back_portal')}">${t('ui.back_portal')}</a>
+        <button type="button" class="reselect" id="reselect" title="${t('ui.reselect')}">${t('ui.reselect')}</button>
+        <label class="small muted" for="win">${t('win.label')}</label>
+        <select id="win" aria-label="${t('win.label')}">${opts}</select>
+        ${SNAP ? '' : `<span class="winrng" title="${t('win.custom_hint')}">
+          <input type="date" id="wina" aria-label="${t('win.from')}" value="${(D && D.sysmx_span ? D.sysmx_span[0] : ((Q.get('wina') || '')))}">
+          <span class="muted">~</span>
+          <input type="date" id="winb" aria-label="${t('win.to')}" value="${(D && D.sysmx_span ? D.sysmx_span[1] : ((Q.get('winb') || '')))}">
+          <button type="button" id="winapply">${t('win.apply')}</button></span>`}
+        ${showToggle ? `<div class="toggle" role="group" aria-label="language">
+          <a class="${EN ? '' : 'on'}" href="${href('zh')}" style="padding:6px 12px;color:inherit">${EN ? 'ZH' : '中文'}</a><a class="${EN ? 'on' : ''}" href="${href('en')}" style="padding:6px 12px;color:inherit">EN</a></div>` : ''}
+      </div>`;
+    document.getElementById('reselect').addEventListener('click', () => { SYSV = null; TAB = 'overview'; location.hash = 'tab=overview'; renderTabs(); render(); const w = document.getElementById('win'); if (w) { w.focus(); } window.scrollTo({ top: 0, behavior: 'smooth' }); });
+    document.getElementById('win').addEventListener('change', e => { WIN = winFromSlug(e.target.value); const u = new URL(location.href); u.searchParams.set('win', winSlug(WIN)); history.replaceState(null, '', u); load(); });
+    // ★自定义起止日期窗(含两端;用户令 2026-09-21):校验 a ≤ b、且不超出数据月范围,然后按同一路径重载
+    const _ap = document.getElementById('winapply');
+    if (_ap) _ap.addEventListener('click', () => {
+      const a = (document.getElementById('wina') || {}).value, b = (document.getElementById('winb') || {}).value;
+      if (!a || !b) { alert(t('win.need_both')); return; }
+      if (a > b) { alert(t('win.bad_order')); return; }
+      WIN = `${a}~${b}`;
+      const u = new URL(location.href); u.searchParams.set('win', WIN); u.searchParams.delete('wina'); u.searchParams.delete('winb');
+      history.replaceState(null, '', u); GEN.win = null; load();
+    });
+    document.querySelectorAll('.toggle a.on').forEach(a => { a.style.background = 'var(--ink)'; a.style.color = '#fff'; });
+  }
+  function portalHref() { try { const q = new URLSearchParams(location.search).get('portal'); if (q) { localStorage.setItem('guanlan.portal', q); return q; } const s = localStorage.getItem('guanlan.portal'); if (s) return s; const r = document.referrer; if (r && !/\/v2(\?|#|$)/.test(r) && /门户|portal|_demo\/site|18084/.test(decodeURIComponent(r))) { localStorage.setItem('guanlan.portal', r); return r; } return '/'; } catch (e) { return '/'; } }   // 2026-09-06 用户批: 门户链接改指 /v2; 来路是门户时记住它作返回目标
+  function renderTabs() {
+    document.getElementById('tabs').innerHTML = TABS.map(id => `<button class="${id === TAB ? 'on' : ''}" data-tab="${id}">${t('tab.' + id)}</button>`).join('');
+    document.querySelectorAll('#tabs button').forEach(b => b.addEventListener('click', () => { TAB = b.dataset.tab; location.hash = 'tab=' + TAB; renderTabs(); render(); }));
+  }
+
+  // ── 总览 ──
+  function unitState(row) {
+    // 台级状态 = 六列里最严重的设备状态。不可判只在没有任何良好/优秀评估时才成为台级状态 ——
+    // 它说的是"这一列看不见", 不是"这台有问题"; 有别的列看得见且正常, 台级就按看得见的算。
+    const ks = ['振动', '温度', '润滑', '油液', '报警', '工单'].map(c => stateKey((row[c] || {}).ostate)).filter(Boolean);
+    if (!ks.length) return 'unk';
+    if (ks.includes('bad')) return 'bad';
+    if (ks.includes('warn')) return 'warn';
+    const seen = ks.filter(k => k !== 'unk');
+    if (!seen.length) return 'unk';
+    return seen.includes('ok') ? 'ok' : 'ex';
+  }
+  function overview(d) {
+    const F = d.fus || {}, M = (d.m8 && d.m8.mtbf) || {}, C = d.control || {};
+    // 损失按 control.states 分项取 (与经典页同源): 停机 / 停机(调度令) / 限电·绑定。
+    // 首次点检逮: 对 energy.monthly.loss 求和是"理论−上网"总损失 (24,819), 拿它标"停机损失"= 标错标签
+    // (memory headline-number-mislabels-own-decomposition) —— 数字标错比缺数字更坏。
+    const LS = {}; (C.states || []).forEach(x => { LS[x.k] = x.loss; });
+    const kpi = [
+      [fmt(C.avail, 1), '%', t('kpi.avail', { win: winName(d.win) }), C.avail >= 95 ? 'ok' : 'warn'],
+      [fmt(M.停机总时长h), 'h', t('kpi.downtime', { n: fmt(M.停机事件数) }), 'warn'],
+      [fmt(M.MTBF_h), 'h', t('kpi.mtbo'), ''],
+      [fmt(LS['停机']), 'MWh', t('kpi.loss', { win: winName(d.win) }) + (LS['停机(调度令)'] != null ? ` · ${t('kpi.loss_dispatch', { n: fmt(LS['停机(调度令)']) })}` : ''), ''],
+      [fmt(LS['限电·绑定']), 'MWh', t('kpi.curtail'), ''],
+      [`${fmt(d.kpi && d.kpi.报警台)} <small>/ ${fmt(d.kpi && d.kpi.全场)}</small>`, '', t('kpi.alarm_units'), (d.kpi && d.kpi.报警台) ? 'warn' : 'ok', t('kpi.alarm_units.hint')],
+    ];
+    // 第 5 元素 = 口径提示 (title): 同名不同轴的数字必须自带口径, 否则读者会与"全场状态"格对不上
+    // (2026-09-08 xzy 测试报告: "报警台数信息不相符" —— 33 是九系统轴, 格子是六列设备状态轴)。
+    let h = `<section><div class="kpis">` + kpi.map(([v, u, l, c, ti]) => `<div class="kpi ${c}"${ti ? ` title="${esc(ti)}"` : ''}><b>${v}${u ? `<small>${u}</small>` : ''}</b><span>${l}</span></div>`).join('') + `</div></section>`;
+
+    // 需要关注: 链盘 rows (已按 bad>warn>note 排), 取 bad/warn; 依据取 watch.why
+    const why = {}; (d.watch || []).forEach(w => { why[w.t] = w.why; });
+    const rows = (((F.链盘 || {}).rows) || []).filter(r => r.lvl === 'bad' || r.lvl === 'warn');
+    h += `<section><div class="sechead"><h2>${t('ov.attention')}</h2><span class="meta">${t('ov.attention_n', { n: rows.length })}</span><span class="meta">${t('ov.ranked_by')}</span></div>`;
+    // 融合面缺件时**如实说明**(用 /api/fleet 的 fus.gap 原文),否则读者看到两片空白,
+    // 分不清"没有需要关注的台"与"这一面的数据没到位"(2026-09-19 用户报"下面没有计算输出")。
+    if (F.gap) h += `<div class="card mb" style="margin-bottom:10px"><p class="small" style="color:var(--warning)">${dv(F.gap)}</p><p class="small muted">${t('ov.fus_gap_hint')}</p></div>`;
+    h += `<div class="cards">`;
+    rows.forEach(r => {
+      const st = ST[r.ostate] || (r.lvl === 'bad' ? 'bad' : 'warn');
+      const stuckKey = STEP[r.stuck] || String(r.stuck || '').toLowerCase();
+      const stuckStep = stuckKey === 'evidence' ? t('step.site_records') : t('step.' + (stuckKey || 'evidence'));
+      const stuck = r.stuck ? t('step.stuck_at', { step: stuckStep }) : '';
+      const nxt = dt(r.next);
+      // ★2026-09-22 实逮(用户测试 2): 「需要关注」的行来自**融合面链盘**(d.fus.链盘.rows),
+      //   而依据过去只从 SCADA 的 d.watch 取 —— 两边机组集合不一致时 why[r.t] 就是空 ⇒
+      //   「查看完整依据」展开是**空白**(实测 WTG09 齿轮箱 bad 台:watch 里没有它)。
+      //   修法: 缺依据时按**本行自己的字段**组句(部件/链路进度/卡在哪一步/证据源),永不空。
+      const evTxt = (why[r.t] || '').trim() || t('ov.basis_fusion', {
+        part: dv(r.part || '—'),
+        done: (r.done == null ? '—' : r.done),
+        n: ((r.steps || []).length || '—'),
+        stuck: r.stuck ? stuckStep : t('ov.all_steps_done'),
+        srcs: (r.srcs || []).join(' / ') || t('ov.no_srcs'),
+      });
+      h += `<div class="card" id="u-${r.t}" style="--sev:var(--${{ bad: 'critical', warn: 'warning', unk: 'unknown', ok: 'good', ex: 'excellent' }[st]})">
+        <div class="head"><a class="unit" href="/turbine/${r.t}">${unitNo(r.t)}</a><span class="sys">${dv(r.part || '')}</span>${stateChip(r.ostate || (r.lvl === 'bad' ? '危险' : '报警'))}</div>
+        <details class="evidence-detail"><summary>${t('ov.evidence')}</summary><div class="why">${dv(evTxt)}</div></details>
+        <div class="evid">${tierChip(r.estate)}${r.srcs && r.srcs.length ? `<span class="rv">${r.srcs.length} sources</span>` : ''}</div>
+        ${stuck || nxt ? `<div class="caveat"><b>${t('ov.caveat')}</b>${esc(stuck)}${stuck && nxt ? ' — ' : ''}${esc(nxt)}</div>` : ''}</div>`;
+    });
+    h += `</div></section>`;
+
+    // 全场状态条
+    const mat = F.矩阵 || [];
+    const hot = new Set(rows.map(r => r.t));
+    h += `<section><div class="sechead"><h2>${t('ov.strip')}</h2><span class="meta">${t('ov.strip_hint')}</span>
+      <div class="legend">${['bad', 'warn', 'unk', 'ok', 'ex'].map(k => `<span><i style="background:var(--${{ bad: 'critical', warn: 'warning', unk: 'unknown', ok: 'good', ex: 'excellent' }[k]})"></i>${t(stKey(k))}</span>`).join('')}</div></div>
+      <div class="strip" style="--n:${mat.length || 38}"><div class="cells">`
+      + (mat.length ? mat.map(r => { const k = unitState(r); return `<div class="cell ${k} ${hot.has(r.turbine) ? 'hot' : ''}" data-u="${r.turbine}" title="${unitNo(r.turbine)} ${t(stKey(k))}"></div>`; }).join('')
+                       : `<div class="quiet" style="padding:10px">${esc(F.gap || t('ov.fus_gap_hint'))}</div>`)
+      + `</div><div class="nums">` + mat.map(r => `<span class="${hot.has(r.turbine) ? 'hot' : ''}">${unitNo(r.turbine).replace('#', '')}</span>`).join('') + `</div>`
+      + `<div class="quiet">${rows.length ? t('ov.quiet', { n: Math.max(0, (mat.length || 38) - rows.length) }) : t('ov.quiet_all', { n: mat.length || 38 })}</div></div></section>`;
+
+    // 九系统
+    const dist = d.sysdist || {};
+    h += `<section><div class="sechead"><h2>${t('ov.systems')}</h2><span class="meta">${t('ov.systems_hint')}</span></div><div class="systems">`
+      + SYS9.map(s => { const c = dist[s] || {}; const n = Object.values(c).reduce((a, b) => a + b, 0) || 1;
+        const seg = [['危险', 'critical'], ['报警', 'warning'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].filter(([k]) => c[k]);
+        return `<div class="sysc"><div class="t"><b>${sysName(s)}</b><a href="#tab=component&sys=${encodeURIComponent(sysSlug(s))}">${t('ui.open')}</a></div>
+          <div class="bar">${seg.map(([k, v]) => `<i style="width:${(c[k] / n * 100).toFixed(1)}%;background:var(--${v})"></i>`).join('')}</div>
+          <div class="cnt">${seg.map(([k, v]) => `<span><i style="background:var(--${v})"></i>${c[k]}</span>`).join('')}</div></div>`; }).join('')
+      + `</div></section>`;
+    return h;
+  }
+
+  // ── 原文数据: 公网英文不展示中文原文, 未覆盖项用可审核占位符标明。 ──
+  let VERB = 0;
+  function dv(s) {
+    s = displayText(s);
+    s = s == null ? '' : String(s);
+    if (!EN || !CJK.test(s)) return esc(s);
+    VERB++; return `<span class="review-required" title="${esc(t('ui.verbatim'))}">${REVIEW_REQUIRED}</span>`;
+  }
+  function askStepText(x) {
+    let s = '';
+    if (x && typeof x === 'object') s = [x.k, x.v].filter(v => v != null && String(v)).join(' · ');
+    else s = String(x == null ? '' : x);
+    if (!EN) return dv(s);
+    s = s
+      .replace(/启动/g, 'Starting')
+      .replace(/预取/g, 'Prefetch')
+      .replace(/推理/g, 'Reasoning')
+      .replace(/取证/g, 'Evidence retrieval')
+      .replace(/成文/g, 'Drafting')
+      .replace(/校闸/g, 'Grounding gate')
+      .replace(/交叉审核/g, 'Cross-review')
+      .replace(/完成/g, 'Done')
+      .replace(/失败/g, 'Failed')
+      .replace(/第\s*(\d+\s*\/\s*\d+)\s*轮/g, (_, n) => 'round ' + n.replace(/\s+/g, ''))
+      .replace(/\d{1,2}\s*一站式证据包/g, 'focus-unit evidence pack')
+      .replace(/闭环与在升证据\s*(\d*)\s*条/g, (_, n) => 'closure and trend evidence' + (n ? `: ${n} records` : ''))
+      .replace(/全场判级\s*(\d*)\s*台/g, (_, n) => 'fleet ratings' + (n ? `: ${n} units` : ''))
+      .replace(/换模型复核中/g, 'reviewing with another model');
+    if (CJK.test(s)) s = 'Working';
+    return esc(s);
+  }
+  function recordValue(obj, key) {
+    const value = obj[key], original = obj._original && obj._original[key] || (RECORD_EN[value] ? value : null);
+    return dv(value);
+  }
+  const verbNote = () => (EN && VERB) ? `<p class="small muted" style="margin-top:8px">${t('ui.verbatim_note', { n: VERB })}</p>` : '';
+
+  // ── 检修助手: 三窗并列 (抢修 / 预防性维护 / 故障树), 各自输入与结果 ──
+  const SYSOPT = ['变桨', '偏航', '主轴承', '齿轮箱', '发电机', '变流器', '塔架与基础', '主控与传感网', '叶片与叶根'];
+  const HOT = ['3225', '3130', '13902', '10105', '3224'];
+  const ASST = { fault: '', prev: 'pitch', tree: 'pitch', out: { fault: '', prev: '', tree: '' } };
+
+  function assistant() {
+    const sel = (id) => `<select id="asstIn_${id}" style="flex:1 1 200px">${SYSOPT.map(s => `<option value="${sysSlug(s)}" ${ASST[id] === sysSlug(s) ? 'selected' : ''}>${sysName(s)}</option>`).join('')}</select>`;
+    const btn = (id) => `<button class="btn" data-asst="${id}">${t('ui.query')}</button>`;
+    const row = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px';
+    return `<section>
+      <div class="eyebrow">${t('asst.head')} — ${t('asst.head_note')}</div>
+      <div class="panel mb"><h3>${t('asst.corrective')} <span class="small muted" style="font-weight:400">${t('asst.corrective_flow')}</span></h3>
+        <div style="${row}"><input id="asstIn_fault" placeholder="${esc(t('asst.code_placeholder'))}" value="${esc(ASST.fault)}" style="flex:1 1 220px">${btn('fault')}
+          <span class="small muted">${t('asst.common')} ${HOT.map(c => `<a href="#" data-code="${c}">${c}</a>`).join(' · ')}</span></div>
+        <div id="asstOut_fault" style="margin-top:10px">${ASST.out.fault}</div></div>
+      <div class="panel mb"><h3>${t('asst.preventive')} <span class="small muted" style="font-weight:400">${t('asst.preventive_flow')}</span></h3>
+        <div style="${row}">${sel('prev')}${btn('prev')}</div>
+        <div id="asstOut_prev" style="margin-top:10px">${ASST.out.prev}</div></div>
+      <div class="panel mb"><h3>${t('asst.faulttree')} <span class="small muted" style="font-weight:400">${t('asst.faulttree_flow')}</span></h3>
+        <div style="${row}">${sel('tree')}${btn('tree')}</div>
+        <div id="asstOut_tree" style="margin-top:10px">${ASST.out.tree}</div></div></section>`;
+  }
+  function bindAssistant() {
+    document.querySelectorAll('[data-asst]').forEach(b => b.addEventListener('click', () => asstGo(b.dataset.asst)));
+    document.querySelectorAll('[data-code]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); document.getElementById('asstIn_fault').value = a.dataset.code; asstGo('fault'); }));
+    const inp = document.getElementById('asstIn_fault'); if (inp) inp.addEventListener('keydown', e => { if (e.key === 'Enter') asstGo('fault'); });
+  }
+  async function asstGo(id) {
+    const out = document.getElementById('asstOut_' + id), el = document.getElementById('asstIn_' + id);
+    if (!out || !el) return;
+    ASST[id] = el.value.trim();
+    const v = ASST[id] || (id === 'fault' ? '' : 'pitch');
+    const apiValue = id === 'fault' ? v : sysFromSlug(v);
+    const qs = id === 'fault' ? `kind=fault&code=${encodeURIComponent(apiValue)}` : `kind=${id}&system=${encodeURIComponent(apiValue)}`;
+    out.innerHTML = `<p class="small muted">${t('asst.querying')}</p>`;
+    let d;
+    try { d = await (await fetch(`/api/ont_chain?${qs}${EN ? '&lang=en&keep=1' : ''}`)).json(); }
+    catch (e) { out.innerHTML = `<p class="small" style="color:var(--critical)">${esc(t('asst.query_failed', { e: e.message }))}</p>`; return; }
+    VERB = 0;
+    const x = d.data;
+    out.innerHTML = (id === 'fault' && x) ? faultCards(x) : (id === 'tree' && x) ? treeCards(x) : (id === 'prev' && x) ? prevCards(x)
+      : `<pre class="small" style="white-space:pre-wrap;margin:0">${dv(d.text || d.err || '')}</pre>`;
+    out.innerHTML += verbNote();
+    ASST.out[id] = out.innerHTML;
+  }
+  const box = 'border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:11px 13px';
+  function faultCards(x) {
+    const sev = x.n_site >= 1000 ? 'var(--critical)' : x.n_site >= 100 ? 'var(--warning)' : 'var(--muted)';
+    const nm = (x.name == null || /^(nan|none|null)$/i.test(String(x.name).trim()) || !String(x.name).trim()) ? t('asst.no_name') : dv(x.name);
+    let h = `<div class="panel mb"><h3 style="margin:0">${esc(x.code)} · ${nm}</h3>
+      <p class="small muted" style="margin-top:5px">${t('asst.identity', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: `<b style="color:${sev}">${fmt(x.n_site)}</b>` })}${x.alarm_type ? ` · ${t('asst.alarm_type', { t: esc(x.alarm_type) })}` : ''}</p>`;
+    if (x.failmodes && x.failmodes.length) h += `<div style="margin-top:10px"><b class="small muted">${t('asst.failmodes')}</b>`
+      + x.failmodes.map(f => `<div class="small" style="margin-top:5px">· ${dv(f.name)} <span class="muted">(${t('asst.fm_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })})</span></div>`).join('') + `</div>`;
+    h += `</div>`;
+    if (x.steps && x.steps.length) {
+      h += `<div class="panel mb"><h3>${t('asst.steps', { n: x.steps.length })} <span class="small muted" style="font-weight:400">${dv(x.wi_source || '')}</span></h3>
+        <p class="small muted">${t('asst.steps_note')}</p>
+        <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px;margin-top:10px">`
+        + x.steps.map(st => `<div style="${box}">
+          <div class="small" style="color:var(--accent-deep);font-weight:600">${t('asst.step_n', { n: st.序 })}${st.类别 ? ` · ${dv(st.类别)}` : ''}${st.qty ? ` · ${t('asst.qty', { q: dv(st.qty) })}` : ''}</div>
+          <div style="margin-top:5px;line-height:1.65">${recordValue(st, '步骤')}</div>
+          ${(st.tags && st.tags.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.tags')}</b> ${st.tags.map(g => `<code style="border:1px solid var(--line);border-radius:4px;padding:1px 6px;margin-right:5px">${esc(g)}</code>`).join('')}</div>` : ''}
+          ${(st.nodes && st.nodes.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.nodes', { c: dv(st.circuit || '') })}</b> <span class="muted">(${t('asst.nodes_note')})</span>
+            <div class="tblwrap"><table style="margin-top:3px"><tr><th style="width:38%">${t('asst.node')}</th><th>${t('asst.check')}</th></tr>
+            ${st.nodes.map(n => `<tr><td class="small">${dv(n.name)}</td><td class="small">${recordValue(n, 'check')}</td></tr>`).join('')}</table></div>
+            ${(st.known_tags && st.known_tags.length) ? `<div class="muted" style="margin-top:4px">${t('asst.known_tags')} ${st.known_tags.map(k => `<code style="margin-right:5px">${esc(k.tag)}</code>`).join('')}</div>` : ''}</div>` : ''}
+          ${(st.specs && st.specs.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.specs')}</b>
+            <div class="tblwrap"><table style="margin-top:3px"><tr><th style="width:64px">${t('asst.spec_no')}</th><th>${t('asst.spec_name')}</th><th class="r" style="width:74px">${t('asst.spec_val')}</th><th class="r" style="width:96px">${t('asst.spec_range')}</th></tr>
+            ${st.specs.map(sp => `<tr><td class="small">${esc(sp.no)}</td><td class="small">${dv(sp.text)}</td><td class="small r">${sp.val != null ? esc(sp.val) + esc(sp.unit || '') : '—'}</td><td class="small r">${sp.lo != null ? `${esc(sp.lo)} ~ ${esc(sp.hi)}` : '—'}</td></tr>`).join('')}</table></div></div>` : ''}
+          ${(st.drawings && st.drawings.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.drawings')}</b>${st.drawings.map(dw => `<div class="muted" style="margin-top:2px">· ${recordValue(dw, 'name')}</div>`).join('')}</div>` : ''}
+        </div>`).join('') + `</div></div>`;
+    } else h += `<div class="panel mb"><p class="small">${t('asst.no_steps')}</p></div>`;
+    if (x.workorders && x.workorders.length) h += `<div class="panel mb"><h3>${t('asst.wo_head', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: fmt(x.wo_total) })}</h3>
+      <div class="tblwrap"><table style="margin-top:8px"><tr><th style="width:92px">${t('ui.date')}</th><th style="width:64px">${t('ui.unit')}</th><th>${t('asst.wo_fault')}</th><th style="width:140px">${t('asst.wo_action')}</th></tr>
+      ${x.workorders.map(w => `<tr><td class="small">${esc(w.date)}</td><td class="small">${esc(unitNo(w.turbine))}</td><td class="small">${recordValue(w, 'name')}</td><td class="small">${dv(w.action)} ${dv(w.part)}</td></tr>`).join('')}</table></div>
+      <p class="small muted" style="margin-top:6px">${t('asst.wo_note')}</p></div>`;
+    return h;
+  }
+  function treeCards(x) {
+    const nodes = x.nodes || [];
+    if (!nodes.length) return `<p class="small muted">${t('asst.tree_empty')}</p>`;
+    const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
+    let h = `<div class="panel mb"><h3>${t('asst.tree_head', { sys: sysName(x.system), n: nodes.length })}</h3><p class="small muted">${t('asst.tree_note')}</p></div>`;
+    nodes.forEach(n => {
+      const w = Math.max(3, (n.n_alarm + n.n_wo * 10) / maxE * 100);
+      const col = n.n_wo >= 5 ? 'var(--critical)' : n.n_alarm >= 100 ? 'var(--warning)' : 'var(--accent)';
+      h += `<div class="panel mb"><div style="flex:1;min-width:200px"><b>${dv(n.name)}</b>
+        <div style="height:7px;border-radius:3px;background:${col};width:${w.toFixed(0)}%;margin-top:5px;max-width:100%"></div>
+        <div class="small muted" style="margin-top:4px">${t('asst.tree_counts', { a: fmt(n.n_alarm), w: fmt(n.n_wo) })}${n.codes && n.codes.length ? ` · ${t('asst.tree_codes', { c: n.codes.map(esc).join(', ') })}` : ''}</div></div>`;
+      if (n.causes && n.causes.length) h += `<div style="margin-top:10px;padding-left:14px;border-left:2px dashed var(--line)"><div class="small muted" style="margin-bottom:6px">${t('asst.causes')}</div>
+        <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">`
+        + n.causes.map(c => `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px"><div><b class="small">${dv(c.name)}</b> <span class="small muted">${t('asst.cause_counts', { r: fmt(c.n_ref), w: fmt(c.n_wo), c: fmt(c.n_codes) })}</span></div>
+          ${(c.steps || []).map(s => `<div class="small muted" style="margin-top:5px;line-height:1.6">· ${dv(s)}</div>`).join('')}</div>`).join('') + `</div></div>`;
+      else h += `<p class="small muted" style="margin-top:8px">${t('asst.no_causes')}</p>`;
+      h += `</div>`;
+    });
+    return h;
+  }
+  function prevCards(x) {
+    let h = `<div class="panel mb"><h3>${t('asst.prev_head', { sys: sysName(x.system), f: x.n_fm, n: x.n_task })}</h3></div>`;
+    if (x.failmodes && x.failmodes.length) h += `<div class="panel mb"><b class="small muted">${t('asst.prev_fm')}</b>`
+      + x.failmodes.slice(0, 8).map(f => `<div class="small" style="margin-top:5px">· ${dv(f.name)} <span class="muted">— ${t('asst.tree_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })}</span></div>`).join('') + `</div>`;
+    if (x.tasks && x.tasks.length) h += `<div class="panel mb"><b class="small muted">${t('asst.prev_tasks')}</b>
+      <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px;margin-top:8px">`
+      + x.tasks.map(k => `<div style="${box}"><div>${dv(k.name)}</div><div class="small muted" style="margin-top:4px">${k.interval ? t('asst.interval', { i: dv(k.interval) }) : ''}${k.wi ? ` · ${t('asst.wi_steps', { n: k.wi.n_step })}` : ''}</div>
+        ${k.wi && k.wi.steps.length ? `<div class="small" style="margin-top:6px">${k.wi.steps.map(s => `<div style="margin-top:3px;line-height:1.55">${esc(s.序)}. ${recordValue(s, '步骤')}</div>`).join('')}</div>` : ''}</div>`).join('') + `</div></div>`;
+    else h += `<p class="small muted">${t('asst.prev_empty')}</p>`;
+    return h;
+  }
+
+  // ── 检修决策 · 本体问答 (模型参与的板块) ──
+  //   API: POST /api/ask {q, model, cat(中文类名), xrev} → GET /api/ask_status 轮询 (running/done/error)
+  //   模型清单唯一来源 = /api/ask_models (本地与云端同形); 档位说明按 id 查语言包, 查不到按"会外发"处理。
+  const CATS = [['equip', '设备类'], ['sched', '检修排程决策'], ['loss', '损失分析'], ['risk', '安全风险预测']];
+  const MDL_LOC = { 'qwen3:8b': 'local', 'qwen3.8:27b': 'local', 'qwen3:30b-a3b': 'local', 'deepseek-r1:14b': 'local', 'deepseek-r1:32b': 'local', 'deepseek-chat': 'cloud', 'kimi-fast': 'cloud', 'qwen-plus': 'cloud', 'deepseek-v4-pro': 'cloud' };
+  const ASK = { cat: 'equip', models: null, model: '', q: '', out: '', timer: null };
+  const mdlKey = (id, f) => (L['mdl.' + id + '.' + f] != null) ? t('mdl.' + id + '.' + f) : null;
+  const mdlLabel = (id) => (mdlKey(id, 'nm') || id) + (MDL_LOC[id] ? ' · ' + t('mdl.' + MDL_LOC[id]) : '') + (mdlKey(id, 't') ? ' · ' + mdlKey(id, 't') : '');
+  const mdlNote = (id) => !MDL_LOC[id] ? `<span style="color:var(--warning)">${t('mdl.unknown_warn')}</span>`
+    : MDL_LOC[id] === 'local' ? t('mdl.zero_outbound') : `<span style="color:var(--warning)">${t('mdl.outbound_warn')}</span>`;
+
+  function decision() {
+    const cat = ASK.cat, catName = t('cat.' + cat);
+    const pills = CATS.map(([id]) => `<button class="pill ${id === cat ? 'on' : ''}" data-cat="${id}">${t('cat.' + id)}</button>`).join('');
+    const qs = [1, 2, 3].map(i => `<div class="qitem" data-q="${esc(t('cat.' + cat + '.q' + i))}">${t('cat.' + cat + '.q' + i)}</div>`).join('');
+    const models = ASK.models;
+    const opts = models === null ? `<option value="">…</option>`
+      : Object.keys(models).length ? Object.keys(models).map(k => `<option value="${esc(k)}" ${k === ASK.model ? 'selected' : ''}>${esc(mdlLabel(k))}</option>`).join('')
+      : `<option value="">${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')}</option>`;
+    return `<section>
+      <div class="eyebrow">${t('dec.head')}</div>
+      ${SNAP ? '' : `<p class="small muted">${t('dec.classic')} <a href="/?lang=${LANG}&win=${encodeURIComponent(WIN)}">${t('ui.open_classic')}</a></p>`}
+      <div style="display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap">${pills}</div>
+      <div class="panel mb"><h3>${t('ask.common_q', { cat: catName })}</h3>
+        <p class="small muted">${t('cat.' + cat + '.hint')}${EN ? '. ' : '。'}${t('ask.grounding')}</p>
+        ${qs}
+        <div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0;align-items:center">
+          <input id="askq" type="text" placeholder="${esc(t('ask.placeholder', { cat: catName }))}" value="${esc(ASK.q)}" style="flex:1;min-width:280px">
+          <label class="small muted" for="askm">${t('ask.model')}</label>
+          <select id="askm">${opts}</select>
+          <span id="mdlnote" class="small">${ASK.model ? mdlNote(ASK.model) : ''}</span>
+          <label class="small" style="cursor:pointer;white-space:nowrap" title="${esc(t('ask.xrev_title'))}"><input type="checkbox" id="askxrev"> ${t('ask.xrev')} <span class="muted">${t('ask.xrev_note')}</span></label>
+          <button class="btn" id="askgo">${t('ask.go')}</button></div>
+        <div id="askout" class="small">${ASK.out || t('ask.idle')}</div></div></section>`;
+  }
+  async function bindDecision() {
+    document.querySelectorAll('[data-cat]').forEach(b => b.addEventListener('click', () => { ASK.cat = b.dataset.cat; ASK.q = (document.getElementById('askq') || {}).value || ''; render(); }));
+    document.querySelectorAll('.qitem').forEach(q => q.addEventListener('click', () => { document.getElementById('askq').value = q.dataset.q; askGo(); }));
+    document.getElementById('askgo').addEventListener('click', askGo);
+    document.getElementById('askq').addEventListener('keydown', e => { if (e.key === 'Enter') askGo(); });
+    document.getElementById('askm').addEventListener('change', e => { ASK.model = e.target.value; document.getElementById('mdlnote').innerHTML = mdlNote(ASK.model); });
+    if (ASK.models === null) {
+      try { const j = await (await fetch('/api/ask_models')).json(); ASK.models = j.models || {}; window.LOCAL_AI_DOWN = !!(j.offline && j.offline.local_ai_running === false); } catch (e) { ASK.models = {}; }
+      const ks = Object.keys(ASK.models);
+      if (!ASK.model || !ks.includes(ASK.model)) ASK.model = ks[0] || '';
+      if (TAB === 'decision') render();
+      return;
+    }
+    askPoll(true);   // 页面打开时如已有推理在跑, 接上进度; 空闲则只刷一次
+  }
+  const askStatusUrl = () => '/api/ask_status' + (EN ? '?lang=en' : '?lang=zh');
+  const answerRefCount = (text) => new Set([...String(text || '').matchAll(/\[\s*(?:object id|source|id|对象id|对象|来源|出处)?\s*[::]?\s*([a-z]+\/[A-Za-z0-9_/.\-]+)\s*\]/gi)].map(m => m[1])).size;
+  // 契约引用匹配 (确定性规则, 不调模型): (a) 文本点名 claim_id; (b) 问题含条目 systems 名; (c) 问题含机组号 (WTG/tid/#号) 且条目标题点名同号. 定论/准定论 优先, 最多 5 条.
+  async function askRefs(q, answer) {
+    try {
+      if (FACTS === null) { try { const r = await fetch('/api/facts'); FACTS = r.ok ? await r.json() : false; } catch (e) { FACTS = false; } }
+      if (!FACTS || !FACTS.cards) return '';
+      const text = q + '\n' + answer, tids = new Set([...q.matchAll(/(?:WTG|tid|#)\s*0*(\d{1,2})(?!\d)|(\d{1,2})\s*[号#]/g)].map(m => String(Number(m[1] || m[2]))));
+      const rank = { '定论': 0, '准定论·预警': 1, '候选': 2, '参考': 3, 'INSUFFICIENT': 4, '撤回': 5 };
+      const hits = FACTS.cards.map(c => { const why = [];
+        if (text.includes(c.claim_id)) why.push('id');
+        (c.systems || []).forEach(sname => { if (sname && q.includes(sname)) why.push(sname); });
+        const ct = [...(c.title || '').matchAll(/(?:WTG|tid)\s*0*(\d{1,2})(?!\d)/g)].map(m => String(Number(m[1])));
+        if (ct.some(n => tids.has(n))) why.push('tid');
+        return why.length ? { c, why } : null; }).filter(Boolean).sort((a, b) => (rank[a.c.verdict] ?? 9) - (rank[b.c.verdict] ?? 9) || b.why.length - a.why.length).slice(0, 5);
+      const sha = (FACTS.contract_sha256 || '').slice(0, 16);
+      if (!hits.length) return `<div class="small muted" style="margin-top:8px" data-contract="${esc(sha)}">${t('ask.refs.none', { sha })}</div>`;
+      const cites = await Promise.all(hits.map(async h => { try { const r = await fetch('/api/facts/claim?id=' + encodeURIComponent(h.c.claim_id)); const j = r.ok ? await r.json() : null; return (j && j.claim && j.claim.cite) || ''; } catch (e) { return ''; } }));
+      return `<div class="small" style="margin-top:8px" data-contract="${esc(sha)}"><b>${t('ask.refs.title', { n: hits.length, sha })}</b><ul style="margin:4px 0 0 18px;line-height:1.7">${hits.map((h, i) => `<li><code>${esc(h.c.claim_id)}</code> · ${esc(h.c.verdict)} · ${esc((h.c.title || '').slice(0, 90))}${cites[i] ? `<div class="muted">${esc(cites[i])}</div>` : ''}</li>`).join('')}</ul></div>`;
+    } catch (e) { return ''; }
+  }
+  async function askGo() {
+    const q = document.getElementById('askq').value, out = document.getElementById('askout');
+    ASK.q = q;
+    if (!q.trim()) { out.textContent = t('ask.empty'); return; }
+    const body = { q, display_q: q, lang: EN ? 'en' : 'zh', model: ASK.model, cat: (CATS.find(([id]) => id === ASK.cat) || [])[1] || '', xrev: !!(document.getElementById('askxrev') || {}).checked };
+    let d;
+    try { const r = await fetch('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok || !/json/.test(r.headers.get('content-type') || '')) { out.innerHTML = t('ask.offline'); return; } d = await r.json(); }
+    catch (e) { out.innerHTML = t('ask.offline'); return; }
+    if (d.err) { out.innerHTML = dv(d.err); return; }
+    askPoll();
+  }
+  async function askPoll(once) {
+    const el = document.getElementById('askout'); if (!el) return;
+    // 离线快照下 /api/ask_status 回的是静态站的 404 页 (非 JSON) —— 判成"需连接系统", 不报解析错
+    let s; try { const r = await fetch(askStatusUrl()); if (!r.ok || !/json/.test(r.headers.get('content-type') || '')) { el.innerHTML = t('ask.offline'); ASK.out = el.innerHTML; return; } s = await r.json(); } catch (e) { el.innerHTML = t('ask.offline'); ASK.out = el.innerHTML; return; }
+    // 页面打开 (once) 时只接"正在跑"的进度: 服务端 ASK 是全局态, done/error 是上一位提问者的结果 ——
+    // 云上实逮: 英文页一打开回放了 19 小时前别人的中文答案。本会话没提问就不显示任何答案。
+    if (once && s.state !== 'running' && s.state !== 'partial') { el.innerHTML = t('ask.idle'); ASK.out = ''; return; }
+    if (s.state === 'partial') {
+      // 先快后准 (用户裁 2026-09-07): 初答 (契约直出) 先显示, 继续轮询, 27B 复核完成后整段替换
+      el.innerHTML = `<p><span class="badge warn">${t('ask.partial')}</span> ${t('ask.elapsed', { s: s.elapsed })} · <span style="color:var(--accent-deep)">${askStepText(s.step || '')}</span></p>
+        <pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px dashed var(--line);font-family:inherit;margin:0">${esc(String(s.answer || ''))}</pre>`;
+      clearTimeout(ASK.timer); ASK.timer = setTimeout(() => askPoll(), 2000);
+    } else if (s.state === 'running') {
+      const steps = s.steps || [];
+      el.innerHTML = `<b>${t('ask.running')}</b> ${t('ask.elapsed', { s: s.elapsed })} · <span style="color:var(--accent-deep)">${askStepText(s.step || '')}</span>
+        <div class="small muted" style="margin-top:5px">${esc(s.model || '')} · ${mdlNote(s.model)}</div>
+        ${steps.length ? `<div class="small muted" style="margin-top:4px">${steps.slice(-6).map(askStepText).join(' → ')}</div>` : ''}
+        <div class="small muted" style="margin-top:4px">${t('ask.question', { q: dv(String((EN ? ASK.q : s.q) || '').slice(0, 90)) })}</div>`;
+      clearTimeout(ASK.timer); ASK.timer = setTimeout(() => askPoll(), 2000);
+    } else if (s.state === 'done') {
+      const answer = String(s.answer || '');
+      if (EN && CJK.test(answer)) {
+        el.innerHTML = `<p><span class="badge warn">${t('ask.en_qa_failed')}</span></p>`;
+        ASK.out = el.innerHTML;
+        return;
+      }
+      const v = s.verify || {}, n = Math.max(v.引用id数 || 0, answerRefCount(answer));
+      const badge = (v.全通过 ? `<span class="badge ok">${t('ask.verify_ok', { n })}</span>` : `<span class="badge warn">${t('ask.verify_bad', { n })}</span>`) + (s.refined_from ? ` <span class="badge ok">${t('ask.refined', { m: esc(s.refined_from) })}</span>` : '');
+      const R = s.review;
+      const rv = R ? `<div class="panel mb" style="border-left:3px solid ${(R.issues || []).length ? 'var(--warning)' : 'var(--good)'};margin-top:10px">
+        <h3>${t('ask.review', { m: esc(R.model || '') })} <span class="small" style="font-weight:400;color:${(R.issues || []).length ? 'var(--warning)' : 'var(--good)'}">${dv(R.verdict || '')}</span></h3>
+        <p class="small muted">${t('ask.review_note')}</p>
+        ${(R.issues || []).map(i => `<div class="small" style="padding:5px 0;border-top:1px solid var(--line)"><span class="badge warn">${dv(i.type)}</span> <b>「${dv(i.quote)}」</b> <span class="muted">${dv(i.why)}</span></div>`).join('')}
+        ${R.err ? `<p class="small" style="color:var(--critical)">${t('ask.review_err', { e: dv(R.err) })}</p>` : ''}</div>` : '';
+      el.innerHTML = `<p>${badge} · ${t('ask.done_meta', { m: esc(s.model || ''), s: s.elapsed, n: (s.steps || []).length })}</p>
+        <pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px solid var(--line);font-family:inherit;margin:0">${esc(answer)}</pre>${rv}`;
+      // 契约引用 (用户批 2026-09-06 "问答接 qa_refs"): 回答落地后按 问题/回答 匹配契约条目, 附 qa_refs 引文; 无匹配明说"未被契约背书", 不伪造
+      askRefs(String((EN ? ASK.q : s.q) || ASK.q || ''), answer).then(h => { if (!h || el.dataset.refsFor === answer.slice(0, 40)) return; const d = document.createElement('div'); d.innerHTML = h; el.appendChild(d); el.dataset.refsFor = answer.slice(0, 40); ASK.out = el.innerHTML; });
+    } else if (s.state === 'error') {
+      el.innerHTML = t('ask.error', { e: dv(s.err || '') });
+    } else if (!once) {
+      el.innerHTML = t('ask.idle');
+    }
+    ASK.out = el.innerHTML;
+  }
+
+  // ── 汇报定制: 组件库 → 画布 → 预览 (一页纸) → 模型编排 / 导出 ──
+  //   API: GET /api/rpt_compose?need&canvas&model → {blocks, narrative, model, secs, ungrounded, err}
+  //        GET /api/rpt_export?fmt&blocks&aud&win&narr → .docx/.xlsx
+  const CH = window.__charts({ t, fmt, esc, dv, EN, displayText, verb: () => { VERB++; } });
+  const RPT_GROUPS = ['gen', 'rel', 'flt', 'unit', 'end'];
+  const RPT_BLOCKS = { kpi: 'gen', water: 'gen', emon: 'gen', blame: 'gen', ram: 'rel', parts: 'rel', attrib: 'rel', pareto: 'flt', quad: 'flt', frate: 'flt', units: 'unit', chain: 'unit', loop: 'unit', plan: 'end', caliber: 'end', narr: 'end' };
+  const RPT_ICON = { kpi: '▦', water: '▤', emon: '▥', blame: '◧', ram: '◈', parts: '▤', attrib: '▬', pareto: '▮', quad: '◇', frate: '▪', units: '☰', chain: '⛓', loop: '✓', plan: '✎', caliber: '⚑', narr: '✍' };
+  const RPT_PRESETS = { month: ['kpi', 'water', 'emon', 'blame', 'ram', 'pareto', 'units', 'plan', 'caliber'], week: ['kpi', 'frate', 'units', 'plan', 'caliber'], topic: ['units', 'chain', 'ram', 'loop', 'caliber'], year: ['kpi', 'water', 'emon', 'ram', 'loop', 'caliber'], blank: [] };
+  const RPT = { canvas: [], aud: 'in', need: '', model: '', models: null, compose: null, narr: null, out: '' };
+  // 契约生成 (用户批 2026-09-06): 结论/时间窗只经 /api/facts (derived/detail_cards.json) 进纸, 不手写. FACTS = null 未取 / false 无 / {…}.
+  let FACTS = null;
+  function rptFacts() { if (!FACTS || !FACTS.cards) return ''; const cards = FACTS.cards.filter(c => c.verdict === '定论' || c.verdict === '准定论·预警'); const rows = cards.map(c => `<tr><td><code>${esc(c.claim_id)}</code></td><td>${esc(c.verdict)}</td><td>${esc(c.title)}</td><td>${esc((c.systems || []).join(', ') || '—')}</td><td>${esc(c.aggregation || '—')}</td></tr>`).join('');
+    return `<div class="rpt-blk" data-contract="${esc(FACTS.contract_sha256 || '')}"><div class="blk-ti">${t('rpt.facts.title')} <span class="small muted">${t('rpt.facts.sub', { n: cards.length, sha: (FACTS.contract_sha256 || '').slice(0, 16) })}</span></div><div style="overflow:auto"><table class="tbl"><tr><th>claim</th><th>${t('rpt.facts.verdict')}</th><th>${t('rpt.facts.text')}</th><th>${t('rpt.facts.sys')}</th><th>${t('rpt.facts.agg')}</th></tr>${rows}</table></div></div>`; }
+  // 判别值 (API 按 _KEEP_ZH 原样保留中文, 前端拿来做判别) —— 显示时查语言包。
+  // 查不到才回落原文标注: 振动线的技术速记 (「2R倒谱自基线红警」) 不可枚举也不该猜译。
+  const enumWord = (p) => { const k = String(p || ''); for (const pre of ['attr.', 'vp.']) if (L[pre + k] != null) return t(pre + k); return null; };
+  const partName = (p) => SYS_KEY[p] ? sysName(p) : (enumWord(p) ? esc(enumWord(p)) : dv(p));
+  const partPlain = (p) => SYS_KEY[p] ? sysName(p) : (enumWord(p) || displayText(p));
+
+  function rptK(v, u, lab, note, cls) { return `<div class="kpi ${cls || ''}"><b>${v}<small>${u}</small></b><span>${lab}<br><span style="opacity:.8">${note || ''}</span></span></div>`; }
+  function rptBlock(k, d) {
+    const E = (d.control && d.control.energy) || {}, em = E.monthly || [], R = (d.rel && !d.rel.err) ? d.rel : null, M = (d.m8 && !d.m8.err) ? d.m8.mtbf : null, F = d.faults || {}, FU = d.fus || {}, BD = FU.链盘 || {};
+    const wrap = (ti, b, src) => `<div class="panel mb"><h3>${ti}</h3>${b}${src ? `<p class="small muted" style="margin-top:8px;padding-top:7px;border-top:1px solid var(--line)">${t('rpt.caliber_lbl')}${src}</p>` : ''}</div>`;
+    if (k === 'kpi') { const last = em[em.length - 1] || {}, prev = em[em.length - 2] || {}, ok = (last.cov || 0) >= .5 && (prev.cov || 0) >= .5, dl = (a, b) => b ? ((a - b) / b * 100) : 0, mom = dl(last.act, prev.act);
+      return `<div class="kpis" style="margin-bottom:14px">${rptK(fmt(E.act), 'MWh', t('rpt.kpi.act'), t('rpt.kpi.theo', { v: fmt(E.theo) }) + ' | ' + (ok ? t('rpt.kpi.mom', { v: (mom >= 0 ? '+' : '') + mom.toFixed(0) }) : t('rpt.kpi.mom_na')))}
+        ${rptK(fmt(E.eflh), 'h', t('rpt.kpi.eflh'), t('rpt.kpi.annual', { v: fmt(Math.round((E.eflh || 0) / Math.max(em.filter(x => x.cov >= .5).length, 1) * 12)) }))}
+        ${rptK(fmt(d.control && d.control.avail, 1), '%', t('rpt.kpi.avail'), t('rpt.kpi.avail_n'), (d.control && d.control.avail >= 95) ? 'ok' : 'warn')}
+        ${rptK(fmt(E.loss_pct, 1), '%', t('rpt.kpi.loss'), fmt(E.loss) + ' MWh', E.loss_pct >= 15 ? 'warn' : '')}
+        ${rptK(M ? fmt(M.MTBF_h) : '—', 'h', t('rpt.kpi.mtbo'), t('rpt.kpi.mtbo_n', { n: M ? fmt(M.停机事件数) : '—', h: M ? M.MDT_h : '—' }))}
+        ${rptK((BD.rows || []).length, t('unit.units'), t('rpt.kpi.action'), t('rpt.kpi.action_n', { a: (FU.kpi || {}).定论 || 0, b: (FU.kpi || {}).预警 || 0, c: (FU.kpi || {}).候选观察 || 0 }), (BD.rows || []).length > 20 ? 'warn' : '')}</div>`; }
+    if (k === 'water') return wrap(t('rpt.water.title'), CH.waterfall(E) + `<p class="small muted" style="margin-top:6px">${t('rpt.water.note', { theo: fmt(E.theo), act: fmt(E.act), loss: fmt(E.loss), pct: E.loss_pct })} | ${E.cap_ok ? t('rpt.water.cap_ok', { cap: fmt(E.cap) }) : t('rpt.water.cap_bad', { cap: fmt(E.cap) })}</p>`, t('rpt.water.cal'));
+    if (k === 'emon') return wrap(t('rpt.emon.title'), em.length ? `<div class="tblwrap"><table><tr><th>${t('rpt.emon.m')}</th><th class="r">${t('rpt.emon.act')}</th><th class="r">${t('rpt.emon.loss')}</th><th class="r">${t('rpt.emon.pct')}</th><th class="r">${t('rpt.emon.eflh')}</th><th>${t('rpt.emon.cov')}</th></tr>${em.map(x => `<tr><td>${x.m}</td><td class="r">${fmt(x.act)}</td><td class="r">${fmt(x.loss)}</td><td class="r">${x.loss_pct}%</td><td class="r">${x.eflh}</td><td class="small" style="color:${x.cov < .5 ? 'var(--warning)' : 'var(--muted)'}">${(x.cov * 100).toFixed(0)}%${x.cov < .5 ? t('rpt.emon.partial') : ''}</td></tr>`).join('')}</table></div>` : '—', t('rpt.emon.cal'));
+    if (k === 'blame') return wrap(t('rpt.blame.title'), CH.blameBar(E) + `<p class="small muted" style="margin-top:8px">${t('rpt.blame.note')}<br><b>${t('rpt.blame.note2')}</b></p>`, t('rpt.blame.cal'));
+    if (k === 'ram') return R ? wrap(t('rpt.ram.title'), CH.ramChart(R.表, partPlain) + `<p class="small muted" style="margin-top:6px">${t('rpt.ram.note', { a: R.设备类停机h, b: R.外部类停机h })}</p>`, t('rpt.ram.cal')) : '';
+    if (k === 'parts') return R ? wrap(t('rpt.parts.title'), `<div class="tblwrap"><table><tr><th>${t('rpt.parts.part')}</th><th class="r">${t('rpt.parts.h')}</th><th class="r">${t('rpt.parts.ev')}</th><th class="r">${t('rpt.parts.mtbf')}</th><th class="r">${t('rpt.parts.mdt')}</th><th class="r">${t('rpt.parts.units')}</th></tr>${R.表.slice(0, 9).map(x => `<tr><td class="small">${partName(x.部件)}${x.外部 ? t('rpt.parts.ext') : ''}</td><td class="r">${x.停机时长h}</td><td class="r">${x.事件数}</td><td class="r">${x.MTBF_h || '—'}</td><td class="r">${x.MDT_h}</td><td class="r">${x.影响台数}</td></tr>`).join('')}</table></div>`, t('rpt.parts.cal')) : '';
+    if (k === 'attrib') return R ? wrap(t('rpt.attrib.title'), CH.attribBar(R.表, R.总停机h) + `<p class="small muted" style="margin-top:6px">${t('rpt.attrib.note')}</p>`, '') : '';
+    if (k === 'pareto') return wrap(t('rpt.pareto.title'), CH.paretoChart(F.pareto_n || [], t('unit.n'), ''), t('rpt.pareto.cal'));
+    if (k === 'quad') return wrap(t('rpt.quad.title'), CH.quadChart(F.quad || []) + `<p class="small muted" style="margin-top:6px">${t('rpt.quad.note')}</p>`, t('rpt.quad.cal'));
+    if (k === 'frate') return wrap(t('rpt.frate.title'), CH.rateChart(F.monthly || { months: [], vals: [] }) + `<p class="small muted" style="margin-top:6px">${t('rpt.frate.note')}</p>`, t('rpt.frate.cal'));
+    if (k === 'units') { const rows = (BD.rows || []).slice(0, 14), stuck = Object.entries(BD.stuck || {}).map(([a, b]) => t('rpt.units.stuck', { n: b, s: t('step.' + (STEP[a] || 'evidence')) })).join(' · ');
+      return wrap(t('rpt.units.title'), `<p class="small muted" style="margin-bottom:8px">${t('rpt.units.head', { n: (BD.rows || []).length, tot: (D.kpi || {}).全场 || 38 })}${stuck}</p><div class="tblwrap"><table><tr><th style="width:52px">${t('ui.unit')}</th><th style="width:80px">${t('rpt.units.state')}</th><th>${t('rpt.units.ev')}</th><th style="width:56px">${t('rpt.units.prog')}</th><th style="width:90px">${t('rpt.units.at')}</th></tr>
+        ${rows.map(r => `<tr><td><b>${unitNo(r.t)}</b></td><td>${stateChip(r.ostate)}</td><td class="small muted">${r.part && r.part.indexOf('未收录') < 0 ? `<b style="color:var(--ink)">${dv(r.part)}</b> · ` : ''}${(r.srcs || []).map(dv).join('/') || '—'}${r.rising ? ` · <span style="color:var(--critical)">${t('rpt.units.rising', { r: r.rising.ratio })}</span>` : ''}</td><td class="r small">${r.done}/6</td><td class="small" style="color:var(--warning)">${r.stuck ? t('step.' + (STEP[r.stuck] || 'evidence')) : t('rpt.units.clear')}</td></tr>`).join('')}</table></div>`, t('rpt.units.cal')); }
+    if (k === 'chain') return wrap(t('rpt.chain.title'), `<p class="small muted">${t('rpt.chain.note', { s: Object.entries(BD.stuck || {}).map(([a, b]) => t('rpt.units.stuck', { n: b, s: t('step.' + (STEP[a] || 'evidence')) })).join(', ') })}</p>`, t('rpt.chain.cal', { b: dv(BD.wo_bound || '') }));
+    if (k === 'loop') { const cl = BD.closed || [];
+      return wrap(t('rpt.loop.title'), `<div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('rpt.loop.comp')}</th><th>${t('rpt.loop.date')}</th><th class="r">${t('rpt.loop.pre')}</th><th class="r">${t('rpt.loop.post')}</th><th class="r">${t('rpt.loop.ratio')}</th></tr>${cl.map(x => `<tr><td><b>${unitNo(x.turbine)}</b></td><td class="small">${dv(x.component)}</td><td class="small">${esc(x.replace_date)}</td><td class="r">${x.pre}</td><td class="r">${x.post}</td><td class="r" style="color:var(--good)"><b>${x.ratio}×</b></td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('rpt.loop.note')}</p>`, t('rpt.loop.cal')); }
+    if (k === 'plan') return wrap(t('rpt.plan.title'), `<ol style="margin:0 0 0 20px;line-height:1.85"><li>${t('rpt.plan.1', { n: (BD.stuck || {}).机制 || 0 })}</li><li>${t('rpt.plan.2')}</li><li>${t('rpt.plan.3')}</li><li>${t('rpt.plan.4')}</li></ol>`, t('rpt.plan.cal'));
+    if (k === 'narr') { const N = RPT.narr; if (!N || !N.text) return wrap(t('rpt.narr.title'), `<p class="small muted">${t('rpt.narr.empty')}</p>`, '');
+      return wrap(t('rpt.narr.title'), `<p style="line-height:1.9;margin:0">${esc(N.text).split('\n').join('<br>')}</p>${(N.ungrounded || []).length ? `<p class="small" style="color:var(--warning);margin-top:8px">${t('rpt.narr.ungr', { v: N.ungrounded.map(esc).join(' / ') })}</p>` : ''}`, t('rpt.narr.cal', { m: esc(N.model), g: (N.ungrounded || []).length ? t('rpt.narr.g_n', { n: N.ungrounded.length }) : t('rpt.narr.g_ok') })); }
+    if (k === 'caliber') { const part = F.partial || [];
+      return wrap(t('rpt.cal.title'), `<ul style="margin:0 0 0 20px;line-height:1.85"><li><b>${t('rpt.cal.period')}</b> ${winName(d.win)}${part.length ? t('rpt.cal.partial', { m: part.join('/'), c: Math.round((F.cov_min || 0) * 100) }) : ''}</li><li><b>${t('rpt.cal.denom')}</b> ${t('rpt.cal.denom_d')}</li><li><b>${t('rpt.cal.stop')}</b> ${t('rpt.cal.stop_d')}</li><li><b>${t('rpt.cal.wo')}</b> ${t('rpt.cal.wo_d')}</li><li><b>${t('rpt.cal.rating')}</b> ${t('rpt.cal.rating_d')}</li>${RPT.aud === 'up' ? `<li><b>${t('rpt.cal.deid')}</b> ${t('rpt.cal.deid_d')}</li>` : ''}</ul>`, ''); }
+    return '';
+  }
+  function report() {
+    const d = D, FU = d.fus || {}, on = k => RPT.canvas.includes(k);
+    const models = RPT.models, opts = models === null ? '<option value="">…</option>' : Object.keys(models).length ? Object.keys(models).map(k => `<option value="${esc(k)}" ${k === RPT.model ? 'selected' : ''}>${esc(mdlLabel(k))}</option>`).join('') : `<option value="">${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')}</option>`;
+    const canCompose = RPT.compose === true && models && Object.keys(models).length;
+    let h = `<section><div class="eyebrow">${t('rpt.eyebrow')}</div><div class="panel mb noprint">
+      <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px"><span class="small" style="font-weight:600">${t('rpt.presets')}</span>
+        ${Object.keys(RPT_PRESETS).map(k => `<button class="pill" data-preset="${k}">${t('rpt.preset.' + k)}</button>`).join('')}<span style="flex:1"></span>
+        <select id="rptaud"><option value="in" ${RPT.aud === 'in' ? 'selected' : ''}>${t('rpt.aud_in')}</option><option value="up" ${RPT.aud === 'up' ? 'selected' : ''}>${t('rpt.aud_up')}</option></select>
+        <button class="btn quiet" id="rptprint">${t('rpt.print')}</button><button class="btn quiet" data-export="docx">${t('rpt.export_docx')}</button><button class="btn quiet" data-export="xlsx">${t('rpt.export_xlsx')}</button>
+        ${RPT.aud === 'up' ? `<span class="small" style="color:var(--warning);flex-basis:100%">${t('rpt.up_note')}</span>` : ''}</div>
+      <div class="small" style="font-weight:600;margin-bottom:7px">${t('rpt.library')} <span style="font-weight:400;color:var(--muted)">${t('rpt.library_note', { n: RPT.canvas.length })}</span></div>
+      <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">${RPT_GROUPS.map(g => `<div class="small muted" style="text-align:right;white-space:nowrap">${t('rpt.g.' + g)}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${Object.keys(RPT_BLOCKS).filter(k => RPT_BLOCKS[k] === g).map(k => `<button class="pill ${on(k) ? 'on' : ''}" data-block="${k}" style="border-radius:7px;padding:5px 11px;font-size:12.5px;opacity:${on(k) ? 1 : .8}">${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')}${on(k) ? ' ✓' : ''}</button>`).join('')}</div>`).join('')}</div>
+      <div id="rpthint" class="small muted" style="margin-top:9px;padding-top:8px;border-top:1px solid var(--line);min-height:19px">${t('rpt.hint_idle')}</div>
+      <div style="margin-top:11px;padding-top:10px;border-top:1px solid var(--line)"><div class="small" style="font-weight:600;margin-bottom:6px">${t('rpt.need_head')} <span id="rptmdlnote" style="font-weight:400;color:var(--muted)">${t('rpt.need_hint')}${RPT.model ? (MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `<span style="color:var(--warning)">${t('rpt.cloud_note')}</span>`) : ''}</span></div>
+        <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input id="rptneed" placeholder="${esc(t('rpt.need_ph'))}" value="${esc(RPT.need)}" style="flex:1 1 300px;min-width:220px"><select id="rptmdl">${opts}</select><button class="btn" id="rptgo" ${canCompose ? '' : 'disabled'}>${t('rpt.compose')}</button></div>
+        <div id="rptout" class="small muted" style="margin-top:7px">${RPT.out || (RPT.compose === false ? t('rpt.no_compose') : (models && !Object.keys(models).length ? (window.LOCAL_AI_DOWN ? t('ask.local_down') : t('rpt.no_models')) : ''))}</div></div></div>`;
+    if (!RPT.canvas.length) return h + `<div class="paper" style="text-align:center;padding:56px 40px"><div class="small muted">${t('rpt.preview_empty')}</div></div></section>`;
+    // 数据时点只有形如 YYYY-MM-DD 才当日期用 —— 实测该字段可能是一句说明 ("振动证据包…"), 截 10 字就成乱码
+    const asof = (String(FU.数据时点 || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0];   // 实测该字段是一句说明 ("振动证据包 · 出具 2026-08-26 · …"), 取其中第一个日期
+    h += `<div class="paper"><div class="paper-hd"><div><div class="paper-ti">${t('app.brand')} · ${t('rpt.paper_title')}</div><div class="small muted" style="margin-top:3px">${t('rpt.paper_sub', { win: winName(d.win), n: (d.kpi || {}).全场 || 38 })}${RPT.aud === 'up' ? ' | ' + t('rpt.up_tag') : ''}</div></div><div class="small muted" style="text-align:right">${t('rpt.chapters', { n: RPT.canvas.length })}</div></div>`;
+    RPT.canvas.forEach((k, i) => { if (!RPT_BLOCKS[k]) return;
+      const bb = (s, act, dis) => `<button class="pill" data-mv="${act}" data-k="${k}" ${dis ? 'disabled' : ''} style="padding:1px 8px;font-size:11px;opacity:${dis ? .3 : .7}">${s}</button>`;
+      h += `<div class="noprint" style="display:flex;gap:3px;justify-content:flex-end;margin-bottom:-6px;position:relative;z-index:2">${bb('↑', 'up', i === 0)}${bb('↓', 'down', i === RPT.canvas.length - 1)}${bb('×', 'del', false)}</div>${rptBlock(k, d)}`; });
+    h += rptFacts();
+    h += `<div class="paper-ft"><span>${t('rpt.foot_src')}${asof ? ' | ' + t('rpt.foot_asof', { d: asof }) : ''}</span><span>${t('rpt.foot_trace')}${RPT.aud === 'up' ? ' | ' + t('rpt.foot_deid') : ''}</span></div></div></section>`;
+    return h;
+  }
+  async function bindReport() {
+    const R = document.getElementById('root');
+    R.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => { RPT.canvas = RPT_PRESETS[b.dataset.preset].slice(); render(); }));
+    R.querySelectorAll('[data-block]').forEach(b => { const k = b.dataset.block;
+      b.addEventListener('click', () => { const i = RPT.canvas.indexOf(k); if (i >= 0) RPT.canvas.splice(i, 1); else RPT.canvas.push(k); render(); });
+      const hint = document.getElementById('rpthint');
+      b.addEventListener('mouseenter', () => { hint.innerHTML = `<b style="color:var(--ink)">${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')}</b> — ${t('rpt.b.' + k + '.d')}`; });
+      b.addEventListener('mouseleave', () => { hint.textContent = t('rpt.hint_idle'); }); });
+    R.querySelectorAll('[data-mv]').forEach(b => b.addEventListener('click', () => { const k = b.dataset.k, i = RPT.canvas.indexOf(k); if (i < 0) return;
+      if (b.dataset.mv === 'del') RPT.canvas.splice(i, 1); else { const j = i + (b.dataset.mv === 'up' ? -1 : 1); if (j < 0 || j >= RPT.canvas.length) return; [RPT.canvas[i], RPT.canvas[j]] = [RPT.canvas[j], RPT.canvas[i]]; } render(); }));
+    document.getElementById('rptaud').addEventListener('change', e => { RPT.aud = e.target.value; render(); });
+    document.getElementById('rptprint').addEventListener('click', () => window.print());
+    R.querySelectorAll('[data-export]').forEach(b => b.addEventListener('click', () => rptExport(b.dataset.export)));
+    document.getElementById('rptneed').addEventListener('keydown', e => { if (e.key === 'Enter') rptCompose(); });
+    document.getElementById('rptmdl').addEventListener('change', e => { RPT.model = e.target.value; RPT.need = document.getElementById('rptneed').value; render(); });
+    document.getElementById('rptgo').addEventListener('click', rptCompose);
+    if (FACTS === null) { try { const r = await fetch('/api/facts'); FACTS = r.ok ? await r.json() : false; } catch (e) { FACTS = false; } if (FACTS) render(); }
+    if (RPT.models === null) {
+      try { const j = await (await fetch('/api/ask_models')).json(); RPT.models = j.models || {}; window.LOCAL_AI_DOWN = !!(j.offline && j.offline.local_ai_running === false); } catch (e) { RPT.models = {}; }
+      const ks = Object.keys(RPT.models); if (!RPT.model || !ks.includes(RPT.model)) RPT.model = ks[0] || '';
+      try { const r = await fetch('/api/rpt_compose?probe=1'); RPT.compose = !!(r && r.ok); } catch (e) { RPT.compose = false; }
+      if (TAB === 'report') render();
+    }
+  }
+  async function rptCompose() {
+    const need = document.getElementById('rptneed').value, out = document.getElementById('rptout');
+    RPT.need = need;
+    if (!need.trim()) { out.innerHTML = t('rpt.need_empty'); return; }
+    out.innerHTML = `<span style="color:var(--accent-deep)">${t('rpt.composing')}</span>${MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `<span style="color:var(--warning)">${t('rpt.cloud_note')}</span>`}`;
+    let d;
+    try { d = await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPT.canvas.join(','))}&model=${encodeURIComponent(RPT.model)}`)).json(); }
+    catch (e) { out.innerHTML = `<span style="color:var(--critical)">${esc(t('rpt.compose_fail', { e: e.message }))}</span>`; RPT.out = out.innerHTML; return; }
+    if (d.err) { out.innerHTML = `<span style="color:var(--critical)">${dv(d.err)}</span>`; RPT.out = out.innerHTML; return; }
+    RPT.narr = { text: d.narrative, model: d.model, ungrounded: d.ungrounded || [] };
+    const bl = (d.blocks || []).filter(b => RPT_BLOCKS[b]);
+    if (bl.length) RPT.canvas = (d.narrative ? ['narr'] : []).concat(bl); else if (d.narrative && !RPT.canvas.includes('narr')) RPT.canvas.unshift('narr');
+    RPT.out = t('rpt.composed', { n: bl.length, narr: d.narrative ? t('rpt.narr_plus') : '', m: esc(d.model || ''), s: d.secs }) + ' · ' + ((d.ungrounded || []).length ? `<span style="color:var(--warning)">${t('rpt.ungrounded_n', { n: d.ungrounded.length })}</span>` : t('rpt.grounded'));
+    render();
+  }
+  function rptExport(fmtx) {
+    if (!RPT.canvas.length) { alert(t('rpt.empty_canvas')); return; }
+    const n = (RPT.narr && RPT.narr.text) ? RPT.narr.text : '';
+    const url = `/api/rpt_export?fmt=${fmtx}&blocks=${encodeURIComponent(RPT.canvas.join(','))}&aud=${RPT.aud}&win=${encodeURIComponent(WIN)}&narr=${encodeURIComponent(n.slice(0, 4000))}`;
+    const a = document.createElement('a'); a.href = url; a.download = ''; document.body.appendChild(a); a.click(); a.remove();
+  }
+
+  // ── 故障统计: 报警面 (率/帕累托/四象限/台级/季节) + 停机面 (RAM/归因/停机帕累托/台级) ──
+  const winBanner = (d, extra) => `<div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('win.banner', { win: winName(d.win) })}</b>${extra ? ' | ' + extra : ''}</div>`;
+  const barCell = (v, mx, hot) => `<i class="bar" style="width:${Math.round(v / mx * 90)}px;background:${hot ? 'var(--critical)' : 'var(--accent)'}"></i><span class="num small">${fmt(v)}</span>`;
+  function fault() {
+    const d = D, F2 = d.faults || {}, pm = F2.partial || [], m = (d.m8 && !d.m8.err) ? d.m8.mtbf : null;
+    const sec = (ti, meta) => `<div class="eyebrow" style="grid-column:1/-1;margin-top:4px">${ti} <span style="text-transform:none;letter-spacing:0;color:var(--muted)">${meta}</span></div>`;
+    const partialWarn = pm.length ? `<span style="color:var(--warning)">${t('win.partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })}</span>` : '';
+    let h = `<section><div class="eyebrow">${t('flt.eyebrow')}</div>${winBanner(d, partialWarn)}<div class="grid2">`;
+    h += sec(t('flt.alarm_face'), t('flt.alarm_meta', { n: fmt(F2.total), c: F2.n_codes, win: winName(d.win) }));
+    const mo = F2.monthly || { months: [], vals: [] }, i0 = pm.length ? mo.months.indexOf(pm[0]) : -1;
+    h += `<div class="panel span2"><h3>${t('flt.rate')}</h3>${CH.rateChart(mo)}<p class="small muted" style="margin-top:6px"><b>${t('flt.rate_note')}</b> — ${pm.length ? t('flt.rate_partial', { m: pm.join('/') }) : t('flt.rate_full')}${(i0 >= 0 && mo.per_day) ? ' ' + t('flt.rate_example', { m: pm[0], v: mo.per_day[i0] }) : ''}</p></div>`;
+    h += `<div class="panel"><h3>${t('flt.pareto_n')}</h3>${CH.paretoChart(F2.pareto_n, t('unit.n'), t('flt.pareto_n_note'))}</div>`;
+    h += `<div class="panel"><h3>${t('flt.pareto_d')}</h3>${CH.paretoChart(F2.pareto_d, ' h', t('flt.pareto_d_note'))}</div>`;
+    h += `<div class="panel span2"><h3>${t('flt.quad')}</h3>${CH.quadChart(F2.quad)}<p class="small muted" style="margin-top:6px">${t('flt.quad_note', { n: F2.n_codes, m: (F2.quad || []).length })} ${pm.length ? `<span style="color:var(--warning)">${t('flt.quad_partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })}</span> ` : ''}${t('flt.quad_note2')}</p></div>`;
+    const pt = F2.per_t || [], maxT = Math.max(1, ...pt.map(x => x.v));
+    h += `<div class="panel"><h3>${t('flt.per_t', { n: pt.length })}</h3><div class="tblwrap"><table><tr><th style="width:70px">${t('ui.unit')}</th><th>${t('ui.count')}</th><th class="r" style="width:80px">${t('flt.per_t_share')}</th></tr>${pt.map(x => `<tr><td><b>${unitNo(x.k)}</b></td><td>${barCell(x.v, maxT, x.v / maxT > .6)}</td><td class="r small">${(x.v / F2.total * 100).toFixed(1)}%</td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('flt.per_t_note', { n: fmt(F2.total) })}</p></div>`;
+    if (d.m8 && d.m8.err) h += `<div class="panel" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'm8', e: dv(d.m8.err) })}</p></div>`;
+    if (m && d.m8.seasonal && d.m8.seasonal.length) h += `<div class="panel"><h3>${t('flt.seasonal')} <span class="small muted" style="font-weight:400">(${t('flt.seasonal_unit')})</span></h3>${CH.multiline({ months: d.m8.seasonal[0].months, series: d.m8.seasonal.slice(0, 4).map(x => ({ name: displayText(x.name).slice(0, 16), vals: x.vals })), xlab: t('flt.seasonal_x') })}<p class="small muted" style="margin-top:6px">${t('flt.seasonal_note')}</p></div>`;
+    if (m) {
+      const RT = (d.rel && !d.rel.err) ? d.rel : null;
+      h += sec(t('flt.stop_face'), t('flt.stop_meta', { h: fmt(m.停机总时长h), n: fmt(m.停机事件数) }));
+      if (RT) { const nore = (RT.表 || []).find(x => x.部件 === '无记录停机') || {};
+        h += `<div class="panel span2"><h3>${t('flt.ram')}</h3>${CH.ramChart(RT.表, partPlain)}<p class="small muted" style="margin-top:6px">${t('flt.ram_note', { a: RT.设备类停机h, b: RT.外部类停机h })}</p><div style="margin-top:12px">${CH.attribBar(RT.表, RT.总停机h)}</div><p class="small muted" style="margin-top:2px">${t('flt.attrib_note', { h: nore.停机时长h || 0 })}</p></div>`; }
+      h += `<div class="panel"><h3>${t('flt.stop_pareto')}</h3>${CH.paretoChart((d.m8.stop_pareto || []).map(x => ({ k: displayText(x.k).split(';')[0].slice(0, 26), v: x.h })), ' h', t('flt.stop_pareto_note'))}<p class="small muted" style="margin-top:4px">${dv(m.口径)}</p></div>`;
+      const top = m.台级top || [], mx = Math.max(1, ...top.map(x => x.时长h));
+      h += `<div class="panel"><h3>${t('flt.stop_units', { n: top.length })}</h3><div class="tblwrap"><table><tr><th style="width:64px">${t('ui.unit')}</th><th>${t('flt.stop_h')}</th><th class="r" style="width:56px">${t('flt.stop_n')}</th></tr>${top.map(x => `<tr><td><b>${unitNo(x.t)}</b></td><td>${barCell(x.时长h, mx, x.时长h / mx > .6)}h</td><td class="r small">${x.次}</td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('flt.stop_units_note')}</p></div>`;
+    }
+    return h + `</div></section>`;
+  }
+
+  // ── 电量算账: 四 KPI / 电量平衡瀑布 / 责任分账 / 工况态明细 (只出当期账; 控制参数件在发电性能页) ──
+  function energy() {
+    const d = D, E = (d.control || {}).energy, S = (d.control || {}).states || [];
+    if (!E) return `<section><div class="panel"><p class="small muted">${t('ui.empty')}</p></div></section>`;
+    let h = `<section><div class="kpis" style="margin-bottom:12px">
+      <div class="kpi"><b>${fmt(E.theo)}<small>MWh</small></b><span>${t('en.kpi_theo')}</span></div>
+      <div class="kpi ok"><b>${fmt(E.act)}<small>MWh</small></b><span>${t('en.kpi_act', { h: fmt(E.eflh) })}</span></div>
+      <div class="kpi ${E.loss_pct >= 10 ? 'warn' : ''}"><b>${fmt(E.loss_pct, 1)}<small>%</small></b><span>${t('en.kpi_loss', { v: fmt(E.loss) })}</span></div>
+      <div class="kpi ${E.cap_ok ? 'ok' : 'bad'}"><b>${E.cap_ok ? t('en.cap_pass') : t('en.cap_fail')}</b><span>${t('en.cap_note', { op: E.cap_ok ? '<' : '≥', cap: fmt(E.cap) })}</span></div></div>
+      <div class="grid2">
+      <div class="panel span2"><h3>${t('en.balance')} <span class="small muted" style="font-weight:400">${t('en.win', { win: winName(d.win) })}</span></h3>${CH.waterfall(E)}</div>
+      <div class="panel"><h3>${t('en.blame')}</h3>${CH.blameBar(E)}<p class="small muted" style="margin-top:6px">${t('en.blame_note', { n: fmt(Math.round(((E.items || []).find(x => x.k === '停机') || {}).loss || 0)) })}</p></div>`;
+    const mxp = Math.max(1, ...S.map(x => x.pct));
+    h += `<div class="panel"><h3>${t('en.states')} <span class="small muted" style="font-weight:400">${t('en.win', { win: winName(d.win) })}</span></h3><div class="tblwrap"><table><tr><th>${t('en.state')}</th><th>${t('en.share')}</th><th class="r" style="width:90px">${t('en.loss_mwh')}</th></tr>
+      ${S.map(x => `<tr><td class="small">${esc(CH.lossName(x.k))}</td><td><i class="bar" style="width:${Math.round(x.pct / mxp * 84)}px;background:${x.k.indexOf('停机') >= 0 && x.k.indexOf('调度') < 0 ? 'var(--critical)' : 'var(--accent)'}"></i><span class="num small">${x.pct}% · ${fmt(Math.round(x.h))}h</span></td><td class="r small">${fmt(Math.round(x.loss))}</td></tr>`).join('')}</table></div>
+      <p class="small muted" style="margin-top:6px">${dv(d.control.anchors || '')}<br>${t('en.states_note')}</p></div></div></section>`;
+    return h;
+  }
+
+  // ── 部件问题: 九系统卡 → 系统详情 (逐台依据) / 部件可靠性表 / 跨系统关注台 ──
+  let SYSV = (location.hash.match(/sys=([^&]+)/) || [])[1]; SYSV = SYSV ? sysFromSlug(decodeURIComponent(SYSV)) : null;
+  const STC = { bad: 'var(--critical)', warn: 'var(--warning)', unk: 'var(--unknown)', ok: 'var(--good)', ex: 'var(--excellent)' };
+  function sysDetail(d, s) {
+    const rows = (d.systems || {})[s] || [], dist = (d.sysdist || {})[s] || {}, gd = (d.systems || {})[s + '·良好'] || [];
+    const cnt = k => dist[k] || 0, N = Object.values(dist).reduce((a, b) => a + b, 0) || 1;
+    const al = rows.filter(r => r.st === '报警'), un = rows.filter(r => r.st === '不可判');
+    const tbl = (title, list) => list.length ? `<div class="panel mb"><h3>${title} ${t('cmp.n_units', { n: list.length })}</h3><div class="tblwrap"><table><tr><th style="width:74px">${t('ui.unit')}</th><th>${t('cmp.basis')}</th><th style="width:110px">${t('cmp.detail')}</th></tr>
+      ${list.map(r => `<tr><td><a href="/turbine/${r.t}"><b>${unitNo(r.t)}</b></a></td><td class="small">${dv(r.why)}</td><td><a class="small" href="/problem/${r.t}/${encodeURIComponent(s)}">${t('cmp.problem_page')}</a></td></tr>`).join('')}</table></div></div>` : '';
+    let h = `<section><p class="small"><a href="#tab=component" data-back="1">${t('ui.back_to', { tab: t('tab.component') })}</a></p>
+      <div class="panel mb"><h3>${t('cmp.detail_head', { sys: sysName(s), n: N })}</h3>
+        <div style="display:flex;height:12px;border-radius:4px;overflow:hidden;margin:8px 0 4px">${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => cnt(k) ? `<i style="width:${(cnt(k) / N * 100).toFixed(1)}%;background:var(--${c})"></i>` : '').join('')}</div>
+        <div class="small muted">${t('cmp.counts', { a: cnt('报警'), u: cnt('不可判'), g: cnt('良好'), e: cnt('优秀') })}</div></div>`;
+    h += tbl(t('cmp.alarm_units'), al) + tbl(t('cmp.unk_units'), un);
+    if (gd.length) h += `<details class="panel mb"><summary style="cursor:pointer;font-weight:600">${t('cmp.good_units', { n: gd.length })}</summary><div class="tblwrap"><table style="margin-top:10px"><tr><th style="width:74px">${t('ui.unit')}</th><th>${t('cmp.basis')}</th><th style="width:110px">${t('cmp.detail')}</th></tr>${gd.map(r => `<tr><td><a href="/turbine/${r.t}"><b>${unitNo(r.t)}</b></a></td><td class="small">${dv(r.why)}</td><td><a class="small" href="/problem/${r.t}/${encodeURIComponent(s)}">${t('cmp.problem_page')}</a></td></tr>`).join('')}</table></div></details>`;
+    if (!al.length && !un.length && !gd.length) h += `<div class="panel"><p class="small">${t('cmp.none')}</p></div>`;
+    return h + `</section>`;
+  }
+  function component() {
+    const d = D;
+    if (SYSV) return sysDetail(d, SYSV);
+    const SD = d.sysdist || {};
+    // ★判级按所选时间窗重算是后台算(首次约 25 s)⇒ 先出上一口径并**在脸上写清**,轮询到就自动换掉
+    let h = (d.sysmx_pending ? `<section><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('cmp.grading_pending')}</b> ${t('cmp.grading_pending_why', { span: (d.sysmx_span || []).join(' ~ ') })}</div></section>` : '')
+      + `<section><div class="eyebrow">${t('cmp.by_system')}</div><div class="panel mb"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px">`
+      + SYS9.filter(s => SD[s]).map(s => { const dist = SD[s], a = dist['报警'] || 0, u = dist['不可判'] || 0, N = Object.values(dist).reduce((x, y) => x + y, 0) || 1;
+        const col = a >= 8 ? 'var(--critical)' : a >= 3 ? 'var(--warning)' : a ? 'var(--accent)' : 'var(--good)';
+        return `<div class="sysc" role="button" tabindex="0" data-sys="${esc(sysSlug(s))}" style="cursor:pointer;border-left:3px solid ${col}"><div class="t"><b>${sysName(s)}</b></div>
+          <div class="bar">${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => dist[k] ? `<i style="width:${(dist[k] / N * 100).toFixed(1)}%;background:var(--${c})"></i>` : '').join('')}</div>
+          <div class="small muted">${t('cmp.alarm_n', { a: `<b style="color:${col}">${a}</b>` })}${u ? t('cmp.unk_n', { u }) : ''}${t('cmp.fleet_n', { n: N })}</div></div>`; }).join('')
+      + `</div><p class="small muted" style="margin-top:8px">${t('cmp.cards_note')}</p></div>`;
+    if (d.rel && d.rel.err) h += `<div class="panel mb" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'rel', e: dv(d.rel.err) })}</p></div>`;
+    if (d.rel && !d.rel.err) { const R = d.rel, tb = R.表 || [], maxH = Math.max(1, ...tb.map(r => r.停机时长h));
+      const eh = (((d.control || {}).energy || {}).items || []).filter(x => x.k === '停机').map(x => x.h)[0];
+      h += `<div class="eyebrow">${t('cmp.rel_eyebrow', { win: dv(R.窗) })}</div><div class="panel mb"><h3>${t('cmp.rel_head')} <span class="small muted" style="font-weight:400">${t('cmp.rel_meta', { t: R.总停机h, a: R.设备类停机h, b: R.外部类停机h })}</span></h3>
+        <p class="small muted" style="margin:-2px 0 8px">${t('cmp.rel_note', { h: eh == null ? '' : ' ' + fmt(eh) + 'h' })}</p>
+        <div class="tblwrap"><table><tr><th>${t('cmp.rel_part')}</th><th>${t('cmp.rel_h')}</th><th class="r">${t('cmp.rel_ev')}</th><th class="r">${t('cmp.rel_mtbo')}</th><th class="r">${t('cmp.rel_mdt')}</th><th class="r">${t('cmp.rel_units')}</th><th>${t('cmp.rel_fm')}</th></tr>
+        ${tb.map(r => { const sev = r.部件 === '无记录停机' ? 'var(--unknown)' : r.外部 ? 'var(--accent)' : (r.占停机 >= .06 ? 'var(--critical)' : r.占停机 >= .03 ? 'var(--warning)' : 'var(--good)');
+          return `<tr><td><b>${partName(r.部件)}</b>${r.外部 ? ` <span class="badge">${t('cmp.external')}</span>` : ''}</td><td><i class="bar" style="width:${Math.round(r.停机时长h / maxH * 72)}px;background:${sev}"></i><span class="num small">${r.停机时长h}h · ${(r.占停机 * 100).toFixed(1)}%</span></td><td class="r">${r.事件数}</td><td class="r">${r.外部 ? '—' : (r.MTBF_h || '—')}</td><td class="r">${r.MDT_h}</td><td class="r">${r.影响台数}</td><td class="small">${dv(r.典型失效模式 || '—')}</td></tr>`; }).join('')}</table></div><p class="small muted">${dv(R.口径)}</p></div>`; }
+    if (d.fus && !d.fus.err) h += `<div class="panel mb"><p class="small">${t('cmp.fusion_moved')} <a href="#tab=vibration"><b>${t('tab.vibration')}</b></a> ${t('cmp.fusion_n', { n: (d.fus.表 || []).length })}</p></div>`;
+    h += `<div class="eyebrow">${t('cmp.cross_eyebrow')}</div><div class="panel mb"><h3>${t('cmp.watch_head', { n: (d.watch || []).length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th class="r">${t('cmp.n_sys')}</th><th>${t('cmp.hit_sys')}</th><th>${t('cmp.basis')}</th></tr>
+      ${(d.watch || []).map(w => `<tr><td><a href="/turbine/${w.t}"><b>${unitNo(w.t)}</b></a></td><td class="r">${w.n}</td><td class="small">${(w.syss || []).map(x => `<a href="/problem/${w.t}/${encodeURIComponent(sysFromSlug(x) || x)}">${sysName(x)}</a>`).join(' · ')}</td><td class="small">${dv(w.why)}</td></tr>`).join('')}</table></div></div></section>`;
+    return h;
+  }
+  function bindComponent() {
+    document.querySelectorAll('[data-sys]').forEach(el => { const go = () => { SYSV = sysFromSlug(el.dataset.sys); location.hash = 'tab=component&sys=' + encodeURIComponent(el.dataset.sys); render(); };
+      el.addEventListener('click', go); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); });
+    document.querySelectorAll('[data-back]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); SYSV = null; location.hash = 'tab=component'; render(); }));
+  }
+
+  // ── 系统维护: 数据维护面 (两层: 数据 / 机理), 取 /api/maint_survey ──
+  const MT = { data: null };
+  function system() {
+    if (!MT.data) return `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel"><p class="small muted">${t('mt.surveying')}</p></div></section>`;
+    const md = MT.data, rows = md.rows || [];
+    if (md.error) return `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel"><p class="small" style="color:var(--critical)">${dv(md.error)}</p></div></section>`;
+    let h = `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel mb"><h3>${t('mt.head', { n: rows.length })}</h3><p class="small muted">${t('mt.note')}</p></div>`;
+    // 层字段在 lang=en 下被经典枚举表投影成 'Data' / 'Domain model' (英文点检实逮: 两层表全空) —— 中英值都认
+    const LAYERS = [[['数据', 'Data'], 'mt.layer_data', 'mt.layer_data_note', 'var(--accent)'], [['机理', 'Domain model', 'Mechanism'], 'mt.layer_mech', 'mt.layer_mech_note', 'var(--warning)']];
+    LAYERS.forEach(([lvs, kn, kd, col]) => {
+      const rs = rows.filter(r => lvs.includes(String(r.层))); if (!rs.length) return;
+      h += `<div class="panel mb" style="border-left:3px solid ${col}"><h3>${t(kn)} (${rs.length})</h3><p class="small muted">${t(kd)}</p>
+        <div class="tblwrap"><table style="margin-top:10px"><tr><th style="width:18%">${t('mt.src')}</th><th style="width:20%">${t('mt.cov')}</th><th style="width:13%">${t('mt.last')}</th><th style="width:10%">${t('mt.freq')}</th><th style="width:11%">${t('mt.resp')}</th><th>${t('mt.how')}</th></tr>
+        ${rs.map(r => `<tr><td class="small"><b>${r.存在 ? '' : '✗ '}${publicMeta(r.名, 'name')}</b></td><td class="small num">${publicMeta(r.覆盖, 'coverage')}${r.条数 ? `<span class="muted">${t('mt.rows_n', { n: fmt(r.条数) })}</span>` : ''}</td><td class="small muted">${esc(r.末次更新 || '—')}</td><td class="small">${publicMeta(r.频率, 'freq')}</td><td class="small">${publicMeta(r.责任, 'responsible')}</td>
+          <td class="small"><div class="muted">${t('mt.put')} <code>${publicMeta(r.位置, 'path')}</code></div><div style="margin-top:3px">${t('mt.run')} <code>${publicMeta(r.摄入, 'run')}</code></div><div class="muted" style="margin-top:3px">${publicMeta(r.说明, 'note')}</div></td></tr>`).join('')}</table></div></div>`; });
+    return h + `</section>`;
+  }
+  async function bindSystem() {
+    if (MT.data) return;
+    try { MT.data = await (await fetch(`/api/maint_survey${EN ? '?lang=en&keep=1' : ''}`)).json(); } catch (e) { MT.data = { error: t('mt.fail', { e: e.message }) }; }
+    if (TAB === 'system') render();
+  }
+
+  // ── 发电性能: 按所选时间窗重算 (2026-09-21 用户令"发电性能随时间窗变化");
+  //     控制参数一致性 (m9) + 曲线欠发候选 + 七镜头 19 图 (/api/curves?win=, 懒取; 首次后台算 → building 轮询) ──
+  const GEN = { win: null, curves: null, err: null, building: false, tries: 0 };
+  function generation() {
+    const d = D, M9 = (d.m9 && !d.m9.err) ? d.m9 : null, F = d.control || null;
+    const sp = (d.sysmx_span || []).join(' ~ ') || '';
+    let h = `<section><div class="eyebrow">${t('gen.eyebrow')}</div><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('gen.fixed_win')}</b> ${sp ? t('gen.win_range', { span: sp }) : ''}<div class="muted" style="margin-top:3px">${t('gen.win_why')}</div></div>`;
+    if (d.m9 && d.m9.err) h += `<div class="panel mb" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'm9', e: dv(d.m9.err) })}</p></div>`;
+    if (M9) { const b9 = M9.β_schedule || {}, outs = b9.离群 || [];
+      h += `<div class="panel mb"${d.m9_pending ? ' style="border-left:3px solid var(--accent)"' : ''}><h3>${t('gen.ctrl')} <span class="small muted" style="font-weight:400">${t('gen.ctrl_win', { win: dv(M9.窗) })}</span>${d.m9_pending ? ` <span class="chip">${t('gen.recomputing_short')}</span>` : ''}</h3><div style="display:flex;flex-wrap:wrap;gap:18px">
+        <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.cap')}</div>${CH.capAxis(M9.P封顶, M9.ω封顶)}<p class="small muted" style="margin-top:4px">${t('gen.cap_note')}</p></div>
+        <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.beta')}</div>${CH.betaBar(b9, unitNo)}<p class="small muted" style="margin-top:4px">${t('gen.beta_note', { units: outs.map(x => `<a href="/turbine/${x.t}">${unitNo(x.t)}</a>`).join(' / '), k: outs.length ? Math.round(Math.max(...outs.map(x => x.平均偏)) / Math.max(b9.全场同档差中位 || 0, .01)) : '—', v: dv(b9.判) })}</p>
+          <div class="small" style="font-weight:600;margin:12px 0 4px">${t('gen.ruler')}</div>${CH.rulerBar(M9.尺子)}<p class="small muted" style="margin-top:4px">${t('gen.ruler_note')}</p></div></div></div>`; }
+    if (F && F.curve) h += `<div class="panel mb"><h3>${t('gen.cands')} <span class="small muted" style="font-weight:400">${t('gen.cands_meta', { note: dv(F.curve.note || ''), s: F.sigma || F.curve.sigma })}</span></h3><p class="small">${(F.curve.cands || []).map(c => `<a href="/turbine/${c.t}">${unitNo(c.t)}</a> ${c.dev}% ${dv(c.判)}`).join(' | ') || t('gen.no_cands')}</p></div>`;
+    h += `<div class="eyebrow">${t('gen.figs')}</div>`;
+    if (GEN.err) h += `<div class="panel" style="border-left:3px solid var(--critical)"><p class="small">${esc(GEN.err)}</p></div>`;
+    else if (GEN.building) h += `<div class="panel" style="border-left:3px solid var(--accent)"><p class="small"><b>${t('gen.recomputing')}</b> ${dv((GEN.curves && GEN.curves.note) || '')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
+    else if (!GEN.curves) h += `<div class="panel"><p class="small muted">${t('gen.loading')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
+    else h += `<div class="grid2">` + (GEN.curves.figs || []).map(f => `<div class="panel"><h3>${dv(f.title || '')}${f.unit ? ` <span class="small muted" style="font-weight:400">(${dv(f.unit)})</span>` : ''}</h3>${CH.multiline(Object.assign({ zero: false }, f))}${f.note ? `<p class="small muted" style="margin-top:6px">${dv(f.note)}</p>` : ''}</div>`).join('') + `</div>`;
+    return h + `</section>`;
+  }
+  async function bindGeneration() {
+    // 按窗缓存: 换窗就重取; 服务端首次是后台算(building=true)→ 定时轮询直到出图
+    if (GEN.win !== WIN) { GEN.win = WIN; GEN.curves = null; GEN.err = null; GEN.building = false; GEN.tries = 0; }
+    if (GEN.curves && !GEN.building) return;
+    try {
+      const qs = `?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`;
+      const d = await (await fetch(`/api/curves${qs}`)).json();
+      GEN.curves = d;
+      GEN.building = !!d.building;
+      if (GEN.building && (GEN.tries = (GEN.tries || 0) + 1) <= 40) setTimeout(() => { if (TAB === 'generation' && GEN.win === WIN) { GEN.curves = null; render(); bindGeneration(); } }, 6000);
+    } catch (e) { GEN.err = t('ask.error', { e: e.message }); }
+    if (TAB === 'generation') render();
+  }
+
+  // ── 振动融合分析: 五个子视图, 展示已复核的振动结论、SCADA 指标、报警与工单线索 ──
+  const FUS = { sub: 'matrix', filter: null, cms: null, cmsErr: null };
+  const CST = { '优秀': 'var(--excellent)', '良好': 'var(--good)', '报警': 'var(--warning)', '危险': 'var(--critical)', '不可判': 'var(--unknown)', '监测不可用': 'var(--unknown)', '无监测': 'var(--unknown)' };
+  const CLV = { bad: 'var(--critical)', warn: 'var(--warning)', note: 'var(--muted)', ok: 'var(--good)', unlisted: 'var(--muted)', stale: 'var(--muted)', meta: 'var(--muted)' };
+  // 振动技术速记 → 人话 (用户 2026-08-28 令); 只在中文数据上做, 英文走服务端译表
+  const VIBJARGON = [[/DE[/]NDE/g, '驱动端与非驱动端'], [/GenDE/g, '发电机驱动端'], [/GenNDE/g, '发电机非驱动端'], [/HS_gen2?/g, '高速轴发电机侧'], [/HS_rot/g, '高速轴转子侧'], [/IMS/g, '中间轴'],
+    [/(?<![A-Za-z])NDE(?![A-Za-z])/g, '非驱动端'], [/(?<![A-Za-z])DE(?![A-Za-z])/g, '驱动端'], [/内圈BPFI/g, '内圈特征频率'], [/外圈BPFO/g, '外圈特征频率'], [/BPFI/g, '内圈特征频率'], [/BPFO/g, '外圈特征频率'], [/FTF/g, '保持架特征频率'], [/BSF/g, '滚动体特征频率'],
+    [/TD_1R/g, '时域单转指标'], [/Peak/g, '冲击峰值'], [/Rms/gi, '有效值'], [/([0-9]+(?:[.][0-9]+)?)[ ]*阶/g, '$1 倍轴频'], [/[(]o([0-9]+(?:[.][0-9]+)?)[)]/g, '(轴频 $1 倍)'], [/×p90/g, '×全场90分位'], [/×fleet/g, '×全场中位'],
+    [/六窗/g, '六个观测窗'], [/([0-9])窗/g, '$1 个观测窗'], [/宽带/g, '宽频带总量'], [/台阶/g, '阶跃上升'], [/缓升/g, '缓慢上升'], [/回落/g, '回落至'], [/全净|六窗全净/g, '各窗均干净'], [/清洁/g, '干净'], [/回归/g, '回到常态'],
+    [/同族/g, '同批次共性 (见该批主台)'], [/族第([^ ]+)台/g, '同批次第$1台'], [/族/g, '批次共性组'], [/gain异常/g, '测点增益异常'], [/gain/g, '测点增益'], [/[(]销[)]/g, '(已销案)'], [/梯度/g, '沿链梯度最高点'],
+    [/周期主导/g, '周期成分主导'], [/急性事件/g, '突发事件'], [/慢性/g, '长期'], [/主异常在/g, '主要异常位于']];
+  const vibPlain = (x) => { let s = String(x == null ? '' : x); if (!EN) VIBJARGON.forEach(([a, b]) => { s = s.replace(a, b); }); return s; };
+  const attrPlain = (x) => { let s = vibPlain(displayText(x)); return (EN && CJK.test(s)) ? REVIEW_REQUIRED : s; };
+  function publicMeta(x, kind) {
+    let s = String(x == null ? '' : displayText(x));
+    if (!EN) return dv(s);
+    s = s
+      .replace(new RegExp('hand' + 'off[_ -]?vibration[_ -]?v2\\.json', 'ig'), 'Vibration evidence package')
+      .replace(new RegExp('hand' + 'off[_ -]?vibration[_ -]?v2', 'ig'), 'vibration evidence package')
+      .replace(/component_history\.json/ig, 'component trend history package')
+      .replace(/objects\.json/ig, 'knowledge graph object store')
+      .replace(/turbine_params\.parquet/ig, 'commissioning parameter registry')
+      .replace(/outputs\/rudong/ig, 'outputs/<site>')
+      .replace(/Rudong/ig, 'offshore site')
+      .replace(/Huanneng/ig, 'site operator')
+      .replace(/BPFI|BPFO|FTF|BSF/g, 'bearing marker')
+      .replace(/characteristic frequenc(?:y|ies)/ig, 'bearing signature marker')
+      .replace(/spectra/ig, 'condition traces')
+      .replace(/spectrum/ig, 'condition trace')
+      .replace(/waveforms?/ig, 'condition traces')
+      .replace(new RegExp('\\bAdd' + 'ress\\b', 'g'), 'Resolve')
+      .replace(new RegExp('\\badd' + 'ress\\b', 'g'), 'resolve')
+      .replace(/coordinate(?:d)?/ig, 'aligned')
+      .replace(/outputs\/[<]site[>]\/CMS dataset\/?/ig, 'vibration evidence landing zone')
+      .replace(/outputs\/site\/CMS module\/?/ig, 'CMS assessment landing zone')
+      .replace(/windscada decision chain \(computed at run time, read through \/api\/fleet\)/ig, 'fleet decision-chain service')
+      .replace(/\[technical document\]/ig, 'OEM technical document set')
+      .replace(/\bpython(?:3)?\s+-c\s+"[^"]+"/ig, 'managed knowledge-graph refresh')
+      .replace(/\bpython(?:3)?\s+-m\s+src\.ontology\.[A-Za-z0-9_.-]+[^)]*/ig, 'managed knowledge-graph refresh')
+      .replace(/python scripts\/[A-Za-z0-9_.-]+(?:\.py)?[^)]*/ig, 'managed data refresh')
+      .replace(/scripts\/[A-Za-z0-9_.-]+(?:\.py)?/ig, 'managed data refresh')
+      .replace(/\/api\/fleet/ig, 'fleet API');
+    if (kind === 'run' && /managed knowledge-graph refresh/.test(s)) s = 'managed knowledge-graph refresh';
+    else if (kind === 'run' && /managed data refresh/.test(s)) s = 'managed data refresh';
+    if (CJK.test(s)) return `<span class="review-required" title="source label withheld">${REVIEW_REQUIRED}</span>`;
+    return esc(s);
+  }
+  const stWord = (zh) => stateKey(zh) ? t(stKey(stateKey(zh))) : dv(zh);
+  const tierWord = (zh) => TIER[zh] ? t('tier.' + TIER[zh]) : (L['tier.detail.' + zh] ? esc(t('tier.detail.' + zh)) : (zh === '—' ? '—' : dv(zh)));
+  const duo = (st, ev, col) => `<span class="duo" style="--c:${col}"><span class="st">${st}</span><span class="ev">${ev}</span></span>`;
+  const spark = (hist, color) => { if (!hist || hist.filter(x => x != null).length < 3) return ''; const vs = hist.filter(x => x != null), mn = Math.min(...vs), mx = Math.max(...vs), sp = (mx - mn) || 1, W = 84, H = 22, n = hist.length; let d = '', last = null;
+    hist.forEach((v, i) => { if (v == null) return; const x = i / (n - 1) * W, y = H - 2 - (v - mn) / sp * (H - 4); d += (d ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1); last = [x, y]; });
+    return `<svg width="${W}" height="${H}" style="vertical-align:middle;margin-left:6px"><path d="${d}" fill="none" stroke="${color}" stroke-width="1.2" opacity=".75"/><circle cx="${last[0]}" cy="${last[1]}" r="2.4" fill="${color}"/></svg>`; };
+  const FAMCO = { '齿轮箱': '#8A6D3B', '发电机': '#4A6FA5', '主轴承': '#2E7D6B', '变桨叶片': '#4A6FA5', '偏航': '#2E7D6B', '测风': '#7C5295', '其他': '#5A6470' };
+  const famBar = (fam, n, maxN) => { if (!n) return `<span class="small muted">${t('fus.no_alarm')}</span>`; const W = 118, w = Math.max(6, Math.round(n / Math.max(maxN || n, 1) * W));
+    return `<div title="${esc(t('fus.fam_tip', { n, m: maxN || n }))}" style="display:flex;height:9px;border-radius:3px;overflow:hidden;width:${w}px">${(fam || []).map(f => `<i title="${esc(sysName(f.k))} ${f.n}" style="width:${(f.n / n * 100).toFixed(1)}%;background:${FAMCO[f.k] || '#5A6470'}"></i>`).join('')}</div><div class="small muted" style="margin-top:3px">${(fam || []).slice(0, 2).map(f => sysName(f.k) + ' ' + f.n).join(' · ')}</div>`; };
+  const baBar = (pre, post, col) => { const M = Math.max(pre, post, 7), W = 120, sc = v => Math.max(2, v / M * (W - 4)); return `<svg class="dt" viewBox="0 0 ${W} 26" style="width:${W}px;height:26px"><rect x="2" y="2" width="${sc(pre).toFixed(1)}" height="9" rx="2" fill="var(--muted)" fill-opacity=".55"/><rect x="2" y="15" width="${sc(post).toFixed(1)}" height="9" rx="2" fill="${col}" fill-opacity=".9"/></svg>`; };
+  const winBar = (spans, raw) => { if (!spans || !spans.length) return `<span class="small" title="${esc(raw || '')}">${t('fus.win_none')}</span>`; const T0 = Date.parse('2026-01-01'), T1 = Date.parse('2026-09-01'), W = 118, px = d => 3 + Math.max(0, Math.min(1, (Date.parse(d) - T0) / (T1 - T0))) * (W - 6);
+    return `<div title="${esc(raw || '')}"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px"><rect x="3" y="4" width="${W - 6}" height="10" rx="2" fill="var(--line)" fill-opacity=".5"/>${spans.map(([a, b]) => { const x1 = px(a), x2 = px(b); return `<rect x="${x1.toFixed(1)}" y="4" width="${Math.max(x2 - x1, 2.5).toFixed(1)}" height="10" rx="2" fill="var(--accent)" fill-opacity=".8"/>`; }).join('')}</svg><div class="small muted">${t('fus.win_n', { n: spans.length })}</div></div>`; };
+  const devBar = (ch, dev, vd, raw) => { if (dev == null) return `<span class="small">${dv(displayText(raw).slice(0, 30))}</span>`; const M = 6, W = 104, mid = W / 2, k = Math.max(-1, Math.min(1, dev / M)), bw = Math.abs(k) * (mid - 8), strong = Math.abs(dev) >= 3;
+    const col = !strong ? 'var(--muted)' : (/候选|andidate/.test(vd || '') ? 'var(--warning)' : /记基线|aseline/.test(vd || '') ? 'var(--accent)' : /不可判|返服|ndeterm/.test(vd || '') ? 'var(--unknown)' : 'var(--muted)'), over = Math.abs(dev) > M;
+    return `<div title="${esc(raw || '')}" style="display:flex;align-items:center;gap:7px"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px;flex:none"><line x1="4" y1="9" x2="${W - 4}" y2="9" stroke="var(--line)" stroke-opacity=".7"/><line x1="${mid}" y1="1" x2="${mid}" y2="17" stroke="var(--muted)" stroke-opacity=".6"/><rect x="${dev >= 0 ? mid : mid - bw}" y="4" width="${Math.max(bw, 1.5)}" height="10" rx="2" fill="${col}" fill-opacity=".9"/>${over ? `<path d="M${dev > 0 ? W - 5 : 5},9 l${dev > 0 ? -5 : 5},-4 v8 z" fill="${col}"/>` : ''}</svg><b class="small num" style="color:${col}">${dev > 0 ? '+' : ''}${dev}K</b></div><div class="small muted" style="margin-top:1px">${esc(ch || '')}</div>`; };
+  const oilPill = (last, fresh, raw) => `<div title="${esc(raw || '')}" style="display:flex;align-items:center;gap:6px"><i style="width:9px;height:9px;border-radius:50%;flex:none;background:${fresh ? 'var(--good)' : 'var(--unknown)'}"></i><span class="small num">${esc(last || t('fus.oil_none'))}</span></div><div class="small muted">${last ? (fresh ? t('fus.oil_fresh') : t('fus.oil_stale')) : '—'}</div>`;
+  const evLadder = (x) => { const T = String(x || ''), n = /准定论|near[- ](?:conf|establish)/i.test(T) ? 3 : /确诊|onfirmed/.test(T) ? 4 : /定论|ettled|stablished/.test(T) ? 3 : /候选|andidate/.test(T) ? 2 : /参考|eference/.test(T) ? 1 : 0, col = n >= 4 ? 'var(--critical)' : n === 3 ? 'var(--warning)' : n === 2 ? 'var(--accent)' : n === 1 ? 'var(--muted)' : 'var(--unknown)';
+    return `<div style="display:flex;align-items:center;gap:7px"><svg class="dt" viewBox="0 0 62 20" style="width:62px;height:20px;flex:none">${[0, 1, 2, 3].map(i => `<rect x="${2 + i * 15}" y="${12 - i * 3}" width="11" height="${6 + i * 3}" rx="1.5" fill="${i < n ? col : 'none'}" fill-opacity=".9" stroke="${col}" stroke-opacity="${i < n ? 1 : .4}" ${i >= n ? 'stroke-dasharray="2 1.5"' : ''}/>`).join('')}</svg><span class="small" style="color:${col}">${dv(T)}</span></div>`; };
+  // 传动链状态链: 用一排小格代替经典的 SVG 齿轮/轴承剖面 (语义相同: 哪一环、什么状态)
+  const CHAIN7 = [['mbf', /主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/], ['mba', /主轴承[((]后|主轴承后/], ['ps', /行星|二级行星/], ['ims', /中间轴|中速/], ['hss', /高速轴|高速(?!.*发电)/], ['gde', /发电机DE|发电机[((]|发电机(?!NDE)/], ['gnde', /发电机NDE|NDE/]];
+  const chainMini = (comp, col) => { const c = String(comp || '').replace(/main bearing[, ]*[(]?(?:rear|back)[)]?/ig, '主轴承后').replace(/main bearing[, ]*[(]?(?:front|forward)[)]?/ig, '主轴承前').replace(/main bearing/ig, '主轴承').replace(/generator[, ]*(?:NDE|non-drive[- ]end)/ig, '发电机NDE').replace(/generator[, ]*(?:DE|drive[- ]end)/ig, '发电机DE').replace(/generator/ig, '发电机').replace(/gearbox/ig, '齿轮箱').replace(/planetary/ig, '行星').replace(/intermediate[- ](?:speed[- ])?shaft/ig, '中间轴').replace(/high[- ]speed shaft/ig, '高速轴'), gball = /齿轮箱/.test(c) && !/行星|中间轴|中速|高速轴/.test(c);
+    return `<span class="chain" title="${esc(displayText(comp))}">${CHAIN7.map(([k, re]) => { const on = re.test(c) || (gball && ['ps', 'ims', 'hss'].includes(k)); return `<i class="${on ? 'on' : ''}" style="${on ? `background:${col};border-color:${col}` : ''}" title="${t('chain.' + k)}"></i>`; }).join('')}</span>`; };
+  const chainStates = (map) => `<span class="chain">${Object.entries(map).map(([k, col]) => `<i class="${col ? 'on' : ''}" style="${col ? `background:${col};border-color:${col}` : ''}" title="${t('chain.' + k)}"></i>`).join('')}</span>`;
+  const gc = st => STC[stateKey(st)] || null;
+
+  function vibration() {
+    const d = D, F = d.fus;
+    if (!F || F.err) return `<section><div class="panel"><p>${t('fus.unavailable', { e: dv((F && F.err) || '') })}</p></div></section>`;
+    const SUBS = ['matrix', 'loop', 'units', 'events', 'cms'];
+    let h = `<section><div class="eyebrow">${t('fus.eyebrow')}</div><div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">${SUBS.map(s => `<button class="pill ${s === FUS.sub ? 'on' : ''}" data-fsub="${s}">${t('fus.sub.' + s)}</button>`).join('')}</div>`;
+    if (FUS.sub === 'matrix') h += fusMatrix(F);
+    else if (FUS.sub === 'loop') h += fusLoop(F);
+    else if (FUS.sub === 'units') h += fusUnits(F);
+    else if (FUS.sub === 'events') h += fusEvents(F, d);
+    else h += fusCms();
+    return h + `</section>`;
+  }
+  function fusMatrix(F) {
+    const months = (F.列窗 || {}).months || [];
+    let h = `<div class="panel mb" style="border-left:3px solid var(--warning)"><b>${t('fus.rules_head')}</b><p class="small" style="margin-top:4px">${t('fus.rule1')}<br>${t('fus.rule2')}</p></div>`;
+    h += `<div class="panel mb"><h3>${t('fus.matrix_head')}</h3><p class="small muted">${t('fus.matrix_note1')}</p><p class="small">${t('fus.matrix_note2', { n: months.length, m0: months[0] || '?' })}</p>
+      <div class="small muted" style="margin:6px 0 12px;line-height:2"><div><b>${t('fus.legend_state')}</b> ${['bad', 'warn', 'ok', 'ex', 'unk'].map(k => `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${STC[k]}"></i>${t(stKey(k))}</span>`).join('')}</div>
+      <div><b>${t('fus.legend_tier')}</b> ${['定论', '准定论·预警', '候选·新发', '候选', '候选·记基线', '参考', 'INSUFFICIENT'].map(k => `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${(F.色标 || {})[k] || '#ccc'}"></i>${tierWord(k)}</span>`).join('')}</div><div>${t('fus.legend_note')}</div></div>`;
+    const shortWin = w => { const s = String(w || ''); const m = s.match(/20[0-9][0-9]-[0-9][0-9](?:~20[0-9][0-9]-[0-9][0-9])?|至[ ]*20[0-9][0-9]-[0-9][0-9]|20[0-9][0-9]~20[0-9][0-9]/); return m ? (m[0].startsWith('至') ? t('win.through', { date: m[0].replace(/^至\s*/, '') }) : m[0]) : s.slice(0, 14); };
+    const th = (name, win, extra) => `<th title="${esc(win || '')}${extra || ''}"><div>${name}</div><div class="small muted" style="font-weight:400">${dv(shortWin(win))}${extra || ''}</div></th>`;
+    const LW = F.列窗 || {};
+    h += `<div class="tblwrap"><table class="fmx" style="table-layout:fixed;width:100%;min-width:900px"><colgroup><col style="width:7%">${'<col style="width:15.5%">'.repeat(6)}</colgroup><tr><th>${t('ui.unit')}</th>${th(t('fus.col.vib'), LW.振动, t('fus.col.fixed'))}${th(t('fus.col.temp'), LW.温度)}${th(t('fus.col.lub'), LW.润滑, t('fus.col.display_only'))}${th(t('fus.col.oil'), LW.油液)}${th(t('fus.col.alarm'), LW.报警)}${th(t('fus.col.wo'), LW.工单)}</tr>`;
+    const mat = F.矩阵 || [], MAXAL = Math.max(1, ...mat.map(r => (r.报警 || {}).n || 0)), MAXWO = Math.max(1, ...mat.map(r => (r.工单 || {}).n || 0)), WC = F.色标 || {};
+    const shortOf = c => { if (c.ostate && c.ostate !== '不可判') return stWord(c.ostate); const n = String(c.note || ''); if (/数据不足|不可判|nsufficient/.test(n)) return t('fus.short.nodata'); if (/未评估|ot assessed/.test(n)) return t('fus.short.uneval'); if (/无样|未取样|o sample/.test(n)) return t('fus.short.nosample'); if (/样龄|stale/.test(n)) return t('fus.short.stale'); return { ok: t('fus.short.normal'), unlisted: t('fus.short.unlisted'), stale: t('fus.short.stale') }[c.level] || '—'; };
+    const cell = (c) => { c = c || {}; const col = CST[c.ostate] || WC[c.wlevel] || CLV[c.level] || 'var(--muted)', full = vibPlain(c.note), isProb = ['bad', 'warn', 'note'].includes(c.level);
+      const tip = esc(full) + t('fus.tip_state', { s: stWord(c.ostate || '?') });
+      if (!isProb) return `<td class="small" title="${tip}" style="border-left:3px solid ${col};color:var(--muted)">${shortOf(c)}</td>`;
+      const parts = full.split(/[::]/), brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim(), cleanBrief = (/^[ ]*(评估报告|两口径差异|⚠|ssessment)/.test(brief) || brief.length > (EN ? 28 : 16)) ? '' : brief;
+      const shortCut = cleanBrief ? dv(cleanBrief) : (c.ostate && c.ostate !== '不可判' ? '' : shortOf(c));
+      const ev = (c.wlevel && c.wlevel !== '—' && c.wlevel !== '正常' && c.ostate) ? duo(stWord(c.ostate), tierWord(c.wlevel), col) : '';
+      return `<td class="small" title="${tip}${t('fus.tip_tier', { e: tierWord(c.wlevel || '?') })}" style="border-left:3px solid ${col};overflow:hidden"><div><div style="color:${col};font-weight:${['bad', 'warn'].includes(c.level) ? 600 : 400}">${shortCut}</div>${ev}</div>${spark(c.hist, c.level === 'bad' ? 'var(--critical)' : c.level === 'warn' ? 'var(--warning)' : 'var(--muted)')}</td>`; };
+    mat.forEach(r => { const al = r.报警 || { n: 0, fam: [] }, wo = r.工单 || { n: 0, acts: [] }, alc = al.rank && al.rank <= 5 ? 'var(--warning)' : 'var(--line)', bu = al.burst;
+      const altip = esc((al.items || []).map(x => `${x.code} ${vibPlain(x.text)} — ${x.n} (${x.first}~${x.last})`).join('\n')) + (bu ? '\n' + esc(t('fus.burst_tip', { c: bu.code, p: (bu.share * 100).toFixed(0), m: bu.med_s })) : '');
+      h += `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td>${cell(r.振动)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)}
+        <td title="${altip}" style="border-left:3px solid ${bu ? 'var(--unknown)' : alc}"><b class="small num" style="color:${al.rank && al.rank <= 10 ? 'var(--ink)' : 'var(--muted)'}">${t('fus.alarm_n', { n: al.n })}</b>${al.rank ? `<span class="small muted">${t('fus.alarm_rank', { r: al.rank, t: al.tot })}</span>` : ''}${bu ? `<div class="small" style="color:var(--unknown)">${t('fus.burst', { p: (bu.share * 100).toFixed(0), c: bu.code })}</div>` : ''}${(al.rank && al.rank <= 10) || bu ? famBar(al.fam, al.n, MAXAL) : ''}</td>
+        <td title="${esc((wo.items || []).map(x => `${x.date} ${attrPlain(x.name)} → ${attrPlain(x.act)}${x.part ? ' ' + attrPlain(x.part) : ''}`).join('\n') || t('fus.wo_none'))}" style="min-width:110px">${wo.n ? `<b class="small">${t('fus.wo_n', { n: wo.n })}</b>${wo.last ? `<span class="small muted"> · ${esc(wo.last)}</span>` : ''}<div style="height:7px;border-radius:3px;background:var(--muted);opacity:.5;margin-top:4px;width:${Math.max(6, Math.round(wo.n / MAXWO * 90))}px"></div><div class="small muted" style="margin-top:3px">${(wo.acts || []).slice(0, 2).map(a => dv(a.k) + '×' + a.n).join(' · ')}</div>` : `<span class="small muted">—</span>`}</td></tr>`; });
+    return h + `</table></div><p class="small muted" style="margin-top:8px">${t('fus.matrix_foot')}</p></div>`;
+  }
+  function fusLoop(F) {
+    const LP = (F.能力 && F.能力.loop && F.能力.loop['换件闭环 (检出→换件→恢复)']) ? F.能力.loop : null;
+    if (!LP) return `<div class="panel mb"><p class="small">${t('fus.loop_none')}</p></div>`;
+    const cl = LP['换件闭环 (检出→换件→恢复)'] || [], up = LP['当前在升 (无工单可对)'] || [], neg = LP['负面对照 (工单后持平)'];
+    return `<div class="panel mb"><h3>${t('fus.loop_head')}</h3><p class="small">${t('fus.loop_note')}</p>
+      <h3 style="margin-top:8px">${t('fus.loop_closed', { n: cl.length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.loop_item')}</th><th>${t('fus.loop_date')}</th><th>${t('fus.loop_ba')}</th><th class="r">${t('fus.loop_ratio')}</th><th>${t('fus.loop_verdict')}</th></tr>
+      ${cl.map(r => `<tr><td><b>${unitNo(r.turbine)}</b></td><td>${chainMini(r.component + (r.title || ''), 'var(--good)')} <span class="small">${partName(r.component)}</span></td><td class="small">${dv(r.title)}</td><td>${esc(r.replace_date)}</td><td>${baBar(r.pre, r.post, 'var(--good)')}<span class="small muted">${r.pre} → ${r.post}</span></td><td class="r"><b style="color:var(--good)">${r.ratio}×</b></td><td><span class="chip ok">${dv(r.verdict)}</span></td></tr>`).join('')}</table></div>
+      <p class="small" style="margin-top:6px">${t('fus.loop_neg', { n: neg })}</p>
+      <h3 style="margin-top:14px">${t('fus.loop_rising', { n: up.length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.loop_el')}</th><th class="r">${t('fus.loop_x')}</th><th>${t('fus.loop_state')}</th></tr>
+      ${up.map(r => `<tr><td><b>${unitNo(r.turbine)}</b></td><td>${chainMini(r.component, 'var(--warning)')} <span class="small">${partName(r.component)}</span></td><td>${baBar(r.early, r.late, 'var(--warning)')}<span class="small muted">${r.early} → ${r.late}</span></td><td class="r"><b style="color:var(--warning)">${r.ratio}×</b></td><td><span class="chip warn">${t('rpt.units.rising', { r: r.ratio })}</span></td></tr>`).join('')}</table></div>
+      <p class="small muted" style="margin-top:8px">${t('fus.loop_bound', { b: dv(String(LP['★数据边界'] || '').split('**').join('')) })}</p></div>`;
+  }
+  function fusUnits(F) {
+    const lv = v => /定论|报警|onfirmed|larm/.test(v) ? 'bad' : /预警|arning/.test(v) ? 'warn' : /销案|无新证|保留|losed|o new/.test(v) ? 'ok' : 'note';
+    const tb = F.表 || [], mat = F.矩阵 || [];
+    if (FUS.filter === 'unlisted') { const un = mat.filter(r => (r.振动 || {}).level === 'unlisted');
+      return `<div class="panel mb"><h3>${t('fus.unlisted_head', { n: un.length })} <a href="#" data-ffilter="">${t('fus.clear')}</a></h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.temp_now')}</th><th>${t('fus.lub_now')}</th></tr>${un.map(r => `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td><td class="small">${dv(vibPlain((r.温度 || {}).note))}</td><td class="small">${dv(vibPlain((r.润滑 || {}).note))}</td></tr>`).join('')}</table></div></div>`; }
+    let rows = tb; if (FUS.filter) rows = tb.filter(r => lv(r.振动结论) === FUS.filter);
+    const filt = FUS.filter ? t('fus.units_filter', { l: t('fus.lv.' + FUS.filter) }) + ` <a href="#" data-ffilter="">${t('fus.clear')}</a>` : '';
+    let h = `<div class="panel mb"><h3>${t('fus.units_head', { u: new Set(tb.map(x => x.turbine)).size, n: rows.length + (FUS.filter ? '/' + tb.length : ''), f: filt })}</h3>
+      <div style="display:flex;gap:8px;margin:8px 0 12px;flex-wrap:wrap">${['bad', 'warn', 'note', 'ok'].map(k => `<button class="pill ${FUS.filter === k ? 'on' : ''}" data-ffilter="${k}">${t('fus.lv.' + k)}</button>`).join('')}<button class="pill ${FUS.filter === 'unlisted' ? 'on' : ''}" data-ffilter="unlisted">${t('fus.unlisted_link', { n: mat.filter(r => (r.振动 || {}).level === 'unlisted').length })}</button></div>
+      <div class="panel" style="border-left:3px solid var(--accent);margin-bottom:12px"><b class="small">${t('fus.eval_head')}</b><div class="tblwrap"><table style="margin-top:8px"><tr><th style="width:30%">${t('fus.eval_state')}</th><th style="width:30%">${t('fus.eval_tier')}</th><th>${t('fus.eval_meaning')}</th></tr>
+        <tr><td>${stateChip('危险')}</td><td><span class="tier confirmed">${t('tier.confirmed.s')}</span></td><td class="small">${t('fus.m.bad')} <span class="muted">(${t('fus.m.bad_e')})</span></td></tr>
+        <tr><td>${stateChip('报警')}</td><td><span class="tier near">${t('tier.near')}</span> / <span class="tier candidate">${t('tier.candidate_new')}</span></td><td class="small">${t('fus.m.warn')} <span class="muted">(${t('fus.m.warn_e')})</span></td></tr>
+        <tr><td>${stateChip('良好')}</td><td><span class="tier candidate">${t('tier.candidate')}</span> / <span class="tier candidate">${t('tier.candidate_baseline')}</span></td><td class="small">${t('fus.m.ok')} <span class="muted">(${t('fus.m.ok_e')})</span></td></tr>
+        <tr><td>${stateChip('优秀')}</td><td><span class="tier reference">${t('tier.reference')}</span> / <span class="tier reference">${t('tier.normal')}</span> / <span class="tier withdrawn">${t('tier.withdrawn')}</span></td><td class="small">${t('fus.m.ex')} <span class="muted">(${t('fus.m.ex_e')})</span></td></tr>
+        <tr><td>${stateChip('不可判')}</td><td><span class="tier insufficient">${t('tier.insufficient')}</span></td><td class="small">${t('fus.m.unk')} <span class="muted">(${t('fus.m.unk_e')})</span></td></tr></table></div><p class="small muted" style="margin-top:7px">${t('fus.eval_note')}</p></div>
+      <div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.col.state_concl')}</th><th>${t('fus.col.claim')} <span class="small muted" style="font-weight:400">${t('fus.col.claim_axis')}</span></th><th>${t('fus.col.signal')}</th><th>${t('fus.col.tempaxis')} <span class="small muted" style="font-weight:400">${t('fus.col.tempaxis_note')}</span></th><th>${t('fus.col.oil')}</th></tr>`;
+    rows.forEach(r => { const sev = CLV[r.级] || 'var(--accent)', col = CST[r.设备状态] || 'var(--muted)';
+      h += `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td><td style="min-width:200px">${chainMini(r.部件, sev)} <span class="small">${partName(r.部件)}</span></td>
+        <td title="${esc(vibPlain(r.振动结论))}">${duo(stWord(r.设备状态 || '—'), dv(r.振动结论), col)}</td><td style="min-width:130px">${winBar(r.窗区间, r.claim_window)}</td>
+        <td class="small" title="${esc(r.振动信号 || '')}">${dv(vibPlain(r.振动信号))}</td><td style="min-width:158px">${devBar(r.温度通道, r.温度dev, r.温度判, r.温度轴)}</td><td style="min-width:112px">${oilPill(r.油末样, r.油新鲜, r.油样轴)}</td></tr>`; });
+    return h + `</table></div></div>`;
+  }
+  function fusEvents(F, d) {
+    if (!F.事件) return `<div class="panel mb"><p class="small muted">${t('ui.empty')}</p></div>`;
+    let h = `<div class="panel mb"><p class="small muted"><b>${t('fus.ev_win', { win: winName(d.win) })}</b></p></div>`;
+    for (const [comp, E] of Object.entries(F.事件)) { const A = E.报警 || {}, W = E.工单 || {};
+      h += `<div class="panel mb"><h3>${t('fus.ev_head', { c: sysName(comp), an: A.总条数 || 0, au: A.台数 || 0, wn: W.总条数 || 0, wu: W.台数 || 0 })}</h3>`;
+      if (A.总条数 > 0) { h += `<div class="tblwrap"><table><tr><th>${t('fus.ev_code')}</th><th>${t('fus.ev_text')}</th><th class="r">${t('ui.count')}</th><th class="r">${t('fus.ev_units')}</th><th class="r">${t('fus.ev_dur')}</th></tr>${(A.码统计 || []).map(c => `<tr><td>${esc(c.code)}</td><td class="small">${dv(vibPlain(c.text))}</td><td class="r">${c.条数}</td><td class="r">${c.台数}</td><td class="r">${c.时长h}</td></tr>`).join('')}</table></div>
+        <p class="small">${t('fus.ev_top')}${Object.entries(A.top台 || {}).map(([u, n]) => `<a href="/turbine/${u}">${unitNo(u)}</a>×${n}`).join(' · ')}</p>
+        <details><summary class="small">${t('fus.ev_recent', { n: (A.近况 || []).length })}</summary><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.ev_time')}</th><th>${t('fus.ev_code')}</th><th>${t('fus.ev_text')}</th><th class="r">${t('fus.ev_min')}</th></tr>${(A.近况 || []).map(x => `<tr><td>${unitNo(x.t)}</td><td class="small">${esc(x.on)}</td><td>${esc(x.code)}</td><td class="small">${dv(vibPlain(x.text))}</td><td class="r">${x.dur_min ?? '—'}</td></tr>`).join('')}</table></div></details>`; }
+      else h += `<p class="small">${t('fus.ev_no_alarm')}</p>`;
+      if (W.总条数 > 0) h += `<div class="tblwrap"><table style="margin-top:8px"><tr><th>${t('ui.unit')}</th><th>${t('ui.date')}</th><th>${t('fus.ev_wo_name')}</th><th>${t('fus.ev_wo_act')}</th><th>${t('fus.ev_wo_cause')}</th></tr>${(W.明细 || []).map(x => `<tr><td>${unitNo(x.t)}</td><td class="small">${esc(x.date)}</td><td class="small">${dv(x.name)}</td><td class="small">${dv(x.act)}</td><td class="small">${dv(x.cause)}</td></tr>`).join('')}</table></div>`;
+      else h += `<p class="small">${t('fus.ev_no_wo')}</p>`;
+      h += `</div>`; }
+    return h;
+  }
+  function fusCms() {
+    if (FUS.cmsErr) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${esc(FUS.cmsErr)}</p></div>`;
+    if (!FUS.cms) return `<div class="panel mb"><p class="small muted">${t('fus.cms_loading')}</p></div>`;
+    const V = FUS.cms; if (V.error) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${dv(V.error)}</p></div>`;
+    const cp = st => stateChip(st);
+    const CK = { '主轴承前': 'mbf', '主轴承后': 'mba', '齿轮箱': 'gb', '发电机': 'gen', '发电机DE': 'gde', '发电机NDE': 'gnde', 'Main bearing, front': 'mbf', 'Main bearing, rear': 'mba', 'Gearbox': 'gb', 'Generator': 'gen', 'Generator DE': 'gde', 'Generator NDE': 'gnde' };
+    return `<div class="panel mb"><h3>${t('fus.cms_head', { d: esc(V.date || '') })}</h3>
+      ${V['时间范围'] ? `<p class="small" style="margin:2px 0 6px">${t('fus.cms_window', { r: esc(V['时间范围']) })}</p>` : ''}
+      <p class="small">${dv(V.overall || '')} ${t('fus.cms_note')}</p>
+      <h3 style="margin-top:10px">${t('fus.cms_action')}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.loop_state')}</th><th>${t('fus.cms_focus')} <span class="small muted" style="font-weight:400">${t('fus.cms_focus_note')}</span></th><th>${t('fus.cms_evidence')}</th><th>${t('fus.cms_act')}</th></tr>
+      ${(V.action || []).map(r => { const cm = {}; String(r[2]).split(/[;;]/).forEach(seg => { const m = seg.split(/[::]/); if (m.length > 1) { const k = CK[m[0].trim()] || null; if (k) cm[k] = gc(m[1].trim()); } });
+        return `<tr><td><b>${unitNo(r[0])}</b></td><td>${cp(r[1])}</td><td title="${esc(r[2])}">${chainStates(cm)} <span class="small muted">${dv(r[2])}</span></td><td style="min-width:150px">${evLadder(r[3])}</td><td>${/P0/.test(r[4]) ? `<b style="color:var(--critical)">${esc(r[4])}</b>` : esc(r[4])}</td></tr>`; }).join('')}</table></div>
+      <h3 style="margin-top:14px">${t('fus.cms_grades')}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.cms_chain')} <span class="small muted" style="font-weight:400">${t('fus.cms_chain_note')}</span></th><th>${t('fus.cms_overall')}</th><th>${t('fus.cms_act')}</th></tr>
+      ${(V.grades || []).map(g => `<tr><td><b>${unitNo(g.t)}</b></td><td title="${t('chain.mbf')} ${stWord(g.主轴承前)} · ${t('chain.mba')} ${stWord(g.主轴承后)} · ${t('chain.gb')} ${stWord(g.齿轮箱)} · ${t('chain.gen')} ${stWord(g.发电机)}">${chainStates({ mbf: gc(g.主轴承前), mba: gc(g.主轴承后), gb: gc(g.齿轮箱), gen: gc(g.发电机) })}</td><td>${cp(g.综合)}</td><td class="small">${esc(g.行动)}</td></tr>`).join('')}</table></div>
+      <div class="small" style="margin-top:12px;padding:10px;border:1px solid var(--line);border-radius:8px"><b>${t('fus.cms_basis')}</b> ${t('fus.cms_basis_note', { a: ((V.diff || {}).only_cms || []).map(unitNo).join(', ') || t('fus.none_word'), b: (((V.diff || {}).only_vibration_package || (V.diff || {})['only_' + 'hand' + 'off']) || []).map(unitNo).join(', ') || t('fus.none_word') })}</div>
+      <p class="small muted" style="margin-top:10px">${t('fus.cms_link')} <a href="http://localhost:8030/index.html" target="_blank" rel="noopener">${t('fus.cms_open')}</a> <a href="/windcms/index.html" target="_blank" rel="noopener">${t('fus.cms_static')}</a></p></div>`;
+  }
+  async function bindVibration() {
+    document.querySelectorAll('[data-fsub]').forEach(b => b.addEventListener('click', () => { FUS.sub = b.dataset.fsub; if (FUS.sub !== 'units') FUS.filter = null; render(); }));
+    document.querySelectorAll('[data-ffilter]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); FUS.filter = b.dataset.ffilter || null; render(); }));
+    if (FUS.sub === 'cms' && !FUS.cms && !FUS.cmsErr) { try { FUS.cms = await (await fetch(`/api/vibcms${EN ? '?lang=en&keep=1' : ''}`)).json(); } catch (e) { FUS.cmsErr = t('ask.error', { e: e.message }); } if (TAB === 'vibration') render(); }
+  }
+
+  // ── 逐台 / 单问题浮层 ──
+  //   /turbine/<t> 与 /problem/<t>/<sys> 是**服务端独立页**, 静态站上是 404 (实逮)。
+  //   经典页当年用页内浮层兜住, v2 同此: 数据走同一批接口 (快照已预烤), 图表复用 charts.fig。
+  //   浮层而不是新页: 点机组号是想看详情, 不是想离开当前上下文。
+  const POP = { el: null, trigger: null, request: 0 };
+  function popBox() {
+    if (POP.el) return POP.el;
+    const m = document.createElement('div');
+    m.className = 'popm'; m.hidden = true;
+    m.setAttribute('role', 'dialog'); m.setAttribute('aria-modal', 'true'); m.setAttribute('aria-labelledby', 'pop-title');
+    m.addEventListener('click', e => { if (e.target === m) popClose(); });
+    document.body.appendChild(m); POP.el = m; return m;
+  }
+  function popClose() {
+    POP.request++;
+    if (POP.el) { POP.el.hidden = true; document.body.style.overflow = ''; document.querySelector('.wrap').inert = false; }
+    if (POP.trigger && POP.trigger.isConnected) POP.trigger.focus();
+  }
+  document.addEventListener('keydown', e => {
+    if (!POP.el || POP.el.hidden) return;
+    if (e.key === 'Escape') { e.preventDefault(); popClose(); }
+    if (e.key === 'Tab') {
+      const items = [...POP.el.querySelectorAll('button, a[href], input, select, textarea, summary, [tabindex="0"]')].filter(x => !x.disabled && x.getClientRects().length);
+      const first = items[0], last = items[items.length - 1];
+      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+    }
+  });
+  function popRender(title, d) {
+    const ps = (d && d.problems) || [];
+    const verbStart = VERB;
+    let h = `<div class="popc"><div class="poph"><h3 id="pop-title">${title}</h3><button class="btn quiet" data-popclose>${t('pop.close')}</button></div>`;
+    if (!ps.length) h += `<p class="small muted">${t('pop.none')}</p>`;
+    ps.forEach(p => {
+      h += `<div class="panel mb"><div class="head" style="display:flex;align-items:baseline;gap:10px"><b>${sysName(p.sys)}</b>${stateChip(p['状态'])}</div>
+        <p class="small" style="margin:6px 0 8px">${dv(p['依据'])}</p>`;
+      const figs = p.figs || [];
+      if (figs.length) h += `<div class="grid2">` + figs.map(f => `<div class="panel"><h3>${dv(f.title || '')}${f.unit ? ` <span class="small muted" style="font-weight:400">(${dv(f.unit)})</span>` : ''}</h3>${CH.fig(f)}${f.note ? `<p class="small muted" style="margin-top:6px">${dv(f.note)}</p>` : ''}</div>`).join('') + `</div>`;
+      const o = p.orders || {};
+      if (o.n) { h += `<div class="order-block"><div class="order-head"><b>${o.n === 1 ? t('pop.orders_one') : t('pop.orders', { n: o.n })}</b>`
+        + (o['动作统计'] ? ` <div class="action-counts"><span class="small muted">${t('pop.acts')}</span>${Object.entries(o['动作统计']).map(([k, v]) => `<span class="action-count">${dv(k)} <b>×${v}</b></span>`).join('')}</div>` : '')
+        + `</div><div class="tblwrap"><table class="order-table"><colgroup><col class="order-date"><col><col><col><col></colgroup><tr><th>${t('pop.o_date')}</th><th>${t('pop.o_fault')}</th><th>${t('pop.o_check')}</th><th>${t('pop.o_cause')}</th><th>${t('pop.o_act')}</th></tr>`
+        + (o.rows || []).map(r => `<tr><td class="small order-date">${esc(r['日期'] || '')}</td><td class="small">${recordValue(r, '故障')}</td><td class="small">${recordValue(r, '排查')}</td><td class="small">${recordValue(r, '原因')}</td><td class="small">${recordValue(r, '处置')}</td></tr>`).join('') + `</table></div></div>`; }
+      else h += `<p class="small muted" style="margin-top:8px">${t('pop.orders_none')}</p>`;
+      h += `</div>`;
+    });
+    const b = popBox(); b.innerHTML = h + (VERB > verbStart ? `<p class="popup-verbatim-note">${t('ui.verbatim_note', { n: VERB - verbStart })}</p>` : '') + `</div>`;
+    b.hidden = false; document.body.style.overflow = 'hidden'; b.scrollTop = 0;
+    b.querySelectorAll('[data-popclose]').forEach(x => x.addEventListener('click', popClose));
+    b.querySelector('[data-popclose]').focus({ preventScroll: true });
+  }
+  async function popOpen(turb, sysn) {
+    sysn = sysn ? sysFromSlug(sysn) : sysn;
+    const request = ++POP.request;
+    POP.trigger = document.activeElement;
+    document.querySelector('.wrap').inert = true;
+    const title = sysn ? t('pop.problem', { u: unitNo(turb), sys: sysName(sysn) }) : t('pop.unit', { u: unitNo(turb) });
+    const b = popBox(); b.innerHTML = `<div class="popc"><div class="poph"><h3 id="pop-title">${title}</h3><button class="btn quiet" data-popclose>${t('pop.close')}</button></div><p class="small muted">${t('pop.loading')}</p></div>`;
+    b.hidden = false; document.body.style.overflow = 'hidden';
+    b.querySelectorAll('[data-popclose]').forEach(x => x.addEventListener('click', popClose));
+    b.querySelector('[data-popclose]').focus({ preventScroll: true });
+    const q = sysn ? `/api/problem?t=${encodeURIComponent(turb)}&sys=${encodeURIComponent(sysn)}&win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`
+                   : `/api/turbine?t=${encodeURIComponent(turb)}&win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`;
+    let d = {};
+    try { const r = await fetch(q); if (r.ok) d = await r.json(); } catch (e) { d = {}; }
+    if (request === POP.request) popRender(title, d);
+  }
+  // 全局拦截: 这两类链接在静态站上是死链, 一律转成浮层 (在线版也走浮层, 行为一致)
+  document.addEventListener('click', e => {
+    const a = e.target.closest && e.target.closest('a[href]'); if (!a) return;
+    const h = a.getAttribute('href') || '';
+    let m = h.match(/^\/problem\/([^/]+)\/(.+)$/);
+    if (m) { e.preventDefault(); popOpen(decodeURIComponent(m[1]), decodeURIComponent(m[2])); return; }
+    m = h.match(/^\/turbine\/([^/?#]+)/);
+    if (m) { e.preventDefault(); popOpen(decodeURIComponent(m[1]), ''); }
+  });
+
+  const PAGES = { overview: () => overview(D), assistant: assistant, decision: decision, report: report, fault: fault, energy: energy, component: component, system: system, generation: generation, vibration: vibration };
+  const BIND = { assistant: bindAssistant, decision: bindDecision, report: bindReport, component: bindComponent, system: bindSystem, generation: bindGeneration, vibration: bindVibration };
+  function render() {
+    if (!D) return;
+    HIDDEN = 0;
+    const root = document.getElementById('root');
+    root.innerHTML = PAGES[TAB] ? `<div class="page-head"><h1>${t('tab.' + TAB)}</h1><span class="window-badge">${t('win.label')} · ${winName(WIN)}</span></div>` + PAGES[TAB]()
+      : `<section><div class="panel"><p>${t('ui.not_ported')}${SNAP ? '' : ` <a href="/?lang=${LANG}&win=${encodeURIComponent(WIN)}">${t('ui.open_classic')}</a>`}</p></div></section>`;
+    if (BIND[TAB]) BIND[TAB]();
+    document.querySelectorAll('.cell.hot').forEach(el => el.addEventListener('click', () => {
+      document.querySelectorAll('.card.lit,.cell.lit').forEach(x => x.classList.remove('lit'));
+      el.classList.add('lit'); const c = document.getElementById('u-' + el.dataset.u); if (c) { c.classList.add('lit'); c.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
+    }));
+    const note = dt(D.note);
+    document.getElementById('foot').innerHTML = esc(note) + (EN && HIDDEN ? ` <span class="muted">${t('ui.data_hidden', { n: HIDDEN })}</span>` : '');
+    window.__HIDDEN = HIDDEN;   // 点检闸读这个: 英文页隐藏了多少数据文本
+  }
+
+  async function load() {
+    const r = await fetch(`/api/fleet?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`);
+    D = await r.json();
+    renderTop(D); renderTabs(); render();
+    // ★判级矩阵/控制参数按窗重算是后台算的(首次约 25 s / 1 s;用户令 2026-09-21)⇒ 页面先出上一口径并
+    //   **如实标注**,然后轮询直到按窗结果就位(同一窗只算一次,服务端常驻缓存)。
+    if ((D.win_pending || D.sysmx_pending || D.m9_pending)
+        && (window.__PENDING_TRIES = (window.__PENDING_TRIES || 0) + 1) <= 40) {
+      setTimeout(() => { if (D && (D.win_pending || D.sysmx_pending || D.m9_pending)) load().catch(() => {}); }, 5000);
+    } else if (!D.win_pending && !D.sysmx_pending && !D.m9_pending) { window.__PENDING_TRIES = 0; }
+  }
+  window.addEventListener('hashchange', () => { const m = location.hash.match(/tab=([a-z]+)/); const sv = (location.hash.match(/sys=([^&]+)/) || [])[1]; SYSV = sv ? sysFromSlug(decodeURIComponent(sv)) : null; if (m && TABS.includes(m[1])) { TAB = m[1]; renderTabs(); render(); } });
+  renderTop(null); renderTabs();
+  load().catch(e => { document.getElementById('root').innerHTML = `<div class="panel"><b>${t('ui.error')}</b> <span class="muted">${esc(e.message)}</span></div>`; console.error(e); });
+})();
+
+</script>
+</body>
+</html>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
release/web/assets/index-B2WouQlf.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
release/web/assets/index-DSk4PwRE.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 336 - 0
release/web/detail_v2.html


+ 9 - 0
release/web/favicon.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Guanlan wind intelligence">
+  <rect width="64" height="64" rx="14" fill="#070b11"/>
+  <circle cx="48" cy="16" r="12" fill="#43c6d6" opacity="0.18"/>
+  <path d="M12 46c8-6 17-7 27-3 5 2 9 2 13-1" fill="none" stroke="#43c6d6" stroke-width="3.2" stroke-linecap="round" opacity="0.82"/>
+  <path d="M32 17v30" stroke="#e2eaf3" stroke-width="3.4" stroke-linecap="round"/>
+  <circle cx="32" cy="25" r="4.2" fill="#e2eaf3"/>
+  <path d="M32 25 20 17M32 25l14-6M32 25l7 15" stroke="#43c6d6" stroke-width="3.4" stroke-linecap="round"/>
+  <path d="M49 45h4M11 52h42" stroke="#cf9236" stroke-width="2.2" stroke-linecap="round" opacity="0.75"/>
+</svg>

+ 2 - 2
release/web/index.html

@@ -4,8 +4,8 @@
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>观澜 · 风电数据分析与决策支持</title>
-  <script type="module" crossorigin src="./assets/index-CK3ivS0h.js"></script>
-  <link rel="stylesheet" crossorigin href="./assets/index-97FhsCXN.css">
+  <script type="module" crossorigin src="./assets/index-B2WouQlf.js"></script>
+  <link rel="stylesheet" crossorigin href="./assets/index-DSk4PwRE.css">
 </head>
 <body>
   <div id="app"></div>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov