/* 观澜 新前端 —— 真正的 .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 ? `${t('win.custom_sel', { a: WIN.split('~')[0], b: WIN.split('~')[1] })} ` : '';
const opts = `` + WIN_PRESETS.filter(([w]) => !BAKED || BAKED.includes(w)).map(([w]) => `${winName(w)} `).join('') + ` `
+ (custOpt ? `${custOpt} ` : '')
+ (months.length ? `` + months.slice().reverse().map(m => `${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}
`;
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 => `${t('tab.' + 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 `
${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) => `${SYSOPT.map(s => `${sysName(s)} `).join('')} `;
const btn = (id) => `${t('ui.query')} `;
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')}
${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')})
${t('asst.node')} ${t('asst.check')}
${st.nodes.map(n => `${dv(n.name)} ${recordValue(n, 'check')} `).join('')}
${(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')}
${t('asst.spec_no')} ${t('asst.spec_name')} ${t('asst.spec_val')} ${t('asst.spec_range')}
${st.specs.map(sp => `${esc(sp.no)} ${dv(sp.text)} ${sp.val != null ? esc(sp.val) + esc(sp.unit || '') : '—'} ${sp.lo != null ? `${esc(sp.lo)} ~ ${esc(sp.hi)}` : '—'} `).join('')}
` : ''}
${(st.drawings && st.drawings.length) ? `
${t('asst.drawings')} ${st.drawings.map(dw => `
· ${recordValue(dw, 'name')}
`).join('')}
` : ''}
`).join('') + `
`;
} else h += ``;
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) })}
${t('ui.date')} ${t('ui.unit')} ${t('asst.wo_fault')} ${t('asst.wo_action')}
${x.workorders.map(w => `${esc(w.date)} ${esc(unitNo(w.turbine))} ${recordValue(w, 'name')} ${dv(w.action)} ${dv(w.part)} `).join('')}
${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]) => `${t('cat.' + 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 => `${esc(mdlLabel(k))} `).join('')
: `${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')} `;
return ``;
}
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 })} ${hits.map((h, i) => `${esc(h.c.claim_id)} · ${esc(h.c.verdict)} · ${esc((h.c.title || '').slice(0, 90))}${cites[i] ? `${esc(cites[i])}
` : ''} `).join('')} `;
} 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) })}
claim ${t('rpt.facts.verdict')} ${t('rpt.facts.text')} ${t('rpt.facts.sys')} ${t('rpt.facts.agg')} ${rows}
`; }
// 判别值 (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 ? `${t('rpt.emon.m')} ${t('rpt.emon.act')} ${t('rpt.emon.loss')} ${t('rpt.emon.pct')} ${t('rpt.emon.eflh')} ${t('rpt.emon.cov')} ${em.map(x => `${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') : ''} `).join('')}
` : '—', 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'), `${t('rpt.parts.part')} ${t('rpt.parts.h')} ${t('rpt.parts.ev')} ${t('rpt.parts.mtbf')} ${t('rpt.parts.mdt')} ${t('rpt.parts.units')} ${R.表.slice(0, 9).map(x => `${partName(x.部件)}${x.外部 ? t('rpt.parts.ext') : ''} ${x.停机时长h} ${x.事件数} ${x.MTBF_h || '—'} ${x.MDT_h} ${x.影响台数} `).join('')}
`, 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}
${t('ui.unit')} ${t('rpt.units.state')} ${t('rpt.units.ev')} ${t('rpt.units.prog')} ${t('rpt.units.at')}
${rows.map(r => `${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')} `).join('')}
`, 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'), `${t('ui.unit')} ${t('rpt.loop.comp')} ${t('rpt.loop.date')} ${t('rpt.loop.pre')} ${t('rpt.loop.post')} ${t('rpt.loop.ratio')} ${cl.map(x => `${unitNo(x.turbine)} ${dv(x.component)} ${esc(x.replace_date)} ${x.pre} ${x.post} ${x.ratio}× `).join('')}
${t('rpt.loop.note')}
`, t('rpt.loop.cal')); }
if (k === 'plan') return wrap(t('rpt.plan.title'), `${t('rpt.plan.1', { n: (BD.stuck || {}).机制 || 0 })} ${t('rpt.plan.2')} ${t('rpt.plan.3')} ${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'), `${t('rpt.cal.period')} ${winName(d.win)}${part.length ? t('rpt.cal.partial', { m: part.join('/'), c: Math.round((F.cov_min || 0) * 100) }) : ''}${t('rpt.cal.denom')} ${t('rpt.cal.denom_d')}${t('rpt.cal.stop')} ${t('rpt.cal.stop_d')}${t('rpt.cal.wo')} ${t('rpt.cal.wo_d')}${t('rpt.cal.rating')} ${t('rpt.cal.rating_d')} ${RPT.aud === 'up' ? `${t('rpt.cal.deid')} ${t('rpt.cal.deid_d')} ` : ''} `, ''); }
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 => `${esc(mdlLabel(k))} `).join('') : `${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')} `;
const canCompose = RPT.compose === true && models && Object.keys(models).length;
let h = `${t('rpt.eyebrow')}
${t('rpt.presets')}
${Object.keys(RPT_PRESETS).map(k => `${t('rpt.preset.' + k)} `).join('')}
${t('rpt.aud_in')} ${t('rpt.aud_up')}
${t('rpt.print')} ${t('rpt.export_docx')} ${t('rpt.export_xlsx')}
${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 => `${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')}${on(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')} `) : ''}
${opts} ${t('rpt.compose')}
${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) => `
${s} `;
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 })} ${t('ui.unit')} ${t('ui.count')} ${t('flt.per_t_share')} ${pt.map(x => `${unitNo(x.k)} ${barCell(x.v, maxT, x.v / maxT > .6)} ${(x.v / F2.total * 100).toFixed(1)}% `).join('')}
${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 })} ${t('ui.unit')} ${t('flt.stop_h')} ${t('flt.stop_n')} ${top.map(x => `${unitNo(x.t)} ${barCell(x.时长h, mx, x.时长h / mx > .6)}h ${x.次} `).join('')}
${t('flt.stop_units_note')}
`;
}
return h + `
`;
}
// ── 电量算账: 四 KPI / 电量平衡瀑布 / 责任分账 / 工况态明细 (只出当期账; 控制参数件在发电性能页) ──
function energy() {
const d = D, E = (d.control || {}).energy, S = (d.control || {}).states || [];
if (!E) return ``;
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) })} ${t('en.state')} ${t('en.share')} ${t('en.loss_mwh')}
${S.map(x => `${esc(CH.lossName(x.k))} ${x.pct}% · ${fmt(Math.round(x.h))}h ${fmt(Math.round(x.loss))} `).join('')}
${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 })} ` : '';
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 })} `;
if (!al.length && !un.length && !gd.length) h += ``;
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' })}
${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')}
${tb.map(r => { const sev = r.部件 === '无记录停机' ? 'var(--unknown)' : r.外部 ? 'var(--accent)' : (r.占停机 >= .06 ? 'var(--critical)' : r.占停机 >= .03 ? 'var(--warning)' : 'var(--good)');
return `${partName(r.部件)} ${r.外部 ? ` ${t('cmp.external')} ` : ''}${r.停机时长h}h · ${(r.占停机 * 100).toFixed(1)}% ${r.事件数} ${r.外部 ? '—' : (r.MTBF_h || '—')} ${r.MDT_h} ${r.影响台数} ${dv(r.典型失效模式 || '—')} `; }).join('')}
${dv(R.口径)}
`; }
if (d.fus && !d.fus.err) h += ``;
h += `${t('cmp.cross_eyebrow')}
${t('cmp.watch_head', { n: (d.watch || []).length })} ${t('ui.unit')} ${t('cmp.n_sys')} ${t('cmp.hit_sys')} ${t('cmp.basis')}
${(d.watch || []).map(w => `${unitNo(w.t)} ${w.n} ${(w.syss || []).map(x => `${sysName(x)} `).join(' · ')} ${dv(w.why)} `).join('')}
`;
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 ``;
const md = MT.data, rows = md.rows || [];
if (md.error) return ``;
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)}
${t('mt.src')} ${t('mt.cov')} ${t('mt.last')} ${t('mt.freq')} ${t('mt.resp')} ${t('mt.how')}
${rs.map(r => `${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')}
`).join('')}
`; });
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 += ``;
else if (GEN.building) h += `${t('gen.recomputing')} ${dv((GEN.curves && GEN.curves.note) || '')}
`;
else if (!GEN.curves) h += ``;
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 => `${t('fus.sub.' + 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)}${t('ui.unit')} ${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 `${shortOf(c)} `;
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 `${spark(c.hist, c.level === 'bad' ? 'var(--critical)' : c.level === 'warn' ? 'var(--warning)' : 'var(--muted)')} `; };
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 += `${unitNo(r.turbine)} ${cell(r.振动)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)}
${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(' · ')}
` : `— `} `; });
return h + `
${t('fus.matrix_foot')}
`;
}
function fusLoop(F) {
const LP = (F.能力 && F.能力.loop && F.能力.loop['换件闭环 (检出→换件→恢复)']) ? F.能力.loop : null;
if (!LP) return ``;
const cl = LP['换件闭环 (检出→换件→恢复)'] || [], up = LP['当前在升 (无工单可对)'] || [], neg = LP['负面对照 (工单后持平)'];
return `${t('fus.loop_head')} ${t('fus.loop_note')}
${t('fus.loop_closed', { n: cl.length })} ${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')}
${cl.map(r => `${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)} `).join('')}
${t('fus.loop_neg', { n: neg })}
${t('fus.loop_rising', { n: up.length })} ${t('ui.unit')} ${t('fus.col.part')} ${t('fus.loop_el')} ${t('fus.loop_x')} ${t('fus.loop_state')}
${up.map(r => `${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 })} `).join('')}
${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')} ${t('ui.unit')} ${t('fus.temp_now')} ${t('fus.lub_now')} ${un.map(r => `${unitNo(r.turbine)} ${dv(vibPlain((r.温度 || {}).note))} ${dv(vibPlain((r.润滑 || {}).note))} `).join('')}
`; }
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 => `${t('fus.lv.' + k)} `).join('')}${t('fus.unlisted_link', { n: mat.filter(r => (r.振动 || {}).level === 'unlisted').length })}
${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')}
${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')} `;
rows.forEach(r => { const sev = CLV[r.级] || 'var(--accent)', col = CST[r.设备状态] || 'var(--muted)';
h += `${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.油样轴)} `; });
return h + `
`;
}
function fusEvents(F, d) {
if (!F.事件) return ``;
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 += `
${t('fus.ev_code')} ${t('fus.ev_text')} ${t('ui.count')} ${t('fus.ev_units')} ${t('fus.ev_dur')} ${(A.码统计 || []).map(c => `${esc(c.code)} ${dv(vibPlain(c.text))} ${c.条数} ${c.台数} ${c.时长h} `).join('')}
${t('fus.ev_top')}${Object.entries(A.top台 || {}).map(([u, n]) => `${unitNo(u)} ×${n}`).join(' · ')}
${t('fus.ev_recent', { n: (A.近况 || []).length })} ${t('ui.unit')} ${t('fus.ev_time')} ${t('fus.ev_code')} ${t('fus.ev_text')} ${t('fus.ev_min')} ${(A.近况 || []).map(x => `${unitNo(x.t)} ${esc(x.on)} ${esc(x.code)} ${dv(vibPlain(x.text))} ${x.dur_min ?? '—'} `).join('')}
`; }
else h += `
${t('fus.ev_no_alarm')}
`;
if (W.总条数 > 0) h += `
${t('ui.unit')} ${t('ui.date')} ${t('fus.ev_wo_name')} ${t('fus.ev_wo_act')} ${t('fus.ev_wo_cause')} ${(W.明细 || []).map(x => `${unitNo(x.t)} ${esc(x.date)} ${dv(x.name)} ${dv(x.act)} ${dv(x.cause)} `).join('')}
`;
else h += `
${t('fus.ev_no_wo')}
`;
h += `
`; }
return h;
}
function fusCms() {
if (FUS.cmsErr) return ``;
if (!FUS.cms) return ``;
const V = FUS.cms; if (V.error) return ``;
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')} ${t('ui.unit')} ${t('fus.loop_state')} ${t('fus.cms_focus')} ${t('fus.cms_focus_note')} ${t('fus.cms_evidence')} ${t('fus.cms_act')}
${(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 `${unitNo(r[0])} ${cp(r[1])} ${chainStates(cm)} ${dv(r[2])} ${evLadder(r[3])} ${/P0/.test(r[4]) ? `${esc(r[4])} ` : esc(r[4])} `; }).join('')}
${t('fus.cms_grades')} ${t('ui.unit')} ${t('fus.cms_chain')} ${t('fus.cms_chain_note')} ${t('fus.cms_overall')} ${t('fus.cms_act')}
${(V.grades || []).map(g => `${unitNo(g.t)} ${chainStates({ mbf: gc(g.主轴承前), mba: gc(g.主轴承后), gb: gc(g.齿轮箱), gen: gc(g.发电机) })} ${cp(g.综合)} ${esc(g.行动)} `).join('')}
${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} ${t('pop.close')} `;
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('')}
` : '')
+ `
${t('pop.o_date')} ${t('pop.o_fault')} ${t('pop.o_check')} ${t('pop.o_cause')} ${t('pop.o_act')} `
+ (o.rows || []).map(r => `${esc(r['日期'] || '')} ${recordValue(r, '故障')} ${recordValue(r, '排查')} ${recordValue(r, '原因')} ${recordValue(r, '处置')} `).join('') + `
`; }
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.close')} ${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]()
: ``;
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); });
})();