Bläddra i källkod

2.23.0 vibration:matrix 子视图迁移完成(561 文本+1 表一致, 含 1 例外): 逐字转写 fusMatrix 与 spark/famBar/duo 等构件 + VIBJARGON 原样抽出 + pill 键名修 fus.sub.*; 对拍门例外改包含匹配并作用于表格; 余 4 子视图未迁故 vibration 不入 DONE

zhouyang.xie 1 vecka sedan
förälder
incheckning
8dfa29944f

+ 1 - 1
VERSION

@@ -1 +1 @@
-2.22.1
+2.23.0

+ 4 - 1
app_common/app_common_guanlan/version.py

@@ -24,7 +24,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.22.1'         # ★ 版本只改这里
+VERSION = '2.23.0'         # ★ 版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -54,6 +54,9 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.23.0', date='2026-09-29', level='minor',
+         title='vibration 的 matrix 子视图迁移完成(5 子视图中的第 1 个),对拍 561 条文本一致',
+         note='逐子视图推进 ⇒ 中版本 +1。① `Vibration.vue` + `src/lib/vib.ts`:把旧 `fusMatrix` 与它的构件(`spark`/`famBar`/`duo`/`tierWord`/`shortOf`/`cell`/状态色表)**逐字转写**,用 v-html 渲染 —— 旧代码本就是拼 HTML 字符串,转写后 DOM 与文本节点边界天然一致。② `VIBJARGON`(技术速记→人话,6 行长正则数组)改为**从 app.js 原样抽出**(`src/lib/vibjargon.ts`),避免手抄漏项。③ pill 键名实逮:旧用 `fus.sub.<s>`(我写成 `fus.s.<s>` ⇒ 5 个 pill 全显键名)。④ 对拍结果:`vibration:matrix` **561 条文本 + 1 张表一致**(含 1 条登记例外);既有四页签重跑仍全过。⑤ **vibration 仍未列入 DONE** —— 余下 4 个子视图(loop/units/events/cms)未迁;cms 还需按需异步取 `/api/vibcms`。⑥ 对拍门增强:例外清单改为"包含匹配"并**同时作用于文本与表格**。'),
     dict(version='2.22.1', date='2026-09-29', level='patch',
          title='对拍门支持子视图(页签:子视图);vibration 侦察结论入档',
          note='进行中 ⇒ 小版本 +1。① 对拍门新增**子视图**口径:`node tools/render_parity.mjs <网关> vibration:matrix` —— 基准侧加载后**点一下**对应 pill(旧页面 `data-fsub`)再抽锚点,Vue 侧把 `sub` 传进组件;`renderTab(d, tab, sub)`、Workbench 增 `sub` prop、页签文案比对含 `[data-fsub]`。② vibration 侦察结论(本轮实测):该页签内部分 **5 个子视图**(matrix/loop/units/events/cms,旧 `FUS.sub`),**不含图表**(无需 ECharts),但依赖约 10 个 SVG/HTML 小构件(`spark`/`famBar`/`baBar`/`winBar`/`devBar`/`chainMini`/`chainStates`/`oilPill`/`evLadder`/`gc`),且 `cms` 子视图按需异步取 `/api/vibcms` ⇒ 需逐子视图迁并对拍。③ 四个已迁页签重跑仍全过(overview 138 · energy 51+图11/11 · component 260 · fault 133 含 1 例外)。'),

+ 77 - 76
app_frontEnd/web/src/App.vue

@@ -1,76 +1,77 @@
-<script setup lang="ts">
-/**
- * 观澜前端外壳(P11)—— Vue 3 + Vite + TypeScript。
- *
- * 两种模式:
- *  · 工作台:与重构前 `/detail/v2` 同构(页签清单一致),数据取 `/api/fleet?win=`(与 app.js 的 load() 同一个接口);
- *  · 契约自检:把冻契约里的关键接口各取一次,显示 状态/耗时/顶层键。
- *
- * 迁移纪律(用户令 2026-09-29):页签逐个搬,**与重构前页面对拍通过后**才删对应 Python 前端源码。
- */
-import { onMounted, ref, watch } from 'vue'
-import ContractSmoke from './views/ContractSmoke.vue'
-import Workbench from './views/Workbench.vue'
-import { callApi } from './api/contract'
-
-type Mode = 'workbench' | 'smoke' | 'about'
-const mode = ref<Mode>('workbench')
-const tab = ref<string>('overview')
-const d = ref<any>(null)
-const win = ref<string>('')
-const loading = ref(false)
-const err = ref('')
-
-async function load() {
-  loading.value = true
-  err.value = ''
-  const q = win.value ? `?win=${encodeURIComponent(win.value)}` : ''
-  const r = await callApi<any>('fleet', { method: 'GET' })
-  // callApi 走的是契约里的固定路径;窗参数在这里补上(契约只约定路径与方法)
-  if (q) {
-    try {
-      const resp = await fetch('/api/fleet' + q)
-      d.value = await resp.json()
-    } catch (e) {
-      err.value = e instanceof Error ? e.message : String(e)
-    }
-  } else if (r.ok) {
-    d.value = r.data
-    if (d.value?.win) win.value = d.value.win
-  } else {
-    err.value = r.error || `HTTP ${r.status}`
-  }
-  loading.value = false
-}
-
-watch(win, (v) => { if (v && v !== d.value?.win) void load() })
-onMounted(load)
-</script>
-
-<template>
-  <header class="top">
-    <div class="brand">观澜 · 风电数据分析与决策支持</div>
-    <nav>
-      <button :class="{ on: mode === 'workbench' }" @click="mode = 'workbench'">工作台</button>
-      <button :class="{ on: mode === 'smoke' }" @click="mode = 'smoke'">契约自检</button>
-      <button :class="{ on: mode === 'about' }" @click="mode = 'about'">关于本前端</button>
-    </nav>
-  </header>
-  <main>
-    <template v-if="mode === 'workbench'">
-      <p v-if="loading" class="hint" style="padding:0 18px">取数中(/api/fleet)…</p>
-      <p v-else-if="err" class="hint" style="padding:0 18px;color:var(--bad)">取数失败:{{ err }}</p>
-      <Workbench v-else-if="d" :d="d" :tab="tab" @tab="tab = $event" />
-    </template>
-    <ContractSmoke v-else-if="mode === 'smoke'" />
-    <section v-else class="card">
-      <h2>关于本前端</h2>
-      <ul>
-        <li>技术栈:<b>Vue 3 + Vite + TypeScript + Node.js</b>(用户令 2026-09-29)</li>
-        <li>与后端交互:只走网关一个源站,路径与形状以 <code>app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json</code> 冻契约为准</li>
-        <li>数据来源:与重构前 <code>/detail/v2</code> 同一个接口 <code>/api/fleet?win=</code></li>
-        <li>迁移纪律:页签逐个搬,与重构前页面对拍通过后才删对应 Python 源码</li>
-      </ul>
-    </section>
-  </main>
-</template>
+<script setup lang="ts">
+/**
+ * 观澜前端外壳(P11)—— Vue 3 + Vite + TypeScript。
+ *
+ * 两种模式:
+ *  · 工作台:与重构前 `/detail/v2` 同构(页签清单一致),数据取 `/api/fleet?win=`(与 app.js 的 load() 同一个接口);
+ *  · 契约自检:把冻契约里的关键接口各取一次,显示 状态/耗时/顶层键。
+ *
+ * 迁移纪律(用户令 2026-09-29):页签逐个搬,**与重构前页面对拍通过后**才删对应 Python 前端源码。
+ */
+import { onMounted, ref, watch } from 'vue'
+import ContractSmoke from './views/ContractSmoke.vue'
+import Workbench from './views/Workbench.vue'
+import { callApi } from './api/contract'
+
+type Mode = 'workbench' | 'smoke' | 'about'
+const mode = ref<Mode>('workbench')
+const tab = ref<string>('overview')
+const sub = ref<string | null>(null)
+const d = ref<any>(null)
+const win = ref<string>('')
+const loading = ref(false)
+const err = ref('')
+
+async function load() {
+  loading.value = true
+  err.value = ''
+  const q = win.value ? `?win=${encodeURIComponent(win.value)}` : ''
+  const r = await callApi<any>('fleet', { method: 'GET' })
+  // callApi 走的是契约里的固定路径;窗参数在这里补上(契约只约定路径与方法)
+  if (q) {
+    try {
+      const resp = await fetch('/api/fleet' + q)
+      d.value = await resp.json()
+    } catch (e) {
+      err.value = e instanceof Error ? e.message : String(e)
+    }
+  } else if (r.ok) {
+    d.value = r.data
+    if (d.value?.win) win.value = d.value.win
+  } else {
+    err.value = r.error || `HTTP ${r.status}`
+  }
+  loading.value = false
+}
+
+watch(win, (v) => { if (v && v !== d.value?.win) void load() })
+onMounted(load)
+</script>
+
+<template>
+  <header class="top">
+    <div class="brand">观澜 · 风电数据分析与决策支持</div>
+    <nav>
+      <button :class="{ on: mode === 'workbench' }" @click="mode = 'workbench'">工作台</button>
+      <button :class="{ on: mode === 'smoke' }" @click="mode = 'smoke'">契约自检</button>
+      <button :class="{ on: mode === 'about' }" @click="mode = 'about'">关于本前端</button>
+    </nav>
+  </header>
+  <main>
+    <template v-if="mode === 'workbench'">
+      <p v-if="loading" class="hint" style="padding:0 18px">取数中(/api/fleet)…</p>
+      <p v-else-if="err" class="hint" style="padding:0 18px;color:var(--bad)">取数失败:{{ err }}</p>
+      <Workbench v-else-if="d" :d="d" :tab="tab" :sub="sub" @tab="tab = $event" @sub="sub = $event" />
+    </template>
+    <ContractSmoke v-else-if="mode === 'smoke'" />
+    <section v-else class="card">
+      <h2>关于本前端</h2>
+      <ul>
+        <li>技术栈:<b>Vue 3 + Vite + TypeScript + Node.js</b>(用户令 2026-09-29)</li>
+        <li>与后端交互:只走网关一个源站,路径与形状以 <code>app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json</code> 冻契约为准</li>
+        <li>数据来源:与重构前 <code>/detail/v2</code> 同一个接口 <code>/api/fleet?win=</code></li>
+        <li>迁移纪律:页签逐个搬,与重构前页面对拍通过后才删对应 Python 源码</li>
+      </ul>
+    </section>
+  </main>
+</template>

