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