vib.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * 振动融合(vibration)子视图的**拼装**(P11 迁移)。
  3. *
  4. * ★ 口径:旧 `app.js` 的这些视图本来就是拼 HTML 字符串;这里**逐字转写**并用 `v-html` 渲染,
  5. * 于是 DOM 与文本节点边界与重构前一致 —— 严格对拍要的正是这个(不是"看起来差不多")。
  6. * 本文件当前只含 `matrix` 子视图(默认),其余子视图(loop/units/events/cms)待迁。
  7. */
  8. import { t } from '../i18n/zh'
  9. import { unitNo, winName } from './render'
  10. import { partName, sysName } from './render2'
  11. import { VIBJARGON } from './vibjargon'
  12. export const esc = (s: unknown): string => String(s == null ? '' : s)
  13. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  14. /** 旧 `displayText(s)`(app.js:36-43):英文页面走译表,中文口径**原样返回** */
  15. export const displayText = (s: unknown): string => String(s == null ? '' : s)
  16. /** 旧 `dv()`(中文口径 = esc) */
  17. export const dv = (s: unknown): string => esc(s)
  18. const ST: Record<string, string> = { 危险: 'bad', 报警: 'warn', 不可判: 'unk', 良好: 'ok', 优秀: 'ex' }
  19. const ST_ALIAS: Record<string, string> = {
  20. critical: 'bad', alarm: 'warn', undeterminable: 'unk', indeterminate: 'unk', 'not evaluable': 'unk',
  21. good: 'ok', excellent: 'ex', warn: 'warn', bad: 'bad', ok: 'ok', unk: 'unk', ex: 'ex',
  22. }
  23. const stateKey = (v: unknown): string | undefined => ST[String(v)] || ST_ALIAS[String(v)]
  24. export const STC: Record<string, string> = { bad: 'var(--critical)', warn: 'var(--warning)', unk: 'var(--unknown)', ok: 'var(--good)', ex: 'var(--excellent)' }
  25. const CST: Record<string, string> = { 优秀: 'var(--excellent)', 良好: 'var(--good)', 报警: 'var(--warning)', 危险: 'var(--critical)', 不可判: 'var(--unknown)', 监测不可用: 'var(--unknown)', 无监测: 'var(--unknown)' }
  26. const CLV: Record<string, string> = { bad: 'var(--critical)', warn: 'var(--warning)', note: 'var(--muted)', ok: 'var(--good)', unlisted: 'var(--muted)', stale: 'var(--muted)', meta: 'var(--muted)' }
  27. const TIER: Record<string, string> = {
  28. 定论: 'confirmed', '准定论·预警': 'near', '候选·新发': 'candidate_new', 候选: 'candidate',
  29. '候选·记基线': 'candidate_baseline', 参考: 'reference', INSUFFICIENT: 'insufficient', 正常: 'normal', 撤回: 'withdrawn',
  30. }
  31. export const stKey = (k: string): string => 'st.' + (k === 'ex' ? 'excellent' : k)
  32. export const stWord = (zh: unknown): string => {
  33. const k = stateKey(zh)
  34. return k ? t(stKey(k)) : dv(zh)
  35. }
  36. /** 旧 `tierWord`(中文分支=语言包有则取,否则 dv) */
  37. export const tierWord = (zh: unknown): string => {
  38. const k = TIER[String(zh)]
  39. const key = 'tier.' + k
  40. return k ? t(key) : (String(zh) === '—' ? '—' : dv(zh))
  41. }
  42. export const duo = (st: string, ev: string, col: string): string =>
  43. `<span class="duo" style="--c:${col}"><span class="st">${st}</span><span class="ev">${ev}</span></span>`
  44. /** 旧 `vibPlain`:中文口径下逐条替换技术速记 */
  45. export const vibPlain = (x: unknown): string => {
  46. let s = String(x == null ? '' : x)
  47. for (const [a, b] of VIBJARGON) s = s.replace(a, b)
  48. return s
  49. }
  50. export const attrPlain = (x: unknown): string => vibPlain(x)
  51. /** 旧 `spark`(历史曲线小图,逐字转写) */
  52. const spark = (hist: number[] | undefined, color: string): string => {
  53. if (!hist || hist.filter((x) => x != null).length < 3) return ''
  54. const vs = hist.filter((x) => x != null)
  55. const mn = Math.min(...vs), mx = Math.max(...vs), sp = (mx - mn) || 1
  56. const W = 84, H = 22, n = hist.length
  57. let d = '', last: number[] | null = null
  58. hist.forEach((v, i) => {
  59. if (v == null) return
  60. const x = i / (n - 1) * W, y = H - 2 - (v - mn) / sp * (H - 4)
  61. d += (d ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1)
  62. last = [x, y]
  63. })
  64. 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 as number[])[0]}" cy="${(last as number[])[1]}" r="2.4" fill="${color}"/></svg>`
  65. }
  66. const FAMCO: Record<string, string> = { 齿轮箱: '#8A6D3B', 发电机: '#4A6FA5', 主轴承: '#2E7D6B', 变桨叶片: '#4A6FA5', 偏航: '#2E7D6B', 测风: '#7C5295', 其他: '#5A6470' }
  67. const famBar = (fam: any[] | undefined, n: number, maxN: number): string => {
  68. if (!n) return `<span class="small muted">${t('fus.no_alarm')}</span>`
  69. const W = 118, w = Math.max(6, Math.round(n / Math.max(maxN || n, 1) * W))
  70. 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>`
  71. }
  72. /** 旧 `fusMatrix(F)` —— 逐字转写(默认子视图) */
  73. export function matrixHtml(F: any): string {
  74. const months = ((F.列窗 || {}).months) || []
  75. 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>`
  76. 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>
  77. <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>
  78. <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>`
  79. const shortWin = (w: unknown): string => {
  80. const s = String(w || '')
  81. 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]/)
  82. return m ? (m[0].startsWith('至') ? t('win.through', { date: m[0].replace(/^至\s*/, '') }) : m[0]) : s.slice(0, 14)
  83. }
  84. const th = (name: string, win: string, extra?: string): string =>
  85. `<th title="${esc(win || '')}${extra || ''}"><div>${name}</div><div class="small muted" style="font-weight:400">${dv(shortWin(win))}${extra || ''}</div></th>`
  86. const LW = F.列窗 || {}
  87. 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>`
  88. const mat = F.矩阵 || []
  89. const MAXAL = Math.max(1, ...mat.map((r: any) => (r.报警 || {}).n || 0))
  90. const MAXWO = Math.max(1, ...mat.map((r: any) => (r.工单 || {}).n || 0))
  91. const WC = F.色标 || {}
  92. const shortOf = (c: any): string => {
  93. if (c.ostate && c.ostate !== '不可判') return stWord(c.ostate)
  94. const n = String(c.note || '')
  95. if (/数据不足|不可判|nsufficient/.test(n)) return t('fus.short.nodata')
  96. if (/未评估|ot assessed/.test(n)) return t('fus.short.uneval')
  97. if (/无样|未取样|o sample/.test(n)) return t('fus.short.nosample')
  98. if (/样龄|stale/.test(n)) return t('fus.short.stale')
  99. const m: Record<string, string> = { ok: t('fus.short.normal'), unlisted: t('fus.short.unlisted'), stale: t('fus.short.stale') }
  100. return m[c.level] || '—'
  101. }
  102. const cell = (c0: any): string => {
  103. const c = c0 || {}
  104. const col = CST[c.ostate] || WC[c.wlevel] || CLV[c.level] || 'var(--muted)'
  105. const full = vibPlain(c.note)
  106. const isProb = ['bad', 'warn', 'note'].includes(c.level)
  107. const tip = esc(full) + t('fus.tip_state', { s: stWord(c.ostate || '?') })
  108. if (!isProb) return `<td class="small" title="${tip}" style="border-left:3px solid ${col};color:var(--muted)">${shortOf(c)}</td>`
  109. const parts = full.split(/[::]/)
  110. const brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim()
  111. const cleanBrief = (/^[ ]*(评估报告|两口径差异|⚠|ssessment)/.test(brief) || brief.length > 16) ? '' : brief
  112. const shortCut = cleanBrief ? dv(cleanBrief) : (c.ostate && c.ostate !== '不可判' ? '' : shortOf(c))
  113. const ev = (c.wlevel && c.wlevel !== '—' && c.wlevel !== '正常' && c.ostate) ? duo(stWord(c.ostate), tierWord(c.wlevel), col) : ''
  114. 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>`
  115. }
  116. mat.forEach((r: any) => {
  117. const al = r.报警 || { n: 0, fam: [] }, wo = r.工单 || { n: 0, acts: [] }
  118. const alc = al.rank && al.rank <= 5 ? 'var(--warning)' : 'var(--line)'
  119. const bu = al.burst
  120. const altip = esc((al.items || []).map((x: any) => `${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 })) : '')
  121. h += `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td>${cell(r.振动)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)}
  122. <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>
  123. <td title="${esc((wo.items || []).map((x: any) => `${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: any) => dv(a.k) + '×' + a.n).join(' · ')}</div>` : `<span class="small muted">—</span>`}</td></tr>`
  124. })
  125. return h + `</table></div><p class="small muted" style="margin-top:8px">${t('fus.matrix_foot')}</p></div>`
  126. }
  127. export const baBar = (pre: number, post: number, col: string): string => {
  128. const M = Math.max(pre, post, 7), W = 120, sc = (v: number) => Math.max(2, v / M * (W - 4))
  129. 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>`
  130. }
  131. export const chainMini = (comp: unknown, col: string): string => { 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);
  132. 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>`; };
  133. /** 旧 `fusLoop(F)` —— 端到端闭环(逐字转写,app.js:912-922) */
  134. export function loopHtml(F: any): string {
  135. const LP = (F.能力 && F.能力.loop && F.能力.loop['换件闭环 (检出→换件→恢复)']) ? F.能力.loop : null
  136. if (!LP) return `<div class="panel mb"><p class="small">${t('fus.loop_none')}</p></div>`
  137. const cl = LP['换件闭环 (检出→换件→恢复)'] || []
  138. const up = LP['当前在升 (无工单可对)'] || []
  139. const neg = LP['负面对照 (工单后持平)']
  140. return `<div class="panel mb"><h3>${t('fus.loop_head')}</h3><p class="small">${t('fus.loop_note')}</p>
  141. <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>
  142. ${cl.map((r: any) => `<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>
  143. <p class="small" style="margin-top:6px">${t('fus.loop_neg', { n: neg })}</p>
  144. <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>
  145. ${up.map((r: any) => `<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>
  146. <p class="small muted" style="margin-top:8px">${t('fus.loop_bound', { b: dv(String(LP['★数据边界'] || '').split('**').join('')) })}</p></div>`
  147. }
  148. export const devBar = (ch: unknown, dev: number | null, vd: unknown, raw: unknown): string => { 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;
  149. 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;
  150. 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>`; };
  151. export const winBar = (spans: any[] | undefined, raw: unknown): string => { 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);
  152. 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>`; };
  153. export const oilPill = (last: unknown, fresh: unknown, raw: unknown): string => `<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>`;
  154. /** 旧 `stateChip`(app.js:84) */
  155. export const stateChip = (zh: unknown, cls?: string): string => {
  156. const k = stateKey(zh)
  157. return k ? `<span class="chip ${k} ${cls || ''}">${t(stKey(k))}</span>` : (zh ? `<span class="chip">${esc(zh)}</span>` : '')
  158. }
  159. /** 旧 `fusUnits(F)` —— 逐台情况(逐字转写,app.js:924-944);filter 对应旧 `FUS.filter` */
  160. export function unitsHtml(F: any, filter: string | null): string {
  161. const lv = (v: unknown): string => /定论|报警|onfirmed|larm/.test(String(v)) ? 'bad'
  162. : /预警|arning/.test(String(v)) ? 'warn'
  163. : /销案|无新证|保留|losed|o new/.test(String(v)) ? 'ok' : 'note'
  164. const tb = F.表 || [], mat = F.矩阵 || []
  165. if (filter === 'unlisted') {
  166. const un = mat.filter((r: any) => (r.振动 || {}).level === 'unlisted')
  167. 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: any) => `<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>`
  168. }
  169. let rows = tb
  170. if (filter) rows = tb.filter((r: any) => lv(r.振动结论) === filter)
  171. const filt = filter ? t('fus.units_filter', { l: t('fus.lv.' + filter) }) + ` <a href="#" data-ffilter="">${t('fus.clear')}</a>` : ''
  172. let h = `<div class="panel mb"><h3>${t('fus.units_head', { u: new Set(tb.map((x: any) => x.turbine)).size, n: rows.length + (filter ? '/' + tb.length : ''), f: filt })}</h3>
  173. <div style="display:flex;gap:8px;margin:8px 0 12px;flex-wrap:wrap">${['bad', 'warn', 'note', 'ok'].map((k) => `<button class="pill ${filter === k ? 'on' : ''}" data-ffilter="${k}">${t('fus.lv.' + k)}</button>`).join('')}<button class="pill ${filter === 'unlisted' ? 'on' : ''}" data-ffilter="unlisted">${t('fus.unlisted_link', { n: mat.filter((r: any) => (r.振动 || {}).level === 'unlisted').length })}</button></div>
  174. <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>
  175. <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>
  176. <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>
  177. <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>
  178. <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>
  179. <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>
  180. <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>`
  181. rows.forEach((r: any) => {
  182. const sev = CLV[r.级] || 'var(--accent)', col = CST[r.设备状态] || 'var(--muted)'
  183. 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>
  184. <td title="${esc(vibPlain(r.振动结论))}">${duo(stWord(r.设备状态 || '—'), dv(r.振动结论), col)}</td><td style="min-width:130px">${winBar(r.窗区间, r.claim_window)}</td>
  185. <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>`
  186. })
  187. return h + `</table></div></div>`
  188. }
  189. export const CHAIN7 = [['mbf', /主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/], ['mba', /主轴承[((]后|主轴承后/], ['ps', /行星|二级行星/], ['ims', /中间轴|中速/], ['hss', /高速轴|高速(?!.*发电)/], ['gde', /发电机DE|发电机[((]|发电机(?!NDE)/], ['gnde', /发电机NDE|NDE/]];
  190. export const gc = st => STC[stateKey(st)] || null;
  191. export 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>`;
  192. export 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)';
  193. 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>` };
  194. /** 旧 `fusEvents(F, d)` —— 报警工单(逐字转写,app.js:946-958) */
  195. export function eventsHtml(F: any, d: any): string {
  196. if (!F.事件) return `<div class="panel mb"><p class="small muted">${t('ui.empty')}</p></div>`
  197. let h = `<div class="panel mb"><p class="small muted"><b>${t('fus.ev_win', { win: winName(d.win) })}</b></p></div>`
  198. for (const [comp, E0] of Object.entries(F.事件 as Record<string, any>)) {
  199. const E = E0 as any
  200. const A = E.报警 || {}, W = E.工单 || {}
  201. 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>`
  202. if (A.总条数 > 0) {
  203. 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: any) => `<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>
  204. <p class="small">${t('fus.ev_top')}${Object.entries(A.top台 || {}).map(([u, n]) => `<a href="/turbine/${u}">${unitNo(u)}</a>×${n}`).join(' · ')}</p>
  205. <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: any) => `<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>`
  206. } else h += `<p class="small">${t('fus.ev_no_alarm')}</p>`
  207. 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: any) => `<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>`
  208. else h += `<p class="small">${t('fus.ev_no_wo')}</p>`
  209. h += `</div>`
  210. }
  211. return h
  212. }
  213. /** 旧 `fusCms()`(app.js:960-976)—— 数据来自 `/api/vibcms`(按需异步取) */
  214. export function cmsHtml(V: any, err?: string | null): string {
  215. if (err) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${esc(err)}</p></div>`
  216. if (!V) return `<div class="panel mb"><p class="small muted">${t('fus.cms_loading')}</p></div>`
  217. if (V.error) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${dv(V.error)}</p></div>`
  218. const CK: Record<string, string> = {
  219. '主轴承前': 'mbf', '主轴承后': 'mba', '齿轮箱': 'gb', '发电机': 'gen', '发电机DE': 'gde', '发电机NDE': 'gnde',
  220. 'Main bearing, front': 'mbf', 'Main bearing, rear': 'mba', 'Gearbox': 'gb', 'Generator': 'gen',
  221. 'Generator DE': 'gde', 'Generator NDE': 'gnde',
  222. }
  223. return `<div class="panel mb"><h3>${t('fus.cms_head', { d: esc(V.date || '') })}</h3>
  224. ${V['时间范围'] ? `<p class="small" style="margin:2px 0 6px">${t('fus.cms_window', { r: esc(V['时间范围']) })}</p>` : ''}
  225. <p class="small">${dv(V.overall || '')} ${t('fus.cms_note')}</p>
  226. <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>
  227. ${(V.action || []).map((r: any) => {
  228. const cm: Record<string, string | null> = {}
  229. String(r[2]).split(/[;;]/).forEach((seg: string) => {
  230. const m = seg.split(/[::]/)
  231. if (m.length > 1) { const k = CK[m[0].trim()] || null; if (k) cm[k] = gc(m[1].trim()) }
  232. })
  233. return `<tr><td><b>${unitNo(r[0])}</b></td><td>${stateChip(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>`
  234. }).join('')}</table></div>
  235. <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>
  236. ${(V.grades || []).map((g: any) => `<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>${stateChip(g.综合)}</td><td class="small">${esc(g.行动)}</td></tr>`).join('')}</table></div>
  237. <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>
  238. <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>`
  239. }
  240. export const FUS_SUBS = ['matrix', 'loop', 'units', 'events', 'cms', 'portal_cms'] as const
  241. import { PORTAL_CMS_HTML } from './portalCms'
  242. /** ★P65 门户「振动·CMS」节 —— 作为并列子选项卡「振动 CMS」,并把门户里的**静态数字**换成
  243. * **按当前时间窗实时算**的值(候选观察/全场来自 `d.fus.kpi`,当前窗来自 `d.win`),
  244. * 这样它和其余子选项卡一样受时间窗影响。 */
  245. export function portalCmsHtml(d: any, cms: any): string {
  246. const fus = (d && d.fus) || {}
  247. const kpi = fus.kpi || {}
  248. const n = (v: any) => (typeof v === 'number' ? v : Number(v) || 0)
  249. const alert = n(kpi['候选观察'])
  250. const total = n(kpi['全场'] ?? kpi['收录台'])
  251. const okn = Math.max(total - alert, 0)
  252. const win = String((d && d.win) || '')
  253. const ev = (((fus['事件'] || {})['主轴'] || {})['工单'] || {})
  254. const tickets = n(ev['总条数'])
  255. const c = cms || {}
  256. const grades = Array.isArray(c.grades) ? c.grades : []
  257. const cnt = (g: string) => grades.filter((x: any) => String(x['综合'] || '') === g).length
  258. const actions = Array.isArray(c.action) ? c.action.length : 0
  259. const cmsWin = String(c['窗'] || '')
  260. const cmsRange = String(c['时间范围'] || '').replace(/^\S+\s*/, '').slice(0, 60)
  261. const cmsDate = String(c.date || '')
  262. const chip = (cls: string, s: string) => '<span class="chip' + (cls ? ' ' + cls : '') + '">' + s + '</span>'
  263. let html = PORTAL_CMS_HTML
  264. // 门户里写死的静态数字(接口不提供记录/谱计数)先移除
  265. html = html.replace(/<span class="chip[^"]*">TCM[^<]*<\/span>/, '')
  266. // ★P71 精确替换 hero 内的整个 .chips 容器(此前是往第一个 </div> 塞 ⇒ 位置错、显示乱)
  267. const chips = [
  268. chip('s al', '需关注 ' + alert + ' 台'),
  269. chip('s gd', '未见异常 ' + okn + ' 台'),
  270. chip('ev', '证据窗末 ' + (String(fus['证据窗末'] || c.date || '').slice(0, 10) || '—')),
  271. chip('', '当前时间窗 · ' + win),
  272. chip('ev', '窗口内报警工单 · ' + tickets + ' 条'),
  273. chip('', 'CMS 采集窗 ' + (cmsWin || '—') + (cmsRange ? ' · ' + cmsRange : '')),
  274. chip('', 'CMS 裁决 · 优秀 ' + cnt('优秀') + ' / 良好 ' + cnt('良好') + ' / 报警 ' + cnt('报警')),
  275. chip('', '行动项 ' + actions + ' 条' + (cmsDate ? ' · 数据时点 ' + cmsDate : '')),
  276. ].join('')
  277. if (/<div class="chips">[\s\S]*?<\/div>/.test(html)) html = html.replace(/<div class="chips">[\s\S]*?<\/div>/, '<div class="chips">' + chips + '</div>')
  278. else html = html.replace('</div>', '<div class="chips">' + chips + '</div></div>', 1)
  279. return html
  280. }
  281. /** ★P71 「振动 CMS」整页文档:门户 CSS + 该节(供同源 iframe srcdoc 使用,渲染与门户一致)。 */
  282. export function portalCmsDoc(d: any, cms: any, portalCss: string): string {
  283. return '<!doctype html><html lang="zh" data-theme="light"><head><meta charset="utf-8">'
  284. + '<base target="_top"><style>' + portalCss + '</style></head><body>'
  285. + portalCmsHtml(d, cms) + '</body></html>'
  286. }