/* 观澜 新前端 —— 真正的 .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, '&').replace(//g, '>').replace(/"/g, '"'); // 数据文本按标准词库精确翻译;未覆盖文本保持可见并计数,不能靠隐藏通过英文检查。 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 ? `${t(stKey(k))}` : (zh ? `${esc(dt(zh))}` : ''); } function tierChip(zh) { const k = TIER[zh]; if (!k) return ''; return `${t('tier.' + k + '.s')}`; } // 枚举短名 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 ? `` : ''; const opts = `` + WIN_PRESETS.filter(([w]) => !BAKED || BAKED.includes(w)).map(([w]) => ``).join('') + `` + (custOpt ? `${custOpt}` : '') + (months.length ? `` + months.slice().reverse().map(m => ``).join('') + `` : ''); // 静态快照下 ?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 = ` ${t('app.brand')}${t('app.subtitle')} · ${d ? fmt(fleetCount) : '—'} ${fleetUnitLabel}
${t('ui.back_portal')} ${SNAP ? '' : ` ~ `} ${showToggle ? `
${EN ? 'ZH' : '中文'}EN
` : ''}
`; 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 => ``).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.报警台)} / ${fmt(d.kpi && d.kpi.全场)}`, '', t('kpi.alarm_units'), (d.kpi && d.kpi.报警台) ? 'warn' : 'ok', t('kpi.alarm_units.hint')], ]; // 第 5 元素 = 口径提示 (title): 同名不同轴的数字必须自带口径, 否则读者会与"全场状态"格对不上 // (2026-09-08 xzy 测试报告: "报警台数信息不相符" —— 33 是九系统轴, 格子是六列设备状态轴)。 let h = `
` + kpi.map(([v, u, l, c, ti]) => `
${v}${u ? `${u}` : ''}${l}
`).join('') + `
`; // 需要关注: 链盘 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 += `

${t('ov.attention')}

${t('ov.attention_n', { n: rows.length })}${t('ov.ranked_by')}
`; // 融合面缺件时**如实说明**(用 /api/fleet 的 fus.gap 原文),否则读者看到两片空白, // 分不清"没有需要关注的台"与"这一面的数据没到位"(2026-09-19 用户报"下面没有计算输出")。 if (F.gap) h += `

${dv(F.gap)}

${t('ov.fus_gap_hint')}

`; h += `
`; 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 += `
${unitNo(r.t)}${dv(r.part || '')}${stateChip(r.ostate || (r.lvl === 'bad' ? '危险' : '报警'))}
${t('ov.evidence')}
${dv(evTxt)}
${tierChip(r.estate)}${r.srcs && r.srcs.length ? `${r.srcs.length} sources` : ''}
${stuck || nxt ? `
${t('ov.caveat')}${esc(stuck)}${stuck && nxt ? ' — ' : ''}${esc(nxt)}
` : ''}
`; }); h += `
`; // 全场状态条 const mat = F.矩阵 || []; const hot = new Set(rows.map(r => r.t)); h += `

${t('ov.strip')}

${t('ov.strip_hint')}
${['bad', 'warn', 'unk', 'ok', 'ex'].map(k => `${t(stKey(k))}`).join('')}
` + (mat.length ? mat.map(r => { const k = unitState(r); return `
`; }).join('') : `
${esc(F.gap || t('ov.fus_gap_hint'))}
`) + `
` + mat.map(r => `${unitNo(r.turbine).replace('#', '')}`).join('') + `
` + `
${rows.length ? t('ov.quiet', { n: Math.max(0, (mat.length || 38) - rows.length) }) : t('ov.quiet_all', { n: mat.length || 38 })}
`; // 九系统 const dist = d.sysdist || {}; h += `

${t('ov.systems')}

${t('ov.systems_hint')}
` + 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 `
${sysName(s)}${t('ui.open')}
${seg.map(([k, v]) => ``).join('')}
${seg.map(([k, v]) => `${c[k]}`).join('')}
`; }).join('') + `
`; 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 `${REVIEW_REQUIRED}`; } 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) ? `

${t('ui.verbatim_note', { n: VERB })}

` : ''; // ── 检修助手: 三窗并列 (抢修 / 预防性维护 / 故障树), 各自输入与结果 ── 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) => ``; const btn = (id) => ``; const row = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px'; return `
${t('asst.head')} — ${t('asst.head_note')}

${t('asst.corrective')} ${t('asst.corrective_flow')}

${btn('fault')} ${t('asst.common')} ${HOT.map(c => `${c}`).join(' · ')}
${ASST.out.fault}

${t('asst.preventive')} ${t('asst.preventive_flow')}

${sel('prev')}${btn('prev')}
${ASST.out.prev}

${t('asst.faulttree')} ${t('asst.faulttree_flow')}

${sel('tree')}${btn('tree')}
${ASST.out.tree}
`; } 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 = `

${t('asst.querying')}

`; let d; try { d = await (await fetch(`/api/ont_chain?${qs}${EN ? '&lang=en&keep=1' : ''}`)).json(); } catch (e) { out.innerHTML = `

${esc(t('asst.query_failed', { e: e.message }))}

`; return; } VERB = 0; const x = d.data; out.innerHTML = (id === 'fault' && x) ? faultCards(x) : (id === 'tree' && x) ? treeCards(x) : (id === 'prev' && x) ? prevCards(x) : `
${dv(d.text || d.err || '')}
`; 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 = `

${esc(x.code)} · ${nm}

${t('asst.identity', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: `${fmt(x.n_site)}` })}${x.alarm_type ? ` · ${t('asst.alarm_type', { t: esc(x.alarm_type) })}` : ''}

`; if (x.failmodes && x.failmodes.length) h += `
${t('asst.failmodes')}` + x.failmodes.map(f => `
· ${dv(f.name)} (${t('asst.fm_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })})
`).join('') + `
`; h += `
`; if (x.steps && x.steps.length) { h += `

${t('asst.steps', { n: x.steps.length })} ${dv(x.wi_source || '')}

${t('asst.steps_note')}

` + x.steps.map(st => `
${t('asst.step_n', { n: st.序 })}${st.类别 ? ` · ${dv(st.类别)}` : ''}${st.qty ? ` · ${t('asst.qty', { q: dv(st.qty) })}` : ''}
${recordValue(st, '步骤')}
${(st.tags && st.tags.length) ? `
${t('asst.tags')} ${st.tags.map(g => `${esc(g)}`).join('')}
` : ''} ${(st.nodes && st.nodes.length) ? `
${t('asst.nodes', { c: dv(st.circuit || '') })} (${t('asst.nodes_note')})
${st.nodes.map(n => ``).join('')}
${t('asst.node')}${t('asst.check')}
${dv(n.name)}${recordValue(n, 'check')}
${(st.known_tags && st.known_tags.length) ? `
${t('asst.known_tags')} ${st.known_tags.map(k => `${esc(k.tag)}`).join('')}
` : ''}
` : ''} ${(st.specs && st.specs.length) ? `
${t('asst.specs')}
${st.specs.map(sp => ``).join('')}
${t('asst.spec_no')}${t('asst.spec_name')}${t('asst.spec_val')}${t('asst.spec_range')}
${esc(sp.no)}${dv(sp.text)}${sp.val != null ? esc(sp.val) + esc(sp.unit || '') : '—'}${sp.lo != null ? `${esc(sp.lo)} ~ ${esc(sp.hi)}` : '—'}
` : ''} ${(st.drawings && st.drawings.length) ? `
${t('asst.drawings')}${st.drawings.map(dw => `
· ${recordValue(dw, 'name')}
`).join('')}
` : ''}
`).join('') + `
`; } else h += `

${t('asst.no_steps')}

`; if (x.workorders && x.workorders.length) h += `

${t('asst.wo_head', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: fmt(x.wo_total) })}

${x.workorders.map(w => ``).join('')}
${t('ui.date')}${t('ui.unit')}${t('asst.wo_fault')}${t('asst.wo_action')}
${esc(w.date)}${esc(unitNo(w.turbine))}${recordValue(w, 'name')}${dv(w.action)} ${dv(w.part)}

${t('asst.wo_note')}

`; return h; } function treeCards(x) { const nodes = x.nodes || []; if (!nodes.length) return `

${t('asst.tree_empty')}

`; const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10)); let h = `

${t('asst.tree_head', { sys: sysName(x.system), n: nodes.length })}

${t('asst.tree_note')}

`; 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 += `
${dv(n.name)}
${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(', ') })}` : ''}
`; if (n.causes && n.causes.length) h += `
${t('asst.causes')}
` + n.causes.map(c => `
${dv(c.name)} ${t('asst.cause_counts', { r: fmt(c.n_ref), w: fmt(c.n_wo), c: fmt(c.n_codes) })}
${(c.steps || []).map(s => `
· ${dv(s)}
`).join('')}
`).join('') + `
`; else h += `

${t('asst.no_causes')}

`; h += `
`; }); return h; } function prevCards(x) { let h = `

${t('asst.prev_head', { sys: sysName(x.system), f: x.n_fm, n: x.n_task })}

`; if (x.failmodes && x.failmodes.length) h += `
${t('asst.prev_fm')}` + x.failmodes.slice(0, 8).map(f => `
· ${dv(f.name)} — ${t('asst.tree_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })}
`).join('') + `
`; if (x.tasks && x.tasks.length) h += `
${t('asst.prev_tasks')}
` + x.tasks.map(k => `
${dv(k.name)}
${k.interval ? t('asst.interval', { i: dv(k.interval) }) : ''}${k.wi ? ` · ${t('asst.wi_steps', { n: k.wi.n_step })}` : ''}
${k.wi && k.wi.steps.length ? `
${k.wi.steps.map(s => `
${esc(s.序)}. ${recordValue(s, '步骤')}
`).join('')}
` : ''}
`).join('') + `
`; else h += `

${t('asst.prev_empty')}

`; 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] ? `${t('mdl.unknown_warn')}` : MDL_LOC[id] === 'local' ? t('mdl.zero_outbound') : `${t('mdl.outbound_warn')}`; function decision() { const cat = ASK.cat, catName = t('cat.' + cat); const pills = CATS.map(([id]) => ``).join(''); const qs = [1, 2, 3].map(i => `
${t('cat.' + cat + '.q' + i)}
`).join(''); const models = ASK.models; const opts = models === null ? `` : Object.keys(models).length ? Object.keys(models).map(k => ``).join('') : ``; return `
${t('dec.head')}
${SNAP ? '' : `

${t('dec.classic')} ${t('ui.open_classic')}

`}
${pills}

${t('ask.common_q', { cat: catName })}

${t('cat.' + cat + '.hint')}${EN ? '. ' : '。'}${t('ask.grounding')}

${qs}
${ASK.model ? mdlNote(ASK.model) : ''}
${ASK.out || t('ask.idle')}
`; } 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 `
${t('ask.refs.none', { sha })}
`; 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 `
${t('ask.refs.title', { n: hits.length, sha })}
`; } 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 = `

${t('ask.partial')} ${t('ask.elapsed', { s: s.elapsed })} · ${askStepText(s.step || '')}

${esc(String(s.answer || ''))}
`; clearTimeout(ASK.timer); ASK.timer = setTimeout(() => askPoll(), 2000); } else if (s.state === 'running') { const steps = s.steps || []; el.innerHTML = `${t('ask.running')} ${t('ask.elapsed', { s: s.elapsed })} · ${askStepText(s.step || '')}
${esc(s.model || '')} · ${mdlNote(s.model)}
${steps.length ? `
${steps.slice(-6).map(askStepText).join(' → ')}
` : ''}
${t('ask.question', { q: dv(String((EN ? ASK.q : s.q) || '').slice(0, 90)) })}
`; 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 = `

${t('ask.en_qa_failed')}

`; ASK.out = el.innerHTML; return; } const v = s.verify || {}, n = Math.max(v.引用id数 || 0, answerRefCount(answer)); const badge = (v.全通过 ? `${t('ask.verify_ok', { n })}` : `${t('ask.verify_bad', { n })}`) + (s.refined_from ? ` ${t('ask.refined', { m: esc(s.refined_from) })}` : ''); const R = s.review; const rv = R ? `

${t('ask.review', { m: esc(R.model || '') })} ${dv(R.verdict || '')}

${t('ask.review_note')}

${(R.issues || []).map(i => `
${dv(i.type)} 「${dv(i.quote)}」 ${dv(i.why)}
`).join('')} ${R.err ? `

${t('ask.review_err', { e: dv(R.err) })}

` : ''}
` : ''; el.innerHTML = `

${badge} · ${t('ask.done_meta', { m: esc(s.model || ''), s: s.elapsed, n: (s.steps || []).length })}

${esc(answer)}
${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 => `${esc(c.claim_id)}${esc(c.verdict)}${esc(c.title)}${esc((c.systems || []).join(', ') || '—')}${esc(c.aggregation || '—')}`).join(''); return `
${t('rpt.facts.title')} ${t('rpt.facts.sub', { n: cards.length, sha: (FACTS.contract_sha256 || '').slice(0, 16) })}
${rows}
claim${t('rpt.facts.verdict')}${t('rpt.facts.text')}${t('rpt.facts.sys')}${t('rpt.facts.agg')}
`; } // 判别值 (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 `
${v}${u}${lab}
${note || ''}
`; } 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) => `

${ti}

${b}${src ? `

${t('rpt.caliber_lbl')}${src}

` : ''}
`; 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 `
${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' : '')}
`; } if (k === 'water') return wrap(t('rpt.water.title'), CH.waterfall(E) + `

${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) })}

`, t('rpt.water.cal')); if (k === 'emon') return wrap(t('rpt.emon.title'), em.length ? `
${em.map(x => ``).join('')}
${t('rpt.emon.m')}${t('rpt.emon.act')}${t('rpt.emon.loss')}${t('rpt.emon.pct')}${t('rpt.emon.eflh')}${t('rpt.emon.cov')}
${x.m}${fmt(x.act)}${fmt(x.loss)}${x.loss_pct}%${x.eflh}${(x.cov * 100).toFixed(0)}%${x.cov < .5 ? t('rpt.emon.partial') : ''}
` : '—', t('rpt.emon.cal')); if (k === 'blame') return wrap(t('rpt.blame.title'), CH.blameBar(E) + `

${t('rpt.blame.note')}
${t('rpt.blame.note2')}

`, t('rpt.blame.cal')); if (k === 'ram') return R ? wrap(t('rpt.ram.title'), CH.ramChart(R.表, partPlain) + `

${t('rpt.ram.note', { a: R.设备类停机h, b: R.外部类停机h })}

`, t('rpt.ram.cal')) : ''; if (k === 'parts') return R ? wrap(t('rpt.parts.title'), `
${R.表.slice(0, 9).map(x => ``).join('')}
${t('rpt.parts.part')}${t('rpt.parts.h')}${t('rpt.parts.ev')}${t('rpt.parts.mtbf')}${t('rpt.parts.mdt')}${t('rpt.parts.units')}
${partName(x.部件)}${x.外部 ? t('rpt.parts.ext') : ''}${x.停机时长h}${x.事件数}${x.MTBF_h || '—'}${x.MDT_h}${x.影响台数}
`, t('rpt.parts.cal')) : ''; if (k === 'attrib') return R ? wrap(t('rpt.attrib.title'), CH.attribBar(R.表, R.总停机h) + `

${t('rpt.attrib.note')}

`, '') : ''; 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 || []) + `

${t('rpt.quad.note')}

`, t('rpt.quad.cal')); if (k === 'frate') return wrap(t('rpt.frate.title'), CH.rateChart(F.monthly || { months: [], vals: [] }) + `

${t('rpt.frate.note')}

`, 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'), `

${t('rpt.units.head', { n: (BD.rows || []).length, tot: (D.kpi || {}).全场 || 38 })}${stuck}

${rows.map(r => ``).join('')}
${t('ui.unit')}${t('rpt.units.state')}${t('rpt.units.ev')}${t('rpt.units.prog')}${t('rpt.units.at')}
${unitNo(r.t)}${stateChip(r.ostate)}${r.part && r.part.indexOf('未收录') < 0 ? `${dv(r.part)} · ` : ''}${(r.srcs || []).map(dv).join('/') || '—'}${r.rising ? ` · ${t('rpt.units.rising', { r: r.rising.ratio })}` : ''}${r.done}/6${r.stuck ? t('step.' + (STEP[r.stuck] || 'evidence')) : t('rpt.units.clear')}
`, t('rpt.units.cal')); } if (k === 'chain') return wrap(t('rpt.chain.title'), `

${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(', ') })}

`, t('rpt.chain.cal', { b: dv(BD.wo_bound || '') })); if (k === 'loop') { const cl = BD.closed || []; return wrap(t('rpt.loop.title'), `
${cl.map(x => ``).join('')}
${t('ui.unit')}${t('rpt.loop.comp')}${t('rpt.loop.date')}${t('rpt.loop.pre')}${t('rpt.loop.post')}${t('rpt.loop.ratio')}
${unitNo(x.turbine)}${dv(x.component)}${esc(x.replace_date)}${x.pre}${x.post}${x.ratio}×

${t('rpt.loop.note')}

`, t('rpt.loop.cal')); } if (k === 'plan') return wrap(t('rpt.plan.title'), `
  1. ${t('rpt.plan.1', { n: (BD.stuck || {}).机制 || 0 })}
  2. ${t('rpt.plan.2')}
  3. ${t('rpt.plan.3')}
  4. ${t('rpt.plan.4')}
`, t('rpt.plan.cal')); if (k === 'narr') { const N = RPT.narr; if (!N || !N.text) return wrap(t('rpt.narr.title'), `

${t('rpt.narr.empty')}

`, ''); return wrap(t('rpt.narr.title'), `

${esc(N.text).split('\n').join('
')}

${(N.ungrounded || []).length ? `

${t('rpt.narr.ungr', { v: N.ungrounded.map(esc).join(' / ') })}

` : ''}`, 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'), ``, ''); } return ''; } function report() { const d = D, FU = d.fus || {}, on = k => RPT.canvas.includes(k); const models = RPT.models, opts = models === null ? '' : Object.keys(models).length ? Object.keys(models).map(k => ``).join('') : ``; const canCompose = RPT.compose === true && models && Object.keys(models).length; let h = `
${t('rpt.eyebrow')}
${t('rpt.presets')} ${Object.keys(RPT_PRESETS).map(k => ``).join('')} ${RPT.aud === 'up' ? `${t('rpt.up_note')}` : ''}
${t('rpt.library')} ${t('rpt.library_note', { n: RPT.canvas.length })}
${RPT_GROUPS.map(g => `
${t('rpt.g.' + g)}
${Object.keys(RPT_BLOCKS).filter(k => RPT_BLOCKS[k] === g).map(k => ``).join('')}
`).join('')}
${t('rpt.hint_idle')}
${t('rpt.need_head')} ${t('rpt.need_hint')}${RPT.model ? (MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `${t('rpt.cloud_note')}`) : ''}
${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')) : ''))}
`; if (!RPT.canvas.length) return h + `
${t('rpt.preview_empty')}
`; // 数据时点只有形如 YYYY-MM-DD 才当日期用 —— 实测该字段可能是一句说明 ("振动证据包…"), 截 10 字就成乱码 const asof = (String(FU.数据时点 || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0]; // 实测该字段是一句说明 ("振动证据包 · 出具 2026-08-26 · …"), 取其中第一个日期 h += `
${t('app.brand')} · ${t('rpt.paper_title')}
${t('rpt.paper_sub', { win: winName(d.win), n: (d.kpi || {}).全场 || 38 })}${RPT.aud === 'up' ? ' | ' + t('rpt.up_tag') : ''}
${t('rpt.chapters', { n: RPT.canvas.length })}
`; RPT.canvas.forEach((k, i) => { if (!RPT_BLOCKS[k]) return; const bb = (s, act, dis) => ``; h += `
${bb('↑', 'up', i === 0)}${bb('↓', 'down', i === RPT.canvas.length - 1)}${bb('×', 'del', false)}
${rptBlock(k, d)}`; }); h += rptFacts(); h += `
${t('rpt.foot_src')}${asof ? ' | ' + t('rpt.foot_asof', { d: asof }) : ''}${t('rpt.foot_trace')}${RPT.aud === 'up' ? ' | ' + t('rpt.foot_deid') : ''}
`; 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 = `${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')} — ${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 = `${t('rpt.composing')}${MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `${t('rpt.cloud_note')}`}`; 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 = `${esc(t('rpt.compose_fail', { e: e.message }))}`; RPT.out = out.innerHTML; return; } if (d.err) { out.innerHTML = `${dv(d.err)}`; 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 ? `${t('rpt.ungrounded_n', { n: d.ungrounded.length })}` : 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) => `
${t('win.banner', { win: winName(d.win) })}${extra ? ' | ' + extra : ''}
`; const barCell = (v, mx, hot) => `${fmt(v)}`; 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) => `
${ti} ${meta}
`; const partialWarn = pm.length ? `${t('win.partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })}` : ''; let h = `
${t('flt.eyebrow')}
${winBanner(d, partialWarn)}
`; 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 += `

${t('flt.rate')}

${CH.rateChart(mo)}

${t('flt.rate_note')} — ${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] }) : ''}

`; h += `

${t('flt.pareto_n')}

${CH.paretoChart(F2.pareto_n, t('unit.n'), t('flt.pareto_n_note'))}
`; h += `

${t('flt.pareto_d')}

${CH.paretoChart(F2.pareto_d, ' h', t('flt.pareto_d_note'))}
`; h += `

${t('flt.quad')}

${CH.quadChart(F2.quad)}

${t('flt.quad_note', { n: F2.n_codes, m: (F2.quad || []).length })} ${pm.length ? `${t('flt.quad_partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })} ` : ''}${t('flt.quad_note2')}

`; const pt = F2.per_t || [], maxT = Math.max(1, ...pt.map(x => x.v)); h += `

${t('flt.per_t', { n: pt.length })}

${pt.map(x => ``).join('')}
${t('ui.unit')}${t('ui.count')}${t('flt.per_t_share')}
${unitNo(x.k)}${barCell(x.v, maxT, x.v / maxT > .6)}${(x.v / F2.total * 100).toFixed(1)}%

${t('flt.per_t_note', { n: fmt(F2.total) })}

`; if (d.m8 && d.m8.err) h += `

${t('flt.block_err', { k: 'm8', e: dv(d.m8.err) })}

`; if (m && d.m8.seasonal && d.m8.seasonal.length) h += `

${t('flt.seasonal')} (${t('flt.seasonal_unit')})

${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') })}

${t('flt.seasonal_note')}

`; 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 += `

${t('flt.ram')}

${CH.ramChart(RT.表, partPlain)}

${t('flt.ram_note', { a: RT.设备类停机h, b: RT.外部类停机h })}

${CH.attribBar(RT.表, RT.总停机h)}

${t('flt.attrib_note', { h: nore.停机时长h || 0 })}

`; } h += `

${t('flt.stop_pareto')}

${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'))}

${dv(m.口径)}

`; const top = m.台级top || [], mx = Math.max(1, ...top.map(x => x.时长h)); h += `

${t('flt.stop_units', { n: top.length })}

${top.map(x => ``).join('')}
${t('ui.unit')}${t('flt.stop_h')}${t('flt.stop_n')}
${unitNo(x.t)}${barCell(x.时长h, mx, x.时长h / mx > .6)}h${x.次}

${t('flt.stop_units_note')}

`; } return h + `
`; } // ── 电量算账: 四 KPI / 电量平衡瀑布 / 责任分账 / 工况态明细 (只出当期账; 控制参数件在发电性能页) ── function energy() { const d = D, E = (d.control || {}).energy, S = (d.control || {}).states || []; if (!E) return `

${t('ui.empty')}

`; let h = `
${fmt(E.theo)}MWh${t('en.kpi_theo')}
${fmt(E.act)}MWh${t('en.kpi_act', { h: fmt(E.eflh) })}
${fmt(E.loss_pct, 1)}%${t('en.kpi_loss', { v: fmt(E.loss) })}
${E.cap_ok ? t('en.cap_pass') : t('en.cap_fail')}${t('en.cap_note', { op: E.cap_ok ? '<' : '≥', cap: fmt(E.cap) })}

${t('en.balance')} ${t('en.win', { win: winName(d.win) })}

${CH.waterfall(E)}

${t('en.blame')}

${CH.blameBar(E)}

${t('en.blame_note', { n: fmt(Math.round(((E.items || []).find(x => x.k === '停机') || {}).loss || 0)) })}

`; const mxp = Math.max(1, ...S.map(x => x.pct)); h += `

${t('en.states')} ${t('en.win', { win: winName(d.win) })}

${S.map(x => ``).join('')}
${t('en.state')}${t('en.share')}${t('en.loss_mwh')}
${esc(CH.lossName(x.k))}${x.pct}% · ${fmt(Math.round(x.h))}h${fmt(Math.round(x.loss))}

${dv(d.control.anchors || '')}
${t('en.states_note')}

`; 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 ? `

${title} ${t('cmp.n_units', { n: list.length })}

${list.map(r => ``).join('')}
${t('ui.unit')}${t('cmp.basis')}${t('cmp.detail')}
${unitNo(r.t)}${dv(r.why)}${t('cmp.problem_page')}
` : ''; let h = `

${t('ui.back_to', { tab: t('tab.component') })}

${t('cmp.detail_head', { sys: sysName(s), n: N })}

${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => cnt(k) ? `` : '').join('')}
${t('cmp.counts', { a: cnt('报警'), u: cnt('不可判'), g: cnt('良好'), e: cnt('优秀') })}
`; h += tbl(t('cmp.alarm_units'), al) + tbl(t('cmp.unk_units'), un); if (gd.length) h += `
${t('cmp.good_units', { n: gd.length })}
${gd.map(r => ``).join('')}
${t('ui.unit')}${t('cmp.basis')}${t('cmp.detail')}
${unitNo(r.t)}${dv(r.why)}${t('cmp.problem_page')}
`; if (!al.length && !un.length && !gd.length) h += `

${t('cmp.none')}

`; return h + `
`; } function component() { const d = D; if (SYSV) return sysDetail(d, SYSV); const SD = d.sysdist || {}; // ★判级按所选时间窗重算是后台算(首次约 25 s)⇒ 先出上一口径并**在脸上写清**,轮询到就自动换掉 let h = (d.sysmx_pending ? `
${t('cmp.grading_pending')} ${t('cmp.grading_pending_why', { span: (d.sysmx_span || []).join(' ~ ') })}
` : '') + `
${t('cmp.by_system')}
` + 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 `
${sysName(s)}
${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => dist[k] ? `` : '').join('')}
${t('cmp.alarm_n', { a: `${a}` })}${u ? t('cmp.unk_n', { u }) : ''}${t('cmp.fleet_n', { n: N })}
`; }).join('') + `

${t('cmp.cards_note')}

`; if (d.rel && d.rel.err) h += `

${t('flt.block_err', { k: 'rel', e: dv(d.rel.err) })}

`; 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 += `
${t('cmp.rel_eyebrow', { win: dv(R.窗) })}

${t('cmp.rel_head')} ${t('cmp.rel_meta', { t: R.总停机h, a: R.设备类停机h, b: R.外部类停机h })}

${t('cmp.rel_note', { h: eh == null ? '' : ' ' + fmt(eh) + 'h' })}

${tb.map(r => { const sev = r.部件 === '无记录停机' ? 'var(--unknown)' : r.外部 ? 'var(--accent)' : (r.占停机 >= .06 ? 'var(--critical)' : r.占停机 >= .03 ? 'var(--warning)' : 'var(--good)'); return ``; }).join('')}
${t('cmp.rel_part')}${t('cmp.rel_h')}${t('cmp.rel_ev')}${t('cmp.rel_mtbo')}${t('cmp.rel_mdt')}${t('cmp.rel_units')}${t('cmp.rel_fm')}
${partName(r.部件)}${r.外部 ? ` ${t('cmp.external')}` : ''}${r.停机时长h}h · ${(r.占停机 * 100).toFixed(1)}%${r.事件数}${r.外部 ? '—' : (r.MTBF_h || '—')}${r.MDT_h}${r.影响台数}${dv(r.典型失效模式 || '—')}

${dv(R.口径)}

`; } if (d.fus && !d.fus.err) h += `

${t('cmp.fusion_moved')} ${t('tab.vibration')} ${t('cmp.fusion_n', { n: (d.fus.表 || []).length })}

`; h += `
${t('cmp.cross_eyebrow')}

${t('cmp.watch_head', { n: (d.watch || []).length })}

${(d.watch || []).map(w => ``).join('')}
${t('ui.unit')}${t('cmp.n_sys')}${t('cmp.hit_sys')}${t('cmp.basis')}
${unitNo(w.t)}${w.n}${(w.syss || []).map(x => `${sysName(x)}`).join(' · ')}${dv(w.why)}
`; 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 `
${t('mt.eyebrow')}

${t('mt.surveying')}

`; const md = MT.data, rows = md.rows || []; if (md.error) return `
${t('mt.eyebrow')}

${dv(md.error)}

`; let h = `
${t('mt.eyebrow')}

${t('mt.head', { n: rows.length })}

${t('mt.note')}

`; // 层字段在 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 += `

${t(kn)} (${rs.length})

${t(kd)}

${rs.map(r => ``).join('')}
${t('mt.src')}${t('mt.cov')}${t('mt.last')}${t('mt.freq')}${t('mt.resp')}${t('mt.how')}
${r.存在 ? '' : '✗ '}${publicMeta(r.名, 'name')}${publicMeta(r.覆盖, 'coverage')}${r.条数 ? `${t('mt.rows_n', { n: fmt(r.条数) })}` : ''}${esc(r.末次更新 || '—')}${publicMeta(r.频率, 'freq')}${publicMeta(r.责任, 'responsible')}
${t('mt.put')} ${publicMeta(r.位置, 'path')}
${t('mt.run')} ${publicMeta(r.摄入, 'run')}
${publicMeta(r.说明, 'note')}
`; }); return h + `
`; } 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 = `
${t('gen.eyebrow')}
${t('gen.fixed_win')} ${sp ? t('gen.win_range', { span: sp }) : ''}
${t('gen.win_why')}
`; if (d.m9 && d.m9.err) h += `

${t('flt.block_err', { k: 'm9', e: dv(d.m9.err) })}

`; if (M9) { const b9 = M9.β_schedule || {}, outs = b9.离群 || []; h += `

${t('gen.ctrl')} ${t('gen.ctrl_win', { win: dv(M9.窗) })}${d.m9_pending ? ` ${t('gen.recomputing_short')}` : ''}

${t('gen.cap')}
${CH.capAxis(M9.P封顶, M9.ω封顶)}

${t('gen.cap_note')}

${t('gen.beta')}
${CH.betaBar(b9, unitNo)}

${t('gen.beta_note', { units: outs.map(x => `${unitNo(x.t)}`).join(' / '), k: outs.length ? Math.round(Math.max(...outs.map(x => x.平均偏)) / Math.max(b9.全场同档差中位 || 0, .01)) : '—', v: dv(b9.判) })}

${t('gen.ruler')}
${CH.rulerBar(M9.尺子)}

${t('gen.ruler_note')}

`; } if (F && F.curve) h += `

${t('gen.cands')} ${t('gen.cands_meta', { note: dv(F.curve.note || ''), s: F.sigma || F.curve.sigma })}

${(F.curve.cands || []).map(c => `${unitNo(c.t)} ${c.dev}% ${dv(c.判)}`).join(' | ') || t('gen.no_cands')}

`; h += `
${t('gen.figs')}
`; if (GEN.err) h += `

${esc(GEN.err)}

`; else if (GEN.building) h += `

${t('gen.recomputing')} ${dv((GEN.curves && GEN.curves.note) || '')}

`; else if (!GEN.curves) h += `

${t('gen.loading')}

`; else h += `
` + (GEN.curves.figs || []).map(f => `

${dv(f.title || '')}${f.unit ? ` (${dv(f.unit)})` : ''}

${CH.multiline(Object.assign({ zero: false }, f))}${f.note ? `

${dv(f.note)}

` : ''}
`).join('') + `
`; return h + `
`; } 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, '中间轴'], [/(? { 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/') .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 `${REVIEW_REQUIRED}`; 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) => `${st}${ev}`; 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 ``; }; const FAMCO = { '齿轮箱': '#8A6D3B', '发电机': '#4A6FA5', '主轴承': '#2E7D6B', '变桨叶片': '#4A6FA5', '偏航': '#2E7D6B', '测风': '#7C5295', '其他': '#5A6470' }; const famBar = (fam, n, maxN) => { if (!n) return `${t('fus.no_alarm')}`; const W = 118, w = Math.max(6, Math.round(n / Math.max(maxN || n, 1) * W)); return `
${(fam || []).map(f => ``).join('')}
${(fam || []).slice(0, 2).map(f => sysName(f.k) + ' ' + f.n).join(' · ')}
`; }; const baBar = (pre, post, col) => { const M = Math.max(pre, post, 7), W = 120, sc = v => Math.max(2, v / M * (W - 4)); return ``; }; const winBar = (spans, raw) => { if (!spans || !spans.length) return `${t('fus.win_none')}`; 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 `
${spans.map(([a, b]) => { const x1 = px(a), x2 = px(b); return ``; }).join('')}
${t('fus.win_n', { n: spans.length })}
`; }; const devBar = (ch, dev, vd, raw) => { if (dev == null) return `${dv(displayText(raw).slice(0, 30))}`; 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 `
${over ? `` : ''}${dev > 0 ? '+' : ''}${dev}K
${esc(ch || '')}
`; }; const oilPill = (last, fresh, raw) => `
${esc(last || t('fus.oil_none'))}
${last ? (fresh ? t('fus.oil_fresh') : t('fus.oil_stale')) : '—'}
`; 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 `
${[0, 1, 2, 3].map(i => `= n ? 'stroke-dasharray="2 1.5"' : ''}/>`).join('')}${dv(T)}
`; }; // 传动链状态链: 用一排小格代替经典的 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 `${CHAIN7.map(([k, re]) => { const on = re.test(c) || (gball && ['ps', 'ims', 'hss'].includes(k)); return ``; }).join('')}`; }; const chainStates = (map) => `${Object.entries(map).map(([k, col]) => ``).join('')}`; const gc = st => STC[stateKey(st)] || null; function vibration() { const d = D, F = d.fus; if (!F || F.err) return `

${t('fus.unavailable', { e: dv((F && F.err) || '') })}

`; const SUBS = ['matrix', 'loop', 'units', 'events', 'cms']; let h = `
${t('fus.eyebrow')}
${SUBS.map(s => ``).join('')}
`; 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 + `
`; } function fusMatrix(F) { const months = (F.列窗 || {}).months || []; let h = `
${t('fus.rules_head')}

${t('fus.rule1')}
${t('fus.rule2')}

`; h += `

${t('fus.matrix_head')}

${t('fus.matrix_note1')}

${t('fus.matrix_note2', { n: months.length, m0: months[0] || '?' })}

${t('fus.legend_state')} ${['bad', 'warn', 'ok', 'ex', 'unk'].map(k => `${t(stKey(k))}`).join('')}
${t('fus.legend_tier')} ${['定论', '准定论·预警', '候选·新发', '候选', '候选·记基线', '参考', 'INSUFFICIENT'].map(k => `${tierWord(k)}`).join('')}
${t('fus.legend_note')}
`; 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) => `
${name}
${dv(shortWin(win))}${extra || ''}
`; const LW = F.列窗 || {}; h += `
${''.repeat(6)}${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.工单)}`; 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 ``; 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 ``; }; 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 += `${cell(r.振动)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)} `; }); return h + `
${t('ui.unit')}
${shortOf(c)}
${shortCut}
${ev}
${spark(c.hist, c.level === 'bad' ? 'var(--critical)' : c.level === 'warn' ? 'var(--warning)' : 'var(--muted)')}
${unitNo(r.turbine)}${t('fus.alarm_n', { n: al.n })}${al.rank ? `${t('fus.alarm_rank', { r: al.rank, t: al.tot })}` : ''}${bu ? `
${t('fus.burst', { p: (bu.share * 100).toFixed(0), c: bu.code })}
` : ''}${(al.rank && al.rank <= 10) || bu ? famBar(al.fam, al.n, MAXAL) : ''}
${wo.n ? `${t('fus.wo_n', { n: wo.n })}${wo.last ? ` · ${esc(wo.last)}` : ''}
${(wo.acts || []).slice(0, 2).map(a => dv(a.k) + '×' + a.n).join(' · ')}
` : `—`}

${t('fus.matrix_foot')}

`; } function fusLoop(F) { const LP = (F.能力 && F.能力.loop && F.能力.loop['换件闭环 (检出→换件→恢复)']) ? F.能力.loop : null; if (!LP) return `

${t('fus.loop_none')}

`; const cl = LP['换件闭环 (检出→换件→恢复)'] || [], up = LP['当前在升 (无工单可对)'] || [], neg = LP['负面对照 (工单后持平)']; return `

${t('fus.loop_head')}

${t('fus.loop_note')}

${t('fus.loop_closed', { n: cl.length })}

${cl.map(r => ``).join('')}
${t('ui.unit')}${t('fus.col.part')}${t('fus.loop_item')}${t('fus.loop_date')}${t('fus.loop_ba')}${t('fus.loop_ratio')}${t('fus.loop_verdict')}
${unitNo(r.turbine)}${chainMini(r.component + (r.title || ''), 'var(--good)')} ${partName(r.component)}${dv(r.title)}${esc(r.replace_date)}${baBar(r.pre, r.post, 'var(--good)')}${r.pre} → ${r.post}${r.ratio}×${dv(r.verdict)}

${t('fus.loop_neg', { n: neg })}

${t('fus.loop_rising', { n: up.length })}

${up.map(r => ``).join('')}
${t('ui.unit')}${t('fus.col.part')}${t('fus.loop_el')}${t('fus.loop_x')}${t('fus.loop_state')}
${unitNo(r.turbine)}${chainMini(r.component, 'var(--warning)')} ${partName(r.component)}${baBar(r.early, r.late, 'var(--warning)')}${r.early} → ${r.late}${r.ratio}×${t('rpt.units.rising', { r: r.ratio })}

${t('fus.loop_bound', { b: dv(String(LP['★数据边界'] || '').split('**').join('')) })}

`; } 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 `

${t('fus.unlisted_head', { n: un.length })} ${t('fus.clear')}

${un.map(r => ``).join('')}
${t('ui.unit')}${t('fus.temp_now')}${t('fus.lub_now')}
${unitNo(r.turbine)}${dv(vibPlain((r.温度 || {}).note))}${dv(vibPlain((r.润滑 || {}).note))}
`; } 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) }) + ` ${t('fus.clear')}` : ''; let h = `

${t('fus.units_head', { u: new Set(tb.map(x => x.turbine)).size, n: rows.length + (FUS.filter ? '/' + tb.length : ''), f: filt })}

${['bad', 'warn', 'note', 'ok'].map(k => ``).join('')}
${t('fus.eval_head')}
${t('fus.eval_state')}${t('fus.eval_tier')}${t('fus.eval_meaning')}
${stateChip('危险')}${t('tier.confirmed.s')}${t('fus.m.bad')} (${t('fus.m.bad_e')})
${stateChip('报警')}${t('tier.near')} / ${t('tier.candidate_new')}${t('fus.m.warn')} (${t('fus.m.warn_e')})
${stateChip('良好')}${t('tier.candidate')} / ${t('tier.candidate_baseline')}${t('fus.m.ok')} (${t('fus.m.ok_e')})
${stateChip('优秀')}${t('tier.reference')} / ${t('tier.normal')} / ${t('tier.withdrawn')}${t('fus.m.ex')} (${t('fus.m.ex_e')})
${stateChip('不可判')}${t('tier.insufficient')}${t('fus.m.unk')} (${t('fus.m.unk_e')})

${t('fus.eval_note')}

`; rows.forEach(r => { const sev = CLV[r.级] || 'var(--accent)', col = CST[r.设备状态] || 'var(--muted)'; h += ``; }); return h + `
${t('ui.unit')}${t('fus.col.part')}${t('fus.col.state_concl')}${t('fus.col.claim')} ${t('fus.col.claim_axis')}${t('fus.col.signal')}${t('fus.col.tempaxis')} ${t('fus.col.tempaxis_note')}${t('fus.col.oil')}
${unitNo(r.turbine)}${chainMini(r.部件, sev)} ${partName(r.部件)} ${duo(stWord(r.设备状态 || '—'), dv(r.振动结论), col)}${winBar(r.窗区间, r.claim_window)} ${dv(vibPlain(r.振动信号))}${devBar(r.温度通道, r.温度dev, r.温度判, r.温度轴)}${oilPill(r.油末样, r.油新鲜, r.油样轴)}
`; } function fusEvents(F, d) { if (!F.事件) return `

${t('ui.empty')}

`; let h = `

${t('fus.ev_win', { win: winName(d.win) })}

`; for (const [comp, E] of Object.entries(F.事件)) { const A = E.报警 || {}, W = E.工单 || {}; h += `

${t('fus.ev_head', { c: sysName(comp), an: A.总条数 || 0, au: A.台数 || 0, wn: W.总条数 || 0, wu: W.台数 || 0 })}

`; if (A.总条数 > 0) { h += `
${(A.码统计 || []).map(c => ``).join('')}
${t('fus.ev_code')}${t('fus.ev_text')}${t('ui.count')}${t('fus.ev_units')}${t('fus.ev_dur')}
${esc(c.code)}${dv(vibPlain(c.text))}${c.条数}${c.台数}${c.时长h}

${t('fus.ev_top')}${Object.entries(A.top台 || {}).map(([u, n]) => `${unitNo(u)}×${n}`).join(' · ')}

${t('fus.ev_recent', { n: (A.近况 || []).length })}
${(A.近况 || []).map(x => ``).join('')}
${t('ui.unit')}${t('fus.ev_time')}${t('fus.ev_code')}${t('fus.ev_text')}${t('fus.ev_min')}
${unitNo(x.t)}${esc(x.on)}${esc(x.code)}${dv(vibPlain(x.text))}${x.dur_min ?? '—'}
`; } else h += `

${t('fus.ev_no_alarm')}

`; if (W.总条数 > 0) h += `
${(W.明细 || []).map(x => ``).join('')}
${t('ui.unit')}${t('ui.date')}${t('fus.ev_wo_name')}${t('fus.ev_wo_act')}${t('fus.ev_wo_cause')}
${unitNo(x.t)}${esc(x.date)}${dv(x.name)}${dv(x.act)}${dv(x.cause)}
`; else h += `

${t('fus.ev_no_wo')}

`; h += `
`; } return h; } function fusCms() { if (FUS.cmsErr) return `

${esc(FUS.cmsErr)}

`; if (!FUS.cms) return `

${t('fus.cms_loading')}

`; const V = FUS.cms; if (V.error) return `

${dv(V.error)}

`; 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 `

${t('fus.cms_head', { d: esc(V.date || '') })}

${V['时间范围'] ? `

${t('fus.cms_window', { r: esc(V['时间范围']) })}

` : ''}

${dv(V.overall || '')} ${t('fus.cms_note')}

${t('fus.cms_action')}

${(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 ``; }).join('')}
${t('ui.unit')}${t('fus.loop_state')}${t('fus.cms_focus')} ${t('fus.cms_focus_note')}${t('fus.cms_evidence')}${t('fus.cms_act')}
${unitNo(r[0])}${cp(r[1])}${chainStates(cm)} ${dv(r[2])}${evLadder(r[3])}${/P0/.test(r[4]) ? `${esc(r[4])}` : esc(r[4])}

${t('fus.cms_grades')}

${(V.grades || []).map(g => ``).join('')}
${t('ui.unit')}${t('fus.cms_chain')} ${t('fus.cms_chain_note')}${t('fus.cms_overall')}${t('fus.cms_act')}
${unitNo(g.t)}${chainStates({ mbf: gc(g.主轴承前), mba: gc(g.主轴承后), gb: gc(g.齿轮箱), gen: gc(g.发电机) })}${cp(g.综合)}${esc(g.行动)}
${t('fus.cms_basis')} ${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') })}

${t('fus.cms_link')} ${t('fus.cms_open')} ${t('fus.cms_static')}

`; } 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/ 与 /problem// 是**服务端独立页**, 静态站上是 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 = `

${title}

`; if (!ps.length) h += `

${t('pop.none')}

`; ps.forEach(p => { h += `
${sysName(p.sys)}${stateChip(p['状态'])}

${dv(p['依据'])}

`; const figs = p.figs || []; if (figs.length) h += `
` + figs.map(f => `

${dv(f.title || '')}${f.unit ? ` (${dv(f.unit)})` : ''}

${CH.fig(f)}${f.note ? `

${dv(f.note)}

` : ''}
`).join('') + `
`; const o = p.orders || {}; if (o.n) { h += `
${o.n === 1 ? t('pop.orders_one') : t('pop.orders', { n: o.n })}` + (o['动作统计'] ? `
${t('pop.acts')}${Object.entries(o['动作统计']).map(([k, v]) => `${dv(k)} ×${v}`).join('')}
` : '') + `
` + (o.rows || []).map(r => ``).join('') + `
${t('pop.o_date')}${t('pop.o_fault')}${t('pop.o_check')}${t('pop.o_cause')}${t('pop.o_act')}
${esc(r['日期'] || '')}${recordValue(r, '故障')}${recordValue(r, '排查')}${recordValue(r, '原因')}${recordValue(r, '处置')}
`; } else h += `

${t('pop.orders_none')}

`; h += `
`; }); const b = popBox(); b.innerHTML = h + (VERB > verbStart ? `` : '') + `
`; 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 = `

${title}

${t('pop.loading')}

`; 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] ? `

${t('tab.' + TAB)}

${t('win.label')} · ${winName(WIN)}
` + PAGES[TAB]() : `

${t('ui.not_ported')}${SNAP ? '' : ` ${t('ui.open_classic')}`}

`; 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 ? ` ${t('ui.data_hidden', { n: HIDDEN })}` : ''); 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 = `
${t('ui.error')} ${esc(e.message)}
`; console.error(e); }); })();