Ver Fonte

删源码第2步: 删除旧页前端资源批(assets/app.js 1092 行 + charts.js 268 + classic_chart.js 2162 = 3522 行, 模块外零引用) + detail_deps 台账按现场重写;★更正: 本条提交时门禁曾因 detail_deps 仍引用已删 app.js 而红, 已在后续提交修正(APPJS 改指 Vue 语言包)

zhouyang.xie há 1 semana atrás
pai
commit
d278a9bbbd

+ 0 - 1092
app_frontEnd/app_frontEnd_guanlan/assets/app.js

@@ -1,1092 +0,0 @@
-/* 观澜 新前端 —— 真正的 .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 处拿它做匹配) 只在**显示时**换成现场说法, 数据与匹配逻辑一概不动。
-  // 表与后端同源 = app_frontEnd/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); });
-})();

+ 0 - 268
app_frontEnd/app_frontEnd_guanlan/assets/charts.js

@@ -1,268 +0,0 @@
-/* 观澜 图元 —— 从经典页 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 };
-};

+ 0 - 2162
app_frontEnd/app_frontEnd_guanlan/assets/classic_chart.js

@@ -1,2162 +0,0 @@
-
-// 设备状态色 = 观澜语义色单源 (2026-08-28 用户裁: 观澜色块体系更好, 保留; windcms 那边改过来)
-// 对照 windcms 现值见 design.py 的 --st-* (迁移期保留, 供振动线对齐用)
-const C={'优秀':'var(--ok)','良好':'var(--info)','报警':'var(--warn)','危险':'var(--bad)','不可判':'var(--unknown)','监测不可用':'var(--unknown)','无监测':'var(--unknown)'};
-// 传动链运动简图 (2026-08-28 用户令"纯图形": 弃方块标签, 用工程简图语言 —
-// 轴=横线 · 齿轮=竖矩形(啮合处相切) · 轴承=剖面符号 · 行星级=齿圈+行星架 · 发电机=定子/转子;
-// 只有命中部件着色并标名, 其余全灰 = 一眼看出"链条哪一环出问题")
-const baBar=(pre,post,col)=>{
-  const M=Math.max(pre,post,7), W=120, sc2=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="${sc2(pre).toFixed(1)}" height="9" rx="2" fill="var(--muted)" fill-opacity="0.55"/>
-    <rect x="2" y="15" width="${sc2(post).toFixed(1)}" height="9" rx="2" fill="${col}" fill-opacity="0.9"/>
-  </svg>`};
-const esc=t=>String(t==null?'':t).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
-// 温度偏差条: 0 为中线的双向条 (方向即符号, 长度即幅度, 色即判级) + 数字并存
-const devBar=(ch,dev,vd,raw)=>{
-  if(dev==null||dev===undefined) return `<span class="small">${esc(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);
-  // 只有真越档(|dev|>=3K)才上语义色, 其余淡灰 — 避免整列彩条抢注意力
-  const strong = Math.abs(dev) >= 3;
-  const col = !strong ? 'var(--muted)' : (/候选/.test(vd||'')?'var(--warn)':/记基线/.test(vd||'')?'var(--info)':/不可判|返服/.test(vd||'')?'var(--unknown)':'var(--muted)');
-  const 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-width="1" stroke-opacity="0.7"/>
-      <line x1="${mid}" y1="1" x2="${mid}" y2="17" stroke="var(--muted)" stroke-width="1" stroke-opacity="0.6"/>
-      <rect x="${dev>=0?mid:mid-bw}" y="4" width="${Math.max(bw,1.5)}" height="10" rx="2" fill="${col}" fill-opacity="0.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" style="font-variant-numeric:tabular-nums;color:${col}">${dev>0?'+':''}${dev}K</b></div>
-    <div class="small" style="color:var(--muted);margin-top:1px">${esc(ch||'')}</div>`};
-// 证据窗时间条: 固定 2026-01→09 轴, 块=claim 窗覆盖段 (跨行可比, 一眼看窗对不对得上)
-const winBar=(spans,raw)=>{
-  if(!spans||!spans.length) return `<span class="small" title="${esc(raw)}">未标时间窗</span>`;
-  const T0=Date.parse('2026-01-01'), T1=Date.parse('2026-09-01'), W=118;
-  const px=d=>{const v=Date.parse(d); return 3+Math.max(0,Math.min(1,(v-T0)/(T1-T0)))*(W-6)};
-  const blocks=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="0.8"/>`}).join('');
-  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="0.5"/>${blocks}</svg>
-    <div class="small" style="color:var(--muted)">${spans.length} 个窗段</div></div>`};
-// 油液时效胶囊: 绿=近共时可作现时判据 / 灰=stale 只作回测锚
-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(--ok)':'var(--unknown)'}"></i>
-  <span class="small" style="font-variant-numeric:tabular-nums">${last||'无样'}</span></div>
-  <div class="small" style="color:var(--muted)">${last?(fresh?'近共时':'stale &gt;12 个月'):'—'}</div>`;
-// 术语去黑话 (2026-08-28 用户令"翻译·审核黑话"): 内部判级词/口径缩写 → 现场话, 括注保留原词以便对账
-const JARGON=[[/正样本/g,'健康对照台'],[/可降(?![级])/g,'可降级观察'],[/记基线观察/g,'记基线·下轮复核'],
-  [/CLAIM窗|claim窗/g,'证据窗'],[/位次([0-9]+)[/]([0-9]+)/g,'全场第$1名(共$2台)'],[/未列 [((]≠正常[^))]*[))]/g,'未收录 (≠已排除异常)'],
-  [/温度候选[(]定向查[)]/g,'温度偏高·待定向排查'],[/最大偏/g,'偏差最大通道'],[/单次([0-9]+)s/g,'单次循环 $1 秒'],
-  [/时间窗内无样/g,'本时间窗未取样'],[/仲裁输入/g,'待仲裁输入'],[/无新证/g,'本轮无新证据'],[/降观察/g,'降为观察'],
-  [/弱同向新证/g,'弱同向新证据'],[/传感器项/g,'测量链(仪器)问题'],[/份样/g,' 份油样'],[/NBM/g,'基线模型(NBM)']];
-// 表述规范单源 = windcms agent.py 的业主口径注解 (2026-08-28 用户令: 等级评价和表述一定要参照 windcms)。
-// 规范原文: 状态等级打头(优秀=未见异常保持常规监视 / 良好=轻微偏离记录观察 / 报警=存在需现场核实或处置的异常 /
-//   危险=异常严重建议尽快停机检查 / 不可判=数据不足或监测中断); 证据状态放括号里说可信度
-//   (候选=待现场核实 / 准定论·预警=基本确认 / 候选·新发=新出现待核实 / 候选·记基线=已记为基线下窗观察 /
-//    参考=仅记录 / INSUFFICIENT=数据不足 / 撤回=已取消)。
-// ★不自造措辞: 之前我译的"待现场核实/观察即可"虽通顺但偏离了 windcms 词表, 已撤。
-const ST_NOTE = {'优秀':'未见异常, 保持常规监视', '良好':'轻微偏离, 记录观察',
-  '报警':'存在需现场核实或处置的异常', '危险':'异常严重, 建议尽快停机检查', '不可判':'数据不足或监测中断'};
-const EV_NOTE = {'候选':'待现场核实', '准定论·预警':'基本确认', '候选·新发':'新出现待核实',
-  '候选·记基线':'已记为基线, 下窗观察', '参考':'仅记录', 'INSUFFICIENT':'数据不足',
-  '定论':'已定案', '正常':'未见异常', '撤回':'已取消'};
-const plain = t => String(t || '');   // 判级词一律用 windcms 原词, 注解走 ST_NOTE/EV_NOTE
-// 振动技术速记 → 人话 (2026-08-28 用户令: 黑话改掉)。只翻术语不动数字, 原文进悬停。
-// 顺序敏感: 长词先于短词 (DE/NDE 要先于 DE), 否则会被短词切碎。
-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"若先翻 BPFI 会变成"内圈内圈特征频率"
-  [/内圈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 = t => { let x = String(t || ''); VIBJARGON.forEach(([a, b]) => x = x.replace(a, b)); return x; };
-const zh=t=>{let x=String(t==null?'':t); JARGON.forEach(([a,b])=>x=x.replace(a,b)); return x};
-// 族分布堆叠条 (报警列)
-// 族分布条 (2026-08-28 用户逮"故障多少长度都一样"):
-// 条总长 = 该台条数/全场最大 → 跨行可比 (原来段宽按台内占比算, 总和恒 100% 所以每行一样长, 量的信息全丢);
-// 段内比例 = 族构成。总长封顶 118px, 最短 6px 保证可见。
-const famBar=(fam,n,maxN)=>{
-  if(!n) return `<span class="small" style="color:var(--muted)">时间窗内无报警</span>`;
-  const CO={'齿轮箱':'#8A6D3B','发电机':'#4A6FA5','主轴承':'#2E7D6B','变桨叶片':'#4A6FA5','偏航':'#2E7D6B','测风':'#7C5295','其他':'#5A6470'};
-  const W=118, w=Math.max(6, Math.round(n/Math.max(maxN||n,1)*W));
-  return `<div title="${n} 条 · 全场最多 ${maxN||n} 条" style="display:flex;height:9px;border-radius:3px;overflow:hidden;width:${w}px">`
-    +fam.map(f=>`<i title="${f.k} ${f.n}" style="width:${(f.n/n*100).toFixed(1)}%;background:${CO[f.k]||'#5A6470'}"></i>`).join('')
-    +`</div><div class="small" style="color:var(--muted);margin-top:3px">${fam.slice(0,2).map(f=>f.k+' '+f.n).join(' · ')}</div>`};
-const PART2SYS=p=>/主轴承|主轴/.test(p)?'主轴承':/齿轮|行星|中间|中速|高速/.test(p)?'齿轮箱':/发电机/.test(p)?'发电机':p;
-// ────────────────────────────────────────────────────────────────────────
-// 报表汇报端口 (2026-08-28 用户令: 按客户汇报管理要求做定制化报表端口, 一级显示, ontology 支持)
-//
-// 范式不是自创的 — 国内风电场上报集团的月度运行分析报告结构是固定的:
-//   发电完成 → 设备运行 → 故障分析 → 限电情况 → 检修执行 → 重点机组 → 下期计划。
-// 端口做三件事: ①选范式与周期 ②勾章节(定制化) ③选受众(决定脱敏级别)。
-// "一级显示" = 顶部六个核心指标用大字直出, 明细下沉到章节; 领导只看第一屏也拿得到结论。
-// 每个数字带口径与来源, 结论可回溯到本体对象 — 这是它区别于"手搓 PPT"的地方。
-// ────────────────────────────────────────────────────────────────────────
-// 报表画布 (2026-08-28 用户令: "像画布一样让客户选, 不是放太多信息")
-//
-// 改法: 从"满配 + 关掉不要的"翻成"空画布 + 挑要的"。前者默认给一大堆再让人做减法,
-// 客户看到的是负担; 后者每加一块都是一次主动选择, 报告长度天然由客户控制。
-// 组件粒度也从"章"降到"块" — 一张图/一张表就是一块, 挑的时候看得见自己在放什么。
-// 范式(月报/周报/专题/年度) 退化为**预设组合**: 点一下把一组块填进画布, 之后照样能加减调序。
-// 组件按主题分组 (2026-08-28 用户"交互界面设计一下"): 15 块平铺成两行时,
-// 客户要在一堆同质按钮里找, 且原生 title 弹出会遮住旁边两个按钮 (截图逮)。
-// 改为按报告的叙事顺序分五组, 说明走固定说明区不浮动。
-const RPT_GROUPS = [['发电', 'gen'], ['可靠性', 'rel'], ['故障', 'flt'], ['机组', 'unit'], ['收尾', 'end']];
-const RPT_BLOCKS = {
-  kpi:    {g:'gen',  nm:'一级指标',     ic:'▦', d:'六个核心数: 上网电量 / 等效满发 / 可用率 / 损失率 / 停机间隔 / 需行动台'},
-  water:  {g:'gen',  nm:'能量瀑布',     ic:'▤', d:'理论可发 → 逐段扣损 → 实际上网, 用于定位电量流向'},
-  emon:   {g:'gen',  nm:'逐月发电表',   ic:'▥', d:'逐月上网 / 损失 / 损失率 / 等效满发, 含数据覆盖率'},
-  blame:  {g:'gen',  nm:'损失责任分账', ic:'◧', d:'交易调度侧 / 设备侧 / 运行侧 三段占比 — 回答"谁的责任"'},
-  ram:    {g:'rel',  nm:'可靠性象限',   ic:'◈', d:'平均无故障间隔(MTBF)× 单次停机时长(MDT)部件象限, 分出"频发易修"与"罕发难修", 处置方向相反'},
-  parts:  {g:'rel',  nm:'部件停机表',   ic:'▤', d:'逐部件停机时长 / 事件数 / 平均无故障间隔(MTBF)/ 单次停机时长(MDT)/ 影响机组数'},
-  attrib: {g:'rel',  nm:'归因覆盖',     ic:'▬', d:'已归因 vs 无记录停机的占比 — 无记录不等于没有故障'},
-  pareto: {g:'flt',  nm:'故障帕累托',   ic:'▮', d:'按条数降序 + 累计 80% 线, 找关键少数'},
-  quad:   {g:'flt',  nm:'故障四象限',   ic:'◇', d:'条数 × 单次时长, 区分重复触发与实际停机 — 按条数排序会误定优先级'},
-  frate:  {g:'flt',  nm:'月度故障率',   ic:'▪', d:'按天归一的日均条数, 数据不满月单独标注'},
-  units:  {g:'unit', nm:'重点机组',     ic:'☰', d:'需跟踪台的部件 / 证据源 / 六步进度 / 卡在哪一步'},
-  chain:  {g:'unit', nm:'决策链进度',   ic:'⛓', d:'证据→机制→判级→排期→动作→验收 六步的瓶颈分布'},
-  loop:   {g:'unit', nm:'闭环实证',     ic:'✓', d:'检出 → 换件 → 复测回落的历史例证, 证明这条链是通的'},
-  plan:   {g:'end',  nm:'下期建议',     ic:'✎', d:'由数据推出的行动项, 不是套话'},
-  caliber:{g:'end',  nm:'口径与边界',   ic:'⚑', d:'统计期 / 残月 / 工单窗 / 判级性质 — 建议常驻, 没有边界的数字堆不能作汇报依据'},
-  narr:   {g:'end',  nm:'运行总结',     ic:'✍', d:'本地模型基于实测数据生成的运行总结 — 为图表补充文字结论, 供汇报使用'},
-};
-const RPT_PRESETS = {
-  month: {nm:'月度运行分析', blocks:['kpi','water','emon','blame','ram','pareto','units','plan','caliber']},
-  week:  {nm:'周报',        blocks:['kpi','frate','units','plan','caliber']},
-  topic: {nm:'设备专题',    blocks:['units','chain','ram','loop','caliber']},
-  year:  {nm:'年度总结',    blocks:['kpi','water','emon','ram','loop','caliber']},
-  blank: {nm:'空白画布',    blocks:[]},
-};
-function rptInit(){
-  if(!window.RPTAUD) window.RPTAUD='in';
-  // 默认空画布 (2026-08-28 用户令"都去掉, 只剩选项栏"): 打开只有选项栏, 一块都不预填。
-  // 起手给几块仍是替客户做决定 — 画布的意思就是空着等人放东西。
-  if(!window.RPTCANVAS) window.RPTCANVAS=[];
-}
-// 报告定制交互 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持)。
-// 模型做两件事: 按需求挑组件 + 据实测摘要写总结。数字全部来自后端摘要, 生成后过接地闸;
-// 模型自己算的派生值(如占比求和)会被标出来让人核 — 算得对也要标, 因为核不了的数字不能进报告。
-// ★下拉原来写死两个**本地**模型 (qwen3:8b / qwen3.8:27b), 而云端部署上一个都没配;
-//   /api/rpt_compose 在云端的问答微服务里也不存在 (只有 ask / ask_status / ask_models)。
-//   于是这个按钮在生产环境是死的: 点了拿回 404 的 {"err":"not found"}, 显示成 "not found"。
-//   改为按**实际可用**填充: 有编排接口才启用, 模型清单与问答助手同源 (/api/ask_models)。
-//   交付件不该摆一个点了就报错的按钮。
-// ★不能叫 rptInit —— 上面 L1528 已经有一个同名函数 (初始化 RPTAUD / RPTCANVAS)。
-//   函数声明后者覆盖前者, 于是 RPTAUD 永远初始化不了, 渲染直接 ReferenceError,
-//   整个汇报定制面板消失。命名撞车只在运行时才炸, 语法闸看不见。
-// ★模型下拉必须按**实际配了密钥的**填, 不能把 MODEL_SPEC 全列出来 ——
-//   MODEL_SPEC 是"档位说明表"(含本地档), 不是"可用清单"。全列出来并默认选第一个
-//   (qwen3:8b, 本地档) 的后果: 用户一提问就报「模型 qwen3:8b 未配置密钥」。
-//   云主机 2 核 1GB, 本地模型本来就跑不起来。可用清单唯一来源 = /api/ask_models。
-async function fillModels(selId){
-  const sel=document.getElementById(selId); if(!sel) return {};
-  let models={};
-  try{ models=(await (await fetch('/api/ask_models')).json()).models||{} }catch(e){}
-  const ks=Object.keys(models);
-  sel.innerHTML = ks.length
-    ? ks.map(k=>{const v=MODEL_SPEC[k]||{}; return `<option value="${k}">${v.nm||k}${v.loc?' · '+v.loc:''}${v.t?' · '+v.t:''}</option>`}).join('')
-    : '<option value="">无可用模型</option>';
-  return models;
-}
-async function askModelInit(){
-  await fillModels('askm');
-  if(typeof mdlNote==='function') mdlNote();
-}
-async function rptModelInit(){
-  const sel=document.getElementById('rptmdl'), btn=document.getElementById('rptgo'),
-        out=document.getElementById('rptout');
-  if(!sel) return;
-  let hasCompose=false;
-  const models=await fillModels('rptmdl');
-  // 编排已移植上云 (2026-09-01), 故改回**真探测** —— 拦截器已把 rpt_compose 放行到
-  // 真网络, 探得到 200 就启用, 离线打开探不到就禁用并说明。
-  try{ const r=await fetch('/api/rpt_compose?probe=1'); hasCompose = !!(r&&r.ok) }catch(e){ hasCompose=false }
-  const ks=Object.keys(models);
-  // ★这句原文是「本地模型…数据不出本地」—— 选 DeepSeek 时数据当然出本地。
-  //   与徽章同一判据: 按选中档位的 loc 给话, 不写死。
-  const hint=document.getElementById('rpthint');
-  const syncHint=()=>{ if(!hint) return;
-    const v=MODEL_SPEC[sel.value]||{};
-    hint.innerHTML='模型依据需求选取组件, 并基于实测数据生成运行总结'
-      + (v.loc==='本地' ? '; 数据不出本地'
-         : '; <span style="color:var(--warn)">内容会外发到模型厂商</span>'); };
-  sel.addEventListener('change', syncHint); syncHint();
-  if(!hasCompose||!ks.length){
-    if(btn){ btn.disabled=true; btn.style.opacity='.5'; btn.style.cursor='not-allowed' }
-    if(out) out.innerHTML = !ks.length ? '本部署未配置任何模型密钥, 编排不可用'
-                                       : '本部署未启用编排接口, 画布仍可手动拼装';
-  }
-}
-async function rptCompose(){
-  const need=(document.getElementById('rptneed')||{}).value||'';
-  const mdl=(document.getElementById('rptmdl')||{}).value||'qwen3:8b';
-  const out=document.getElementById('rptout');
-  if(!need.trim()){ out.innerHTML='先输入报告要求'; return }
-  const _sp=MODEL_SPEC[mdl]||{}, _loc=(_sp.loc==='本地');
-  out.innerHTML='<span style="color:var(--accent)">模型编排中…</span> 挑组件 + 写总结'
-    + (_loc ? ', 全程零外发' : ', <span style="color:var(--warn)">内容会外发到模型厂商</span>');
-  let d;
-  try{ d=await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPTCANVAS.join(','))}&model=${encodeURIComponent(mdl)}`)).json(); }
-  catch(e){ out.innerHTML=`<span style="color:var(--bad)">生成失败: ${e.message}</span>`; return }
-  if(d.err){ out.innerHTML=`<span style="color:var(--bad)">${d.err}</span>`; return }
-  window.RPTNARR={text:d.narrative, model:d.model, ungrounded:d.ungrounded||[]};
-  const bl=(d.blocks||[]).filter(b=>RPT_BLOCKS[b]);
-  if(bl.length) window.RPTCANVAS = (d.narrative? ['narr'] : []).concat(bl);
-  else if(d.narrative && !RPTCANVAS.includes('narr')) RPTCANVAS.unshift('narr');
-  out.innerHTML=`已按需求编排 <b>${bl.length}</b> 块${d.narrative?' + 一段运行总结':''} ·
-    ${d.model} 用时 ${d.secs}s${(d.ungrounded||[]).length?` · <span style="color:var(--warn)">${d.ungrounded.length} 个数字待核</span>`:' · 数字全部接地'}`;
-  render();
-  setTimeout(()=>{const el=document.getElementById('rptout'); if(el&&!el.innerHTML) el.innerHTML='';},50);
-}
-function rptExport(fmt){
-  // 导出走后端生成真 .docx/.xlsx: 图表块转成对应数据表, 信息不丢且可核对。
-  // 上报版沿用界面同一套脱敏规则, 不需要导出后再手工处理。
-  if(!RPTCANVAS.length){ alert('画布是空的 — 先从组件库选内容'); return }
-  const n=(window.RPTNARR&&RPTNARR.text)?RPTNARR.text:'';
-  const win=(window.D&&D.win)||'2026年';
-  const url=`/api/rpt_export?fmt=${fmt}&blocks=${encodeURIComponent(RPTCANVAS.join(','))}`
-    +`&aud=${RPTAUD}&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();
-}
-function rptHint(k){
-  // 固定说明区而非浮动 tooltip: 原生 title 弹出会盖住相邻按钮 (用户截图逮),
-  // 且鼠标一移就消失, 读不完。放在组件库下方定位不动。
-  const el=document.getElementById('rpthint'); if(!el) return;
-  const v=k?RPT_BLOCKS[k]:null;
-  el.innerHTML = v ? `<b style="color:var(--ink)">${v.ic} ${v.nm}</b> — ${v.d}`
-    : '悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序';
-}
-function rptPreset(k){ window.RPTCANVAS=RPT_PRESETS[k].blocks.slice(); render(); }
-function rptAdd(k){ if(!RPTCANVAS.includes(k)){ RPTCANVAS.push(k); render(); } }
-function rptDel(k){ const i=RPTCANVAS.indexOf(k); if(i>=0){ RPTCANVAS.splice(i,1); render(); } }
-function rptMove(k,dir){
-  const i=RPTCANVAS.indexOf(k), j=i+dir;
-  if(i<0||j<0||j>=RPTCANVAS.length) return;
-  [RPTCANVAS[i],RPTCANVAS[j]]=[RPTCANVAS[j],RPTCANVAS[i]]; render();
-}
-function rptK(v,u,lab,note,cls){
-  return `<div class="kpi ${cls||''}"><b>${v}<span> ${u}</span></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=(t,b,src)=>`<div class="card mb"><h3>${t}</h3>${b}
-    ${src?`<p class="small" style="color:var(--muted);margin-top:8px;padding-top:7px;border-top:1px solid var(--line-2)">口径: ${src}</p>`:''}</div>`;
-  if(k==='kpi'){
-    const last=em[em.length-1]||{}, prev=em[em.length-2]||{};
-    const cmpOK=(last.cov||0)>=0.5 && (prev.cov||0)>=0.5;
-    const dl=(a,b)=>b?((a-b)/b*100):0;
-    return `<div class="kpis" style="margin-bottom:14px">
-      ${rptK((E.act||0).toLocaleString(),'MWh','上网电量',`理论可发 ${(E.theo||0).toLocaleString()} | ${cmpOK?`环比 ${dl(last.act,prev.act)>=0?'+':''}${dl(last.act,prev.act).toFixed(0)}%`:'环比不可比(末月不满)'}`)}
-      ${rptK((E.eflh||0).toLocaleString(),'h','等效满发小时',`年化约 ${Math.round((E.eflh||0)/Math.max(em.filter(x=>x.cov>=0.5).length,1)*12).toLocaleString()} h`)}
-      ${rptK(d.control&&d.control.avail,'%','时间可用率','不含调度令与低风待机',(d.control&&d.control.avail>=95)?'ok':'warn')}
-      ${rptK(E.loss_pct,'%','损失率',`${(E.loss||0).toLocaleString()} MWh`,(E.loss_pct>=15)?'warn':'')}
-      ${rptK(M?M.MTBF_h:'—','h','平均停机间隔',`停机 ${M?M.停机事件数:'—'} 起 · 单次均 ${M?M.MDT_h:'—'}h`)}
-      ${rptK((BD.rows||[]).length,'台','需行动机组',`定论 ${(FU.kpi||{}).定论||0} · 预警 ${(FU.kpi||{}).预警||0} · 候选 ${(FU.kpi||{}).候选观察||0}`,(BD.rows||[]).length>20?'warn':'')}</div>`;
-  }
-  if(k==='water') return wrap('发电完成情况', waterfall(E)+
-    `<p class="small" style="color:var(--muted);margin-top:6px">理论可发 ${(E.theo||0).toLocaleString()} = 上网 ${(E.act||0).toLocaleString()} + 损失 ${(E.loss||0).toLocaleString()} MWh (${E.loss_pct}%)
-    | <span title="不是物理判据: 正常数据下必然通过。它的作用是当损失被高估到不可能时(台账数字虚高曾把损失写成超过全停上限)立刻拦下">量级自检</span>: 理论 ${E.cap_ok?'低于':'≥'} 全额定上限 ${(E.cap||0).toLocaleString()} MWh ${E.cap_ok?'✔':'✗ 数字不可信'}</p>`,
-    'SCADA 10min 工况态分解; 损失=同工况理论出力与实测之差。量级自检为防虚高的下限闸, 不是风资源判据');
-  if(k==='emon') return wrap('逐月发电', em.length?`<table><tr><th>月</th><th class="num">上网 MWh</th><th class="num">损失 MWh</th><th class="num">损失率</th><th class="num">等效满发 h</th><th>覆盖</th></tr>
-    ${em.map(x=>`<tr><td>${x.m}</td><td class="num">${x.act.toLocaleString()}</td><td class="num">${x.loss.toLocaleString()}</td><td class="num">${x.loss_pct}%</td><td class="num">${x.eflh}</td>
-    <td class="small" style="color:${x.cov<0.5?'var(--warn)':'var(--muted)'}">${(x.cov*100).toFixed(0)}%${x.cov<0.5?' 不满月':''}</td></tr>`).join('')}</table>`:'—',
-    '覆盖率<50% 的月不参与环比');
-  if(k==='blame') return wrap('损失责任分账', blameBar(E)+
-    `<p class="small" style="color:var(--muted);margin-top:8px">进设备考核的只有"故障与检修停机"; 限电属交易与调度侧。
-    <br><b>限电不是"低风时段收到的空令"</b>: 限电·绑定时段平均出力 1.435 MW, 正常发电时段 1.412 MW, 比值 1.02 —
-    风况相同而出力被压, 说明限电是真压制; 但"限电命令期·功率未被压制"那一档平均出力仅 0.724 MW(18% 额定), 确属低风时段收令。</p>`,
-    '工况态分解 + 限电命令面绑定判别; 平均出力=实发÷台时, 用作风资源代理量');
-  if(k==='ram') return R?wrap('可靠性 × 可维修性', ramChart(R.表)+
-    `<p class="small" style="color:var(--muted);margin-top:6px">设备类 ${R.设备类停机h}h / 外部类 ${R.外部类停机h}h (电网·风况·运维操作不计设备可靠性)</p>`,
-    'SCADA ≥1h 停机段, 不含调度令; 海上单次停机时长(MDT)含交通窗口'):'';
-  if(k==='parts') return R?wrap('部件停机', `<table><tr><th>部件</th><th class="num">停机h</th><th class="num">事件</th><th class="num">平均无故障间隔(MTBF,h)</th><th class="num">单次停机时长(MDT,h)</th><th class="num">影响机组</th></tr>
-    ${R.表.slice(0,9).map(x=>`<tr><td class="small">${x.部件}${x.外部?' (外部)':''}</td><td class="num">${x.停机时长h}</td><td class="num">${x.事件数}</td>
-    <td class="num">${x.MTBF_h||'—'}</td><td class="num">${x.MDT_h}</td><td class="num">${x.影响台数}</td></tr>`).join('')}</table>`,
-    '归因=段起点±30min 首发码'):'';
-  if(k==='attrib') return R?wrap('归因覆盖', attribBar(R.表,R.总停机h)+
-    `<p class="small" style="color:var(--muted);margin-top:6px">"无记录停机"不是没有故障, 是没归上因。</p>`,''):'';
-  if(k==='pareto') return wrap('故障帕累托', paretoChart(F.pareto_n||[],' 条',''), '报警事件表');
-  if(k==='quad') return wrap('故障模式四象限', quadChart(F.quad||[])+
-    `<p class="small" style="color:var(--muted);margin-top:6px">右下角报警码属重复触发, 不应按条数排定优先级。</p>`,'纵界=10min(一个SCADA采样格)');
-  if(k==='frate') return wrap('月度故障率', rateChart(F.monthly||{months:[],vals:[]})+
-    `<p class="small" style="color:var(--muted);margin-top:6px">柱高=日均条数, 不是月总数。</p>`,'不满月按天归一');
-  if(k==='units'){
-    const rows=(BD.rows||[]).slice(0,14);
-    return wrap('重点机组与处置', `<p class="small" style="color:var(--muted);margin-bottom:8px">需跟踪 ${(BD.rows||[]).length} 台 / 38 | 瓶颈 ${Object.entries(BD.stuck||{}).map(([a,b])=>`<b>${b} 台</b>卡在「${a}」`).join(' · ')}</p>
-      <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th><th>部件与证据</th><th style="width:56px">进度</th><th style="width:62px">卡在</th></tr>
-      ${rows.map(r=>`<tr><td><b>${r.t.replace('WTG','')}#</b></td><td>${chip(r.ostate||'—')}</td>
-      <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}${(r.srcs||[]).join('/')||'—'}${r.rising?` · <span style="color:var(--bad)">在升 ${r.rising.ratio}×</span>`:''}</td>
-      <td class="num small">${r.done}/6</td><td class="small" style="color:var(--warn)">${r.stuck||'全通'}</td></tr>`).join('')}</table>`,
-      '四源融合判级');
-  }
-  if(k==='chain') return wrap('决策链进度', `<p class="small" style="color:var(--muted)">六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。
-    当前 ${Object.entries(BD.stuck||{}).map(([a,b])=>`${b} 台卡在「${a}」`).join(', ')}。</p>`,
-    '「动作」一列不可判 — ' + (BD.wo_bound||''));
-  if(k==='loop'){
-    const cl=BD.closed||[];
-    return wrap('端到端闭环实证', `<table><tr><th>机组</th><th>部件</th><th>换件日期</th><th class="num">换前</th><th class="num">换后</th><th class="num">比值</th></tr>
-      ${cl.map(x=>`<tr><td><b>${x.turbine.replace('WTG','')}#</b></td><td class="small">${x.component}</td><td class="small">${x.replace_date}</td>
-      <td class="num">${x.pre}</td><td class="num">${x.post}</td><td class="num" style="color:var(--ok)"><b>${x.ratio}×</b></td></tr>`).join('')}</table>
-      <p class="small" style="color:var(--muted);margin-top:6px">这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。</p>`,
-      '振动标量前后对比');
-  }
-  if(k==='plan') return wrap('下期计划与建议', `<ol style="margin:0 0 0 20px;line-height:1.85">
-    <li><b>补机制定性</b> — ${(BD.stuck||{}).机制||0} 台停在"判了级但根因未定", 是当前最大单一瓶颈</li>
-    <li><b>回收 25~26 工单</b> — 台账止 2024-11, 本期检修执行与闭环无法自动判定</li>
-    <li><b>油液采样节奏</b> — 四源里油液长期 stale, 只能作回测锚</li>
-    <li><b>限电侧</b> — 损失六成在交易与调度侧, 设备侧改善空间有限</li></ol>`,
-    '建议级 — 排期与动作须经审批');
-  if(k==='narr'){
-    const N=window.RPTNARR;
-    if(!N||!N.text) return wrap('运行总结','<p class="small" style="color:var(--muted)">还没有生成 — 在上面的交互窗输入报告要求。</p>','');
-    return wrap('运行总结', `<p style="line-height:1.9;margin:0">${N.text.replace(/</g,'&lt;').split(String.fromCharCode(10)).join('<br>')}</p>
-      ${(N.ungrounded||[]).length?`<p class="small" style="color:var(--warn);margin-top:8px">
-        ⚠ 以下数值由模型自行推算, 不在原始数据摘要中, 引用前须核对: ${N.ungrounded.join(' / ')}</p>`:''}`,
-      `本地模型 ${N.model} 生成 · 数字取自系统实测摘要 · 接地闸${(N.ungrounded||[]).length?'报 '+N.ungrounded.length+' 处待核':'全通过'}`);
-  }
-  if(k==='caliber'){
-    const part=(F.partial||[]);
-    return wrap('口径与数据边界', `<ul style="margin:0 0 0 20px;line-height:1.85">
-      <li><b>统计期</b> ${d.win}${part.length?` — ${part.join('/')} 数据不满月(覆盖 ${Math.round((F.cov_min||0)*100)}%), 月度已按天归一`:''}</li>
-      <li><b>可用率与损失率分母不同</b> 可用率分母剔除低风待机与调度令(问"该发时能不能发"),
-        损失率分母含全部时段(问"理论能发多少"); 同一低风时段在可用率里不计时间、在损失率里计入基数 —
-        这是两个指标的设计差异不是错, 但两者不可直接相乘或互推。低风待机对损失的贡献仅 118 MWh(0.5%)</li>
-      <li><b>停机</b> 可靠性口径 ≥1h 段, 电量账为逐条工况态 — 两个数都对, 不可相减</li>
-      <li><b>工单</b> 台账 2020~2024-11, 本报告不对本期检修执行下结论</li>
-      <li><b>判级</b> 由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具</li>
-      ${RPTAUD==='up'?'<li><b>脱敏</b> 上报版隐去判据门槛与特征频率</li>':''}</ul>`,'');
-  }
-  return '';
-}
-
-function renderReport(d){
-  rptInit();
-  const FU=d.fus||{};
-  let h=`<div class="eyebrow">汇报定制 · 画布</div>`;
-  // ── 画布控制条: 预设 / 组件库 / 受众 / 打印 ──
-  h+=`<div class="card mb noprint">
-    <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
-      <span class="small" style="font-weight:600">预设</span>
-      ${Object.entries(RPT_PRESETS).map(([k,v])=>`<button onclick="rptPreset('${k}')" style="cursor:pointer;padding:5px 13px;border-radius:15px;
-        border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">${v.nm}</button>`).join('')}
-      <span style="flex:1"></span>
-      <select onchange="window.RPTAUD=this.value;render()" style="padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px">
-        <option value="in"${RPTAUD==='in'?' selected':''}>场站内部 (全量)</option>
-        <option value="up"${RPTAUD==='up'?' selected':''}>上报集团 (脱敏)</option></select>
-      <button onclick="window.print()" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--accent);background:var(--panel);color:var(--accent);font-weight:600;font-size:12.5px">打印 / 存 PDF</button>
-      <button onclick="rptExport('docx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Word</button>
-      <button onclick="rptExport('xlsx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Excel</button>
-      ${RPTAUD==='up'?'<span class="small" style="color:var(--warn);flex-basis:100%">上报版 — 判据门槛与特征频率已脱敏</span>':''}
-    </div>
-    <div class="small" style="font-weight:600;margin-bottom:7px">组件库
-      <span style="font-weight:400;color:var(--muted)">点击加入报告, 再次点击移出 · 画布里已有 <b>${RPTCANVAS.length}</b> 块</span></div>
-    <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">
-      ${RPT_GROUPS.map(([gn,gk])=>`
-        <div class="small" style="color:var(--muted);text-align:right;white-space:nowrap">${gn}</div>
-        <div style="display:flex;gap:6px;flex-wrap:wrap">
-          ${Object.entries(RPT_BLOCKS).filter(([,v])=>v.g===gk).map(([k,v])=>{const on=RPTCANVAS.includes(k);
-            return `<button onmouseenter="rptHint('${k}')" onmouseleave="rptHint()" onfocus="rptHint('${k}')"
-              onclick="rpt${on?'Del':'Add'}('${k}')" style="cursor:pointer;padding:5px 11px;border-radius:7px;
-              border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'var(--panel)'};
-              color:${on?'#fff':'var(--ink)'};font-size:12.5px;opacity:${on?1:0.78}">${v.ic} ${v.nm}${on?' ✓':''}</button>`}).join('')}
-        </div>`).join('')}
-    </div>
-    <div id="rpthint" class="small" style="color:var(--muted);margin-top:9px;padding-top:8px;border-top:1px solid var(--line-2);min-height:19px">
-      悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序</div>
-    <div style="margin-top:11px;padding-top:10px;border-top:1px solid var(--line-2)">
-      <div class="small" style="font-weight:600;margin-bottom:6px">输入报告要求
-        <span id="rpthint" style="font-weight:400;color:var(--muted)">模型依据需求选取组件, 并基于实测数据生成运行总结</span></div>
-      <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
-        <input id="rptneed" placeholder="例如: 上报集团月报, 侧重设备可靠性与重点机组"
-          onkeydown="if(event.key==='Enter')rptCompose()"
-          style="flex:1 1 300px;min-width:220px;padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
-        <select id="rptmdl" style="padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px"></select>
-        <button id="rptgo" onclick="rptCompose()" style="cursor:pointer;padding:7px 18px;border-radius:7px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:600;font-size:13px">生成</button>
-      </div>
-      <div id="rptout" class="small" style="color:var(--muted);margin-top:7px"></div></div></div>`;
-  setTimeout(rptModelInit, 60);
-
-  if(!RPTCANVAS.length)
-    return h+`<div class="paper" style="text-align:center;padding:56px 40px">
-      <div class="small" style="color:var(--muted)">文档预览窗 — 从上面的组件库点选内容, 此处显示报告预览</div></div>`;
-
-  // 报表头卡撤 (2026-08-28 用户令): 场名与统计期页眉已有, 数据源列表与"结论可回溯"声明
-  // 属口径块的内容 — 摆在报告最上面是重复且抢戏。打印时的抬头由下面这行轻量署名承担。
-
-  // ── 文档预览窗 (2026-08-28 用户令: 生成的文档能预览才打印) ──
-  // 画布内容包进"一页纸": 有抬头、有边界、有页脚, 所见即将打印出来的样子。
-  const _asof=(FU.数据时点||'').slice(0,10);
-  h+=`<div class="paper"><div class="paper-hd">
-    <div><div class="paper-ti">如东海上风电场 · 运行分析报告</div>
-      <div class="small" style="color:var(--muted);margin-top:3px">统计期 ${d.win} | 38 × SWT-4.0-130${RPTAUD==='up'?' | 上报版':''}</div></div>
-    <div class="small" style="color:var(--muted);text-align:right">${RPTCANVAS.length} 个章节</div></div>`;
-  RPTCANVAS.forEach((k,i)=>{
-    const v=RPT_BLOCKS[k]; if(!v) return;
-    // 控制条移到块**上方**独立一行 — 原来绝对定位压在卡右上角, 挡住 KPI 数字与图例 (用户截图逮)
-    const bb=(t,fn,dis)=>`<button title="${t}" onclick="${fn}" ${dis?'disabled':''} style="cursor:pointer;padding:1px 8px;border-radius:5px;
-      border:1px solid var(--line);background:transparent;color:var(--muted);font-size:11px;opacity:${dis?0.3:0.7}">${t}</button>`;
-    h+=`<div class="noprint" style="display:flex;gap:3px;justify-content:flex-end;margin-bottom:-6px;position:relative;z-index:2">
-        ${bb('↑',`rptMove('${k}',-1)`,i===0)}${bb('↓',`rptMove('${k}',1)`,i===RPTCANVAS.length-1)}${bb('×',`rptDel('${k}')`,false)}
-      </div>${rptBlock(k,d)}`;
-  });
-  h+=`<div class="paper-ft">
-    <span>数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验${_asof?` | 出件 ${_asof}`:''}</span>
-    <span>结论可回溯至本体对象${RPTAUD==='up'?' | 判据门槛与特征频率已脱敏':''}</span></div></div>`;
-  return h;
-}
-
-// 决策链浮窗 (2026-08-28 用户令: 29号点开应该是决策链的具体信息的浮窗)。
-// 盘上一行只给"卡在哪", 点开给这一台的六步全貌: 每步达没达、依据是什么、为什么卡住。
-// 29# 另有本体链(证据清单/机制/四情景/验收树), 其余台给四源证据明细。
-function popChain(t){
-  const BD=(D&&D.fus&&D.fus.链盘)||{}; const r=(BD.rows||[]).find(x=>x.t===t); if(!r) return;
-  const STC={done:'var(--ok)',open:'var(--warn)',unknown:'var(--muted)'};
-  const STT={done:'已达',open:'未达',unknown:'不可判'};
-  const num='①②③④⑤⑥';
-  // ★列宽按语言取。原来写死 44/52/96px, 是照**两个汉字**量的; 英文 "Verification" /
-  //   "Not evaluable" 塞不下 ⇒ 标签被截成 "Mechanis"/"Scheduli"/"Verificati", chip 压到
-  //   下一列上 (用户截图实逮)。中文页取原值, 一个像素不动。
-  const EN=(typeof window!=='undefined'&&window.__EN), W=EN?[92,104,132]:[44,52,96];
-  const steps=r.steps.map((x,i)=>{
-    const cur=(x.k===r.stuck);
-    return `<div style="display:flex;gap:10px;padding:9px 11px;border-radius:8px;margin-bottom:5px;align-items:flex-start;
-      border:1px solid ${cur?STC[x.st]:'var(--line)'};background:${cur?'var(--bg)':'transparent'}">
-      <div style="flex:none;width:20px;color:var(--muted);font-size:13px">${num[i]}</div>
-      <div style="flex:none;width:${W[0]}px;font-weight:600;font-size:12.5px">${x.k}</div>
-      <div style="flex:none;width:${W[1]}px"><span class="badge" style="border-color:${STC[x.st]};color:${STC[x.st]};white-space:nowrap">${STT[x.st]}</span></div>
-      <div style="flex:none;width:${W[2]}px;font-size:12px">${x.v}</div>
-      <div class="small" style="color:var(--muted);flex:1;line-height:1.5">${x.d}${cur?` <b style="color:${STC[x.st]}">← 卡在这里</b>`:''}</div></div>`;}).join('');
-  // 与后端 _tbi 同口径: 多行台取最严重那行, 否则浮窗四源卡会指向另一个部件
-  const SEV={bad:0,warn:1,note:2,stale:3,ok:4,unlisted:5};
-  const F=(D&&D.fus)||{};
-  const frow=(F.表||[]).filter(x=>x.turbine===t).sort((a,b)=>(SEV[a.级]??9)-(SEV[b.级]??9))[0];
-  let mini='';
-  if(frow){
-    mini=`<div class="pop-mini">
-      <div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${(frow.振动结论||'').slice(0,26)}</span>
-        <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件||''}</div></div>
-      <div><b>证据窗</b>${winBar(frow.时间窗区间,frow.claim_window)}</div>
-      <div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>
-      <div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div></div>`;
-  }
-  let ext='';
-  const C=(typeof ONT!=='undefined'&&ONT)?ONT.chain29:null;
-  if(t==='WTG29'&&C){
-    const ev=(C.evidence||[]).map(e=>`<div class="small" style="color:var(--muted);padding:4px 0;border-bottom:1px solid var(--line-2)">
-      <b style="color:var(--ink)">${e.facet||e.id||''}</b>
-      <span style="color:var(--muted)"> 窗 ${e.claim_window||'—'}</span>
-      <div style="line-height:1.55;margin-top:2px">${(e.summary||'').toString().slice(0,150)}</div></div>`).join('');
-    const sc=(C.scenario&&C.scenario.scenarios)||[];
-    ext=`<div style="margin-top:12px">
-      <div class="small" style="font-weight:600;margin-bottom:5px">① 证据清单 · ${(C.evidence||[]).length} 条 / ${C.mechanism.independent_source_count} 个观测面</div>
-      ${ev}
-      <div class="small" style="color:var(--muted);margin-top:5px">四个观测面是<b>同一条机制链的四个面</b>, 不算四个独立源 — 加权时不可当多源印证。</div>
-      <div class="small" style="font-weight:600;margin:12px 0 5px">② 机制 · ${C.mechanism.name||''}</div>
-      <div class="small" style="color:var(--muted)">${C.mechanism.note||''}</div>
-      <div class="small" style="font-weight:600;margin:12px 0 5px">④ 排期沙盘 · ${sc.length} 个情景</div>
-      <table style="margin-top:2px"><tr><th>情景</th><th>窗</th><th>作业级</th><th>停机损失</th></tr>
-      ${sc.map(x=>`<tr><td class="small"><b>${x.id}</b></td><td class="small">${x['修理窗']||'—'}</td>
-        <td class="small" style="color:var(--muted)">${(x['作业级']||'').slice(0,20)}</td>
-        <td class="small" style="color:var(--muted)">${((x['停机损失']||{})['电费口径']||'—').slice(0,22)}</td></tr>`).join('')}</table>
-      <div class="small" style="font-weight:600;margin:12px 0 5px">⑥ 验收 · ${C.acceptance.state||'待执行'}</div>
-      <div class="small" style="color:var(--muted);line-height:1.55">${(C.acceptance.note||'').slice(0,200)}</div></div>`;
-  }
-  document.body.insertAdjacentHTML('beforeend',
-    `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
-     <div class="pop-hd"><b>${t.replace('WTG','')}# 决策链</b>
-       ${chip(r.ostate||'—')}<span class="small" style="color:var(--muted)">走到 ${r.done}/6 · 卡在「${r.stuck||'全通'}」</span>
-       <span class="pop-x" onclick="closePop()">×</span></div>
-     <div class="pop-bd">
-       <p class="small" style="color:var(--muted);margin-bottom:10px">从发现异常到修完验收的六步。
-         <b>下一步:</b> ${r.next||'—'}</p>
-       ${steps}${mini}${ext}
-       <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
-         <a class="badge" href="/turbine/${t}">该台完整问题页 →</a></div>
-     </div></div></div>`);
-}
-async function popPart(t,part){
-  const sys=PART2SYS(part), win=(D&&D.win)||'2026年';
-  document.body.insertAdjacentHTML('beforeend',
-    `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
-     <div class="pop-hd"><b>${t} · ${part}</b><span class="small" style="color:var(--muted)">载入中…</span>
-     <span class="pop-x" onclick="closePop()">×</span></div><div class="pop-bd" id="popbd"></div></div></div>`);
-  let figs=[], p0=null;
-  try{ const r=await fetch(`/api/problem?t=${t}&sys=${encodeURIComponent(sys)}&win=${encodeURIComponent(win)}`);
-       const d=await r.json(); p0=(d.problems||[])[0]; figs=(p0&&p0.figs)||[]; }catch(e){}
-  const F=(D&&D.fus)||{}, frow=(F.表||[]).filter(x=>x.turbine===t&&PART2SYS(x.部件)===sys)[0];
-  const curves=figs.filter(f=>f.kind!=='text'&&f.kind!=='vib');
-  const mini=[];
-  if(frow){
-    mini.push(`<div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${frow.振动结论}</span>
-      <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件}</div></div>`);
-    mini.push(`<div><b>证据窗</b>${winBar(frow.时间窗区间,frow.claim_window)}</div>`);
-    mini.push(`<div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>`);
-    mini.push(`<div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div>`);
-  }
-  if(p0) mini.unshift(`<div><b>系统判级</b>${chip(p0['状态']||'—')}<div class="small" style="color:var(--muted);margin-top:4px">${(p0['依据']||'').slice(0,70)}</div></div>`);
-  const hd=document.querySelector('#popm .pop-hd span.small');
-  if(hd) hd.outerHTML=`<span class="small" style="color:var(--muted)">${sys} · 窗 ${win}</span>`;
-  document.getElementById('popbd').innerHTML=
-    `<div style="margin-bottom:10px">${dtChainStates({[part.replace(/[((].*/,'')]:LVC2(p0&&p0['状态']==='报警'?'bad':'warn')},260,null)}</div>
-     <div class="pop-mini">${mini.join('')}</div>`
-    +(curves.length?curves.slice(0,2).map(f=>`<div style="margin-bottom:10px">${drawFig(f)}</div>`).join('')
-                   :`<p class="small" style="color:var(--muted)">该部件在本时间窗无 SCADA 曲线 (振动谱级证据走 windcms)</p>`)
-    +`<p class="small" style="margin-top:8px"><a class="tl" href="/problem/${t}/${encodeURIComponent(sys)}">完整问题页 (全部曲线与工单史) →</a>
-      &nbsp;·&nbsp;<a class="tl" href="/turbine/${t}">本台总览 →</a></p>`;
-}
-// 三个场景各自独立提交与展示 (2026-08-28 用户令"都单独放在一个输入窗")。
-// 原来共用一个输入框 + 一个结果区, 切场景就把上一个结果冲掉 —
-// 而实际用法是"先查报警码怎么修, 顺手看这个系统的预防任务和故障树", 三者要并存对照。
-if(!window.ASSTCACHE3) window.ASSTCACHE3={};
-async function asstGo(mode){
-  const m=mode||ASSTMODE;
-  const out=document.getElementById('asstOut_'+m); if(!out) return;
-  const el=document.getElementById('asstIn_'+m); const v=el?el.value.trim():'';
-  out.innerHTML=`<div class="card"><p class="small">查询中…</p></div>`;
-  const qs={fault:`kind=fault&code=${encodeURIComponent(v)}`,
-            prev:`kind=prev&system=${encodeURIComponent(v||'齿轮箱')}`,
-            tree:`kind=tree&system=${encodeURIComponent(v||'齿轮箱')}`}[m];
-  let d;
-  try{ d=await (await fetch('/api/ont_chain?'+qs)).json(); }
-  catch(e){ out.innerHTML=`<div class="card"><p class="small" style="color:var(--bad)">查询失败: ${e.message}</p></div>`; return }
-  out.innerHTML = (m==='fault' && d.data) ? faultCards(d.data)
-    : (m==='tree' && d.data) ? treeCards(d.data)
-    : `<div class="card"><pre class="small" style="white-space:pre-wrap;margin:0;line-height:1.75">${(d.text||'').replace(/</g,'&lt;')}</pre></div>`;
-  window.ASSTCACHE3[m]=out.innerHTML;
-}
-
-function treeCards(x){
-  // 故障树两层: 失效模式(果) ← causedBy ← 部件级原因(因); 条长=实证量, 步骤=手册原文
-  const nodes = x.nodes || [];
-  if(!nodes.length) return `<div class="card"><p class="small">该系统无失效模式记录</p></div>`;
-  const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
-  let h = `<div class="card mb"><h3>${x.system} 故障树 · ${nodes.length} 个失效模式</h3>
-    <p class="small" style="color:var(--muted)">一层=系统×失效类(归纳自本场码表与工单) · 二层=手册排查方向归一+工单原因计数;
-      条长按实证量(报警条数 + 工单×10), 因果来自手册与工单原文非推断, 树深两层不外推。</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(--bad)' : n.n_alarm >= 100 ? 'var(--warn)' : 'var(--info)';
-    h += `<div class="card mb"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
-      <div style="flex:1;min-width:200px"><b>${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" style="color:var(--muted);margin-top:4px">本场报警 ${n.n_alarm} 条 · 工单 ${n.n_wo} 条${n.codes.length?` · 征兆码 ${n.codes.join(', ')}`:''}</div>
-      </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" style="color:var(--muted);margin-bottom:6px">← 可能原因 (手册排查方向 + 工单实证)</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">${c.name}</b>
-              <span class="small" style="color:var(--muted)"> 手册 ${c.n_ref} 次 · 工单 ${c.n_wo} 次 · 涉码 ${c.n_codes}</span></div>
-            ${(c.steps||[]).map(st => `<div class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">· ${st}</div>`).join('')}
-          </div>`).join('') + `</div></div>`;
-    } else {
-      h += `<p class="small" style="color:var(--muted);margin-top:8px">(手册无该类排查条目 — 原因层待补)</p>`;
-    }
-    h += `</div>`;
-  });
-  return h;
-}
-
-function maintCards(md){
-  // 数据维护面: 两层分开 (混在一起会让"机理被数据覆盖" — 更新频率与责任方完全不同)
-  const rows = md.rows || [];
-  if(md.error) return `<div class="card"><p class="small" style="color:var(--bad)">${md.error}</p></div>`;
-  const LAYER = [['数据','随运营滚动 — 新数据到位后跑摄入, 本体与判级自动刷新','var(--accent)'],
-                 ['机理','相对稳定 — 换厂商资料或现场反馈修正时才动; 它是判据的地基, 变更须过审','var(--warn)']];
-  let h = `<div class="card mb"><h3>数据维护面 · ${rows.length} 个源</h3>
-    <p class="small" style="color:var(--muted)">每个源给出: 当前覆盖 / 末次更新 / 约定位置 / 摄入命令 / 更新频率 / 责任方。
-      要补数据就把新文件放到"约定位置"再跑"摄入命令"。</p></div>`;
-  LAYER.forEach(([lv, note, col]) => {
-    const rs = rows.filter(r => r.层 === lv);
-    if(!rs.length) return;
-    h += `<div class="card mb" style="border-left:3px solid ${col}"><h3>${lv}${LAYER_SFX} (${rs.length})</h3>
-      <p class="small" style="color:var(--muted)">${note}</p>
-      <table style="margin-top:10px"><tr><th style="width:20%">源</th><th style="width:22%">当前覆盖</th>
-        <th style="width:14%">末次更新</th><th style="width:10%">频率</th><th style="width:10%">责任</th><th>补数据怎么做</th></tr>
-      ${rs.map(r => `<tr>
-        <td class="small"><b>${r.存在 ? '' : '✗ '}${r.名}</b></td>
-        <td class="small" style="font-variant-numeric:tabular-nums">${r.覆盖}${r.条数 ? ` <span style="color:var(--muted)">(${r.条数.toLocaleString()} 条)</span>` : ''}</td>
-        <td class="small" style="color:var(--muted)">${r.末次更新 || '—'}</td>
-        <td class="small">${r.频率}</td><td class="small">${r.责任}</td>
-        <td class="small"><div style="color:var(--muted)">放到: <code>${r.位置}</code></div>
-          <div style="margin-top:3px">跑: <code>${r.摄入}</code></div>
-          <div style="margin-top:3px;color:var(--muted)">${r.说明}</div></td></tr>`).join('')}</table></div>`;
-  });
-  return h;
-}
-
-function faultCards(x){
-  // 抢修链结构化渲染: 码身份 → 失效模式 → 分步处置(卡片) → 历史工单 → 技术依据
-  const sev = x.n_site>=1000?'var(--bad)':x.n_site>=100?'var(--warn)':'var(--muted)';
-  let h=`<div class="card mb"><div style="display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap">
-    ${x.system?partGlyph(x.system,'var(--accent)'):''}
-    <div style="flex:1;min-width:220px"><h3 style="margin:0">${x.code} · ${x.name}</h3>
-      <p class="small" style="color:var(--muted);margin-top:5px">
-        系统 ${x.system||'未归属'} · 本场 <b style="color:${sev}">${x.n_site}</b> 条${x.alarm_type?` · 告警类型 ${x.alarm_type}`:''}</p></div></div>`;
-  if(x.failmodes&&x.failmodes.length)
-    h+=`<div style="margin-top:10px"><b class="small" style="color:var(--muted)">指向的失效模式</b>
-      ${x.failmodes.map(f=>`<div class="small" style="margin-top:5px">· ${f.name} <span style="color:var(--muted)">(该类本场报警 ${f.n_alarm} 条 / 工单 ${f.n_wo} 条)</span></div>`).join('')}</div>`;
-  h+=`</div>`;
-  if(x.steps&&x.steps.length){
-    h+=`<div class="card mb"><h3>现场处置 ${x.steps.length} 步 <span class="small" style="font-weight:400;color:var(--muted)">(${x.wi_source||''})</span></h3>
-      <p class="small" style="color:var(--muted)">节点部件=手册原文元件位号(确定); 判定规范=实机参数表(2024-10-21 导出)按物理量匹配的<b>候选判据</b>,
-        非厂商绑定 — 现场以铭牌与图纸复核; 参数 ID 与告警码是不同命名空间, 未按同号关联。</p>
-      <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;margin-top:10px">`
-      +x.steps.map(st=>`<div style="border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:11px 13px">
-        <div class="small" style="color:var(--accent);font-weight:600">第 ${st.序} 步${st.类别?` · ${st.类别}`:''}${st.qty?` · 量: ${st.qty}`:''}</div>
-        <div style="margin-top:5px;line-height:1.65">${st.步骤}</div>
-        ${(st.tags&&st.tags.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">节点部件</b>
-          ${st.tags.map(t=>`<code style="background:var(--panel-2);border:1px solid var(--line);border-radius:4px;padding:1px 6px;margin-right:5px">${t}</code>`).join('')}</div>`:''}
-        ${(st.nodes&&st.nodes.length)?`<div class="small" style="margin-top:7px">
-          <b style="color:var(--muted)">应检节点 · ${st.circuit||''} (手册此步未给位号 → 按回路构成展开, 逐点查)</b>
-          <table style="margin-top:3px"><tr><th style="width:38%">节点</th><th>查什么</th></tr>
-          ${st.nodes.map(n=>`<tr><td class="small">${n.name}</td><td class="small">${n.check}</td></tr>`).join('')}</table>
-          ${(st.known_tags&&st.known_tags.length)?`<div style="color:var(--muted);margin-top:4px">该系统手册出现过的位号 (对图纸时参考):
-            ${st.known_tags.map(k=>`<code style="margin-right:5px">${k.tag}</code>`).join('')}</div>`:''}
-          </div>`:''}
-        ${(st.specs&&st.specs.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">判定规范 (实机参数表)</b>
-          <table style="margin-top:3px"><tr><th style="width:64px">参数号</th><th>名称</th><th style="width:74px" class="num">整定值</th><th style="width:96px" class="num">允许范围</th></tr>
-          ${st.specs.map(sp=>`<tr><td class="small">${sp.no}</td><td class="small">${sp.text}</td>
-            <td class="num small">${sp.val!=null?sp.val+(sp.unit||''):'—'}</td>
-            <td class="num small">${sp.lo!=null?`${sp.lo} ~ ${sp.hi}`:'—'}</td></tr>`).join('')}</table></div>`:''}
-        ${(st.drawings&&st.drawings.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">对应图纸</b>
-          ${st.drawings.map(dw=>`<div style="color:var(--muted);margin-top:2px">· ${dw.name}</div>`).join('')}</div>`:''}
-      </div>`).join('')
-      +`</div></div>`;
-  } else {
-    h+=`<div class="card mb"><p class="small">本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或联系厂商支持。</p></div>`;
-  }
-  if(x.workorders&&x.workorders.length)
-    h+=`<div class="card mb"><h3>现场实际怎么修的 (${x.system} 历史工单 ${x.wo_total} 条, 台账 2020~2024)</h3>
-      <table style="margin-top:8px"><tr><th style="width:92px">日期</th><th style="width:64px">机组</th><th>故障</th><th style="width:110px">处置</th></tr>
-      ${x.workorders.map(w=>`<tr><td class="small">${w.date}</td><td class="small">${w.turbine.replace('WTG','')}#</td>
-        <td class="small">${w.name}</td><td class="small">${w.action} ${w.part}</td></tr>`).join('')}</table>
-      <p class="small" style="color:var(--muted);margin-top:6px">工单窗止 2024-11-21; 2025-26 现场未提供 ⇒ "0 条"是记录缺失非未修过。</p></div>`;
-  // 技术依据卡撤 (2026-08-28 用户令): 按文件名关键词匹配出来的文档与本次故障常不相关
-  // (液压压力偏高 → 推出"单叶片吊具培训资料"/"轮毂起吊图纸"), 列出来是噪音不是依据。
-  // ont_doc_search API 保留, 需要时按码精确查, 不再在抢修链里自动铺开。
-  return h;
-}
-
-async function ontChain(kind){
-  const out=document.getElementById('ocOut'); if(!out) return;
-  out.style.display='block'; out.textContent='查询中…';
-  const qs={fault:()=>`kind=fault&code=${encodeURIComponent((document.getElementById('ocCode')||{}).value||'')}`,
-            prev:()=>`kind=prev&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
-            tree:()=>`kind=tree&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
-            plan:()=>'kind=plan&top=12',
-            doc:()=>`kind=doc&keyword=${encodeURIComponent((document.getElementById('ocDoc')||{}).value||'')}`}[kind];
-  try{ const r=await fetch('/api/ont_chain?'+qs()); const d=await r.json();
-       out.textContent=d.text||JSON.stringify(d); }
-  catch(e){ out.textContent='查询失败: '+e.message }
-}
-function closePop(){const m=document.getElementById('popm'); if(m)m.remove()}
-const LVC2=lv=>({bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'}[lv]||'var(--warn)');
-document.addEventListener('keydown',e=>{if(e.key==='Escape')closePop()});
-// 状态链条 (per 节点各自着色版; windcms 结果层用: 四部件等级一次性画在传动链上, 替代四列 chips)
-const dtChainStates=(cmap,W,T2)=>{
-  const w=W||208, Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
-  const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
-      stroke="${LN}" stroke-opacity="0.5" ${dash?'stroke-dasharray="3 2"':''}/>`;
-  const node=(x,key,kind)=>{
-    const col=cmap[key]||null, k=!!col, r=k?6.6:5;
-    const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
-      return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col||MU}" stroke-width="${k?1.7:1.2}"/>`}).join(''):'';
-    const core=`<circle cx="${x}" cy="${Y}" r="${r}" fill="${col||PN}" fill-opacity="${k?0.9:1}" stroke="${col||MU}" stroke-opacity="${k?1:0.8}" stroke-width="${k?2:1.3}"/>`
-      +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.4).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}"/>`:teeth);
-    return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${key}')"><title>${key} — 点看曲线与四源微视图</title>
-        <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core};
-  return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
-  ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
-  <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
-  <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
-  ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
-  ${node(133,'齿轮箱','g')}${node(157,'齿轮箱','g')}${node(181,'齿轮箱','g')}
-  ${node(236,'发电机','b')}${node(282,'发电机','b')}
-  </svg>`};
-// 证据强度阶梯 (确诊4格/准定论3/候选2/参考1/INSUFFICIENT 0虚框) — 文字分级换成可比的阶梯
-const evLadder=t=>{
-  const T=String(t||''), n=/确诊/.test(T)?4:/准定论|定论/.test(T)?3:/候选/.test(T)?2:/参考/.test(T)?1:0;
-  const col=n>=4?'var(--bad)':n===3?'var(--warn)':n===2?'var(--info)':n===1?'var(--muted)':'var(--unknown)';
-  const cells=[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="0.9" stroke="${col}" stroke-opacity="${i<n?1:0.4}" stroke-width="1"
-     ${i>=n?'stroke-dasharray="2 1.5"':''}/>`).join('');
-  return `<div title="${esc(T)}" style="display:flex;align-items:center;gap:7px">
-    <svg class="dt" viewBox="0 0 62 20" style="width:62px;height:20px;flex:none">${cells}</svg>
-    <span class="small" style="color:${col}">${esc(T)}</span></div>`};
-// 部件微缩符号 (矩阵/列表行内用): 与解剖图同一套图形语言的 24px 版 —
-// 齿轮箱=行星轮系 · 发电机=定子转子 · 主轴承=滚子剖面 · 变桨=桨叶轴承 · 偏航=齿圈驱动
-function partIcon(name, col){
-  const c = col || 'currentColor', n = String(name || '');
-  const W = 30, H = 26;
-  // 尺寸走 inline style (全局 svg{width:100%} 会吃掉 width 属性 — 同一个坑第三次: dtSVG/台页/此处)
-  const wrap = body => `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${n}"
-    style="width:${W}px;height:${H}px;min-width:${W}px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block"
-    ><g fill="none" stroke="${c}" stroke-width="1.3">${body}</g></svg>`;
-  if(/齿轮箱|行星|中间轴|中速|高速/.test(n))
-    return wrap(`<circle cx="15" cy="13" r="10.5"/><circle cx="15" cy="13" r="3.4"/>
-      <circle cx="15" cy="5.5" r="3"/><circle cx="8.5" cy="17" r="3"/><circle cx="21.5" cy="17" r="3"/>
-      <path d="M15,13 L15,5.5 M15,13 L8.5,17 M15,13 L21.5,17" stroke-width="0.9" stroke-opacity="0.75"/>`);
-  if(/发电机|定子|转子|DE|NDE/.test(n))
-    return wrap(`<rect x="4" y="6" width="22" height="14" rx="2.5"/>
-      <path d="M4,10 h22 M4,16 h22" stroke-opacity="0.45"/>
-      <rect x="9" y="10" width="12" height="6" rx="1" fill="${c}" fill-opacity="0.18" stroke="none"/>
-      <path d="M1,13 h3 M26,13 h3" stroke-width="1.6"/>`);
-  if(/主轴承|主轴/.test(n)){
-    // 前后两个轴承都画出来 (2026-08-28 用户令), 命中的那个着色加粗, 另一个留灰 —
-    // 左=前(轮毂侧) 右=后(齿轮箱侧), 与传动链图同一朝向; 未指明前后则两个都着色
-    const hasF = /前/.test(n), hasR = /后/.test(n), both = !hasF && !hasR;
-    const brg = (x, on) => {
-      const st = on ? c : 'var(--muted)', w = on ? 1.7 : 1.05, op = on ? 1 : 0.55;
-      return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
-        <rect x="${x}" y="5.5" width="16" height="4" rx="1"/><rect x="${x}" y="16.5" width="16" height="4" rx="1"/>
-        <circle cx="${x + 4.5}" cy="13" r="2.3"/><circle cx="${x + 11.5}" cy="13" r="2.3"/></g>`;
-    };
-    return `<svg viewBox="0 0 46 26" role="img" aria-label="${n}"
-      style="width:46px;height:26px;min-width:46px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block">
-      <g fill="none"><path d="M2,13 h42" stroke="var(--muted)" stroke-width="2.2" stroke-opacity="0.35"/>
-      ${brg(4, hasF || both)}${brg(26, hasR || both)}</g></svg>`;
-  }
-  if(/变桨|桨|叶片|叶根|轮毂/.test(n))
-    return wrap(`<circle cx="15" cy="13" r="7.5"/><circle cx="15" cy="13" r="4"/>
-      <path d="M15,5.5 q7,-3 11,1 M15,20.5 q7,3 11,-1 M7.5,13 q-6,0 -6.5,-4" stroke-width="1.1" stroke-opacity="0.8"/>`);
-  if(/偏航|解缆|扭缆|对风/.test(n))
-    return wrap(`<circle cx="15" cy="15" r="9"/><circle cx="15" cy="15" r="5.5" stroke-dasharray="2 2"/>
-      ${[0,72,144,216,288].map(a=>{const r=(a-90)*Math.PI/180;
-        return `<circle cx="${(15+Math.cos(r)*9).toFixed(1)}" cy="${(15+Math.sin(r)*9).toFixed(1)}" r="1.7"/>`}).join('')}`);
-  if(/变流|变频|逆变/.test(n))
-    return wrap(`<rect x="5" y="6" width="20" height="14" rx="2"/><path d="M11,10 l-3,4 h5 l-3,4" stroke-width="1.2"/>
-      <path d="M18,10 v8 M21.5,10 v8" stroke-opacity="0.6"/>`);
-  if(/塔架|塔筒|基础|法兰/.test(n))
-    return wrap(`<path d="M11,4 h8 l3,17 h-14 z"/><path d="M9,14 h12" stroke-opacity="0.5"/>
-      <path d="M5,21 h20" stroke-width="1.8"/>`);
-  return wrap(`<circle cx="15" cy="13" r="8" stroke-dasharray="3 2.5"/>`);
-}
-
-// 部件小图 (按部件截取: 只画命中的那一段, 不画整条链; 比八节点链条形象 — 带座体/齿形/散热筋等外形特征)
-function partGlyph(name, col, solid){
-  // solid=true 仅用于报警/危险级 — 良好/优秀级用描边不填充, 否则密集表格里满屏深色块
-  // (2026-08-28 用户逮'满屏黑化'; 对应 skill 原则: boldness 只花在一处, 其余安静)
-  const c = col || 'currentColor', n = String(name || ''), MU = 'var(--muted)';
-  const svg = (w, body) => `<svg viewBox="0 0 ${w} 38" role="img" aria-label="${n}"
-    style="width:${w}px;height:38px;min-width:${w}px;vertical-align:-11px;flex:none;display:block">
-    <g fill="none" stroke-linejoin="round">${body}</g></svg>`;
-
-  if(/主轴承|主轴/.test(n)){
-    const F = /前/.test(n), R = /后/.test(n), both = !F && !R;
-    // 带座体的轴承: 座体梯形 + 外圈 + 滚子 + 内圈, 轴穿过
-    const unit = (x, on) => {
-      const st = on ? c : MU, w = on ? 1.7 : 1, op = on ? 1 : 0.5;
-      return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
-        <path d="M${x-2},32 h26 l-3,-8 h-20 z" fill="${on&&solid?c:'none'}" fill-opacity="${on&&solid?0.14:0}"/>
-        <rect x="${x+1}" y="7" width="18" height="4" rx="1"/>
-        <rect x="${x+1}" y="25" width="18" height="4" rx="1"/>
-        <rect x="${x+1}" y="12.5" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
-        <rect x="${x+1}" y="21" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
-        <circle cx="${x+5}" cy="18" r="2.6"/><circle cx="${x+10}" cy="18" r="2.6"/><circle cx="${x+15}" cy="18" r="2.6"/></g>`;
-    };
-    return svg(96, `<path d="M2,18 h92" stroke="${MU}" stroke-width="3" stroke-opacity="0.3" stroke-linecap="round"/>
-      ${unit(8, F||both)}${unit(56, R||both)}
-      <text x="19" y="6" text-anchor="middle" font-size="8" fill="${F||both?c:MU}" stroke="none" opacity="${F||both?1:0.6}">前</text>
-      <text x="67" y="6" text-anchor="middle" font-size="8" fill="${R||both?c:MU}" stroke="none" opacity="${R||both?1:0.6}">后</text>`);
-  }
-
-  if(/齿轮箱|行星|中间轴|中速|高速/.test(n)){
-    const P = /行星/.test(n), M = /中间|中速/.test(n), H = /高速/.test(n), all = !P && !M && !H;
-    const gear = (cx, cy, r, teeth, on) => {
-      const st = on ? c : MU, sw = on ? 1.6 : 1, op = on ? 1 : 0.45;
-      let t = '';
-      for(let i=0;i<teeth;i++){const a=i*2*Math.PI/teeth;
-        t += `<line x1="${(cx+Math.cos(a)*r).toFixed(1)}" y1="${(cy+Math.sin(a)*r).toFixed(1)}"
-              x2="${(cx+Math.cos(a)*(r+2.4)).toFixed(1)}" y2="${(cy+Math.sin(a)*(r+2.4)).toFixed(1)}"/>`}
-      return `<g stroke="${st}" stroke-width="${sw}" stroke-opacity="${op}"><circle cx="${cx}" cy="${cy}" r="${r}"/>${t}
-        <circle cx="${cx}" cy="${cy}" r="${(r*0.3).toFixed(1)}"/></g>`;
-    };
-    return svg(104, `<path d="M3,10 h98 v22 q0,4 -4,4 h-90 q-4,0 -4,-4 z" stroke="${MU}" stroke-opacity="0.45" stroke-dasharray="3 2"/>
-      ${gear(22, 21, 9, 12, P||all)}${gear(22, 21, 3.4, 0, P||all)}
-      ${gear(55, 18, 7.5, 10, M||all)}${gear(80, 25, 5.5, 8, H||all)}
-      <path d="M31,21 h16 M62,20 h11" stroke="${MU}" stroke-width="1.6" stroke-opacity="0.45"/>`);
-  }
-
-  if(/发电机|定子|转子|DE|NDE/.test(n)){
-    const D = /(?<!N)DE/.test(n) && !/NDE/.test(n), ND = /NDE/.test(n), all = !D && !ND;
-    const end = (x, on) => `<g stroke="${on?c:MU}" stroke-width="${on?1.7:1}" stroke-opacity="${on?1:0.5}">
-      <rect x="${x}" y="12" width="9" height="14" rx="2"/><circle cx="${x+4.5}" cy="19" r="2.6"/></g>`;
-    return svg(96, `<g stroke="${c}" stroke-width="1.3">
-      <rect x="18" y="8" width="60" height="22" rx="3"/></g>
-      <g stroke="${MU}" stroke-opacity="0.5" stroke-width="0.9">
-        ${[26,34,42,50,58,66].map(x=>`<line x1="${x}" y1="8" x2="${x}" y2="30"/>`).join('')}</g>
-      <rect x="30" y="14" width="36" height="10" rx="2" stroke="${c}" stroke-width="1.2" fill="${c}" fill-opacity="${solid?0.14:0.04}"/>
-      <path d="M4,19 h14 M78,19 h14" stroke="${MU}" stroke-width="2.4" stroke-opacity="0.35" stroke-linecap="round"/>
-      ${end(8, D||all)}${end(79, ND||all)}
-      <text x="12" y="6" text-anchor="middle" font-size="8" fill="${D||all?c:MU}" stroke="none" opacity="${D||all?1:0.6}">DE</text>
-      <text x="84" y="6" text-anchor="middle" font-size="8" fill="${ND||all?c:MU}" stroke="none" opacity="${ND||all?1:0.6}">NDE</text>`);
-  }
-
-  if(/变桨|桨|叶片|叶根|轮毂/.test(n))
-    return svg(80, `<g stroke="${c}" stroke-width="1.5">
-      <circle cx="26" cy="19" r="12"/><circle cx="26" cy="19" r="7"/>
-      ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
-        return `<circle cx="${(26+Math.cos(r)*9.6).toFixed(1)}" cy="${(19+Math.sin(r)*9.6).toFixed(1)}" r="2.2"/>`}).join('')}</g>
-      <g stroke="${MU}" stroke-width="4" stroke-opacity="0.32" stroke-linecap="round">
-        <path d="M38,19 q22,-3 36,-11"/><path d="M38,19 q22,3 36,11"/></g>`);
-
-  if(/偏航|解缆|扭缆|对风/.test(n))
-    return svg(80, `<g stroke="${c}" stroke-width="1.5"><circle cx="34" cy="21" r="13"/>
-      ${Array.from({length:14},(_,i)=>{const a=i*2*Math.PI/14;
-        return `<line x1="${(34+Math.cos(a)*13).toFixed(1)}" y1="${(21+Math.sin(a)*13).toFixed(1)}"
-                x2="${(34+Math.cos(a)*15.6).toFixed(1)}" y2="${(21+Math.sin(a)*15.6).toFixed(1)}"/>`}).join('')}</g>
-      <g stroke="${MU}" stroke-width="1.2" stroke-opacity="0.6">
-        <circle cx="60" cy="14" r="5"/><circle cx="60" cy="28" r="5"/></g>
-      <path d="M18,6 h32" stroke="${MU}" stroke-width="2" stroke-opacity="0.4"/>`);
-
-  return svg(60, `<circle cx="30" cy="19" r="11" stroke="${MU}" stroke-dasharray="3 2.5" stroke-opacity="0.6"/>`);
-}
-
-// ══ 部件解剖图 (2026-08-28 用户令"部件UI图像化认真对待") ══
-// 原则: 画机制不画名字 — 每张图展示该系统的介质/动力如何流经部件, 箭头带口径标注,
-// 命中部件按状态着色; 一图一论点。与传动链简图分工: 传动链=链上哪一环, 解剖图=环内怎么坏的。
-const ANAT_C = st => ({'报警':'var(--bad)','危险':'var(--bad)','预警':'var(--warn)','良好':'var(--info)',
-                       '优秀':'var(--ok)','不可判':'var(--unknown)'}[st] || 'var(--muted)');
-
-function anatMainBearing(hit, states){
-  // 主轴承集中润滑链: 泵 →(管路)→ 分配器 →(柱塞往复=出脂计量)→ 前/后轴承
-  // 画的是"脂怎么到轴承", 29# 的失效点在泵本体 (柱塞单次时长拖长=打不满)
-  const c = k => ANAT_C((states||{})[k]);
-  const on = k => (hit||'').includes(k);
-  const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
-  return `<figure style="margin:0"><svg viewBox="0 0 660 210" role="img" width="660"
-    aria-label="主轴承集中润滑链: 润滑泵经管路到分配器, 分配器柱塞往复把脂定量送到前后主轴承"
-    style="max-width:100%;height:auto;display:block;margin:0 auto">
-    <g fill="none" stroke="currentColor" stroke-width="1.4" font-family="inherit">
-      <!-- 脂桶+泵 -->
-      <rect x="18" y="60" width="54" height="66" rx="4" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
-      <path d="M28,70 h34 M28,80 h34 M28,90 h34" stroke-opacity="0.35"/>
-      <text x="45" y="142" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">脂桶</text>
-      <circle cx="104" cy="93" r="17" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
-      <path d="M97,86 l14,7 -14,7 z" fill="${hl('泵')}" stroke="none"/>
-      <text x="104" y="128" text-anchor="middle" font-size="11" fill="${hl('泵')}" stroke="none">润滑泵</text>
-      ${on('泵') ? '<text x="104" y="62" text-anchor="middle" font-size="10" fill="var(--bad)" stroke="none">本体失效</text>' : ''}
-      <!-- 管路 -->
-      <path d="M121,93 h58" marker-end="url(#aH)"/>
-      <text x="150" y="86" text-anchor="middle" font-size="10" fill="currentColor" stroke="none" stroke-opacity="0">主管路</text>
-      <!-- 分配器 -->
-      <rect x="182" y="66" width="66" height="54" rx="4" stroke="${hl('分配器')}"/>
-      <path d="M196,80 v26 M215,80 v26 M234,80 v26" stroke-opacity="0.55"/>
-      <text x="215" y="134" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">分配器</text>
-      <text x="215" y="60" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">柱塞往复=出脂计量</text>
-      <!-- 支路到前后轴承 -->
-      <path d="M248,80 h48 q12,0 12,-12 v-14" marker-end="url(#aH)"/>
-      <path d="M248,106 h48 q12,0 12,12 v14" marker-end="url(#aH)"/>
-      <text x="286" y="70" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">前支路</text>
-      <text x="286" y="140" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">后支路</text>
-      <!-- 前后轴承 (剖面) -->
-      ${[['主轴承前', 34], ['主轴承后', 132]].map(([k, y]) => `
-        <g stroke="${c(k)}" stroke-width="${(states||{})[k]==='报警'?2.4:1.4}">
-          <rect x="330" y="${y}" width="86" height="16" rx="2"/>
-          <rect x="330" y="${y+28}" width="86" height="16" rx="2"/>
-          <circle cx="352" cy="${y+22}" r="5"/><circle cx="373" cy="${y+22}" r="5"/><circle cx="394" cy="${y+22}" r="5"/>
-        </g>
-        <text x="373" y="${y+58}" text-anchor="middle" font-size="11" fill="${c(k)}" stroke="none">${k}</text>`).join('')}
-      <!-- 主轴穿过 -->
-      <path d="M300,105 h180" stroke-width="7" stroke-opacity="0.25" stroke-linecap="round"/>
-      <!-- 观测面标注 -->
-      <path d="M215,120 v42" stroke-dasharray="3 3" stroke-opacity="0.6"/>
-      <text x="215" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">因侧: 柱塞计数/单次时长</text>
-      <path d="M373,${(states||{})['主轴承后']?'190':'190'} v-14" stroke-dasharray="3 3" stroke-opacity="0.6"/>
-      <text x="470" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">果侧: 轴承温度 · 振动冲击</text>
-      <defs><marker id="aH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
-        <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
-    </g></svg>
-    <figcaption class="small" style="color:var(--muted);margin-top:6px">
-      脂路: 泵 → 主管路 → 分配器(柱塞定量) → 前/后支路 → 轴承。因侧看柱塞行为, 果侧看轴承温度与冲击 —
-      两侧不是两个独立源, 是同一条机制链的两端。</figcaption></figure>`;
-}
-
-function anatGearbox(hit, states){
-  // 齿轮箱: 一级行星 + 两级平行轴 + 润滑/冷却回路 (油从箱底经泵/滤/冷却器回喷淋)
-  const c = k => ANAT_C((states||{})[k]);
-  const on = k => (hit||'').includes(k);
-  const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
-  return `<figure style="margin:0"><svg viewBox="0 0 660 250" role="img" width="660"
-    aria-label="齿轮箱: 一级行星加两级平行轴传动, 下方为润滑油路(泵-滤芯-冷却器-喷淋)"
-    style="max-width:100%;height:auto;display:block;margin:0 auto">
-    <g fill="none" stroke="currentColor" stroke-width="1.4">
-      <rect x="24" y="18" width="500" height="128" rx="8" stroke-dasharray="5 4" stroke-opacity="0.5"/>
-      <text x="274" y="14" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none" opacity="0.7">齿轮箱壳体 (i=119.752)</text>
-      <!-- 行星级 -->
-      <g stroke="${hl('行星')}" stroke-width="${on('行星')?2.2:1.4}">
-        <circle cx="98" cy="82" r="46"/><circle cx="98" cy="82" r="15"/>
-        <circle cx="98" cy="46" r="14"/><circle cx="67" cy="100" r="14"/><circle cx="129" cy="100" r="14"/>
-        <path d="M98,82 L98,46 M98,82 L67,100 M98,82 L129,100" stroke-width="1"/>
-      </g>
-      <text x="98" y="146" text-anchor="middle" font-size="11" fill="${hl('行星')}" stroke="none">一级行星</text>
-      <path d="M144,82 h40" marker-end="url(#gH)"/>
-      <text x="164" y="74" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">中速</text>
-      <!-- 平行轴两级 -->
-      <g stroke="${hl('中间')}" stroke-width="${on('中间')?2.2:1.4}">
-        <rect x="188" y="52" width="16" height="60" rx="3"/><rect x="188" y="26" width="16" height="26" rx="3"/></g>
-      <text x="196" y="146" text-anchor="middle" font-size="11" fill="${hl('中间')}" stroke="none">中间轴</text>
-      <g stroke="${hl('高速')}" stroke-width="${on('高速')?2.2:1.4}">
-        <rect x="244" y="26" width="16" height="52" rx="3"/><rect x="244" y="78" width="16" height="30" rx="3"/></g>
-      <text x="252" y="146" text-anchor="middle" font-size="11" fill="${hl('高速')}" stroke="none">高速轴</text>
-      <path d="M268,93 h56" marker-end="url(#gH)"/>
-      <text x="296" y="86" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">→ 发电机</text>
-      <!-- 油路 -->
-      <path d="M60,146 v34 h420 v-34" stroke-opacity="0.8"/>
-      <circle cx="180" cy="180" r="13" stroke="${hl('油泵')}"/><path d="M174,174 l12,6 -12,6 z" fill="${hl('油泵')}" stroke="none"/>
-      <text x="180" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">油泵</text>
-      <rect x="242" y="168" width="26" height="24" rx="2" stroke="${hl('滤芯')}"/>
-      <path d="M246,172 l18,16 M264,172 l-18,16" stroke-width="0.9" stroke-opacity="0.6"/>
-      <text x="255" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">滤芯</text>
-      <rect x="322" y="166" width="40" height="28" rx="3" stroke="${hl('冷却')}"/>
-      <path d="M330,170 v20 M340,170 v20 M350,170 v20" stroke-width="0.9" stroke-opacity="0.6"/>
-      <text x="342" y="208" text-anchor="middle" font-size="10" fill="${hl('冷却')}" stroke="none">冷却器</text>
-      <path d="M410,180 v-20" marker-end="url(#gH)"/>
-      <text x="446" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">喷淋回箱</text>
-      <!-- 观测面 -->
-      <text x="560" y="60" font-size="10" fill="currentColor" stroke="none">观测面:</text>
-      <text x="560" y="78" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 振动 (各级测点)</text>
-      <text x="560" y="94" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 轴承/油温 ΔT</text>
-      <text x="560" y="110" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 滤芯压差码</text>
-      <text x="560" y="126" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 油样铁谱</text>
-      <defs><marker id="gH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
-        <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
-    </g></svg>
-    <figcaption class="small" style="color:var(--muted);margin-top:6px">
-      传动: 行星级(输入) → 中间轴 → 高速轴(输出)。油路: 箱底 → 泵 → 滤芯 → 冷却器 → 喷淋回箱 —
-      过热类失效沿油路查(冷却/滤芯), 磨损类沿齿轮线查(振动/铁谱)。</figcaption></figure>`;
-}
-
-function anatPitch(hit, states){
-  // 变桨: 蓄能器储压 → 比例阀 → 油缸推动变桨轴承 → 桨叶; 滑环供电与信号
-  const c = k => ANAT_C((states||{})[k]);
-  const on = k => (hit||'').includes(k);
-  const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
-  return `<figure style="margin:0"><svg viewBox="0 0 660 230" role="img" width="660"
-    aria-label="变桨系统: 蓄能器储压经比例阀推动油缸, 油缸驱动变桨轴承转动桨叶; 滑环传电与信号"
-    style="max-width:100%;height:auto;display:block;margin:0 auto">
-    <g fill="none" stroke="currentColor" stroke-width="1.4">
-      <!-- 滑环 -->
-      <g stroke="${hl('滑环')}"><rect x="20" y="86" width="30" height="50" rx="4"/>
-        <path d="M20,96 h30 M20,110 h30 M20,124 h30" stroke-opacity="0.5"/></g>
-      <text x="35" y="152" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">滑环</text>
-      <path d="M50,111 h34" marker-end="url(#pH)"/>
-      <text x="67" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">电/信号</text>
-      <!-- 蓄能器 -->
-      <g stroke="${hl('蓄能')}" stroke-width="${on('蓄能')?2.2:1.4}">
-        <rect x="88" y="70" width="40" height="82" rx="20"/>
-        <path d="M88,104 h40" stroke-dasharray="3 2"/></g>
-      <text x="108" y="166" text-anchor="middle" font-size="10.5" fill="${hl('蓄能')}" stroke="none">蓄能器</text>
-      <text x="108" y="62" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">氮气预充</text>
-      <path d="M128,111 h36" marker-end="url(#pH)"/>
-      <!-- 比例阀 -->
-      <g stroke="${hl('阀')}"><rect x="166" y="92" width="34" height="38" rx="3"/>
-        <path d="M166,111 h34 M183,92 v38" stroke-opacity="0.5"/></g>
-      <text x="183" y="146" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">比例阀</text>
-      <path d="M200,111 h34" marker-end="url(#pH)"/>
-      <text x="217" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">压力</text>
-      <!-- 油缸 -->
-      <g stroke="${hl('油缸')}" stroke-width="${on('油缸')?2.2:1.4}">
-        <rect x="236" y="94" width="86" height="34" rx="3"/>
-        <rect x="246" y="100" width="30" height="22" rx="2" fill="currentColor" fill-opacity="0.14"/>
-        <path d="M276,111 h66"/></g>
-      <text x="279" y="146" text-anchor="middle" font-size="10.5" fill="${hl('油缸')}" stroke="none">变桨油缸</text>
-      <!-- 变桨轴承 + 桨叶 -->
-      <g stroke="${hl('轴承')}" stroke-width="${on('轴承')?2.2:1.4}">
-        <circle cx="392" cy="111" r="34"/><circle cx="392" cy="111" r="24"/>
-        ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
-          return `<circle cx="${(392+Math.cos(r)*29).toFixed(1)}" cy="${(111+Math.sin(r)*29).toFixed(1)}" r="3.4"/>`}).join('')}</g>
-      <text x="392" y="164" text-anchor="middle" font-size="10.5" fill="${hl('轴承')}" stroke="none">变桨轴承</text>
-      <path d="M426,111 q46,-4 74,-40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
-      <path d="M426,111 q46,4 74,40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
-      <text x="520" y="76" font-size="10.5" fill="currentColor" stroke="none">桨叶 A/B/C</text>
-      <!-- 观测面 -->
-      <path d="M108,152 v30 h284 v-18" stroke-dasharray="3 3" stroke-opacity="0.55"/>
-      <text x="250" y="196" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">
-        观测: 蓄能带宽/停机泄压 · 桨距跟踪偏差 · 三叶零位一致性 · 轴承润滑脂</text>
-      <defs><marker id="pH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
-        <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
-    </g></svg>
-    <figcaption class="small" style="color:var(--muted);margin-top:6px">
-      动力: 蓄能器储压 → 比例阀 → 油缸 → 变桨轴承 → 桨叶。压力侧失效看蓄能带宽与泄压率,
-      执行侧看桨距跟踪偏差, 三叶不一致指向单支路。</figcaption></figure>`;
-}
-
-const ANAT = {'主轴承': anatMainBearing, '齿轮箱': anatGearbox, '变桨': anatPitch};
-
-// 链条缩略版 (表格用: 列宽有限, 全剖面被挤成49px不可读) — 同一图形语言的 sparkline。
-// 2026-08-28 用户"更清楚": 八个一样的圆点分不出段落 → 节点形状语义化 (轴承=双环 · 齿轮=带齿圆)
-// + 三段分区底纹 (主轴 / 齿轮箱 / 发电机), 命中环放大填色+外发光, 仍零文字。
-const dtMini=(comp,sev,W,T2)=>{
-  const c=String(comp||''), w=W||208;
-  const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
-    '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
-    '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
-  const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
-  const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
-  const Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
-  const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
-      stroke="${dash&&gball?sev:LN}" stroke-opacity="${dash&&gball?0.9:0.5}" ${dash?'stroke-dasharray="3 2"':''}/>`;
-  // 轴承节点 = 双环 (外圈+滚道); 齿轮节点 = 圆+四齿
-  const node=(x,n,kind)=>{
-    const k=on(n), col=k?sev:MU, r=k?7:5.4;
-    const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
-      return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col}" stroke-width="${k?1.8:1.2}"/>`}).join(''):'';
-    const core=(k?`<circle class="dt-halo" cx="${x}" cy="${Y}" r="${r+6}" fill="${sev}"/>`:'')
-      +`<circle cx="${x}" cy="${Y}" r="${r}" fill="${k?sev:PN}" fill-opacity="${k?0.9:1}" stroke="${col}" stroke-opacity="${k?1:0.85}" stroke-width="${k?2.2:1.4}"/>`
-      +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.42).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}" stroke="none"/>`:teeth);
-    return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${n}')"><title>${n} — 点看曲线与四源微视图</title>
-        <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core;
-  };
-  return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
-  ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
-  <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
-  <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
-  ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
-  ${node(133,'行星','g')}${node(157,'中间','g')}${node(181,'高速','g')}
-  ${node(236,'发电机DE','b')}${node(282,'发电机NDE','b')}
-  </svg>`};
-const dtSVG=(comp,sev,W)=>{
-  const c=String(comp||''), w=W||660;
-  const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
-    '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
-    '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
-  const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
-  const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
-  const LN='var(--line)', MU='var(--muted)', PN='var(--panel)';
-  const K=660/w, FS=(11*K).toFixed(1), SW=(1.5*K).toFixed(2);   // 缩放补偿: 显示字号/线宽与图宽无关
-  // 部件绘制器: 命中→光晕+sev描边加粗+标名; 未命中→灰描边无字 (纯图形: 只有出问题的那一环有字)
-  const part=(n,label,lx,ly,body,halo)=>{
-    const k=on(n), col=k?sev:MU;
-    return (k&&halo?`<rect class="dt-halo" x="${halo[0]}" y="${halo[1]}" width="${halo[2]}" height="${halo[3]}" rx="8" fill="${sev}"/>`:'')
-      +`<g stroke="${col}" stroke-width="${k?SW:0.9*K}" fill="${PN}" fill-opacity="1">${body}</g>`
-      +(k?`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="${FS}" font-weight="600" fill="${sev}">${label}</text>`:'');
-  };
-  // 轴承剖面符号 (上下外圈+滚子+内圈)
-  const brg=(x,n,label,ly)=>part(n,label,x,ly,
-    `<rect x="${x-17}" y="47" width="34" height="7" rx="1.5"/><rect x="${x-17}" y="96" width="34" height="7" rx="1.5"/>`+
-    [-11,0,11].map(d=>`<circle cx="${x+d}" cy="60" r="4.2"/><circle cx="${x+d}" cy="90" r="4.2"/>`).join('')+
-    `<rect x="${x-17}" y="66" width="34" height="4" rx="1"/><rect x="${x-17}" y="80" width="34" height="4" rx="1"/>`,[x-24,40,48,70]);
-  // 齿轮 = 竖矩形 + 齿纹
-  const gear=(x,cy,D,n,label,ly)=>part(n,label,x+6,ly,
-    `<rect x="${x}" y="${cy-D/2}" width="13" height="${D}" rx="2"/>`+
-    Array.from({length:Math.max(3,Math.round(D/9))},(_,i)=>{const yy=cy-D/2+4+i*(D-8)/Math.max(2,Math.round(D/9)-1);
-      return `<line x1="${x}" y1="${yy}" x2="${x+13}" y2="${yy}" stroke-width="${0.7*K}" stroke-opacity="0.55"/>`}).join(''),[x-7,cy-D/2-7,27,D+14]);
-  const shaft=(x1,x2,y,th)=>`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${LN}" stroke-width="${th||9}" stroke-linecap="round"/>`;
-  return `<svg class="dt" viewBox="0 0 660 150" style="display:block;width:${w}px;max-width:100%;height:auto;margin:6px auto">
-  <!-- 轮毂+叶根 -->
-  <g stroke="${MU}" fill="${PN}"><path d="M22,52 L48,62 L48,88 L22,98 Z" stroke-width="1"/>
-    <line x1="22" y1="75" x2="6" y2="75" stroke-width="3"/><line x1="24" y1="60" x2="10" y2="34" stroke-width="3"/>
-    <line x1="24" y1="90" x2="10" y2="116" stroke-width="3"/></g>
-  ${shaft(46,206,75,12)}
-  ${brg(88,'主轴承前','主轴承 前',126)}${brg(154,'主轴承后','主轴承 后',126)}
-  <!-- 齿轮箱壳 -->
-  <rect x="196" y="16" width="242" height="118" rx="7" fill="none"
-        stroke="${gball?sev:LN}" stroke-width="${gball?1.6:1}" stroke-dasharray="4 3" stroke-opacity="${gball?0.9:0.6}"/>
-  <!-- 一级行星: 齿圈(上下)+行星轮+太阳轮+行星架 -->
-  ${part('行星','行星级',236,146,
-    `<rect x="210" y="24" width="52" height="8" rx="2"/><rect x="210" y="118" width="52" height="8" rx="2"/>
-     <rect x="228" y="38" width="14" height="26" rx="2"/><rect x="228" y="86" width="14" height="26" rx="2"/>
-     <rect x="228" y="62" width="14" height="26" rx="2"/>
-     <path d="M210,75 L228,51 M210,75 L228,99" stroke-width="${1.4*K}" fill="none"/>`,[203,17,66,116])}
-  ${shaft(256,306,75,7)}
-  <!-- 二级 中速→中间轴 (节点相切) -->
-  ${gear(300,75,46,'中间','中间轴',146)}
-  ${gear(300,37,30,'中间','',0)}
-  ${shaft(306,352,37,6)}
-  <!-- 三级 中间轴→高速轴 -->
-  ${gear(348,37,50,'高速','高速轴',146)}
-  ${gear(348,75,26,'高速','',0)}
-  ${shaft(360,438,75,8)}
-  <!-- 联轴器 -->
-  <g stroke="${MU}" fill="${PN}"><rect x="438" y="62" width="6" height="26" rx="1"/><rect x="452" y="62" width="6" height="26" rx="1"/>
-    <line x1="444" y1="75" x2="452" y2="75" stroke-width="2"/></g>
-  <!-- 发电机: 壳+定子+转子+两端轴承 -->
-  <rect x="462" y="34" width="184" height="82" rx="7" fill="none" stroke="${LN}" stroke-dasharray="4 3" stroke-opacity="0.6"/>
-  <g stroke="${MU}" fill="${PN}"><rect x="486" y="42" width="136" height="12" rx="2"/><rect x="486" y="96" width="136" height="12" rx="2"/>
-    <rect x="492" y="60" width="124" height="30" rx="3" fill-opacity="0.35"/></g>
-  ${shaft(430,652,75,8)}
-  ${part('发电机DE','发电机 DE',478,146,`<rect x="470" y="56" width="16" height="12" rx="2"/><rect x="470" y="82" width="16" height="12" rx="2"/>
-     <circle cx="478" cy="64" r="3.4"/><circle cx="478" cy="86" r="3.4"/>`,[464,48,28,54])}
-  ${part('发电机NDE','发电机 NDE',628,146,`<rect x="620" y="56" width="16" height="12" rx="2"/><rect x="620" y="82" width="16" height="12" rx="2"/>
-     <circle cx="628" cy="64" r="3.4"/><circle cx="628" cy="86" r="3.4"/>`,[614,48,28,54])}
-  </svg>`};
-
-function chip(s){return `<span class="chip" style="--c:${C[s]||'#9aa4b0'}">${s}</span>`}
-function sc(v,lo,hi,a,b){return hi>lo? a+(b-a)*(v-lo)/(hi-lo) : (a+b)/2}
-// 图幅分级 (2026-08-28 用户令"根据系统和分类要合理大小"):
-//   L=整行 (传动链/振动标量/功率曲线 — 需要横向空间, 但图身封顶 880/760 不随容器无限拉伸)
-//   M=常规格 (月度趋势/计数条 — 两张并排可比)
-const FIGW = {vib: 880, vibbar: 900, xy: 760, band: 620, bar: 620, line: 620};
-const isL = f => f.size === 'L' || ['vib', 'vibbar', 'xy'].includes(f.kind);
-const figWrap = (f, inner, note) => `<div class="card"${isL(f) ? ' style="grid-column:1/-1"' : ''}>
-  <h3>${f.title || ''}</h3><div style="max-width:${FIGW[f.kind] || 620}px;margin:0 auto">${inner}</div>
-  ${note ? `<p class="small" style="margin-top:6px;color:var(--muted)">${note}</p>` : ''}</div>`;
-
-// 故障模式四象限 (经典可靠性分析)。x=发生条数(对数) y=单次中位时长(对数);
-// 右下=高频短时(抖动/刷屏, 调参或屏蔽) 左上=低频长时(硬故障, 备件/检修)
-// 右上=高频长时(重点治理) 左下=低频短时(本底)。点面积=影响台数, 分离单台刷屏与批次共性。
-function quadChart(items){
-  const it=(items||[]).filter(x=>x&&x.n>0&&x.med>0);
-  if(it.length<3) return '<p class="small">时间窗内码数不足, 不作四象限</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)-0.15,x1=Math.max(...xs)+0.15,y0=Math.min(...ys)-0.15,y1=Math.max(...ys)+0.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);
-  // 分界: 条数中位 与 单次时长 600s(10min, 一个SCADA采样格) — 时长界是物理刻度不是分位数
-  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);
-  const 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);
-    return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}"
-      fill="${hot?'var(--bad)':'var(--accent)'}" fill-opacity="0.5" stroke="${hot?'var(--bad)':'var(--accent)'}" stroke-width="1">
-      <title>${d.code} ${d.k}
-${d.n} 条 · 单次中位 ${d.med}s · 累计 ${d.h}h · ${d.nt} 台</title></circle>`;}).join('');
-  const lab=it.filter(d=>d.n>=nmid||d.med>=600).slice(0,8).map(d=>
-    `<text x="${(PX(d.n)+9).toFixed(1)}" y="${(PY(d.med)+3).toFixed(1)}" font-size="9" fill="var(--muted)">${d.code}</text>`).join('');
-  const qn=(x,y,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" opacity="0.85">${t}</text>
-    <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" opacity="0.6">${sub}</text>`;
-  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="0.4"/>
-     <text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${tick(v)}</text>`).join('');
-  const xt=[1,10,100,1000,10000].filter(v=>lx(v)>=x0&&lx(v)<=x1).map(v=>
-    `<text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    ${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="0.5"/>
-    <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.5"/>
-    ${qn(ML+6,MT+12,'低频长时 · 硬故障','备件与检修窗')}
-    ${qn(W-MR-96,MT+12,'高频长时 · 重点治理','优先派工')}
-    ${qn(ML+6,H-MB-18,'低频短时 · 本底','不单独处置')}
-    ${qn(W-MR-96,H-MB-18,'高频短时 · 重复触发','调整参数或屏蔽, 非硬件故障')}
-    ${pts}${lab}
-    <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-6}" font-size="9.5" fill="var(--muted)" text-anchor="middle">发生条数 (对数)</text>
-    <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle" transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次中位时长 (对数)</text>
-    </svg>`;
-}
-
-// 月度故障率图 (2026-08-28 审核逮的反向误读)。
-// 原图画"月度条数", 末月 1371 条看着是全时间窗最低=在改善; 但 2026-07 只有 6 天数据(覆盖 19.4%),
-// 按日均归一后是 228 条/日 = 全时间窗第二高, 是 6 月(75)的 3 倍 — 方向完全相反。
-// ⇒ 残月绝不能与整月并列比较。柱=日均(可比), 灰斜纹=数据不满月, 数字标总条数(原始事实)。
-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;
-  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]<0.5;
-    if(v==null) return;
-    const hh=v/mx*(H-MT-MB), bx=ML+i*bw+bw*0.16, by=H-MB-hh;
-    bars+=`<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw*0.68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2"
-      fill="${part?'var(--muted)':'var(--accent)'}" fill-opacity="${part?0.32:0.8}"
-      stroke="${part?'var(--warn)':'none'}" stroke-dasharray="${part?'3 2':''}">
-      <title>${m}: ${v} 条/日 (总 ${vs[i]} 条, 数据覆盖 ${(cov[i]*100).toFixed(0)}%)</title></rect>`;
-    lab+=`<text x="${(bx+bw*0.34).toFixed(1)}" y="${(by-4).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`
-      +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+13}" font-size="9" fill="var(--muted)" text-anchor="middle">${m.slice(5)}月</text>`
-      +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+25}" font-size="8" fill="var(--muted)" opacity="0.65" text-anchor="middle">${vs[i]}条</text>`
-      +(part?`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+37}" font-size="8" fill="var(--warn)" text-anchor="middle">仅${Math.round(cov[i]*30)}天</text>`:'');
-  });
-  const yt=[0,.5,1].map(f=>`<text x="${ML-6}" y="${(H-MB-f*(H-MT-MB)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${Math.round(mx*f)}</text>`).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <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}
-    <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
-      transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">条/日</text></svg>`;
-}
-
-// 能量账瀑布 (经典发电量分析)。四行工况表回答不了业主唯一关心的问题: 电去哪了。
-// 左=理论可发, 中间逐段扣损(按损失降序), 右=实际上网; 段高即电量, 一眼看出谁是大头。
-// 物理闸: 理论可发不得超 台数×额定×窗时长 (能量守恒, memory ledger-loss-numbers-need-physical-cap-check)。
-const LOSSPLAIN = {
-  '限电·绑定':'限电 (命令与实际功率对得上)',
-  '限电命令·未绑定':'限电命令期 · 功率未被压制',
-  '停机(调度令)':'调度令停机',
-  '停机':'故障与检修停机',
-  '正常发电':'正常发电时的欠发',
-  '低风待机':'低风待机'};
-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*0.14).toFixed(1)}" y="${y.toFixed(1)}"
-    width="${(cw*0.72).toFixed(1)}" height="${Math.max(hh,1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
-  const lab=(idx,y,t,v,c)=>`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${(y-6).toFixed(1)}" font-size="10"
-      fill="${c||'var(--fg)'}" text-anchor="middle" font-weight="600">${v}</text>`
-    +`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${H-MB+14}" font-size="8.5" fill="var(--muted)" text-anchor="middle">${t}</text>`;
-  h+=bar(0,H-MB-e.theo*sc,e.theo*sc,'var(--info)',0.45)
-    +lab(0,H-MB-e.theo*sc,'理论可发',Math.round(e.theo).toLocaleString());
-  items.forEach(x=>{ i++;
-    const top=H-MB-run*sc, hh=x.loss*sc; run-=x.loss;
-    const nm=LOSSPLAIN[x.k]||x.k;
-    h+=bar(i,top,hh,'var(--bad)',0.55)
-      +`<line x1="${(ML+(i-1)*cw+cw*0.86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML+i*cw+cw*0.14).toFixed(1)}" y2="${top.toFixed(1)}"
-         stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity="0.6"/>`
-      +lab(i,top,nm.length>10?nm.slice(0,9)+'…':nm,'−'+Math.round(x.loss).toLocaleString(),'var(--bad)')
-      +`<title>${nm}: 损失 ${x.loss.toLocaleString()} MWh, 占理论 ${(x.loss/e.theo*100).toFixed(1)}%, 时长 ${x.h.toLocaleString()}h</title>`;
-  });
-  h+=bar(cols-1,H-MB-e.act*sc,e.act*sc,'var(--ok)',0.6)
-    +lab(cols-1,H-MB-e.act*sc,'实际上网',Math.round(e.act).toLocaleString(),'var(--ok)');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
-    <text x="${ML}" y="14" font-size="9.5" fill="var(--muted)">MWh</text>${h}</svg>`;
-}
-
-// 可靠性-可维修性象限 (经典 RAM 分析, 2026-08-28 用户令: 停机事件化与可靠性可视化)。
-// x=MTBF 平均无故障间隔(可靠性), y=MDT 平均单次停机时长(可维修性), 点面积=累计停机时长。
-// 四象限的处置方向完全不同 — 表格按"停机时长"排序把这层结构整个抹掉了:
-//   左上 常坏且难修 → 最高优先级   左下 频发易修 → 查根因(治本, 换件无用)
-//   右上 罕发难修   → 备件与作业窗   右下 少发好修 → 维持
-function ramChart(tb){
-  const it=(tb||[]).filter(x=>x.MTBF_h&&x.MDT_h);
-  if(it.length<3) return '<p class="small">部件数不足, 不作象限</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)-0.08,x1=Math.max(...xs)+0.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].sort((p,q)=>p-q);return z[Math.floor(z.length/2)]};
-  const cx=PX(md(it.map(d=>d.MTBF_h))), cy=PY(md(ys));
-  const mh=Math.max(...it.map(d=>d.停机时长h));
-  const pts=it.map(d=>{
-    const r=4+Math.sqrt(d.停机时长h/mh)*13;
-    const ext=d.外部, hot=(d.MTBF_h<md(it.map(v=>v.MTBF_h))&&d.MDT_h>=md(ys));
-    const c=ext?'var(--muted)':(hot?'var(--bad)':'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?0.3:0.42}" stroke="${c}" stroke-width="1.2"
-      stroke-dasharray="${ext?'3 2':''}"><title>${d.部件}: 平均无故障间隔(MTBF)${d.MTBF_h}h · 单次停机时长(MDT)${d.MDT_h}h · 停机 ${d.停机时长h}h · ${d.事件数}起 · ${d.影响台数} 台机组${ext?' · 外部类不计设备可靠性':''}</title></circle>
-      <text x="${(PX(d.MTBF_h)+r+4).toFixed(1)}" y="${(PY(d.MDT_h)+3.5).toFixed(1)}" font-size="9.5"
-        fill="${ext?'var(--muted)':'currentColor'}">${d.部件.slice(0,7)}</text>`;}).join('');
-  const qn=(x,y,a,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" text-anchor="${a}" opacity="0.9">${t}</text>
-    <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" text-anchor="${a}" opacity="0.62">${sub}</text>`;
-  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="0.3"/>
-     <text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v>=1000?(v/1000)+'k':v}</text>`).join('');
-  const yt=[0,5,10,15,20].filter(v=>v<=y1).map(v=>
-    `<text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${v}</text>`).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    ${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="0.45"/>
-    <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.45"/>
-    ${qn(ML+6,MT+12,'start','常坏且难修','最高优先级')}
-    ${qn(W-MR-6,MT+12,'end','罕发难修','备件与作业窗')}
-    ${qn(ML+6,H-MB-16,'start','频发易修','查根因, 换件无用')}
-    ${qn(W-MR-6,H-MB-16,'end','少发好修','维持')}
-    ${pts}
-    <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-8}" font-size="9.5" fill="var(--muted)" text-anchor="middle">平均无故障间隔 MTBF (h, 对数) →  越右越可靠</text>
-    <text x="13" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
-      transform="rotate(-90 13 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次停机时长(MDT,h)→  越上越难修</text>
-    <circle cx="${W-MR+16}" cy="${MT+40}" r="5" fill="var(--accent)" fill-opacity="0.42" stroke="var(--accent)"/>
-    <text x="${W-MR+26}" y="${MT+44}" font-size="9" fill="var(--muted)">设备类</text>
-    <circle cx="${W-MR+16}" cy="${MT+58}" r="5" fill="var(--muted)" fill-opacity="0.3" stroke="var(--muted)" stroke-dasharray="3 2"/>
-    <text x="${W-MR+26}" y="${MT+62}" font-size="9" fill="var(--muted)">外部类</text>
-    <text x="${W-MR+6}" y="${MT+84}" font-size="8.5" fill="var(--muted)">圆面积 = 累计停机时长</text>
-    <text x="${W-MR+6}" y="${MT+98}" font-size="8.5" fill="var(--muted)">虚线 = 本场中位分界</text>
-    </svg>`;
-}
-
-// 归因覆盖率条: "无记录停机" 是最大单项却因不给 MTBF 而在可靠性排序里隐身 (规划 P1-2)
-function attribBar(tb, tot){
-  const nore=(tb||[]).find(x=>x.部件==='无记录停机');
-  const unk=nore?nore.停机时长h:0, un2=(tb||[]).filter(x=>x.部件==='未归类').reduce((a,b)=>a+b.停机时长h,0);
-  const 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 class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    ${seg(0,w1,'var(--accent)',0.55)}${seg(w1,w2,'var(--warn)',0.5)}${seg(w1+w2,w3,'var(--bad)',0.5)}
-    <text x="4" y="10" font-size="9" fill="var(--muted)">已归因到部件 ${Math.round(ok)}h (${(ok/tot*100).toFixed(0)}%)</text>
-    <text x="${(w1+w2+w3-2).toFixed(1)}" y="10" font-size="9" fill="var(--bad)" text-anchor="end">无记录 ${Math.round(unk)}h (${(unk/tot*100).toFixed(0)}%)</text>
-    <text x="${(w1+w2/2).toFixed(1)}" y="50" font-size="9" fill="var(--warn)" text-anchor="middle">未归类 ${Math.round(un2)}h</text>
-    </svg>`;
-}
-
-// 失败必须响亮 (2026-08-28 全系统盘点逮): 后端 m8/m9/rel/fus 四块都是
-// `except: X = {err: ...}`, 前端一律 `&&!d.X.err` 直接跳过渲染 —— 界面看不出"这块没算出来",
-// 看起来只是"这一页没这个内容"。reliability 的 bool 位取反 bug 就是这样活下来的。
-// ⇒ 出错时改为渲染一张醒目失败卡: 说清哪块没了、意味着什么缺失、错在哪。
-const BLOCKDESC = {
-  rel:  ['部件停机贡献与可靠性', '本页看不到部件级平均无故障间隔(MTBF)/单次停机时长(MDT)与停机归因'],
-  m8:   ['停机事件化与故障季节性', '故障统计页缺停机归因与季节形态'],
-  m9:   ['控制参数一致性', '发电性能页缺封顶分组与桨距调度差'],
-  fus:  ['振动四源融合', '振动融合页缺逐台判级矩阵'],
-};
-function blockErr(key, err){
-  const d = BLOCKDESC[key] || [key, ''];
-  return `<div class="card mb" style="border-left:3px solid var(--bad)">
-    <h3 style="color:var(--bad)">⚠ ${d[0]} — 未算出</h3>
-    <p class="small" style="margin-bottom:6px">${d[1]}。<b>非"本时间窗无数据", 属计算失败</b> — 下方数字不完整, 不要据此下结论。</p>
-    <pre class="small" style="white-space:pre-wrap;color:var(--muted);background:var(--bg);padding:8px 10px;border-radius:6px;margin:0">${String(err).slice(0,300)}</pre></div>`;
-}
-
-// 逐页窗声明 (2026-08-28 审核逮的交互欺骗)。
-// 顶部窗选择器对"发电性能"完全无效 — 那 19 张图是预计算产物, 窗在生成时钉死为 2025-07~2026-01;
-// 用户切到"2026年"图纹丝不动却毫无提示。"控制策略"曾更糟(工况态跟随+参数件固定时间窗并列),
-// 两个窗并列在同一页。窗对不齐 = claim窗≠证据窗, 是判据失效的头号来源, 必须写在脸上。
-const WINSPEC = {
-  '发电性能': {mode:'fixed', win:'2025-07 ~ 2025-12',
-               why:'限电前干净窗 — 2026 起交易策略主导, 曲线与控制参数含策略性少发不可作设备判据。本页含特性曲线与控制参数一致性两件, 同窗'},
-  // 2026-08-28 用户裁: 控制参数件(固定时间窗)迁入发电性能, "电量算账"只留跟随时间窗内容
-  // → 每页只有一个窗制, 混合窗从根上消掉 (原来是本系统唯一的双窗页)
-};
-function winBanner(tab, curWin, extra){
-  const w = WINSPEC[tab];
-  if(!w) return `<div class="winb"><b>时间窗</b> ${curWin} · 本页全部数字随上方窗切换${extra?' | '+extra:''}</div>`;
-  const c = w.mode==='fixed' ? 'var(--warn)' : 'var(--info)';
-  const tag = w.mode==='fixed' ? '固定时间窗 · 不随上方切换' : '本页含两个窗';
-  return `<div class="winb" style="border-left-color:${c}">
-    <b style="color:${c}">${tag}</b> ${w.win}
-    <div class="small" style="color:var(--muted);margin-top:3px">${w.why}${extra?' | '+extra:''}</div></div>`;
-}
-
-// 控制参数三图 (2026-08-28 用户令: 控制参数一致性要图形, 在发电性能展示)。
-// 原来是三段密文字, 关键结论"离散成组≠连续分布"完全看不出来 — 而这正是判"参数级差异"的依据。
-// A 封顶参数数轴: 台数落在几个离散位置上 = 配置/版本差异; 若是磨损或工况会是连续散布。
-// B 桨距同档差: 零中线偏差条, 长度即幅度, 与全场中位同尺比。
-// C 尺子巡检: 极差/σ 比 — 38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰(佐证 A 的分组)。
-function capAxis(P, W9){
-  const W=500, H=158, ML=54, MR=34;
-  const row=(y, spec, unit, 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));
-    const pad=(hi-lo)*0.22||1, x0=lo-pad, x1=hi+pad;
-    const PX=v=>ML+(v-x0)/(x1-x0)*(W-ML-MR);
-    const mn=Math.max(...gs.map(g=>g.n));
-    const dots=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="0.42" stroke="var(--accent)"/>
-        <text x="${PX(g.v).toFixed(1)}" y="${y-r-5}" font-size="9" text-anchor="middle" fill="currentColor">${g.raw}</text>
-        <text x="${PX(g.v).toFixed(1)}" y="${y+3.5}" font-size="9" text-anchor="middle" fill="var(--panel)" font-weight="600">${g.n}</text>`}).join('');
-    const med=spec['中位'];
-    return `<text x="${ML-6}" y="${y+3}" font-size="9.5" text-anchor="end" fill="var(--muted)">${label}</text>
-      <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="0.55"/>
-      <text x="${PX(med).toFixed(1)}" y="${y+28}" font-size="8.5" text-anchor="middle" fill="var(--muted)">中位 ${med}</text>
-      ${dots}`;
-  };
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    ${row(44, P, 'kW', '满发封顶')}
-    ${row(120, W9, 'rpm', '转速封顶')}
-    <text x="${W-MR}" y="12" font-size="8.5" text-anchor="end" fill="var(--muted)">圆内数字=台数, 圆面积∝台数</text></svg>`;
-}
-function betaBar(b){
-  const outs=(b.离群||[]), med=b['全场同档差中位'];
-  const W=500, H=34+outs.length*26, ML=52, MR=58;
-  const mx=Math.max(med, ...outs.map(o=>o.平均偏))*1.18||1;
-  const PX=v=>ML+v/mx*(W-ML-MR);
-  const rows=outs.map((o,i)=>{const y=30+i*26;
-    return `<text x="${ML-6}" y="${y+10}" font-size="9.5" text-anchor="end" fill="currentColor">${o.t.replace('WTG','')}#</text>
-      <rect x="${ML}" y="${y+1}" width="${(PX(o.平均偏)-ML).toFixed(1)}" height="13" rx="2" fill="var(--warn)" fill-opacity="0.62"/>
-      <text x="${(PX(o.平均偏)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--warn)">${o.平均偏}°</text>`}).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
-    <text x="${PX(med).toFixed(1)}" y="12" font-size="8.5" fill="var(--ok)">全场中位 ${med}°</text>
-    <line x1="${ML}" y1="18" x2="${ML}" y2="${H-4}" stroke="var(--line)"/>
-    ${rows}</svg>`;
-}
-function rulerBar(rl){
-  const ks=Object.entries(rl); const W=500, H=26+ks.length*30, ML=88, MR=76;
-  const NORM=3.7;   // 38 台正态抽样极差期望 ≈ 3.7σ
-  const mxr=Math.max(NORM*1.35, ...ks.map(([,v])=>v.极差/v.σ));
-  const 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 `<text x="${ML-6}" y="${y+11}" font-size="9.5" text-anchor="end" fill="currentColor">${k}</text>
-      <rect x="${ML}" y="${y+2}" width="${(PX(ratio)-ML).toFixed(1)}" height="12" rx="2"
-        fill="${wide?'var(--warn)':'var(--accent)'}" fill-opacity="0.55"/>
-      <text x="${(PX(ratio)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--muted)">${ratio.toFixed(1)}σ σ=${v.σ}</text>`}).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
-    <text x="${PX(NORM).toFixed(1)}" y="10" font-size="8.5" fill="var(--ok)" text-anchor="middle">正态期望 3.7σ</text>
-    ${rows}</svg>`;
-}
-
-// 责任分账 (2026-08-28 用户令: 电量算账优化显示)。
-// 瀑布回答"电量流向", 但业主真正要问的下一句是"谁的责任" — 原来只在一段小字里写了个百分比。
-// 三分账: 交易与调度侧(限电两项+调度令停机) / 设备侧(故障与检修停机) / 运行侧(正常发电欠发+低风)。
-function blameBar(e){
-  const cat = k => (k.indexOf('限电')>=0 || k.indexOf('调度')>=0) ? 0 : (k==='停机' ? 1 : 2);
-  const NM = ['交易与调度侧','设备侧','运行侧'];
-  const NOTE = ['限电与调度令, 不进设备责任','故障与检修停机, 唯一进设备考核','正常发电欠发与低风待机'];
-  const CL = ['var(--info)','var(--bad)','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;
-  const 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="0.55"><title>${NM[i]}: ${Math.round(v).toLocaleString()} MWh</title></rect>`
-        + (w>46 ? `<text x="${(x+w/2).toFixed(1)}" y="29" font-size="10.5" fill="currentColor" text-anchor="middle" font-weight="600">${(v/tot*100).toFixed(0)}%</text>` : '');
-    x += w; });
-  // 图例走 HTML 而非 SVG text: SVG 里估算中文字宽不准, 三段说明会互相压字 (实测重叠)
-  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="cnt">${Math.round(v).toLocaleString()} MWh</span>
-    <span class="small" style="color:var(--muted)">${NOTE[i]}</span></div>`).join('');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <text x="0" y="8" font-size="9" fill="var(--muted)">损失 ${Math.round(tot).toLocaleString()} MWh 按责任归属</text>
-    ${seg}</svg>${leg}`;
-}
-
-// 帕累托图 (2026-08-28 用户令: 故障统计按经典分析展示)。
-// 经典做法 = 条形按降序 + 右轴累计百分比折线 + 80% 参考线 → 一眼看出"关键少数";
-// 原来只有两列排序表, 看不出前几项就占了大头。
-function paretoChart(items, unit, note){
-  const it = (items || []).filter(x => x && x.v > 0);
-  if(!it.length) return '<p class="small">时间窗内无数据</p>';
-  // 尺寸 (2026-08-28 排布优化): 原 720x260 + 旋转长标签, 两列并排时被缩到 0.60 倍 = 字高 6.5px 不可读。
-  // 改紧凑版 520x236: x 轴只标码号(水平, 不旋转 → 底部留白 86→40), 全名进悬停与图注前三行。
-  const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14;
-  const n = it.length, tot = it.reduce((a, x) => a + x.v, 0);
-  const mx = Math.max(...it.map(x => x.v));
-  const bw = (W - ML - MR) / n;
-  const short = 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);
-    const bx = ML + i * bw + bw * 0.14, by = H - MB - hh;
-    bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * 0.72).toFixed(1)}" height="${hh.toFixed(1)}"
-      rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? 0.85 : 0.45}"><title>${x.k}: ${x.v}${unit}</title></rect>`;
-    cum += x.v;
-    const pct = cum / tot;
-    if(k80 === null && pct >= 0.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(--warn)"><title>${x.k} · 累计 ${(pct*100).toFixed(0)}%</title></circle>`;
-  });
-  const y80 = MT + 0.2 * (H - MT - MB);
-  const xt = it.map((x, i) => `<text x="${(ML + i*bw + bw/2).toFixed(1)}" y="${H - MB + 13}" font-size="9"
-      fill="var(--muted)" text-anchor="middle">${short(x.k)}</text>`).join('');
-  const yt = [0, .5, 1].map(f => `<text x="${ML-5}" y="${(H-MB-(f*(H-MT-MB))+3).toFixed(1)}" font-size="9"
-      fill="var(--muted)" text-anchor="end">${Math.round(mx*f).toLocaleString()}</text>`).join('')
-    + [0, .5, 1].map(f => `<text x="${W-MR+5}" y="${(MT+(1-f)*(H-MT-MB)+3).toFixed(1)}" font-size="9"
-      fill="var(--warn)">${Math.round(f*100)}%</text>`).join('');
-  const top3 = it.slice(0, 3).map(x => `<b>${short(x.k)}</b> ${String(x.k).replace(/^[0-9]+ */, '').slice(0, 13)}`).join(' · ');
-  return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
-    <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(--warn)"
-      stroke-dasharray="4 3" stroke-opacity="0.7"/>
-    <text x="${W-MR-3}" y="${(y80-4).toFixed(1)}" font-size="9" fill="var(--warn)" text-anchor="end">累计 80%</text>
-    <polyline points="${line.join(' ')}" fill="none" stroke="var(--warn)" stroke-width="1.5"/>${dots}
-    ${xt}${yt}
-    <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-3}" font-size="8.5" fill="var(--muted)" text-anchor="middle">报警码 (悬停看全名)</text></svg>
-    <p class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">
-      ${k80 ? `<b>关键少数: 前 ${k80} 项占 80%</b> (共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit})` : `共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit}`}
-      <br>前三: ${top3}${note ? '<br>' + note : ''}</p>`;
-}
-
-// ── 风场阵列 (2026-08-31 用户令: 风机与环境形象要用视觉体现) ──────────────
-// 视觉语言与推介页 hero 同源: 细线几何 + stroke-opacity 造景深, 不写实。
-// 它同时是**信息图**不只是插画 —— 每台按其最严重系统状态着色, 全场健康分布一眼可见。
-// 快照版总览原本只有一句"内嵌页不可用", 这里正好补上。
-// ── 运维成熟度与四判据 (2026-08-31) ───────────────────────────────
-// 为什么放在检修决策页顶端: 这四条是"系统会主动拦下来"的判据, 不是又一张统计图。
-// 那三个问题(复发/共性/记录断层)在本场存在五年没被发现, 不是没人懂,
-// 是没有任何环节会主动报出来。
-// 本体里的文本用 **x** 标重点 (那是给人读源文件时用的)。直接插进 HTML 会把星号
-// 原样印在客户交付页上。这里只转粗体, 不引入 markdown 解析器 —— 需求就这一条。
-// 用字符类 [*] 而非反斜杠转义 —— CHART_JS 是**非 raw** 的 Python 三引号串,
-// 反斜杠在这里会被 Python 当转义处理: 反斜杠加星号触发 SyntaxWarning, 反斜杠加 n
-// 直接变成真换行, 把单行注释劈成两行 —— 第二行不再是注释, 当场语法错、页面白屏。
-// (写这条注释时我为了举例又写了一次那个转义, 于是当场复现了它描述的 bug,
-//  被成品页语法闸逮到。所以: 这个字符串里能不写反斜杠就不写, 举例也别写。)
-// 层级后缀单独成常量 —— 原来写成 `${lv}层` 时那个 `层` 是模板里的**字面量孤字**,
-// 数据侧的 `机理` 译成了 Domain model 而它没译, 页面上就是 'Domain model层'。
-// 提成常量后它是一个可查表的完整词。
-const LAYER_SFX = '层';
-const TIMES_SFX = '倍';
-
-const mdB = s => String(s == null ? '' : s).replace(/[*][*]([^*]+)[*][*]/g, '<b>$1</b>');
-
-// 快照版总览面板: 用已固化的数据汇总, 而不是道歉。
-// 逐系统两个键: 裸名 = 有问题的机组, `·良好` = 判良好的机组 —— 汇总时要分开数。
-function snapOverview(d){
-  const SY = d.systems || {};
-  const rows = [];
-  Object.keys(SY).forEach(function(k){
-    if (k.indexOf('·') >= 0) return;                 // 只遍历裸名, 良好那半单独取
-    const bad = (SY[k] || []).length;
-    const good = (SY[k + '·良好'] || []).length;
-    if (bad + good) rows.push({sys: k, bad: bad, good: good});
-  });
-  rows.sort(function(a, b){ return b.bad - a.bad; });
-  const K = d.kpi || {};
-  const M = (d.m8 && d.m8.mtbf) || {};
-  let h = '<div class="card mb"><div class="eyebrow">Where to look</div>';
-  h += '<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
-     + '本页为固化快照: 上方每个标签页的数据都已随页面打包, 断网也能看。'
-     + '下表按系统汇总当前状态, 点系统名进该系统的机组清单。</div>';
-  if (rows.length){
-    h += '<table class="tbl"><thead><tr><th>系统</th><th>需跟踪</th><th>判良好</th>'
-       + '<th>占全场</th></tr></thead><tbody>';
-    rows.forEach(function(r){
-      const tot = r.bad + r.good;
-      // 用 data 属性 + 委托事件, **不写嵌套引号** —— CHART_JS 是非 raw 的 Python
-      // 三引号串, 里面的反斜杠转义会被 Python 吃掉, 反引号/单引号一嵌套就断串。
-      // (今天第三次栽在这条上, 每次都是成品页语法闸拦下来的。)
-      h += '<tr><td><a class="tl" href="#" data-gotab="部件问题">'
-         + r.sys + '</a></td>'
-         + '<td class="num"><b style="color:' + (r.bad ? 'var(--warn)' : 'var(--muted)') + '">'
-         + r.bad + '</b></td><td class="num">' + r.good + '</td>'
-         + '<td class="num small">' + tot + '</td></tr>';
-    });
-    h += '</tbody></table>';
-  }
-  h += '<div class="kpis" style="margin-top:12px">'
-     + '<div class="kpi"><b>' + (K['报警台'] != null ? K['报警台'] : '—') + '</b>'
-     + '<span>机组带报警级判定</span></div>'
-     + '<div class="kpi"><b>' + (K['关注台'] != null ? K['关注台'] : '—') + '</b>'
-     + '<span>机组在跟踪清单上</span></div>'
-     + '<div class="kpi"><b>' + (M['停机事件数'] != null ? M['停机事件数'] : '—') + '</b>'
-     + '<span>停机事件 (本时间窗)</span></div></div>';
-  h += '<div style="font-size:12px;color:var(--muted);margin-top:14px;line-height:1.75">'
-     + '<b>各屏回答什么</b><br>'
-     + '电量算账 — 电去哪了, 每一度归到哪个原因<br>'
-     + '部件问题 — 哪台哪系统该管, 依据是什么<br>'
-     + '振动融合分析 — 振动与 SCADA 对上没有<br>'
-     + '发电性能 — 曲线偏了没有, 偏在哪一段<br>'
-     + '故障统计 — 报警的关键少数与可靠性象限<br>'
-     + '检修决策 — 数据本身可不可信, 运维体系差在哪</div>';
-  h += '</div>';
-  return h;
-}
-
-// 总览表里的系统名跳到对应标签页。委托绑定, 只挂一次。
-document.addEventListener('click', function(e){
-  var a = e.target && e.target.closest ? e.target.closest('a[data-gotab]') : null;
-  if (!a) return;
-  e.preventDefault();
-  TAB = a.getAttribute('data-gotab');
-  render();
-});
-
-function dqFindings(d){
-  // 数据可信度 —— 系统自查出的数据质量与口径问题。
-  // 放在判据面板之前: 先说"这些数字可不可信", 再说"数字说明什么"。
-  // 一个只报结论不报数据缺陷的系统, 是在把风险留给读者。
-  if(!d) return '<div class="card">数据可信度加载中…</div>';
-  if(d.err) return '<div class="card">数据可信度: '+d.err+'</div>';
-  const KIND={data_quality:['数据质量','var(--bad)'], caliber:['口径','var(--warn)'],
-              system_coverage:['覆盖','var(--accent)'], tier_trend:['结构趋势','var(--ok)']};
-  const GR={established:['定论','var(--bad)'], candidate:['候选','var(--warn)'],
-            reference:['参考','var(--muted)']};
-  let h='<div class="card"><div class="eyebrow">数据可信度 · 系统自查发现</div>';
-  h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
-   +'先回答"这些数字可不可信", 再谈数字说明什么。以下每条都是在做分析的过程中被判据拦下来的, '
-   +'不是事后补的说明。</div>';
-  (d.rows||[]).forEach(r=>{
-    const k=KIND[r.kind]||['其他','var(--muted)'];
-    const g=GR[r.grade]||[r.grade,'var(--muted)'];
-    h+=`<div style="border:1px solid var(--line);border-left:3px solid ${k[1]};border-radius:var(--r);
-         padding:12px 14px;margin-bottom:10px;background:var(--panel-2)">`;
-    h+=`<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px">
-        <span style="font-size:11px;padding:1px 8px;border-radius:10px;background:${k[1]};color:#fff">${k[0]}</span>
-        <span style="font-size:11px;color:${g[1]};border:1px solid ${g[1]};padding:0 6px;border-radius:10px">${g[0]}</span>
-        <span style="font-size:11px;color:var(--muted);font-family:var(--num)">${r.claim_window||''}</span>
-        </div>`;
-    h+=`<div style="font-size:13px;font-weight:600;line-height:1.5;margin-bottom:6px">${mdB(r.name)}</div>`;
-    if((r.evidence||[]).length){
-      h+='<ul style="margin:4px 0 6px 16px;padding:0;font-size:12px;color:var(--ink);line-height:1.65">';
-      (r.evidence||[]).slice(0,4).forEach(e=>{h+=`<li>${mdB(e)}</li>`});
-      h+='</ul>';
-    }
-    if(r.impact) h+=`<div style="font-size:12px;color:var(--ok);line-height:1.6;margin-top:4px">
-        <b>判别 / 处置</b> ${mdB(r.impact)}</div>`;
-    if(r.got_wrong) h+=`<div style="font-size:12px;color:var(--warn);line-height:1.6;margin-top:6px;
-        padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--line)">
-        <b>首版判错</b> ${mdB(r.got_wrong)}</div>`;
-    // 数据质量标志 + 稳健性复算: 削弱结论的证据, 与证据同级显示
-    if(r.quality_flag) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
-        padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--warn)">
-        <b style="color:var(--warn)">记录质量</b> ${mdB(r.quality_flag)}</div>`;
-    if(r.robust) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
-        <b>稳健性复算</b> ${mdB(r.robust)}</div>`;
-    if(r.basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
-        <b>分类依据</b> ${mdB(r.basis)}</div>`;
-    if(r.sensitivity) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
-        padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--accent)">
-        <b style="color:var(--accent)">敏感性检验</b> ${mdB(r.sensitivity)}</div>`;
-    if(r.age_basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
-        <b>场龄口径</b> ${mdB(r.age_basis)}</div>`;
-    // ★边界从 11px/muted 的脚注提到 12px/正文色 (2026-08-31 独立审逮):
-    //   它**限定结论成立的范围**, 不是补充说明。原样式让它成为卡片里最小最淡的元素,
-    //   排在一串证据数字之后 —— 读者读完数字就走了, 限定语等于没写。
-    if(r.caveat) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
-        padding:6px 10px;background:var(--panel);border-radius:6px;border-left:3px solid var(--muted)">
-        <b>结论边界</b> ${mdB(r.caveat)}</div>`;
-    h+='</div>';
-  });
-  h+='</div>';
-  return h;
-}
-
-function maintFw(d){
-  // 运维管理体系三维骨架 — 架构取自一套跨厂商通用的培训编号体系, 实证取自本场五年工单。
-  // 放在四判据之后: 判据回答"哪台该管", 本面板回答"这个场的运维能力结构长什么样"。
-  // 配色一律走 CSS 变量 —— 页面有明暗两套 :root, 硬编码色在另一套下不可读。
-  if(!d) return '<div class="card">运维体系加载中…</div>';
-  if(d.err) return '<div class="card">运维体系: '+d.err+'</div>';
-  const c=d.coverage||{}, g=d.gaps||{};
-  const tierName={common:'通用 · 换机型仍有效', platform:'机型专属 · 绑定当前机型', basic:'技术基础', eng:'工程改造'};
-  const tierCol={common:'var(--accent)', platform:'var(--warn)', basic:'var(--muted)', eng:'var(--unknown)'};
-  let h='<div class="card"><div class="eyebrow">运维管理体系 · 系统 × 阶段 × 记录</div>';
-  // ★跨厂商借用必须在**用它的地方**声明 (2026-08-31 双票独立命中)。
-  //   ★这套码是**借来的跨厂商分类法**, 不点任何厂商名 (点了等于用否定把机型漏回去)。
-  //   声明写在别的页面等于没声明: 读者是在这一屏做判断的。
-  h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
-   +'三位系统码分两层 (3xx 跨机型通用 / 4xx 机型专属), 判据与缺口取自本场五年工单实证。'
-   +'<b style="color:var(--warn)">口径声明</b>: '
-   // ★整句不嵌 <b> —— 中间插标签会把这段切成多个 PH 片段, 逐片查表再拼回去,
-   //   结果是"arenot"这种缺空格拼接 + 某一片查不到就留中文 (实测两种都发生了)。
-   //   要强调就整句强调, 别在句中加标记。整段一个 key, 查得到就整段换, 查不到就整段留。
-   // ★整句放在**一个**字符串字面量里。apply() 跑在**源码文本**上, 拆成多个字面量拼接时,
-   //   源码里它们被换行加 + 号隔开 ⇒ PH 切出的段与渲染后看到的整句**不是同一个东西**,
-   //   (注意: CHART_JS 是非 raw 的 Python 三引号串, 注释里写反斜杠-n 会变成**真换行**,
-   //    把 // 注释劈成两行 —— 第二行不再是注释, 直接语法错。本行原来就踩了这个坑。)
-   //   我按渲染后的整句注册 key, 页面上却按源码分段查表 → 全部 miss。
-   //   (实测踩到: 助手 --segments 报"命中", 页面上仍是中文。验证跑在渲染后, 翻译跑在源码上。)
-   +'三位码仅用于能力分级与培训投资参照。部件到系统码的映射由本项目建立、未经确认, 因此结构趋势比绝对占比可靠。</div>';
-
-  // ① 覆盖率 — 本轮最实的一条发现
-  h+='<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">';
-  h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--bad)">
-      <div style="font-size:28px;font-weight:600;color:var(--bad);font-family:var(--num)">${c.system_pct}%</div>
-      <div style="font-size:12px;color:var(--muted);margin-top:2px">工单可归属到系统<br><span style="font-family:var(--num)">${c.mapped} / ${c.n_orders}</span></div></div>`;
-  h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--warn)">
-      <div style="font-size:28px;font-weight:600;color:var(--warn);font-family:var(--num)">${c.part_field_pct}%</div>
-      <div style="font-size:12px;color:var(--muted);margin-top:2px">工单填了部件字段<br><span style="font-family:var(--num)">${c.with_part} / ${c.n_orders}</span></div></div>`;
-  h+=`<div style="flex:2.2;min-width:280px;padding:12px 14px;border-radius:var(--r);background:var(--accent-soft);border:1px solid var(--line);border-left:3px solid var(--ok)">
-      <div style="font-size:12px;color:var(--ok);font-weight:600;margin-bottom:4px">怎么补</div>
-      <div style="font-size:12px;color:var(--ink);line-height:1.65">工单模板增加系统码必填项 (31 个系统码下拉), 此后每条工单按构造自动归属。
-      <div style="color:var(--muted);margin-top:4px">归属率低是模板缺字段, 不是维修没做 — 后者要贵得多。</div></div></div>`;
-  h+='</div>';
-
-  // ② 通用 vs 机型专属 — 决定培训投资与合同边界
-  h+='<div style="font-size:13px;font-weight:600;margin:16px 0 4px;color:var(--ink)">作业量结构 · 通用能力 vs 机型专属</div>';
-  h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">通用能力换机型仍成立, 属场站资产; 机型专属绑定当前机队, 更适合合同覆盖</div>';
-  const byT=g.by_tier||{}, tks=Object.keys(byT).sort((a,b)=>byT[b].jobs-byT[a].jobs);
-  h+='<div style="display:flex;height:24px;border-radius:4px;overflow:hidden;margin-bottom:6px;border:1px solid var(--line)">';
-  tks.forEach(t=>{h+=`<div style="width:${byT[t].share}%;background:${tierCol[t]||'var(--muted)'};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-family:var(--num)">${byT[t].share}%</div>`;});
-  h+='</div><div style="font-size:11px;color:var(--muted);margin-bottom:14px">';
-  tks.forEach(t=>{h+=`<span style="margin-right:16px"><span style="display:inline-block;width:9px;height:9px;background:${tierCol[t]||'var(--muted)'};border-radius:2px;margin-right:5px;vertical-align:middle"></span>${tierName[t]||t} · <span style="font-family:var(--num)">${byT[t].jobs}</span> 次</span>`;});
-  h+='</div>';
-
-  // ③ 系统作业量排行
-  h+='<div style="font-size:13px;font-weight:600;margin:16px 0 6px;color:var(--ink)">按系统码的作业量</div>';
-  h+='<table class="tbl"><tr><th>码</th><th>系统</th><th>类别</th><th>作业次数</th><th>台数</th><th>次/台</th></tr>';
-  (d.systems||[]).slice(0,10).forEach(r=>{
-    h+=`<tr><td style="font-family:var(--num)">${r.code}</td><td>${r.zh}<div style="font-size:11px;color:var(--muted)">${r.en}</div></td>
-        <td style="color:${tierCol[r.tier]||'var(--muted)'};font-size:12px">${tierName[r.tier]||r.tier}</td>
-        <td style="font-family:var(--num)">${r.jobs}</td><td style="font-family:var(--num)">${r.n_units}</td><td style="font-family:var(--num)">${r.per_unit}</td></tr>`;});
-  h+='</table>';
-
-  // ④ 阶段 × 出口闸 × 记录
-  h+='<div style="font-size:13px;font-weight:600;margin:18px 0 4px;color:var(--ink)">六个运维阶段与各自的放行条件</div>';
-  h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">放行条件 = 不满足就不该进下一阶段; 记录 = 该阶段必须留下的凭据</div>';
-  h+='<table class="tbl"><tr><th style="width:15%">阶段</th><th style="width:38%">放行条件</th><th>必备记录</th></tr>';
-  (d.stages||[]).forEach(st=>{
-    const rn=(st.records||[]).map(r=>((d.records||{})[r]||{}).zh||r)
-      .map(x=>`<span style="display:inline-block;padding:1px 7px;margin:1px 3px 1px 0;border-radius:10px;background:var(--panel-2);border:1px solid var(--line);font-size:11px">${x}</span>`).join('');
-    // ★英文侧 zh 已被 _proj 投影成英文 (裸 zh/en 成对的处理), 两行会显示成一样的字 ——
-    //   相同就只出一行。出口闸那列同理: 页面原读 st.gate_zh, 而投影后字段名是 gate,
-    //   于是**整列 undefined** —— 而"出口闸"正是这张表最要紧的一列 (无闸的阶段划分只是标签)。
-    h+=`<tr><td><span style="font-family:var(--num);color:var(--muted)">${st.seq}</span> ${st.zh||st.en||''}
-        ${(st.en&&st.en!==st.zh)?`<div style="font-size:11px;color:var(--muted)">${st.en}</div>`:''}</td>
-        <td style="font-size:12px;line-height:1.5">${st.gate||st.gate_zh||''}</td><td>${rn}</td></tr>`;});
-  h+='</table>';
-
-  // ⑤ 未映射 — 不猜归属
-  const um=(g.unmapped_top||[]);
-  if(um.length){
-    h+='<div style="font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6">未映射到系统码的部件 (不猜归属, 留待补映射表): '
-      + um.slice(0,10).map(x=>`${x[0]}<span style="font-family:var(--num)">×${x[1]}</span>`).join('、')+'</div>';}
-  h+='</div>';
-  return h;
-}
-
-function maintStd(d){
-  if(!d) return '<div class="card"><p class="small">载入中…</p></div>';
-  if(d.err) return `<div class="card"><p class="small" style="color:var(--bad)">${d.err}</p></div>`;
-  const S=d.score||{}; let h='';
-  const dims=(S.dims||[]).map(x=>{
-    // ★"不可评估"要在**出现的地方**就自明 —— 用户读到这四个字问"什么意思",
-    //   说明把解释放在上一行的口径说明里不够。它不是"做得差", 是"没法判":
-    //   工单模板里没有记录该证据的位置, 所以既不能记功也不能记过。
-    if(!x.evaluable) return `<div class="kpi"><b style="color:var(--muted)">—</b>
-      <span>${x.dim} · <b style="color:var(--warn)">不可评估</b>
-      <span style="color:var(--muted)">(工单里没有记录这项证据的字段, 所以既不能算做到、也不能算没做到 &mdash; 不是 0 分, 是无法判断)</span><br>
-      <span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
-    const pct=Math.round(x.score/x.full*100);
-    const cls=pct>=70?'ok':pct>=40?'':'bad';
-    return `<div class="kpi ${cls}"><b>${x.score}<span style="font-size:14px;color:var(--muted)">/${x.full}</span></b>
-      <span>${x.dim}<br><span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
-  }).join('');
-  // 两个分并排、各自带标签; 口径与理由见 src/windscada/maint/standards.py。
-  // (页面源码随交付件一起发出去, 内部审核讨论不留在这里。)
-  const _en=(typeof window!=='undefined'&&window.__EN);
-  const twoScore=(lab,val,ev,n,tone)=>`<div style="flex:1;min-width:190px;padding:12px 14px;
-      border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid ${tone}">
-    <div style="font-size:12px;color:var(--muted)">${lab||''}</div>
-    <div style="font-size:30px;font-weight:600;font-family:var(--num);color:${tone};line-height:1.15">
-      ${val!=null?val:'—'}<span style="font-size:14px;color:var(--muted);font-weight:400">/100</span></div>
-    <div style="font-size:11.5px;color:var(--muted);margin-top:2px">${ev} / ${n} ${_en?'dimensions measurable':'维可测'}</div></div>`;
-  h+=`<div class="card mb"><h3>${_en?'Maintenance scorecard':'运维评分卡'}
-      <span class="badge" style="margin-left:10px">${S.stage_label||('第 '+S.stage+' 阶')} · ${S.stage_name}</span></h3>
-    <div style="display:flex;gap:12px;flex-wrap:wrap;margin:4px 0 12px">
-      ${twoScore(S.record_label,S.record_score,S.record_evaluable,S.record_dims,'var(--bad)')}
-      ${twoScore(S.exec_label,S.exec_score,S.exec_evaluable,S.exec_dims,'var(--ok)')}
-    </div>
-    ${S.score_basis?`<p class="small" style="color:var(--muted);margin:0 2px 10px;line-height:1.6">${mdB(S.score_basis)}</p>`:''}
-    <div class="kpis">${dims}</div>
-    <p class="small" style="color:var(--warn);margin:8px 2px 0">${mdB(S.caveat)}</p>
-    ${S.model_source?`<p class="small" style="color:var(--muted);margin:6px 2px 0;line-height:1.6">
-       <b>评分方法来源</b> ${mdB(S.model_source)}</p>`:''}
-    ${S.bottleneck?`<p class="small" style="margin:4px 2px 0">当前瓶颈: <b>${S.bottleneck}</b> — 先补这一项, 其余维度的提升都受它限制。</p>`:''}</div>`;
-  // R1 复发
-  const R=(d.recurrence||[]).slice(0,10);
-  if(R.length) h+=`<div class="card mb"><h3>复发 · ${d.recurrence.length} 组</h3>
-    <p class="small" style="color:var(--muted)">同一机组同一部件多次更换 — 换件未解决产生它的原因。按作业日去重计数, 非工单条数。</p>
-    <table class="tbl"><thead><tr><th>机组</th><th>部件</th><th>作业次数</th><th>首次</th><th>末次</th><th>最短间隔</th></tr></thead><tbody>
-    ${R.map(x=>`<tr><td>${x.turbine}</td><td>${x.part}</td><td><b>${x.n_jobs}</b></td>
-      <td>${x.first}</td><td>${x.last}</td><td>${x.min_gap_months} 月</td></tr>`).join('')}
-    </tbody></table></div>`;
-  // R2 批次候选
-  const B=(d.batch||[]).slice(0,8);
-  if(B.length) h+=`<div class="card mb"><h3>共性候选 · ${d.batch.length} 项</h3>
-    <p class="small" style="color:var(--warn)">仅候选。分布形态无法区分 批次 / 共模应力 / 耗损期 — 三者形态相似而处置方向完全不同(追索赔 / 改运行条件 / 排寿命更换)。定性需物料证据。</p>
-    <table class="tbl"><thead><tr><th>部件</th><th>涉及台数</th><th>作业次数</th><th>每台</th><th>缺什么才能定性</th></tr></thead><tbody>
-    ${B.map(x=>`<tr><td>${x.part}</td><td>${x.n_units}</td><td>${x.n_jobs}</td>
-      <td>${x.per_unit}</td><td class="small" style="color:var(--muted)">${x.needs}</td></tr>`).join('')}
-    </tbody></table></div>`;
-  // R4 断层
-  const D=d.discontinuity||{};
-  if((D.breaks||[]).length) h+=`<div class="card mb"><h3>记录断层</h3>
-    <p class="small">${D.note}</p>
-    ${D.breaks.map(b=>`<p class="small"><b>${b.before} → ${b.after}</b>: ${b.n_before} → ${b.n_after} 条 (<b style="color:var(--bad)">${b.ratio}×</b>) — 机组不会一年之间变坏这么多, 变的是记录制度。</p>`).join('')}
-    </div>`;
-  return h;
-}
-
-function farmScene(d){
-  const RANK={'危险':4,'报警':3,'不可判':2,'良好':1,'优秀':0};
-  const COL={'危险':'var(--bad)','报警':'var(--bad)','不可判':'var(--unknown)',
-             '良好':'var(--ok)','优秀':'var(--st-exc)'};
-  const S={};
-  Object.entries((d&&d.systems)||{}).forEach(([sys,rows])=>(rows||[]).forEach(r=>{
-    if(!r||!r.t) return;
-    const st=String(r.st||'').split('·')[0].trim();
-    if(!(st in RANK)) return;
-    if(!(r.t in S) || RANK[st] > RANK[S[r.t]]) S[r.t]=st;
-  }));
-  // 补全: 预烤 key 里有全场台号; 在线模式下退回已知台集合
-  let all=Object.keys(S);
-  if(typeof window.__SNAP__!=='undefined'){
-    const ks=new Set();
-    Object.keys(window.__SNAP__).forEach(k=>{const m=/[?&]t=([A-Za-z0-9]+)/.exec(k); if(m)ks.add(m[1])});
-    if(ks.size>all.length) all=[...ks];
-  }
-  all=all.sort();
-  if(!all.length) return '';
-  const n=all.length, W=980, H=190, m=30;
-  const per=Math.ceil(n/2), gap=(W-m*2)/(per-1||1);
-  let g='';
-  all.forEach((t,i)=>{
-    const row=i<per?0:1, idx=i<per?i:i-per;
-    const x=m+idx*gap+(row?gap/2:0);
-    const base=row?150:126, hh=row?30:38, op=row?.55:.92, bw=row?12:15;
-    const st=S[t]||'优秀', c=COL[st]||'var(--unknown)';
-    const hub=base-hh;
-    g+=`<g opacity="${op}"><title>${t} · ${st}</title>`
-      +`<path d="M${x} ${base} L${x} ${hub}" stroke="${c}" stroke-width="${row?1:1.3}" fill="none" stroke-linecap="round"/>`
-      +`<path d="M${x} ${hub} L${x} ${hub-bw}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
-      +`<path d="M${x} ${hub} L${(x+bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
-      +`<path d="M${x} ${hub} L${(x-bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
-      +`<circle cx="${x}" cy="${hub}" r="${row?1.5:1.9}" fill="${c}"/></g>`;
-  });
-  const cnt={};
-  Object.values(S).forEach(v=>cnt[v]=(cnt[v]||0)+1);
-  const legend=['危险','报警','不可判','良好','优秀'].filter(k=>cnt[k])
-    .map(k=>`<span style="display:inline-flex;align-items:center;gap:5px">
-      <i style="width:7px;height:7px;border-radius:50%;background:${COL[k]};display:inline-block"></i>${k} ${cnt[k]}</span>`).join('');
-  return `<div class="card mb"><svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block"
-      role="img" aria-label="Fleet layout: each turbine coloured by its most severe system state">
-      <defs><linearGradient id="fsea" x1="0" y1="0" x2="0" y2="1">
-        <stop offset="0" stop-color="var(--accent)" stop-opacity=".14"/>
-        <stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
-      <path d="M0 150 H${W}" stroke="var(--line)" stroke-width="1" fill="none"/>
-      <path d="M0 126 H${W}" stroke="var(--line)" stroke-width="1" fill="none" opacity=".5"/>
-      <rect x="0" y="150" width="${W}" height="${H-150}" fill="url(#fsea)"/>
-      ${g}</svg>
-    <p class="small" style="color:var(--muted);display:flex;gap:16px;flex-wrap:wrap;margin:10px 2px 0">
-      <b style="color:var(--ink)">全场态势</b>${legend}
-      <span style="margin-left:auto">按各台最严重系统状态着色 · 悬停看台号</span></p></div>`;
-}
-
-function drawFig(f){
-  if(f.kind==='vib'){return figWrap(f, dtSVG(f.comp||f.text||'',f.sev||'var(--warn)',880), (f.text||'').replace(/</g,'&lt;'))}
-  if(f.kind==='text'){return `<div class="card"><h3>${f.title||''}</h3><p class="small">${(f.text||'').replace(/</g,'&lt;')}</p></div>`}
-  if(f.kind==='vibbar'){
-    // 倍中位条 (1× 为基准竖线; 绝对值量级差 3 个数量级时按绝对值画条会全部顶满 — 速度有效值 0.001 实例)
-    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>=0.01?v.toFixed(3):v.toExponential(1)};
-    const byM={}; rows.forEach(r=>{(byM[r.指标]=byM[r.指标]||[]).push(r)});
-    const LO=0.5, HI=2.5, W2=190, xr=v=>((Math.max(LO,Math.min(HI,v))-LO)/(HI-LO))*W2;
-    let inner=`<div class="small" style="color:var(--muted);margin-bottom:8px">条为本台与全场中位之比, 竖线取 1× 基准, 右端封顶 2.5×; 色按 z (稳健离群度): 绿&lt;2 · 橙2~3 · 红≥3</div>`;
-    for(const [mk,rs] of Object.entries(byM)){
-      inner+=`<div style="margin-bottom:9px;overflow-x:auto"><b class="small" style="color:var(--muted)">${mk}</b><table style="width:100%;margin-top:2px">`;
-      rs.forEach(r=>{
-        const v=r.倍中位||1, col=Math.abs(r.z)>=3?'var(--bad)':Math.abs(r.z)>=2?'var(--warn)':'var(--ok)';
-        const x1=xr(1), xv=xr(v), left=Math.min(x1,xv), wid=Math.max(Math.abs(xv-x1),1.5);
-        inner+=`<tr><td class="small" style="width:104px">${r.测点}</td>
-          <td style="width:${W2+10}px"><svg class="dt" viewBox="0 0 ${W2} 16" style="width:${W2}px;height:16px">
-            <line x1="0" y1="8" x2="${W2}" y2="8" stroke="var(--line)" stroke-width="1"/>
-            <line x1="${x1}" y1="1" x2="${x1}" y2="15" stroke="var(--muted)" stroke-width="1"/>
-            <rect x="${left}" y="4" width="${wid}" height="8" rx="2" fill="${col}" fill-opacity="0.9"/>
-            ${v>HI?`<path d="M${W2-1},8 l-6,-4 v8 z" fill="${col}"/>`:''}</svg></td>
-          <td class="num small" style="width:60px;color:${col};font-weight:600">${r.倍中位!=null?r.倍中位+'×':'—'}</td>
-          <td class="num small" style="width:80px">${sig(r.本台)}</td>
-          <td class="num small" style="width:78px;color:var(--muted)">中位 ${sig(r.全场中位)}</td>
-          <td class="num small" style="width:56px;color:${Math.abs(r.z)>=3?'var(--bad)':'var(--muted)'}">z ${r.z}</td>
-          <td class="num small" style="width:64px;color:${r.六窗比>1.15?'var(--warn)':'var(--muted)'}">${r.六窗比!=null?'趋势'+r.六窗比+'×':'趋势—'}</td></tr>`;
-      });
-      inner+='</table></div>';
-    }
-    return figWrap(f, inner, f.note);
-  }
-  const W=620,H=250,ML=52,MB=34;
-  let xs=f.months||f.x, n=xs.length; if(!n) return '';
-  const X=i=>sc(i,0,Math.max(n-1,1),ML,W-12);
-  let all=[];
-  if(f.kind==='band'){all=[...(f.self||[]),...(f.med||[]),...(f.q1||[]),...(f.q3||[])]}
-  else if(f.kind==='bars'){all=[0,...(f.vals||[])]}
-  else if(f.kind==='xy'){all=[...(f.self||[]),...(f.med||[])]}
-  else {(f.series||[]).forEach(s=>all.push(...s.vals))}
-  (f.thresholds||[]).forEach(t=>all.push(t.v));
-  all=all.filter(v=>v!==null&&v===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)*0.08; lo-=pad; hi+=pad;
-  const Y=v=>sc(v,lo,hi,H-MB,14);
-  const line=(vals,st)=>{let p=vals.map((v,i)=>v===null||v!==v?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`);
-    let segs=[],cur=[]; p.forEach(q=>{if(q)cur.push(q);else{if(cur.length)segs.push(cur);cur=[]}}); if(cur.length)segs.push(cur);
-    return segs.map(sg=>`<polyline points="${sg.join(' ')}" style="fill:none;${st}"/>`).join('')}
-  let body='';
-  if(f.kind==='band'&&f.q1){
-    let up=f.q3.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean);
-    let dn=f.q1.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean).reverse();
-    if(up.length&&dn.length) body+=`<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="#cbd5e1" fill-opacity="0.45"/>`;
-  }
-  if(f.kind==='bars'){const bw=Math.max(3,(W-ML-20)/n*0.6);
-    body+=f.vals.map((v,i)=>`<rect x="${(X(i)-bw/2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${(Y(Math.max(lo,0))-Y(v)).toFixed(1)}" fill="#0f4c75" fill-opacity="0.75"/>`).join('');}
-  if(f.med) body+=line(f.med,'stroke:#64748b;stroke-width:1.7');
-  if(f.self) body+=line(f.self,'stroke:#c0392b;stroke-width:2.1')+
-    f.self.map((v,i)=>v===null||v!==v?'':`<circle cx="${X(i)}" cy="${Y(v)}" r="2.4" fill="var(--bad)"/>`).join('');
-  const cols=['#0f4c75','#ef6c00','#0e6e66','#7c5295'];
-  (f.series||[]).forEach((s,k)=>{body+=line(s.vals,`stroke:${cols[k%4]};stroke-width:1.8`)});
-  (f.thresholds||[]).forEach(t=>{body+=`<line x1="${ML}" x2="${W-12}" y1="${Y(t.v)}" y2="${Y(t.v)}" stroke="var(--muted)" stroke-dasharray="5 4"/>`+
-    (t.label?`<text x="${ML+6}" y="${Y(t.v)-4}" font-size="10" fill="var(--muted)">${t.label}</text>`:'')});
-  const step=Math.max(1,Math.ceil(n/8));
-  let xt=''; for(let i=0;i<n;i+=step) xt+=`<text x="${X(i)}" y="${H-10}" font-size="10" fill="var(--muted)" text-anchor="middle">${String(xs[i]).includes('-')?String(xs[i]).slice(2,7):String(xs[i])}</text>`;
-  let yt=''; for(let k=0;k<5;k++){const v=lo+(hi-lo)*k/4; yt+=`<text x="${ML-6}" y="${Y(v)+3}" font-size="10" fill="var(--muted)" text-anchor="end">${v.toFixed(Math.abs(hi)<10?1:0)}</text>`+
-    `<line x1="${ML}" x2="${W-12}" y1="${Y(v)}" y2="${Y(v)}" stroke="var(--line-2)"/>`}
-  if(f.band&&f.band.q1){const q1=f.band.q1,q3=f.band.q3;
-    let up='',dn='';
-    for(let i=0;i<n;i++){if(q3[i]!=null)up+=`${X(i)},${Y(q3[i])} `}
-    for(let i=n-1;i>=0;i--){if(q1[i]!=null)dn+=`${X(i)},${Y(q1[i])} `}
-    body=`<polygon points="${up}${dn}" fill="var(--unknown)" fill-opacity="0.16"/>`+body}
-  if(f.xlab) body+=`<text x="${(ML+W)/2}" y="${H-1}" font-size="10" fill="var(--unknown)" text-anchor="middle">${f.xlab}</text>`;
-  let leg = f.kind==='band' ? `<text x="${W-190}" y="12" font-size="10" fill="var(--bad)">— 本台</text><text x="${W-130}" y="12" font-size="10" fill="var(--muted)">— 全场中位</text><text x="${W-60}" y="12" font-size="10" fill="var(--unknown)">▧ 四分位</text>` :
-    ((f.series&&f.series.length<=2)?(()=>{const n2=f.series.length,gap=Math.min(118,Math.floor((W-ML-20)/n2));
-      return f.series.map((s,k)=>`<text x="${W-12-(n2-1-k)*gap}" y="12" font-size="10" fill="${cols[k%4]}" text-anchor="end">— ${s.name}</text>`).join('')})():'');
-  const legendHtml=(f.series&&f.series.length>2)?`<div class="legend">`+f.series.map((s,k)=>
-      `<span class="lgi"><i style="background:${cols[k%4]}"></i>${s.name}</span>`).join('')+`</div>`:'';
-  // 统一走 figWrap: 图身按类别封顶宽度 (曲线760/月度620), 不再随容器拉伸成巨图
-  return figWrap(f, `<svg viewBox="0 0 ${W} ${H}">${yt}${body}${xt}${leg}</svg>`+legendHtml,
-                 f.note||'').replace('<h3>'+(f.title||''), '<h3>'+(f.title||'')+(f.unit?' ('+f.unit+')':''));
-}
-
-function ordersHtml(o){
-  if(!o) return '';
-  let h=`<div class="card mb"><h3>工单处理史 · 该系统 (处理 ${o.n} 次${o.窗?'; 窗 '+o.窗:''})</h3>`;
-  if(o.n===0){h+=`<p class="small">该时间窗内无工单记录。${o.note||''}</p></div>`; return h}
-  const acts=Object.entries(o.动作统计||{}).map(([k,v])=>k+'×'+v).join(' / ');
-  if(acts) h+=`<p class="small" style="margin-bottom:6px">处置动作统计: <b>${acts}</b></p>`;
-  h+=`<table><tr><th style="width:92px">日期</th><th>故障/工作</th><th>排查发现</th><th>处置</th><th>类别</th></tr>`;
-  o.rows.forEach(r=>{h+=`<tr><td class="num" style="text-align:left">${r.日期}</td><td class="small">${r.故障}</td><td class="small">${r.排查&&r.排查!=='nan'?r.排查:'—'}</td><td class="small">${r.处置||'—'}</td><td class="small">${r.类别||''}</td></tr>`});
-  h+=`</table><p class="small">${o.note||''}</p></div>`;
-  return h;
-}
-let ONT=null;
-async function loadDbase(){
-  const el=document.getElementById('dbase'); if(!el) return;
-  if(!ONT) ONT=await (await fetch('/api/ontology')).json();
-  const C=ONT.chain29; let h='';
-  // ① 机制链横图 (决策的证据底座, 图形化)
-  const node=(label,val,color)=>`<div style="min-width:120px;flex:1;text-align:center">
-    <div style="border:1.5px solid ${color};border-radius:10px;padding:8px;background:var(--panel)">
-      <div class="small" style="color:var(--muted);letter-spacing:.05em">${label}</div>
-      <div style="font-weight:600;font-size:13.5px;margin-top:4px">${val}</div></div></div>`;
-  const arrow=`<div style="align-self:center;color:var(--muted);font-size:16px;padding:0 4px">→</div>`;
-  // 每格加一行"这一格回答什么" (2026-08-28 用户问"什么意思" — 六个词现场看不出是干嘛的)
-  const node2=(t,v,c,q)=>`<div style="flex:none;min-width:132px;border:1px solid ${c};border-radius:10px;padding:9px 12px;text-align:center">
-    <div class="small" style="color:var(--muted)">${t}</div>
-    <div style="font-weight:600;margin:4px 0 3px">${v}</div>
-    <div class="small" style="color:var(--muted);line-height:1.45">${q}</div></div>`;
-  // 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用, 我没看太懂")。
-  // 原来是 29# 一台的静态六格 — 打开它做不了任何决定, 是展示牌不是工作面。
-  // 改为: 全部需跟踪台各走到第几步 / 卡在哪一步 / 下一步具体做什么 / 点台号进该台。
-  // 这样它回答的是"今天该管谁、卡在哪、谁去做什么", 而不是"29# 的故事是怎样的"。
-  const BD=(window.D&&D.fus&&D.fus.链盘)?D.fus.链盘:(D.fus&&D.fus.链盘);
-  if(BD&&BD.rows&&BD.rows.length){
-    const SK=Object.entries(BD.stuck||{}).sort((a,b)=>b[1]-a[1]);
-    const tot=BD.rows.length;
-    const cell=st=>st==='done'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;background:var(--accent);opacity:.8"></i>`
-      :st==='unknown'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px dashed var(--muted);opacity:.75"></i>`
-      :`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px solid var(--line)"></i>`;
-    h+=`<div class="card mb"><h3>决策链进度盘 · ${tot} 台需跟踪</h3>
-      <p class="small" style="color:var(--muted);margin-bottom:10px">
-      一台机组从发现异常到修完验收要走六步。这张盘不讲某一台的故事, 用于确认<b>当前需处置机组、各自所处环节、后续动作</b>。
-      <span style="color:var(--accent)">■</span> 已达 ·
-      <span style="color:var(--muted)">▢ 虚线</span> 数据不足不可判 ·
-      <span style="color:var(--muted)">▢ 实线</span> 未达</p>
-      <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px;
-        padding:9px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
-        <b class="small">瓶颈</b>${SK.map(([k,n])=>`<span class="small">
-          <b style="color:${n/tot>0.5?'var(--bad)':'var(--warn)'}">${n} 台</b> 卡在「${k}」</span>`).join('<span class="small" style="color:var(--muted)">·</span>')}
-        ${SK.length&&SK[0][1]/tot>0.5?`<span class="small" style="color:var(--muted)">— 瓶颈集中在一步, 不是"再多看点数据"能解的</span>`:''}
-        <div style="flex-basis:100%;height:0"></div>
-        ${SK.map(([k,n])=>{const r=BD.rows.find(x=>x.stuck===k);
-          /* 值为空就整块不出 —— 原来无条件渲染, 没 next 的两个环节在页面上留下
-             "「机制」24 台的下一步:" 后面空着的悬空标签 (中英文版同样露)。 */
-          return (r&&r.next)?`<div class="small" style="color:var(--muted);margin-top:3px">
-          <b>「${k}」${n} 台的下一步:</b> ${r.next}</div>`:''}).join('')}</div>
-      <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th>
-        <th style="width:126px">六步进度</th><th style="width:70px">卡在</th><th>本台情况</th></tr>`;
-    BD.rows.forEach(r=>{
-      const ris=r.rising;
-      h+=`<tr><td><a class="tl" href="javascript:void(0)" onclick="popChain('${r.t}')" title="点开看这一台的六步决策链"><b>${r.t.replace('WTG','')}#</b></a>
-          ${ris?`<span class="small" style="color:var(--bad)" title="${ris.component} 末三窗/早三窗 ${ris.ratio}×">↑${ris.ratio}×</span>`:''}</td>
-        <td>${chip(r.ostate||'—')}</td>
-        <td style="white-space:nowrap">${r.steps.map((x,i)=>`<span title="${i+1} ${x.k}: ${x.v} — ${x.d}" style="margin-right:3px">${cell(x.st)}</span>`).join('')}
-          <span class="cnt">${r.done}/6</span></td>
-        <td class="small" style="color:${r.stuck_kind==='unknown'?'var(--muted)':'var(--warn)'}">${r.stuck||'全通'}${r.stuck_kind==='unknown'?' <span title="非未执行, 系统无法判定">?</span>':''}</td>
-        <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}证据 ${(r.srcs||[]).join('/')||'—'}
-          ${ris?` · <span style="color:var(--bad)">${ris.component}末三窗/早三窗 ${ris.ratio}×${ris.工单可对===false?', 待现场/工单确认':''}</span>`:''}
-          ${r.hist_loop?` · <span style="color:var(--ok)">${r.hist_loop.replace_date} 有过闭环 ${r.hist_loop.ratio}×</span>`:''}</td></tr>`;});
-    h+=`</table>
-      <p class="small" style="color:var(--muted);margin-top:8px">
-      <b>「动作」一列全场都是不可判</b>, 不是偷懒: ${BD.wo_bound}, 而判级时间窗在 2026 —
-      台账里的历史工单不可能是针对本次问题的, 用"有没有工单"判已派工会造出假的完成态。要填这一列须向现场要 25~26 工单。</p>`;
-    if(BD.closed&&BD.closed.length){
-      h+=`<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
-        <div class="small" style="font-weight:600;margin-bottom:5px">已走完全程并退出盘面 · ${BD.closed.length} 台 (端到端闭环实证)</div>
-        ${BD.closed.map(x=>`<div class="small" style="color:var(--muted)">
-          <a class="tl" href="/turbine/${x.turbine}"><b>${x.turbine.replace('WTG','')}#</b></a>
-          ${x.component}${x.title?' · '+x.title:''} ${x.replace_date} 换件 →
-          振动 ${x.pre} 降至 ${x.post} = <b style="color:var(--ok)">${x.ratio}×</b></div>`).join('')}
-        <div class="small" style="color:var(--muted);margin-top:5px">
-        这三台正是算法当初的检出台 ⇒ <b>故障前能看见、换件后能看见恢复</b>, 这条链是通的; 闭环成功的台会从盘面消失。</div></div>`;
-    }
-    h+=`</div>`;
-  }
-  // ② 四情景损失对比 (log 尺度条形; 数值解析电费口径区间取中值)
-  const sc=C.scenario.scenarios||[];
-  const parseWan=t=>{const m=/([0-9.]+)~([0-9.]+) 万元/.exec(t||'');return m?(+m[1]+ +m[2])/2:null};
-  const rows=sc.map(x=>{
-    let v=parseWan(x.停机损失&&x.停机损失.电费口径), extra=0, lab=x.id;
-    if(x.停机损失&&x.停机损失.大部件成本){extra=350; lab+=' (+大部件成本200-500万)';   // 用 - 不用 ~ : ~ 不在 PH 续接字符类里, 会把整串切断
-    }
-    return {lab, v:(v||0)+extra, base:v||0, extra};});
-  if(rows.length){
-    const mx=Math.max(...rows.map(r=>r.v));
-    h+=`<div class="card mb"><h3>排期沙盘 · 四情景损失对比 (万元, 电价区间中值; 建议级待人裁)</h3>`;
-    rows.forEach(r=>{
-      const w=Math.max(3,Math.round(Math.log10(1+r.v)/Math.log10(1+mx)*320));
-      const col=r.extra?'var(--bad)':(r.lab.startsWith('A')?'var(--ok)':'var(--warn)');
-      h+=`<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
-        <div class="small" style="width:170px;text-align:right">${r.lab}</div>
-        <div style="height:16px;width:${w}px;background:${col};border-radius:4px;opacity:.85"></div>
-        <div class="small" style="font-variant-numeric:tabular-nums"><b>${r.v>=100?r.v.toFixed(0):r.v.toFixed(1)}</b> 万元</div></div>`});
-    h+=`<p class="small">条长=log 尺度 (跨两个数量级); D 含大部件成本中值 350 万 (grade D 弱源)。早修成本被限电稀释, 晚修成本被窗口与船期放大。</p></div>`;
-  }
-  // ③ 天气窗热力条 (近12月, CTV/吊装 双行)
-  const W=ONT.weather||{}; const ms=Object.keys(W).sort().slice(-12);
-  if(ms.length){
-    const cell=(v,d)=>{const r=v/d; const bg=r>0.9?'var(--ok)':r>0.7?'var(--warn)':'var(--bad)';
-      return `<td style="text-align:center;padding:4px"><div style="background:${bg};opacity:${0.25+r*0.6};border-radius:4px;padding:4px 0;font-size:11.5px;font-variant-numeric:tabular-nums">${v}</div></td>`};
-    h+=`<div class="card mb"><h3>海上作业天气窗 · 近12月可作业日 (公开再分析统计, 非派工依据)</h3>
-    <div style="overflow-x:auto"><table><tr><th style="width:90px"></th>${ms.map(m=>`<th class="small" style="text-align:center">${m.slice(2)}</th>`).join('')}</tr>`;
-    h+=`<tr><td class="small">CTV人员转运</td>${ms.map(m=>cell(W[m]['CTV人员转运']||0,W[m]['天数']||30)).join('')}</tr>`;
-    h+=`<tr><td class="small">吊装作业</td>${ms.map(m=>cell(W[m]['吊装作业']||0,W[m]['天数']||30)).join('')}</tr>`;
-    h+=`</table></div><p class="small">CTV 级小修全年窗宽; 吊装级受台风季约束, 色深表示可作业占比。限值取行业常规 (CTV Hs≤1.5m∧风速≤12m/s, 吊装 1.2/8), 非本场合同值。</p></div>`;
-  }
-  el.innerHTML=h;
-}
-async function renderOntology(){
-  const root=document.getElementById('root');
-  if(!ONT){
-    root.innerHTML='<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;margin-top:9px"></div></div>';
-    ONT=await (await fetch('/api/ontology')).json();
-  }
-  const O=ONT; let h='';
-  h+=`<div class="eyebrow">语义本体 · 对象/机制/决策/约束 (Palantir 思路轻量自建; 判读纪律=写入闸, 违反写不进库)</div>`;
-  h+=`<div class="card mb" style="border-left:3px solid var(--accent)"><b>六条写入约束 (库级保证)</b><p class="small">${O.constraints}</p></div>`;
-  h+=`<div class="kpis">`+Object.entries(O.stats).map(([k,v])=>`<div class="kpi"><b>${v}</b><span>${k}</span></div>`).join('')+`</div>`;
-
-  const C=O.chain29;
-  h+=`<div class="card mb"><h3>机制链视图 · 29# 范例 (证据 → 机制 → 判级 → 沙盘 → 动作 → 验收)</h3>
-  <div style="display:flex;gap:10px;overflow-x:auto;padding:6px 0">`;
-  h+=`<div style="min-width:240px;flex:1"><p class="small"><b>① 证据 ×${C.evidence.length} (${C.mechanism.independent_source_count||4} 观测面)</b></p>`;
-  C.evidence.forEach(e=>{h+=`<div class="small" style="border:1px solid var(--line);border-left:3px solid var(--info);border-radius:6px;padding:6px 8px;margin-bottom:6px"><b>${e.facet}</b> · 窗 ${e.claim_window}<br>${(e.summary||'').slice(0,90)}…<br><span style="color:var(--muted)">${e.id}</span></div>`});
-  h+=`</div>`;
-  const box=(t,color,inner)=>`<div style="min-width:210px;flex:1"><p class="small"><b>${t}</b></p><div class="small" style="border:1px solid var(--line);border-left:3px solid ${color};border-radius:6px;padding:8px">${inner}</div></div>`;
-  h+=box('② 机制','var(--warn)',`<b>${C.mechanism.name}</b><br>源数=观测面去重 ${C.mechanism.independent_source_count} (证据${C.evidence.length}条)<br><span style="color:var(--muted)">${C.mechanism.id}</span>`);
-  h+=box('③ 判级','var(--bad)',`<b>${C.verdict.level}</b> · 审级 ${C.verdict.review_tier}<br>${(C.verdict.title||'').slice(0,110)}…<br><span style="color:var(--muted)">${C.verdict.id}</span>`);
-  h+=box('④ 沙盘','var(--accent)',`${(C.scenario.recommendation||'').slice(0,130)}…<br><span style="color:var(--muted)">${C.scenario.id}</span>`);
-  h+=box('⑤ 动作','var(--warn)',`${(C.action.name||'').slice(0,90)}<br>闸: ${C.action.gate?C.action.gate.reversibility+' · '+C.action.gate.review_tier:''} · ${C.action.status||''}<br><span style="color:var(--muted)">${C.action.id}</span>`);
-  h+=box('⑥ 验收状态机','var(--ok)',`状态 <b>${C.acceptance.state||'—'}</b> 分支 ${C.acceptance.branch||'—'}<br>${(C.acceptance.note||'').slice(0,110)}<br><span style="color:var(--muted)">acceptance/WTG29/state</span>`);
-  h+=`</div><p class="small">判读纪律: 六个观测量=一条机制链 (C3 源数按观测面), 禁写多源印证; 判级只在 L1/审级产生, 本体转录带审级。</p></div>`;
-
-  h+=`<div class="card mb"><h3>失效机制库 (${O.mechanisms.length} 族)</h3><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px">`;
-  O.mechanisms.forEach(m=>{h+=`<div class="small" style="border:1px solid var(--line);border-radius:8px;padding:9px"><b>${m.name}</b><br>状态: ${m.status||''}<br>观测面: ${(m.facets||[]).join('/')}<br>涉及: ${(m.affects||[]).map(a=>a.split('/').pop()).join(', ')||'fleet'}<br><span style="color:var(--muted)">${m.id}</span></div>`});
-  h+=`</div></div>`;
-
-  h+=`<div class="card mb"><h3>决策台 (决策捕获 + 月度滚动)</h3><table><tr><th>决策</th><th>裁决人</th><th>时间</th><th>状态</th></tr>`;
-  O.decisions.forEach(x=>{const on=x.status==='pending_user';
-    h+=`<tr><td class="small">${(x.decision||'').slice(0,130)}</td><td>${x.decided_by}</td><td class="small">${x.when}</td>
-    <td><span class="badge" style="border-color:${on?'var(--warn)':'var(--ok)'};color:${on?'var(--warn)':'var(--ok)'}">${x.status}</span></td></tr>`});
-  h+=`</table><p class="small">月度滚动: 数据月 ${O.roll.data_month||'—'} · 上次动作 ${(O.roll.last_roll_actions||[]).join('; ')||'—'} · 变化 ${(O.roll.last_changes||[]).join('; ')||'—'}</p></div>`;
-
-  h+=`<div class="card mb"><h3>对象浏览器 (${Object.values(O.stats).reduce((a,b)=>a+b,0)} 对象)</h3>
-  <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
-    <select id="obType" style="padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink)"><option value="">全部类型</option>${Object.keys(O.stats).map(k=>`<option>${k}</option>`).join('')}</select>
-    <input id="obT" placeholder="台号 如 29" style="padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);width:110px">
-    <button onclick="obGo()" style="cursor:pointer;padding:8px 16px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff">查询</button>
-  </div><div id="obOut" class="small">选类型/台号后查询; 点结果行看对象全文与链接。</div></div>`;
-  root.innerHTML=h;
-  document.getElementById('foot').innerHTML='本体=只读语义层; 判级权在 L1+审级, 模型与网页均零判级权';
-}
-async function obGo(){
-  const t=document.getElementById('obType').value, tb=document.getElementById('obT').value;
-  const d=await (await fetch(`/api/ont_list?type=${encodeURIComponent(t)}&t=${encodeURIComponent(tb)}&limit=60`)).json();
-  const el=document.getElementById('obOut');
-  if(!d.rows||!d.rows.length){el.textContent='无结果';return}
-  el.innerHTML=`<table><tr><th>id</th><th>类型</th><th>要点</th></tr>`+d.rows.map(r=>
-    `<tr style="cursor:pointer" onclick="obOpen('${r.id}')"><td class="small">${r.id}</td><td>${r.type}</td><td class="small">${r.name||r.level||r.status||''} ${r.review_tier?'· '+r.review_tier:''}</td></tr>`).join('')+`</table><div id="obDetail"></div>`;
-}
-async function obOpen(id){
-  const d=await (await fetch('/api/ont_obj?id='+encodeURIComponent(id))).json();
-  const el=document.getElementById('obDetail'); if(!el) return;
-  el.innerHTML=`<pre style="white-space:pre-wrap;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px">${JSON.stringify(d,null,1).replace(/</g,'&lt;')}</pre>`;
-}
-// 模型档位 (2026-08-28 用户令: 模型切换的应用你设计好; 去 30B 用 27B)。
-// 按"这个问题值不值得等"选, 而非凭型号大小猜 — 每档写清 在哪跑 / 要多久 / 适合什么。
-// 云端档单独标"内容会外发", 因为那是一个需要用户知情的选择而不是性能选项。
-// ★页面用的是**这一份** (JS), 上面 L1158 那份是 Python 的 —— 两份同名不同源。
-//   我第一次只改了 Python 那份, 页面一点没变 (deepseek-chat 在成品件里 0 命中)。
-//   同名双实现: 改一处不报错、不生效, 只能靠回查产物发现。
-const MODEL_SPEC = {
-  'qwen3:8b':        {nm:'轻量 8B',  loc:'本地', t:'约 30 到 60 秒', use:'默认档: 快速简答问答', warn:''},
-  'qwen3.8:27b':     {nm:'标准 27B', loc:'本地', t:'约 2 到 4 分钟', use:'复杂问题档: 多台横向比对与机制推断', warn:''},
-  'qwen3:30b-a3b':   {nm:'快 30B (a3b)', loc:'本地', t:'约 20 到 60 秒', use:'8B 成文校验未过时自动升档目标', warn:''},
-  'deepseek-r1:14b': {nm:'本机 DeepSeek 14B', loc:'本地', t:'约 1 到 3 分钟', use:'交叉审核审核档 (作答请优先 27B)', warn:''},
-  'deepseek-r1:32b': {nm:'本机 DeepSeek 32B', loc:'本地', t:'约 3 到 8 分钟', use:'备用: 更强更慢, 引用格式不稳', warn:''},
-  'deepseek-chat':   {nm:'DeepSeek', loc:'云端', t:'约 3 到 10 秒',  use:'默认档, 状态查询与机制推断', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
-  'kimi-fast':       {nm:'Kimi',     loc:'云端', t:'约 5 到 20 秒',  use:'长文归纳', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
-  'qwen-plus':       {nm:'通义千问',  loc:'云端', t:'约 5 到 20 秒',  use:'备用云端档', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
-  'deepseek-v4-pro': {nm:'云端 DS',  loc:'云端', t:'约 2 到 6 分钟', use:'复杂推理与交叉审核', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
-};
-function mdlNote(){
-  const el=document.getElementById('mdlnote'), sel=document.getElementById('askm');
-  if(!el||!sel) return;
-  const v=MODEL_SPEC[sel.value]||{};
-  // ★查不到档位时**不能**默认"零外发" —— 未知档只可能是新加的云端模型。宁可说重不说轻。
-  const note = v.warn ? `<span style="color:var(--warn)">${v.warn}</span>`
-             : (v.loc==='本地' ? '全程零外发' : '<span style="color:var(--warn)">未知档位, 按内容会外发处理</span>');
-  el.innerHTML=`${v.use||''} · ${note}`;
-}
-async function askGo(){
-  const q=document.getElementById('askq').value, m=document.getElementById('askm').value;
-  if(!q.trim()){document.getElementById('askout').textContent='问题为空';return}
-  const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q,model:m,cat:window.ASKCAT||'',xrev:(document.getElementById('askxrev')||{}).checked||false})});
-  const d=await r.json();
-  if(d.err){document.getElementById('askout').textContent=d.err;return}
-  askPoll();
-}
-// ★这句提示原文是「本地直连约 45~250 秒 (复合问题更久; 全程零外发)」—— 那是本地模型
-//   时期的文案, 两处都已失真 (2026-09-01 用户说"太慢", 实测才发现):
-//     ① 时间: 云端档实测 3~7 秒, 而提示写 45~250 秒 —— **是提示语在告诉用户它很慢**
-//     ② 「全程零外发」现在是**假的**: 云端档的问题会发到厂商 API。
-//        向用户保证一件不成立的事, 比写慢了严重得多。
-//   故改为按档给话, 本地档才说零外发。
-function MDLNOTE(m){
-  // ★不用正则: CHART_JS 是**非 raw** 的 Python 三引号串, 反斜杠转义序列会被 Python
-  //   吃掉并报 SyntaxWarning/SyntaxError。本地档判别用朴素前缀/子串即可。
-  //   (本会话第 4 次踩这个坑 —— 而这一次是在**写这条警告的注释里**踩的。)
-  const s0=String(m||'').toLowerCase();
-  const local = s0.indexOf(':')>=0 || ['qwen3','llama','gemma','deepseek-r1'].some(x=>s0.indexOf(x)===0);
-  return local ? '本地直连, 约 45 到 250 秒 (复合问题更久), 全程零外发'
-               : '云端模型, 通常 3 到 10 秒 (复合问题更久), 问题与检索到的证据会发往模型厂商';
-}
-async function askPoll(){
-  const el=document.getElementById('askout'); if(!el) return;
-  const r=await fetch('/api/ask_status'); const s=await r.json();
-  if((s.state==='running'||s.state==='partial')){
-    // 进度上屏 (2026-08-28 用户"刚发现给不出结果"): 原来只有一个秒数在跳, 本地模型一问 45~250s,
-    // 看不见它在干什么就会被读成卡死。现在报当前阶段(预取/推理第n轮/取证工具名/成文/校闸)与轨迹。
-    const st=(s.steps||[]);
-    el.innerHTML=`<b>推理中…</b> ${s.elapsed}s · <span style="color:var(--accent)">${s.step||'启动'}</span>
-      <div class="small" style="color:var(--muted);margin-top:5px">${s.model} · ${MDLNOTE(s.model)}</div>
-      ${st.length?`<div class="small" style="color:var(--muted);margin-top:4px">${st.slice(-6).join(' → ')}</div>`:''}
-      <div class="small" style="color:var(--muted);margin-top:4px">问题: ${(s.q||'').slice(0,90)}</div>`;
-    setTimeout(askPoll, 2000);
-  }else if(s.state==='done'){
-    const v=s.verify||{};
-    const badge=v.全通过?`<span class="badge" style="border-color:var(--ok);color:var(--ok)">溯源核验 ${v.引用id数}/${v.引用id数} 全真</span>`
-                        :`<span class="badge" style="border-color:var(--warn);color:var(--warn)">溯源核验未全过 (引用${v.引用id数||0})</span>`;
-    const R=s.review;
-    const rv=R?`<div class="card mb" style="border-left:3px solid ${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'};margin-top:10px">
-      <h3>交叉审核 · ${R.model} <span class="small" style="font-weight:400;color:${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'}">${R.verdict}</span></h3>
-      <p class="small" style="color:var(--muted);margin-bottom:${R.issues&&R.issues.length?'8px':'0'}">
-        由另一本地模型基于<b>同一份证据</b>复核上述答案。溯源核验仅校验数字与机组号是否越界, 本项校验结论是否有证据支撑。</p>
-      ${(R.issues||[]).map(i=>`<div class="small" style="padding:5px 0;border-top:1px solid var(--line-2)">
-        <span class="badge" style="border-color:var(--warn);color:var(--warn)">${i.type}</span>
-        <b>「${i.quote}」</b> <span style="color:var(--muted)">${i.why}</span></div>`).join('')}
-      ${R.err?`<p class="small" style="color:var(--bad)">审核未完成: ${R.err}</p>`:''}</div>`:'';
-    el.innerHTML=`<p>${badge} · 模型 ${s.model} · 用时 ${s.elapsed}s · ${(s.steps||[]).length} 步</p><pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px solid var(--line)">${(s.answer||'').replace(/</g,'&lt;')}</pre>${rv}`;
-  }else if(s.state==='error'){
-    el.textContent='出错: '+s.err;
-  }
-}
-function winSel(cur){
-  const base=['近30日','近90日','2026年','2026H1','2025H2','全程'];
-  const ms=(window.ALLMONTHS&&ALLMONTHS.length)?ALLMONTHS:[];   // 按月份过滤和选择 (用户令): 预设窗+逐月
-  let h=`<select id="win" onchange="reload(this.value)"><optgroup label="预设窗">`+
-    base.map(w=>`<option ${w===cur?'selected':''}>${w}</option>`).join('')+`</optgroup>`;
-  if(ms.length) h+=`<optgroup label="按月">`+ms.slice().reverse().map(m=>`<option ${m===cur?'selected':''}>${m}</option>`).join('')+`</optgroup>`;
-  else if(!base.includes(cur)) h+=`<option selected>${cur}</option>`;   // 台页/问题页无月轴时保显当前月窗
-  return h+`</select>`}