+ 133 - 0
app_frontEnd/web/src/lib/vib.ts

@@ -0,0 +1,133 @@
+/**
+ * 振动融合(vibration)子视图的**拼装**(P11 迁移)。
+ *
+ * ★ 口径:旧 `app.js` 的这些视图本来就是拼 HTML 字符串;这里**逐字转写**并用 `v-html` 渲染,
+ *   于是 DOM 与文本节点边界与重构前一致 —— 严格对拍要的正是这个(不是"看起来差不多")。
+ *   本文件当前只含 `matrix` 子视图(默认),其余子视图(loop/units/events/cms)待迁。
+ */
+import { t } from '../i18n/zh'
+import { unitNo } from './render'
+import { partName, sysName } from './render2'
+import { VIBJARGON } from './vibjargon'
+
+export const esc = (s: unknown): string => String(s == null ? '' : s)
+  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+
+/** 旧 `dv()`(中文口径 = esc) */
+export const dv = (s: unknown): string => esc(s)
+
+const ST: Record<string, string> = { 危险: 'bad', 报警: 'warn', 不可判: 'unk', 良好: 'ok', 优秀: 'ex' }
+const ST_ALIAS: Record<string, string> = {
+  critical: 'bad', alarm: 'warn', undeterminable: 'unk', indeterminate: 'unk', 'not evaluable': 'unk',
+  good: 'ok', excellent: 'ex', warn: 'warn', bad: 'bad', ok: 'ok', unk: 'unk', ex: 'ex',
+}
+const stateKey = (v: unknown): string | undefined => ST[String(v)] || ST_ALIAS[String(v)]
+export const STC: Record<string, string> = { bad: 'var(--critical)', warn: 'var(--warning)', unk: 'var(--unknown)', ok: 'var(--good)', ex: 'var(--excellent)' }
+const CST: Record<string, string> = { 优秀: 'var(--excellent)', 良好: 'var(--good)', 报警: 'var(--warning)', 危险: 'var(--critical)', 不可判: 'var(--unknown)', 监测不可用: 'var(--unknown)', 无监测: 'var(--unknown)' }
+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)' }
+const TIER: Record<string, string> = {
+  定论: 'confirmed', '准定论·预警': 'near', '候选·新发': 'candidate_new', 候选: 'candidate',
+  '候选·记基线': 'candidate_baseline', 参考: 'reference', INSUFFICIENT: 'insufficient', 正常: 'normal', 撤回: 'withdrawn',
+}
+export const stKey = (k: string): string => 'st.' + (k === 'ex' ? 'excellent' : k)
+export const stWord = (zh: unknown): string => {
+  const k = stateKey(zh)
+  return k ? t(stKey(k)) : dv(zh)
+}
+/** 旧 `tierWord`(中文分支=语言包有则取,否则 dv) */
+export const tierWord = (zh: unknown): string => {
+  const k = TIER[String(zh)]
+  const key = 'tier.' + k
+  return k ? t(key) : (String(zh) === '—' ? '—' : dv(zh))
+}
+export const duo = (st: string, ev: string, col: string): string =>
+  `<span class="duo" style="--c:${col}"><span class="st">${st}</span><span class="ev">${ev}</span></span>`
+
+/** 旧 `vibPlain`:中文口径下逐条替换技术速记 */
+export const vibPlain = (x: unknown): string => {
+  let s = String(x == null ? '' : x)
+  for (const [a, b] of VIBJARGON) s = s.replace(a, b)
+  return s
+}
+export const attrPlain = (x: unknown): string => vibPlain(x)
+
+/** 旧 `spark`(历史曲线小图,逐字转写) */
+const spark = (hist: number[] | undefined, color: string): string => {
+  if (!hist || hist.filter((x) => x != null).length < 3) return ''
+  const vs = hist.filter((x) => x != null)
+  const mn = Math.min(...vs), mx = Math.max(...vs), sp = (mx - mn) || 1
+  const W = 84, H = 22, n = hist.length
+  let d = '', last: number[] | null = 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 as number[])[0]}" cy="${(last as number[])[1]}" r="2.4" fill="${color}"/></svg>`
+}
+
+const FAMCO: Record<string, string> = { 齿轮箱: '#8A6D3B', 发电机: '#4A6FA5', 主轴承: '#2E7D6B', 变桨叶片: '#4A6FA5', 偏航: '#2E7D6B', 测风: '#7C5295', 其他: '#5A6470' }
+const famBar = (fam: any[] | undefined, n: number, maxN: number): string => {
+  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>`
+}
+
+/** 旧 `fusMatrix(F)` —— 逐字转写(默认子视图) */
+export function matrixHtml(F: any): string {
+  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: unknown): string => {
+    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: string, win: string, extra?: string): string =>
+    `<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.矩阵 || []
+  const MAXAL = Math.max(1, ...mat.map((r: any) => (r.报警 || {}).n || 0))
+  const MAXWO = Math.max(1, ...mat.map((r: any) => (r.工单 || {}).n || 0))
+  const WC = F.色标 || {}
+  const shortOf = (c: any): string => {
+    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')
+    const m: Record<string, string> = { ok: t('fus.short.normal'), unlisted: t('fus.short.unlisted'), stale: t('fus.short.stale') }
+    return m[c.level] || '—'
+  }
+  const cell = (c0: any): string => {
+    const c = c0 || {}
+    const col = CST[c.ostate] || WC[c.wlevel] || CLV[c.level] || 'var(--muted)'
+    const full = vibPlain(c.note)
+    const 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(/[::]/)
+    const brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim()
+    const cleanBrief = (/^[ ]*(评估报告|两口径差异|⚠|ssessment)/.test(brief) || brief.length > 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: any) => {
+    const al = r.报警 || { n: 0, fam: [] }, wo = r.工单 || { n: 0, acts: [] }
+    const alc = al.rank && al.rank <= 5 ? 'var(--warning)' : 'var(--line)'
+    const bu = al.burst
+    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 })) : '')
+    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: 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>`
+  })
+  return h + `</table></div><p class="small muted" style="margin-top:8px">${t('fus.matrix_foot')}</p></div>`
+}
+
+export const FUS_SUBS = ['matrix', 'loop', 'units', 'events', 'cms'] as const

+ 7 - 0
app_frontEnd/web/src/lib/vibjargon.ts

@@ -0,0 +1,7 @@
+/** 振动技术速记 → 人话(P11 迁移:**原样抽自** app.js 的 VIBJARGON,勿手改;改动请回 lang/源码侧) */
+export const VIBJARGON: [RegExp, string][] = [[/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, '主要异常位于']];

+ 35 - 0
app_frontEnd/web/src/views/Vibration.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+/**
+ * 振动融合分析(vibration)——对应重构前 `app.js` 的 `vibration()`。
+ *
+ * 该页签内部有 **5 个子视图**(旧 `FUS.sub`):matrix(判级矩阵,默认)/ loop / units / events / cms。
+ * 本版只迁 **matrix**,其余如实标"迁移中";5 个子视图全过对拍后才列入 DONE。
+ */
+import { computed } from 'vue'
+import { t } from '../i18n/zh'
+import { FUS_SUBS, matrixHtml } from '../lib/vib'
+
+const props = defineProps<{ d: any; sub?: string | null }>()
+const emit = defineEmits<{ (e: 'sub', id: string): void }>()
+
+const sub = computed(() => props.sub || 'matrix')
+const F = computed<any>(() => props.d?.fus)
+
+const bodyHtml = computed(() => {
+  const f = F.value
+  if (!f || f.err) return `<section><div class="panel"><p>${t('fus.unavailable', { e: String((f && f.err) || '') })}</p></div></section>`
+  if (sub.value === 'matrix') return matrixHtml(f)
+  return `<div class="panel mb"><p class="small muted">子视图「${t('fus.sub.' + sub.value)}」尚未迁移到 Vue(迁移中)。</p></div>`
+})
+</script>
+
+<template>
+  <section>
+    <div class="eyebrow">{{ t('fus.eyebrow') }}</div>
+    <div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">
+      <button v-for="s in FUS_SUBS" :key="s" class="pill" :class="{ on: s === sub }" :data-fsub="s"
+              @click="emit('sub', s)">{{ t('fus.sub.' + s) }}</button>
+    </div>
+    <div v-html="bodyHtml"></div>
+  </section>
+</template>

+ 3 - 1
app_frontEnd/web/src/views/Workbench.vue

@@ -12,10 +12,11 @@ import { winName } from '../lib/render'
 import Component from './Component.vue'
 import Energy from './Energy.vue'
 import Fault from './Fault.vue'
+import Vibration from './Vibration.vue'
 import Overview from './Overview.vue'
 
 const props = defineProps<{ d: any; tab: string; sys?: string | null; sub?: string | null }>()
-const emit = defineEmits<{ (e: 'tab', id: string): void }>()
+const emit = defineEmits<{ (e: 'tab', id: string): void; (e: 'sub', id: string): void }>()
 
 const pending = computed(() => !DONE.includes(props.tab))
 </script>
@@ -35,6 +36,7 @@ const pending = computed(() => !DONE.includes(props.tab))
   <Energy v-else-if="tab === 'energy'" :d="d" />
   <Component v-else-if="tab === 'component'" :d="d" :sys="sys" />
   <Fault v-else-if="tab === 'fault'" :d="d" />
+  <Vibration v-else-if="tab === 'vibration'" :d="d" :sub="sub" @sub="emit('sub', $event)" />
   <section v-else-if="pending" class="card">
     <h2>{{ t('tab.' + tab) }} <small>(本页签尚未迁移到 Vue)</small></h2>
     <p class="hint">

+ 248 - 42
app_frontEnd/web/target/ssr/entry-server.js

@@ -867,8 +867,8 @@ const TABS = [
   "system"
 ];
 const DONE = ["overview", "energy", "component", "fault"];
-const ST = { 危险: "bad", 报警: "warn", 不可判: "unk", 良好: "ok", 优秀: "ex" };
-const ST_ALIAS = {
+const ST$1 = { 危险: "bad", 报警: "warn", 不可判: "unk", 良好: "ok", 优秀: "ex" };
+const ST_ALIAS$1 = {
   critical: "bad",
   alarm: "warn",
   undeterminable: "unk",
@@ -882,7 +882,7 @@ const ST_ALIAS = {
   unk: "unk",
   ex: "ex"
 };
-const stateKey = (value) => ST[String(value)] || ST_ALIAS[String(value)];
+const stateKey$1 = (value) => ST$1[String(value)] || ST_ALIAS$1[String(value)];
 const STEP = {
   证据: "evidence",
   机制: "mechanism",
@@ -891,7 +891,7 @@ const STEP = {
   动作: "action",
   验收: "acceptance"
 };
-const stKey = (k) => "st." + (k === "ex" ? "excellent" : k);
+const stKey$1 = (k) => "st." + (k === "ex" ? "excellent" : k);
 const unitNo = (wtg) => String(wtg).replace("WTG", "").replace(/^0/, "") + "#";
 const fmt = (n, d = 0) => {
   if (n == null || typeof n === "number" && isNaN(n)) return "—";
@@ -899,13 +899,13 @@ const fmt = (n, d = 0) => {
   if (isNaN(num)) return "—";
   return num.toLocaleString("zh-CN", { maximumFractionDigits: d, minimumFractionDigits: d });
 };
-const stWord = (zh) => {
-  const k = stateKey(zh);
-  return k ? t(stKey(k)) : String(zh ?? "");
+const stWord$1 = (zh) => {
+  const k = stateKey$1(zh);
+  return k ? t(stKey$1(k)) : String(zh ?? "");
 };
 const COLS = ["振动", "温度", "润滑", "油液", "报警", "工单"];
 function unitState(row) {
-  const ks = COLS.map((c) => stateKey((row?.[c] || {}).ostate)).filter(Boolean);
+  const ks = COLS.map((c) => stateKey$1((row?.[c] || {}).ostate)).filter(Boolean);
   if (!ks.length) return "unk";
   if (ks.includes("bad")) return "bad";
   if (ks.includes("warn")) return "warn";
@@ -948,7 +948,7 @@ const sysName = (value) => {
   return SYS_ZH[key] ? t("sys." + key) : String(value ?? "");
 };
 const partName = (p) => sysName(p);
-const _sfc_main$5 = /* @__PURE__ */ defineComponent({
+const _sfc_main$6 = /* @__PURE__ */ defineComponent({
   __name: "Component",
   __ssrInlineRender: true,
   props: {
@@ -1083,13 +1083,13 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
     };
   }
 });
-const _sfc_setup$5 = _sfc_main$5.setup;
-_sfc_main$5.setup = (props, ctx) => {
+const _sfc_setup$6 = _sfc_main$6.setup;
+_sfc_main$6.setup = (props, ctx) => {
   const ssrContext = useSSRContext();
   (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Component.vue");
-  return _sfc_setup$5 ? _sfc_setup$5(props, ctx) : void 0;
+  return _sfc_setup$6 ? _sfc_setup$6(props, ctx) : void 0;
 };
-const _sfc_main$4 = /* @__PURE__ */ defineComponent({
+const _sfc_main$5 = /* @__PURE__ */ defineComponent({
   __name: "EChart",
   __ssrInlineRender: true,
   props: {
@@ -1132,14 +1132,14 @@ const _export_sfc = (sfc, props) => {
   }
   return target;
 };
-const _sfc_setup$4 = _sfc_main$4.setup;
-_sfc_main$4.setup = (props, ctx) => {
+const _sfc_setup$5 = _sfc_main$5.setup;
+_sfc_main$5.setup = (props, ctx) => {
   const ssrContext = useSSRContext();
   (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/components/EChart.vue");
-  return _sfc_setup$4 ? _sfc_setup$4(props, ctx) : void 0;
+  return _sfc_setup$5 ? _sfc_setup$5(props, ctx) : void 0;
 };
-const EChart = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-999bee15"]]);
-const _sfc_main$3 = /* @__PURE__ */ defineComponent({
+const EChart = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-999bee15"]]);
+const _sfc_main$4 = /* @__PURE__ */ defineComponent({
   __name: "Energy",
   __ssrInlineRender: true,
   props: {
@@ -1249,13 +1249,13 @@ const _sfc_main$3 = /* @__PURE__ */ defineComponent({
     };
   }
 });
-const _sfc_setup$3 = _sfc_main$3.setup;
-_sfc_main$3.setup = (props, ctx) => {
+const _sfc_setup$4 = _sfc_main$4.setup;
+_sfc_main$4.setup = (props, ctx) => {
   const ssrContext = useSSRContext();
   (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Energy.vue");
-  return _sfc_setup$3 ? _sfc_setup$3(props, ctx) : void 0;
+  return _sfc_setup$4 ? _sfc_setup$4(props, ctx) : void 0;
 };
-const _sfc_main$2 = /* @__PURE__ */ defineComponent({
+const _sfc_main$3 = /* @__PURE__ */ defineComponent({
   __name: "Fault",
   __ssrInlineRender: true,
   props: {
@@ -1304,10 +1304,10 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
       const m = String(k).match(/^([0-9]+)/);
       return m ? m[1] : String(k).slice(0, 4);
     };
-    const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+    const esc2 = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
     function paretoSummaryHtml(items, unit, note) {
       const it = (items || []).filter((x) => x && x.v > 0);
-      if (!it.length) return esc(t("ch.pareto.none"));
+      if (!it.length) return esc2(t("ch.pareto.none"));
       const tot = it.reduce((a, x) => a + x.v, 0);
       let cum = 0;
       let k80 = null;
@@ -1315,10 +1315,10 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
         cum += it[i].v;
         if (k80 === null && tot && cum / tot >= 0.8) k80 = i + 1;
       }
-      const top3 = it.slice(0, 3).map((x) => `<b>${esc(shortK(x.k))}</b> ${esc(String(x.k).replace(/^[0-9]+ */, "").slice(0, 18))}`).join(" · ");
+      const top3 = it.slice(0, 3).map((x) => `<b>${esc2(shortK(x.k))}</b> ${esc2(String(x.k).replace(/^[0-9]+ */, "").slice(0, 18))}`).join(" · ");
       const total = t("ch.pareto.total", { n: it.length, tot: fmt(Math.round(tot)), unit });
-      const head = k80 ? `<b>${esc(t("ch.pareto.key", { k: k80 }))}</b> (${esc(total)})` : esc(total);
-      return head + "<br>" + esc(t("ch.pareto.top3")) + top3 + (note ? "<br>" + esc(note) : "");
+      const head = k80 ? `<b>${esc2(t("ch.pareto.key", { k: k80 }))}</b> (${esc2(total)})` : esc2(total);
+      return head + "<br>" + esc2(t("ch.pareto.top3")) + top3 + (note ? "<br>" + esc2(note) : "");
     }
     const paretoNSummary = computed(() => paretoSummaryHtml(F.value.pareto_n || [], t("unit.n"), t("flt.pareto_n_note")));
     const paretoDSummary = computed(() => paretoSummaryHtml(F.value.pareto_d || [], " h", t("flt.pareto_d_note")));
@@ -1503,10 +1503,209 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
     };
   }
 });
+const _sfc_setup$3 = _sfc_main$3.setup;
+_sfc_main$3.setup = (props, ctx) => {
+  const ssrContext = useSSRContext();
+  (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Fault.vue");
+  return _sfc_setup$3 ? _sfc_setup$3(props, ctx) : void 0;
+};
+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 esc = (s) => String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+const dv = (s) => esc(s);
+const ST = { 危险: "bad", 报警: "warn", 不可判: "unk", 良好: "ok", 优秀: "ex" };
+const ST_ALIAS = {
+  critical: "bad",
+  alarm: "warn",
+  undeterminable: "unk",
+  indeterminate: "unk",
+  "not evaluable": "unk",
+  good: "ok",
+  excellent: "ex",
+  warn: "warn",
+  bad: "bad",
+  ok: "ok",
+  unk: "unk",
+  ex: "ex"
+};
+const stateKey = (v) => ST[String(v)] || ST_ALIAS[String(v)];
+const STC = { bad: "var(--critical)", warn: "var(--warning)", unk: "var(--unknown)", ok: "var(--good)", ex: "var(--excellent)" };
+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)" };
+const TIER = {
+  定论: "confirmed",
+  "准定论·预警": "near",
+  "候选·新发": "candidate_new",
+  候选: "candidate",
+  "候选·记基线": "candidate_baseline",
+  参考: "reference",
+  INSUFFICIENT: "insufficient",
+  正常: "normal",
+  撤回: "withdrawn"
+};
+const stKey = (k) => "st." + (k === "ex" ? "excellent" : k);
+const stWord = (zh) => {
+  const k = stateKey(zh);
+  return k ? t(stKey(k)) : dv(zh);
+};
+const tierWord = (zh) => {
+  const k = TIER[String(zh)];
+  const key = "tier." + k;
+  return k ? t(key) : String(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 vibPlain = (x) => {
+  let s = String(x == null ? "" : x);
+  for (const [a, b] of VIBJARGON) s = s.replace(a, b);
+  return s;
+};
+const attrPlain = (x) => vibPlain(x);
+const spark = (hist, color) => {
+  if (!hist || hist.filter((x) => x != null).length < 3) return "";
+  const vs = hist.filter((x) => x != null);
+  const mn = Math.min(...vs), mx = Math.max(...vs), sp = mx - mn || 1;
+  const 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>`;
+};
+function matrixHtml(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.矩阵 || [];
+  const MAXAL = Math.max(1, ...mat.map((r) => (r.报警 || {}).n || 0));
+  const MAXWO = Math.max(1, ...mat.map((r) => (r.工单 || {}).n || 0));
+  const 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");
+    const m = { ok: t("fus.short.normal"), unlisted: t("fus.short.unlisted"), stale: t("fus.short.stale") };
+    return m[c.level] || "—";
+  };
+  const cell = (c0) => {
+    const c = c0 || {};
+    const col = CST[c.ostate] || WC[c.wlevel] || CLV[c.level] || "var(--muted)";
+    const full = vibPlain(c.note);
+    const 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(/[::]/);
+    const brief = (parts.length > 1 ? parts.slice(1).join(":") : full).trim();
+    const cleanBrief = /^[ ]*(评估报告|两口径差异|⚠|ssessment)/.test(brief) || brief.length > 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: [] };
+    const alc = al.rank && al.rank <= 5 ? "var(--warning)" : "var(--line)";
+    const 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>`;
+}
+const FUS_SUBS = ["matrix", "loop", "units", "events", "cms"];
+const _sfc_main$2 = /* @__PURE__ */ defineComponent({
+  __name: "Vibration",
+  __ssrInlineRender: true,
+  props: {
+    d: {},
+    sub: {}
+  },
+  emits: ["sub"],
+  setup(__props, { emit: __emit }) {
+    const props = __props;
+    const sub = computed(() => props.sub || "matrix");
+    const F = computed(() => props.d?.fus);
+    const bodyHtml = computed(() => {
+      const f = F.value;
+      if (!f || f.err) return `<section><div class="panel"><p>${t("fus.unavailable", { e: String(f && f.err || "") })}</p></div></section>`;
+      if (sub.value === "matrix") return matrixHtml(f);
+      return `<div class="panel mb"><p class="small muted">子视图「${t("fus.sub." + sub.value)}」尚未迁移到 Vue(迁移中)。</p></div>`;
+    });
+    return (_ctx, _push, _parent, _attrs) => {
+      _push(`<section${ssrRenderAttrs(_attrs)}><div class="eyebrow">${ssrInterpolate(unref(t)("fus.eyebrow"))}</div><div style="${ssrRenderStyle({ "display": "flex", "gap": "8px", "margin-bottom": "14px", "flex-wrap": "wrap" })}"><!--[-->`);
+      ssrRenderList(unref(FUS_SUBS), (s) => {
+        _push(`<button class="${ssrRenderClass([{ on: s === sub.value }, "pill"])}"${ssrRenderAttr("data-fsub", s)}>${ssrInterpolate(unref(t)("fus.sub." + s))}</button>`);
+      });
+      _push(`<!--]--></div><div>${bodyHtml.value ?? ""}</div></section>`);
+    };
+  }
+});
 const _sfc_setup$2 = _sfc_main$2.setup;
 _sfc_main$2.setup = (props, ctx) => {
   const ssrContext = useSSRContext();
-  (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Fault.vue");
+  (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Vibration.vue");
   return _sfc_setup$2 ? _sfc_setup$2(props, ctx) : void 0;
 };
 const _sfc_main$1 = /* @__PURE__ */ defineComponent({
@@ -1606,9 +1805,9 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
       }
       _push(`<div class="cards"><!--[-->`);
       ssrRenderList(rows.value, (r) => {
-        _push(`<div class="card"${ssrRenderAttr("id", `u-${r.t}`)} style="${ssrRenderStyle(`--sev:var(--${unref(SEV)[unref(stateKey)(r.ostate) || (r.lvl === "bad" ? "bad" : "warn")]})`)}"><div class="head"><a class="unit"${ssrRenderAttr("href", `/turbine/${r.t}`)}>${ssrInterpolate(unref(unitNo)(r.t))}</a><span class="sys">${ssrInterpolate(r.part || "")}</span><span class="${ssrRenderClass([unref(stateKey)(r.ostate) || (r.lvl === "bad" ? "bad" : "warn"), "chip"])}">${ssrInterpolate(unref(stWord)(r.ostate || (r.lvl === "bad" ? "危险" : "报警")))}</span></div><details class="evidence-detail"><summary>${ssrInterpolate(unref(t)("ov.evidence"))}</summary><div class="why">${ssrInterpolate(basis(r))}</div></details><div class="evid">`);
+        _push(`<div class="card"${ssrRenderAttr("id", `u-${r.t}`)} style="${ssrRenderStyle(`--sev:var(--${unref(SEV)[unref(stateKey$1)(r.ostate) || (r.lvl === "bad" ? "bad" : "warn")]})`)}"><div class="head"><a class="unit"${ssrRenderAttr("href", `/turbine/${r.t}`)}>${ssrInterpolate(unref(unitNo)(r.t))}</a><span class="sys">${ssrInterpolate(r.part || "")}</span><span class="${ssrRenderClass([unref(stateKey$1)(r.ostate) || (r.lvl === "bad" ? "bad" : "warn"), "chip"])}">${ssrInterpolate(unref(stWord$1)(r.ostate || (r.lvl === "bad" ? "危险" : "报警")))}</span></div><details class="evidence-detail"><summary>${ssrInterpolate(unref(t)("ov.evidence"))}</summary><div class="why">${ssrInterpolate(basis(r))}</div></details><div class="evid">`);
         if (r.estate) {
-          _push(`<span class="${ssrRenderClass([r.estate, "tier"])}">${ssrInterpolate(unref(stWord)(r.estate))}</span>`);
+          _push(`<span class="${ssrRenderClass([r.estate, "tier"])}">${ssrInterpolate(unref(stWord$1)(r.estate))}</span>`);
         } else {
           _push(`<!---->`);
         }
@@ -1627,13 +1826,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
       });
       _push(`<!--]--></div></section><section><div class="sechead"><h2>${ssrInterpolate(unref(t)("ov.strip"))}</h2><span class="meta">${ssrInterpolate(unref(t)("ov.strip_hint"))}</span><div class="legend"><!--[-->`);
       ssrRenderList(["bad", "warn", "unk", "ok", "ex"], (k) => {
-        _push(`<span><i style="${ssrRenderStyle(`background:var(--${unref(SEV)[k]})`)}"></i>${ssrInterpolate(unref(t)(unref(stKey)(k)))}</span>`);
+        _push(`<span><i style="${ssrRenderStyle(`background:var(--${unref(SEV)[k]})`)}"></i>${ssrInterpolate(unref(t)(unref(stKey$1)(k)))}</span>`);
       });
       _push(`<!--]--></div></div><div class="strip" style="${ssrRenderStyle(`--n:${mat.value.length || 38}`)}"><div class="cells">`);
       if (mat.value.length) {
         _push(`<!--[-->`);
         ssrRenderList(mat.value, (r) => {
-          _push(`<div class="${ssrRenderClass([[unref(unitState)(r), hot.value.has(r.turbine) ? "hot" : ""], "cell"])}"${ssrRenderAttr("data-u", r.turbine)}${ssrRenderAttr("title", `${unref(unitNo)(r.turbine)} ${unref(t)(unref(stKey)(unref(unitState)(r)))}`)}></div>`);
+          _push(`<div class="${ssrRenderClass([[unref(unitState)(r), hot.value.has(r.turbine) ? "hot" : ""], "cell"])}"${ssrRenderAttr("data-u", r.turbine)}${ssrRenderAttr("title", `${unref(unitNo)(r.turbine)} ${unref(t)(unref(stKey$1)(unref(unitState)(r)))}`)}></div>`);
         });
         _push(`<!--]-->`);
       } else {
@@ -1674,35 +1873,42 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
     sys: {},
     sub: {}
   },
-  emits: ["tab"],
+  emits: ["tab", "sub"],
   setup(__props, { emit: __emit }) {
     const props = __props;
+    const emit = __emit;
     const pending = computed(() => !DONE.includes(props.tab));
     return (_ctx, _push, _parent, _attrs) => {
-      _push(`<!--[--><nav class="wb-tabs" data-v-9b63d247><!--[-->`);
+      _push(`<!--[--><nav class="wb-tabs" data-v-3d118848><!--[-->`);
       ssrRenderList(unref(TABS), (id) => {
-        _push(`<button class="${ssrRenderClass({ on: id === __props.tab })}" data-v-9b63d247>${ssrInterpolate(unref(t)("tab." + id))}`);
+        _push(`<button class="${ssrRenderClass({ on: id === __props.tab })}" data-v-3d118848>${ssrInterpolate(unref(t)("tab." + id))}`);
         if (!unref(DONE).includes(id)) {
-          _push(`<sup class="todo" data-v-9b63d247>迁移中</sup>`);
+          _push(`<sup class="todo" data-v-3d118848>迁移中</sup>`);
         } else {
           _push(`<!---->`);
         }
         _push(`</button>`);
       });
-      _push(`<!--]--></nav><div class="page-head" data-v-9b63d247><h1 data-v-9b63d247>${ssrInterpolate(unref(t)("tab." + __props.tab))}</h1><span class="window-badge" data-v-9b63d247>${ssrInterpolate(unref(t)("win.label"))} · ${ssrInterpolate(unref(winName)(__props.d?.win))}</span></div>`);
+      _push(`<!--]--></nav><div class="page-head" data-v-3d118848><h1 data-v-3d118848>${ssrInterpolate(unref(t)("tab." + __props.tab))}</h1><span class="window-badge" data-v-3d118848>${ssrInterpolate(unref(t)("win.label"))} · ${ssrInterpolate(unref(winName)(__props.d?.win))}</span></div>`);
       if (__props.tab === "overview") {
         _push(ssrRenderComponent(_sfc_main$1, { d: __props.d }, null, _parent));
       } else if (__props.tab === "energy") {
-        _push(ssrRenderComponent(_sfc_main$3, { d: __props.d }, null, _parent));
+        _push(ssrRenderComponent(_sfc_main$4, { d: __props.d }, null, _parent));
       } else if (__props.tab === "component") {
-        _push(ssrRenderComponent(_sfc_main$5, {
+        _push(ssrRenderComponent(_sfc_main$6, {
           d: __props.d,
           sys: __props.sys
         }, null, _parent));
       } else if (__props.tab === "fault") {
-        _push(ssrRenderComponent(_sfc_main$2, { d: __props.d }, null, _parent));
+        _push(ssrRenderComponent(_sfc_main$3, { d: __props.d }, null, _parent));
+      } else if (__props.tab === "vibration") {
+        _push(ssrRenderComponent(_sfc_main$2, {
+          d: __props.d,
+          sub: __props.sub,
+          onSub: ($event) => emit("sub", $event)
+        }, null, _parent));
       } else if (pending.value) {
-        _push(`<section class="card" data-v-9b63d247><h2 data-v-9b63d247>${ssrInterpolate(unref(t)("tab." + __props.tab))} <small data-v-9b63d247>(本页签尚未迁移到 Vue)</small></h2><p class="hint" data-v-9b63d247> 迁移纪律:页签逐个搬,每搬一个就用 <code data-v-9b63d247>tools/render_parity.mjs</code> 与重构前页面 (现网 <code data-v-9b63d247>/detail/v2</code>)对拍文案/数值/类名序列,**通过后**才删对应的 Python 前端源码。 当前已迁移:${ssrInterpolate(unref(DONE).join("、"))}。 </p></section>`);
+        _push(`<section class="card" data-v-3d118848><h2 data-v-3d118848>${ssrInterpolate(unref(t)("tab." + __props.tab))} <small data-v-3d118848>(本页签尚未迁移到 Vue)</small></h2><p class="hint" data-v-3d118848> 迁移纪律:页签逐个搬,每搬一个就用 <code data-v-3d118848>tools/render_parity.mjs</code> 与重构前页面 (现网 <code data-v-3d118848>/detail/v2</code>)对拍文案/数值/类名序列,**通过后**才删对应的 Python 前端源码。 当前已迁移:${ssrInterpolate(unref(DONE).join("、"))}。 </p></section>`);
       } else {
         _push(`<!---->`);
       }
@@ -1716,7 +1922,7 @@ _sfc_main.setup = (props, ctx) => {
   (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/views/Workbench.vue");
   return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
 };
-const Workbench = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-9b63d247"]]);
+const Workbench = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-3d118848"]]);
 async function renderTab(d, tab = "overview", sub) {
   const app = createSSRApp(Workbench, { d, tab, sub: sub ?? null });
   return await renderToString(app);

+ 14 - 2
app_frontEnd/web/tools/render_parity.mjs

@@ -211,6 +211,11 @@ function cmpSubset(vueNums, baseNums, label) {
  */
 const ALLOW = [
   {
+    tab: 'vibration', prefix: '窗末月? (',
+    why: '与 fault 同类:列窗文案在"页面按窗缓存"与"实时 /api/fleet"两条路径上不同("(变化统计方法=同型机)" vs '
+       + '"(变化口径=同型机)")⇒ 非前端偏差;待后端统一口径后删除本例外。',
+  },
+  {
     tab: 'fault', prefix: '平均停机间隔(MTBO',
     why: '后端两条路径文案不一致(页面的按窗缓存 vs 实时 /api/fleet):"(MTBO统计方法)/(纯故障统计方法)" vs '
        + '"(MTBO口径)/(纯故障口径)"。两侧同一字段、同一模板 ⇒ 非前端偏差;待后端统一口径后删除本例外。',
@@ -221,7 +226,7 @@ const ALLOW = [
 function applyAllow(tab, baseTexts, vueTexts) {
   let used = 0
   const out = baseTexts.map((s, i) => {
-    const rule = ALLOW.find((r) => r.tab === tab && s.startsWith(r.prefix) && (vueTexts[i] || '').startsWith(r.prefix))
+    const rule = ALLOW.find((r) => r.tab === tab && s.includes(r.prefix) && (vueTexts[i] || '').includes(r.prefix))
     if (rule) { used += 1; return vueTexts[i] }
     return s
   })
@@ -265,9 +270,16 @@ for (const spec of TABS) {
     }
   }
   const baseTabs = Array.from(base.doc.querySelectorAll('#tabs button, #root [data-fsub]')).map((b) => norm(b.textContent))
+  // 例外同样作用于表格:单元格若命中登记例外(两侧都以同一前缀开头),取 Vue 侧文本再比
+  const allowTables = (bt, vt) => bt.map((rows, i) => rows.map((cells, r) =>
+    cells.map((c, k) => {
+      const rule = ALLOW.find((x) => x.tab === tab && c.includes(x.prefix)
+        && String((vt[i] || [])[r]?.[k] ?? '').includes(x.prefix))
+      return rule ? String((vt[i] || [])[r][k]) : c
+    })))
   const checks = [
     cmpList(allowRes.texts, textsIn(vroot.wrap), '文本序列'),
-    cmpDeep(tablesIn(base.root), tablesIn(vroot.wrap), '表格'),
+    cmpDeep(allowTables(tablesIn(base.root), tablesIn(vroot.wrap)), tablesIn(vroot.wrap), '表格'),
     cmpList(baseTabs, vroot.tabsLabel, '页签'),
   ]
   const svgN = svgNumbers(base.root)

+ 1 - 1
docs/src/数据要求说明_观澜_2.22.1.md → docs/src/数据要求说明_观澜_2.23.0.md

@@ -1,4 +1,4 @@
-# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.22.1
+# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.23.0
 
 ## 1 文档说明
 

+ 9 - 9
docs/src/系统设计说明_观澜_2.22.1.md → docs/src/系统设计说明_观澜_2.23.0.md

@@ -1,4 +1,4 @@
-# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.22.1
+# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.23.0
 
 ## 1 文档说明
 
@@ -6,19 +6,19 @@
 
 ### 1.1 目的与范围
 
-本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.22.1;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
+本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.23.0;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
 
 本文覆盖十五个设计面:总体架构与分层、目录结构与路径真源、数据接入与重算链、判级与算法、时间窗口径、服务与前端、本体与知识层、本机模型接入、运维控制台与重算编排、安装与服务化与版本管理、质量保证、安全与离线边界、可移植性与资源占用、多场适用性与换场迁移、已知边界与未实现。
 
-本文不重复需求条目本身(那是《需求分析_观澜_2.22.1.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
+本文不重复需求条目本身(那是《需求分析_观澜_2.23.0.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
 
 ### 1.2 读者与用法
 
 现场运维与检修人员可看第 3 章、第 7 章、第 8 章、第 11 章,了解自己能点到的页面背后读的是什么口径;场站管理人员与换场交付人员可看第 6 章、第 7 章、第 16 章、第 17 章,了解判级与可靠性指标的口径与边界,以及换场时要重新标定哪些参数;研发与交付人员应通读全文,重点是第 4 章、第 5 章、第 12 章、第 13 章;验收方可直接按第 13 章的质量门与附录 A 的编写依据逐条复核。
 
-### 1.3 与《需求分析_观澜_2.22.1.docx》的对应关系
+### 1.3 与《需求分析_观澜_2.23.0.docx》的对应关系
 
-需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.22.1.docx》的数据侧口径去向见 16.6 节。
+需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.23.0.docx》的数据侧口径去向见 16.6 节。
 
 | 本文章节 | 需求分析对应章 | 对应关系说明 |
 |---|---|---|
@@ -41,7 +41,7 @@
 
 ### 1.4 口径与依据
 
-本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.22.1;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.22.1.zip。
+本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.23.0;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.23.0.zip。
 
 本文遵循四条写作口径:含"窗"且确实指时间窗口的,一律写全"时间窗"(天气窗、作业窗、预览窗、观测窗属领域词,保持原样);影响的是风电机组时写"影响机组"或"影响机组数";使用英文简写时必须写成"中文(英文简写)"形式,如平均无故障间隔(MTBF)、平均停机间隔(MTBO)、单次停机时长(MDT);全文用简体中文与半角数字与单位。
 
@@ -839,7 +839,7 @@ Windows 安装入口是 install.bat 与 install.ps1,步骤号写死在输出
 
 ### 12.3 安装记录与版本三守卫
 
-安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.22.1,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
+安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.23.0,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
 
 | 字段 | 含义 |
 |---|---|
@@ -1200,7 +1200,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 ### 16.6 与同批交付文档的对应关系
 
-本章对应《需求分析_观澜_2.22.1.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.22.1.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
+本章对应《需求分析_观澜_2.23.0.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.23.0.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
 
 ***
 
@@ -1266,7 +1266,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 | 章节 | 来源文件 |
 |---|---|
-| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.22.1 条目)、docs/需求分析_观澜_2.22.1.docx、scripts/delivery_docs_figures.py |
+| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.23.0 条目)、docs/需求分析_观澜_2.23.0.docx、scripts/delivery_docs_figures.py |
 | 2 设计目标与原则 | docs/系统设计说明.md(四条设计铁律)、app_ontology/.../ontology/mcp_server.py 头部、src/windscada/subsys/fusion.py 头部、src/windscada/perf/reliability.py |
 | 3 总体架构 | configs/serve.json、scripts/guanlan_gateway.py、guanlan.py(组件启动表)、run/pids.json、docs/系统设计说明.md(产物全景) |
 | 4 目录结构与路径真源 | src/paths.py、configs/registry.yaml、docs/系统设计说明.md(路径约定与统一记录)、本次目录实测 |

+ 21 - 20
docs/src/需求分析_观澜_2.22.1.md → docs/src/需求分析_观澜_2.23.0.md

@@ -1,4 +1,4 @@
-# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.22.1
+# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.23.0
 
 ## 1 文档说明
 
@@ -6,7 +6,7 @@
 
 ### 1.1 目的
 
-本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.22.1.docx》《数据要求说明_观澜_2.22.1.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
+本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.23.0.docx》《数据要求说明_观澜_2.23.0.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
 
 本文的写作口径是**只写能取证的事实**:每个数字、每条结论都能指到仓库里的某个文件、某次实跑输出或某条版本记录;查不到、取不到的一律写明「未取证」或「未到位」,不做推测性补全。因此文中会出现少量「未取证」的说明句,那是刻意留下的诚实边界,不是遗漏。
 
@@ -18,19 +18,19 @@
 
 ### 1.3 版本对应关系
 
-系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.22.1,版本记录(HISTORY)共 43 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
+系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.23.0,版本记录(HISTORY)共 44 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
 
 表 1-1 版本与文档的对应关系
 
 | 项 | 值 | 取证方式 |
 |---|---|---|
-| 系统版本 | 2.22.1 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.22.1」 |
-| 交付包名 | app_guanlang_v2.22.1.zip | src/version.py 的 package_name();同一条 check 输出 |
-| 本文版本 | 2.22.1(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
+| 系统版本 | 2.23.0 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.23.0」 |
+| 交付包名 | app_guanlang_v2.23.0.zip | src/version.py 的 package_name();同一条 check 输出 |
+| 本文版本 | 2.23.0(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
 | 版本史条目数 | 13 条(最新一条即本次文档交付) | src/version.py 的 HISTORY;docs/版本记录.md |
 | 版本号规则 | v大版本号.中版本号.小版本号;大改方案或架构、中改非核心功能、小为消缺 | src/version.py 的 BUMP_RULE 与 LEVEL_MEANING;docs/系统设计说明.md §15 |
 | 版本级别机器判据 | level_of(旧, 新) 返回 major 或 minor 或 patch 或 same | src/version.py 的 level_of() |
-| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.22.1 条目的 level 字段 |
+| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.23.0 条目的 level 字段 |
 
 本版相对 2.10.0 的实质变化是**交付文档对外化**:三份交付文档(需求分析、系统设计说明、数据要求说明)全文不体现具体风电场(去标识化),并新增多风电场适用性一章;系统功能未变,属交付物修订。依据用户令原文:「修改三份文档,内容参考样本风电场,但不体现样本风电场,且具有不同风电场适用性」(引用时把样本场名按去标识化口径写成「样本风电场」)。
 
@@ -119,10 +119,11 @@
 
 ### 3.2 需求演进时间轴
 
-表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 43 条)
+表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 44 条)
 
 | 版本 | 日期 | 用户令要点 | 变化级别 |
 |---|---|---|---|
+| 2.23.0 | 2026-09-29 | vibration 的 matrix 子视图迁移完成,对拍 561 条文本一致 | 中(minor) |
 | 2.22.1 | 2026-09-29 | 对拍门支持子视图;vibration 侦察结论入档 | 小(patch) |
 | 2.22.0 | 2026-09-29 | fault 页签迁移完成;对拍门加“显式例外”并记下后端一处口径文案不一致 | 中(minor) |
 | 2.21.2 | 2026-09-29 | fault 对拍推进:Pareto 图内 HTML 摘要逐字复现,图数据改用原值 | 小(patch) |
@@ -167,7 +168,7 @@
 | 0.4.0 | 2026-09-17 | 打包默认不含输入数据与产物与日志与临时文件;无窗口启动;统一配置与日志;页面归口;服务化 | 旧编号(legacy) |
 | 0.2.0 | 2026-09-01 | 含产物的旧交付基线,历史上用作「包内无生成端」那批产物的补救源 | 旧编号(legacy) |
 
-需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 43 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的四十一个版本)。按级别统计,这四十一个版本里「中」级变化18条(2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化23条(2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
+需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 44 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的四十二个版本)。按级别统计,这四十二个版本里「中」级变化19条(2.23.0、2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化23条(2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
 
 ![图 1-1 观澜需求演进时间轴(依据 src/version.py 的 HISTORY)](figures/fig-req-01-需求演进时间轴.png)
 
@@ -186,7 +187,7 @@
 | 所有的计算均要形成观澜的源代码 | 逐族补生成端:变桨面、在升闭环、三层基线、融合面 handoff、总览页、由台账生成 claim | 反向呼应审计成立 3,548 件、不成立 0 件、未归类 0 件 |
 | 运行期一律不从交付包补齐 | 第 ⑤ 步由「补齐随包件」改为「反向呼应审计」,只报账不搬运 | rebuild_all --dry-run 计划中 ⑤ 步命令为 products_reverse_audit.py --check |
 | 清除产物不留备份 | products_state.py --off --yes 改真删除;--on 与门户恢复按钮移除 | 控制台按钮语义与 409 前置校验;docs/系统设计说明.md §7 |
-| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.22.1」 |
+| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.23.0」 |
 | 观澜改为监听所有 IP | serve.json 增 public_host 且出厂为 0.0.0.0,仅门户网关用它,组件仍绑本机 | check 实跑显示 6 个端口全部运行中;README 第二节写明无鉴权风险与回退口径 |
 | 描述口径:时间窗、影响机组、中文(英文简写) | 界面文案与报告构建器统一改写;i18n_en 键同步 | docs/系统设计说明.md §17.1 与 §17.2;语言包 845 条前后端成对 |
 | 整理需求与设计与数据接入各写一份 Word 文档 | docs/src 下 markdown 源件;渲染器输出带域目录的 docx;插图生成器生成 14 张图,数字全部从真件取 | HISTORY 2.10.0 条目;本版三份文档 |
@@ -402,7 +403,7 @@
 | NFR-09 | 安全只读:不写回现场系统、不改原始件、不产生控制指令;对外暴露时须自行在网侧限来源 | 原始件按只读输入对待,落位冲突默认拒绝;对外只有一个网关入口;启动日志每次提醒页面无鉴权 | docs/输入数据放置指导_v0.1.md §3;README_先读我.MD 第二节 |
 | NFR-10 | 可维护性:单一真源,改一处不必改多处;关键口径有机器守卫 | 版本号只有一行真源并由三处守卫盯住;路径、配置、日志、语言各有唯一取用口与审计器 | docs/系统设计说明.md §15.2 与 §11.1;docs/系统设计说明.md §3.1 |
 | NFR-11 | 无人值守与自愈:长跑的服务要能被托管并自动拉起 | 服务体每 15 秒巡检并拉起掉线组件;崩溃重启策略;远程部署一律用服务,不用会话前台进程 | docs/系统设计说明.md §14.1 与 §14.4;README_先读我.MD 第二节 |
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.22.1.docx》第 8 章与第 12 章 |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.23.0.docx》第 8 章与第 12 章 |
 
 ### 6.2 性能与耗时实测
 
@@ -438,7 +439,7 @@
 
 | 真源 | 管什么 | 守卫与实跑结果 |
 |---|---|---|
-| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.22.1 且记录表与代码一致 |
+| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.23.0 且记录表与代码一致 |
 | src/paths.py | 一切路径解析的基准与助手 | 配置审计器检查不手拼路径;实跑无不一致 |
 | configs 目录与登记表 | 端口、模型档、场配置、页面归口、配置登记 | 配置审计实跑已知缺口与白名单 14 条、提示 7 条 |
 | src/logfile.py | 日志目录、命名、行格式、保留策略 | 日志审计实跑无不一致、提示 87 条 |
@@ -449,7 +450,7 @@
 
 ### 7.1 数据族与功能映射
 
-系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.22.1.docx》。
+系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.23.0.docx》。
 
 表 7-1 数据族到功能的映射(样本场实测(2026-09-22))
 
@@ -482,7 +483,7 @@
 
 ### 7.3 与数据要求说明的分工
 
-本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.22.1.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
+本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.23.0.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
 
 ## 8 页面与信息架构需求
 
@@ -564,7 +565,7 @@
 | 运行环境与依赖 | 10 | Python 版本不低于 3.11(实测 3.12.10);九个第三方依赖逐个导入 | 全 OK |
 | 静态质量门 | 4 | 源码可编译 229 个文件;语言包 845 条成对;入口引用闭合 48 条;入口脚本编码守则 | 全 OK |
 | 子进程口径 | 3 | 捕获输出可用;无窗口启动输出进日志;无窗口位已设,标志位 0x8000200 | 全 OK |
-| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.22.1,包名 app_guanlang_v2.22.1.zip);卸载入口两个都在位 | 全 OK |
+| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.23.0,包名 app_guanlang_v2.23.0.zip);卸载入口两个都在位 | 全 OK |
 | 制品与台账审计 | 5 | 反向呼应 3,548 件成立;页面归口检查 21 项;配置统一;日志统一;输入数据放置体检 26,503 件结构合规 | 全 OK |
 | 产物与发布件在位 | 10 | 标准仓、本体对象库、findings、事实契约、门户、仿真合页服务与资料包、三维资产、仿真回放资产、治理清单交付件 | 全 OK |
 | 原始件目录 | 1 | 原始件目录存在(无数据时此项不影响页面) | 全 OK |
@@ -690,7 +691,7 @@
 
 本章有两个词要先定义。「本场」指**当前被选中的场**,由配置选定,不必然等于本文取数的样本风电场;「换场」指从一场切到另一场运行的整套动作(改配置、放数据、重跑、按检查表复验),不是把两场的数据混在一棵树里。本章沿用的占位符与附录 A 一致:`<场>`、`<场站>`、`<机型>` 按实际风电场替换。
 
-本章需求与《数据要求说明_观澜_2.22.1.docx》《系统设计说明_观澜_2.22.1.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
+本章需求与《数据要求说明_观澜_2.23.0.docx》《系统设计说明_观澜_2.23.0.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
 
 取证边界要如实写一句:FR-44 至 FR-51 与 NFR-12 有既有的场配置层、摄入层与场级字段语义登记作依据;FR-52 的跨场横向对比是本次新增的口径要求,其实跑记录与机器守卫随后续版本补齐——本文不谎称已经通过。
 
@@ -769,7 +770,7 @@
 
 | 编号 | 需求 | 判据 | 依据文件 |
 |---|---|---|---|
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.22.1.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.23.0.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
 
 ### 11.9 换场验收需求
 
@@ -912,8 +913,8 @@
 | 场定义与机型与物理约束参数的写法样例 | app_ETL/configs/farms/<场>.yaml |
 | 机型—场站数据契约与字段单位真源 | app_ETL/configs/contracts/<机型>_<场>.yaml 与 app_ETL/configs/canonical/dictionary.yaml |
 | 场级字段语义登记与其它厂商导出形态的对齐 | app_ETL/configs/farms/<场>/field_semantic_registry.yaml |
-| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.22.1.md |
-| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.22.1.md |
+| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.23.0.md |
+| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.23.0.md |
 
 表中 `<场>`、`<场站>`、`<机型>` 为占位符,按实际风电场替换。
 
@@ -956,4 +957,4 @@
 | 换场 | — | 从一场切到另一场运行的整套动作:改配置、放数据、重跑、按检查表复验 | 本文 §11.9 |
 | 缺族降级 | — | 某族数据缺失时按缺件如实标注并降低该族功能,不造数、不用他场数字顶替 | 本文 FR-49 |
 | 按场标定 | — | 把阈值、时间窗锚点、限电窗、温度档带宽等场相关参数按本场取值 | 本文 FR-50 |
-| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.22.1.md |
+| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.23.0.md |

BIN
docs/数据要求说明_观澜_2.22.1.docx


BIN
docs/数据要求说明_观澜_2.23.0.docx


+ 6 - 5
docs/版本记录.md

@@ -7,7 +7,7 @@
 <!-- VERSION-LOG:BEGIN -->
 ## 版本号规则(用户令 2026-09-17)
 
-编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.22.1`。
+编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.23.0`。
 
 | 级别 | 含义 |
 |---|---|
@@ -17,12 +17,13 @@
 
 **怎么改**:改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级
 
-**打包文件名** = `app_guanlang_v2.22.1.zip`(规则:`app_guanlang_v<版本号>.zip`)。
+**打包文件名** = `app_guanlang_v2.23.0.zip`(规则:`app_guanlang_v<版本号>.zip`)。
 
-版本号只写在 `src/version.py` 一行(`VERSION = '2.22.1'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
+版本号只写在 `src/version.py` 一行(`VERSION = '2.23.0'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
 
 | 版本 | 日期 | 级别 | 主要变更 | 说明 |
 |---|---|---|---|---|
+| `v2.23.0` | 2026-09-29 | 中 | vibration 的 matrix 子视图迁移完成(5 子视图中的第 1 个),对拍 561 条文本一致 | 逐子视图推进 ⇒ 中版本 +1。① `Vibration.vue` + `src/lib/vib.ts`:把旧 `fusMatrix` 与它的构件(`spark`/`famBar`/`duo`/`tierWord`/`shortOf`/`cell`/状态色表)**逐字转写**,用 v-html 渲染 —— 旧代码本就是拼 HTML 字符串,转写后 DOM 与文本节点边界天然一致。② `VIBJARGON`(技术速记→人话,6 行长正则数组)改为**从 app.js 原样抽出**(`src/lib/vibjargon.ts`),避免手抄漏项。③ pill 键名实逮:旧用 `fus.sub.<s>`(我写成 `fus.s.<s>` ⇒ 5 个 pill 全显键名)。④ 对拍结果:`vibration:matrix` **561 条文本 + 1 张表一致**(含 1 条登记例外);既有四页签重跑仍全过。⑤ **vibration 仍未列入 DONE** —— 余下 4 个子视图(loop/units/events/cms)未迁;cms 还需按需异步取 `/api/vibcms`。⑥ 对拍门增强:例外清单改为"包含匹配"并**同时作用于文本与表格**。 |
 | `v2.22.1` | 2026-09-29 | 小 | 对拍门支持子视图(页签:子视图);vibration 侦察结论入档 | 进行中 ⇒ 小版本 +1。① 对拍门新增**子视图**口径:`node tools/render_parity.mjs <网关> vibration:matrix` —— 基准侧加载后**点一下**对应 pill(旧页面 `data-fsub`)再抽锚点,Vue 侧把 `sub` 传进组件;`renderTab(d, tab, sub)`、Workbench 增 `sub` prop、页签文案比对含 `[data-fsub]`。② vibration 侦察结论(本轮实测):该页签内部分 **5 个子视图**(matrix/loop/units/events/cms,旧 `FUS.sub`),**不含图表**(无需 ECharts),但依赖约 10 个 SVG/HTML 小构件(`spark`/`famBar`/`baBar`/`winBar`/`devBar`/`chainMini`/`chainStates`/`oilPill`/`evLadder`/`gc`),且 `cms` 子视图按需异步取 `/api/vibcms` ⇒ 需逐子视图迁并对拍。③ 四个已迁页签重跑仍全过(overview 138 · energy 51+图11/11 · component 260 · fault 133 含 1 例外)。 |
 | `v2.22.0` | 2026-09-29 | 中 | fault 页签迁移完成(4/10 过对拍);对拍门加“显式例外”并记下后端一处口径文案不一致 | 用户令「迁 energy/component/fault 三页签」的第三个落地 ⇒ 中版本 +1。① `fault`(故障统计)在**严格整页签**口径下与重构前呈现一致:文本 133 条(含 1 条登记例外)、2 张表全同、图数据 132 ⊆ 旧图。本轮补齐的关键三处:**Pareto 图的 HTML 摘要**("关键少数: 前 N 项占 80%"、"前三: <b>码</b> 名 · …"、note —— 旧 `paretoChart` 返回的 HTML 而非 SVG,用定点取证 `tools/probe_baseline.mjs` 查明)、**季节性图的 HTML 图例**(旧 `multiline` 的 `.legend`,4 条系列名)、**rate 示例的守卫条件**(旧代码 `i0>=0 && mo.per_day` 即便值为 null 也照样渲染)。② 图数据子集规则下把 `__data__` 收敛到"旧图真的写成 SVG 文字的数字"(柱状/条形图的值;散点/折线只标刻度故为空),并改正两处口径:`ramChart` 旧为 **MTBF×MDT 散点**、`attribBar` 旧分组为 **ok=总−无记录−未归类 / unk / un2**。③ 对拍门加**显式例外清单**(ALLOW,每条必须写理由并在结论里报数):已登记 1 条 —— `m8.mtbf.口径` 在后端**两条路径**上文案不一致(页面的按窗缓存给"(MTBO统计方法)/(纯故障统计方法)",实时 `/api/fleet` 给"(MTBO口径)/(纯故障口径)");两侧同字段同模板 ⇒ 非前端偏差,**待后端统一口径后删除该例外**(已作为遗留项记录)。④ 顺带实逮并修正对拍工具:页面有 pending 轮询,桩若记录"最后一次"响应,两侧数据版本会不同 ⇒ 改为"记录页面**第一次**拿到的 /api/fleet 并回放给后续请求"。⑤ 进度:**overview · energy · component · fault 四页签过对拍**;余 vibration/generation/decision/assistant/report/system 六个。 |
 | `v2.21.2` | 2026-09-29 | 小 | fault 对拍推进:Pareto 图内 HTML 摘要逐字复现(含前三),图数据改用原值 | 进行中 ⇒ 小版本 +1。① 定点取证 `tools/probe_baseline.mjs`(在旧页面 DOM 里按文本找父元素)实逮:旧 `paretoChart` 除 SVG 外还返回一段 HTML —— `<b>关键少数: 前 N 项占 80%</b> (共 M 项, 合计 X unit)<br>前三: <b>码</b> 名 · …<br>note`;本版按同构 v-html 复现(三处 Pareto:次数/时长/停机),文本差异由 133≠103 收敛到 **133≠129**。② 图数据比对:`__data__` 改用**原图打印的原值**(旧图 `txt(..., v)` 直接写值,不四舍五入);并把 ramChart(旧为 MTBF×MDT 散点)与 attribBar(旧分组 ok=总−无记录−未归类 / unk=无记录 / un2=未归类)的口径改正 —— 此前我给的是"停机时长柱/设备·外部·无记录",属**口径用错**。③ 仍差 4 条文本:停机 Pareto 摘要"前三"的第 2/3 项与其分隔符(“1020 / 就地操作模式 · / 2700 / 塔筒频率外部允许窗口”)与一处注文顺序;`fault` **仍在 IN_PROGRESS**,不列入 DONE。 |
@@ -69,8 +70,8 @@
 
 ## 本次交付
 
-* 版本:`v2.22.1`(观澜·如东样板 v2)
-* 交付包:`app_guanlang_v2.22.1.zip`
+* 版本:`v2.23.0`(观澜·如东样板 v2)
+* 交付包:`app_guanlang_v2.23.0.zip`
 * 服务名:`guanlan`(Windows 服务 / systemd 单元)
 * 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`
 <!-- VERSION-LOG:END -->

BIN
docs/系统设计说明_观澜_2.22.1.docx → docs/系统设计说明_观澜_2.23.0.docx


BIN
docs/需求分析_观澜_2.22.1.docx → docs/需求分析_观澜_2.23.0.docx


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 4
release/web/assets/index-BFtqPKns.js


+ 1 - 1
release/web/assets/index-iFLOxPfX.css → release/web/assets/index-DLZixVsG.css

@@ -1 +1 @@
-.echart[data-v-999bee15]{width:100%}.wb-tabs[data-v-d9042022]{display:flex;flex-wrap:wrap;gap:6px;padding:10px 18px 0}.wb-tabs button[data-v-d9042022]{padding:6px 12px;border:1px solid var(--line);background:#fff;border-radius:4px 4px 0 0;cursor:pointer;color:var(--ink)}.wb-tabs button.on[data-v-d9042022]{background:var(--brand);border-color:var(--brand);color:#fff}.todo[data-v-d9042022]{font-size:10px;color:var(--muted);margin-left:2px}.wb-tabs button.on .todo[data-v-d9042022]{color:#ffe9a8}.page-head[data-v-d9042022]{display:flex;align-items:baseline;gap:12px;padding:10px 18px 0}.page-head h1[data-v-d9042022]{font-size:18px;margin:0}.window-badge[data-v-d9042022]{color:var(--muted);font-size:12.5px}:root{--ink: #16202c;--muted: #6b7a8d;--line: #d7dee8;--bg: #f5f7fa;--card: #ffffff;--brand: #0b5fa5;--bad: #b42318}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 Microsoft YaHei,PingFang SC,system-ui,sans-serif}.top{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;background:var(--brand);color:#fff}.top .brand{font-size:16px;font-weight:600;letter-spacing:.5px}.top nav button{margin-left:8px;padding:6px 12px;border:1px solid rgba(255,255,255,.5);background:transparent;color:#fff;border-radius:4px;cursor:pointer}.top nav button.on{background:#fff;color:var(--brand)}main{padding:16px 18px 40px}.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:14px 16px;margin-bottom:14px}.card h2{margin:0 0 10px;font-size:15px}.card h2 small{color:var(--muted);font-weight:400}.hint{color:var(--muted);font-size:12.5px}table{width:100%;border-collapse:collapse;font-size:13px}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left}th{background:#eef2f7;font-weight:600}tr.bad td{color:var(--bad)}code{font-family:Consolas,Courier New,monospace;font-size:12.5px}button.re{margin-left:10px;padding:2px 10px;cursor:pointer}ul{margin:6px 0 0 18px;padding:0}
+.echart[data-v-999bee15]{width:100%}.wb-tabs[data-v-3d118848]{display:flex;flex-wrap:wrap;gap:6px;padding:10px 18px 0}.wb-tabs button[data-v-3d118848]{padding:6px 12px;border:1px solid var(--line);background:#fff;border-radius:4px 4px 0 0;cursor:pointer;color:var(--ink)}.wb-tabs button.on[data-v-3d118848]{background:var(--brand);border-color:var(--brand);color:#fff}.todo[data-v-3d118848]{font-size:10px;color:var(--muted);margin-left:2px}.wb-tabs button.on .todo[data-v-3d118848]{color:#ffe9a8}.page-head[data-v-3d118848]{display:flex;align-items:baseline;gap:12px;padding:10px 18px 0}.page-head h1[data-v-3d118848]{font-size:18px;margin:0}.window-badge[data-v-3d118848]{color:var(--muted);font-size:12.5px}:root{--ink: #16202c;--muted: #6b7a8d;--line: #d7dee8;--bg: #f5f7fa;--card: #ffffff;--brand: #0b5fa5;--bad: #b42318}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 Microsoft YaHei,PingFang SC,system-ui,sans-serif}.top{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;background:var(--brand);color:#fff}.top .brand{font-size:16px;font-weight:600;letter-spacing:.5px}.top nav button{margin-left:8px;padding:6px 12px;border:1px solid rgba(255,255,255,.5);background:transparent;color:#fff;border-radius:4px;cursor:pointer}.top nav button.on{background:#fff;color:var(--brand)}main{padding:16px 18px 40px}.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:14px 16px;margin-bottom:14px}.card h2{margin:0 0 10px;font-size:15px}.card h2 small{color:var(--muted);font-weight:400}.hint{color:var(--muted);font-size:12.5px}table{width:100%;border-collapse:collapse;font-size:13px}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left}th{background:#eef2f7;font-weight:600}tr.bad td{color:var(--bad)}code{font-family:Consolas,Courier New,monospace;font-size:12.5px}button.re{margin-left:10px;padding:2px 10px;cursor:pointer}ul{margin:6px 0 0 18px;padding:0}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
release/web/assets/index-DzAKXroE.js


+ 2 - 2
release/web/index.html

@@ -4,8 +4,8 @@
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>观澜 · 风电数据分析与决策支持</title>
-  <script type="module" crossorigin src="./assets/index-BFtqPKns.js"></script>
-  <link rel="stylesheet" crossorigin href="./assets/index-iFLOxPfX.css">
+  <script type="module" crossorigin src="./assets/index-DzAKXroE.js"></script>
+  <link rel="stylesheet" crossorigin href="./assets/index-DLZixVsG.css">
 </head>
 <body>
   <div id="app"></div>

Vissa filer visades inte eftersom för många filer har ändrats