#!/usr/bin/env python3 # -*- coding: utf-8 -*- """windscada serve (M7c): 交互后端 — 工作台(全场)+分台页, 全部带时间窗. 设计: ①台页只渲染"有问题的系统"的证据图(分册两型: A=判据线+摆幅标注, B=本台vs全场四分位带), 无问题不出图; ②台内交互查询: 任一契约通道×时间窗×聚合 即时出图; ③工作台: 系统分类问题(全系统)+故障统计梳理+SOP控制策略; ④时间窗: 月度类数据真窗; 判级类(变桨/偏航/蓄能=近90日, 曲线=2025H2判别窗)固定窗如实标注. 端口 8033. 判级=筛查级/候选, P级归综合报告线.""" import sys as _sys, pathlib as _plb _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1])) from src import paths as P import sys, json, pathlib, re, threading, urllib.parse, collections import hashlib, time sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # 安装根入 sys.path (先于 src.* 导入) from src import paths as _P # 路径唯一真源 (与 cwd 无关) import numpy as np, pandas as pd import math def _jclean(o): """NaN/Inf → null (非法 JSON 防线; pd.DataFrame 会把 None 变 NaN).""" if isinstance(o, float) and (math.isnan(o) or math.isinf(o)): return None if isinstance(o, dict): return {k: _jclean(v) for k, v in o.items()} if isinstance(o, (list, tuple)): return [_jclean(x) for x in o] return o # ── 客户交付脱敏 (2026-08-28 demo 门禁逮出 7 处特征频率 + 21 处判据门槛上屏) ── # 规则来自 memory client-deliverable-hide-core-parameters: 判据名与实测值可见, 数值门槛与特征频率隐去。 # 分级而非一刀切 — 一刀切会把业主必须知道的口径也糊掉: # 脱 特征频率 (134.9Hz 是我们解出来的轴承几何, 属核心参数) # 脱 判据门槛 (≥40kW / |z|≥3 / ≥2.5× — 我们的判别阈值) # 留 时间口径 (≥1h 段 / ≥3 天 — 业主要据此理解统计范围) # 留 物理常数 (Cp ≤ 0.593 贝兹极限 — 公开知识, 不是我们的) # 留 台数与占比 (≥2 个系统报警 — 业主自己数得出来) # 内部模式: WINDSCADA_INTERNAL=1 import os as _os INTERNAL = _os.environ.get('WINDSCADA_INTERNAL', '') == '1' # ★ Hz 后不能用 \b: 中文属于 \w, "597Hz线" 里 z 与 线 之间没有词边界 ⇒ \b 不成立, 实测漏脱 WTG05 的 597Hz。 # ★ 前瞻也不能用 \w: 中文同属 \w, "HS_gen测点274Hz" 里 274 前是"点" ⇒ 断言失败漏脱。 # 只需排除数字与小数点 (防止把 1274 截成 274)。 _RE_HZ = re.compile(r'(?r.st==='报警')` 零命中 → **报警 12 台整表不渲染**, # 而柱状条读的是 sysdist (**键**没被翻) 仍显示 "Alarm 12" ⇒ 数字与明细自相矛盾。 # · ostate 被翻成 Good/Excellent → `C[c.ostate]` (C 是中文键) 全部 MISS # → 融合矩阵**每一格颜色都退成灰**。 # 判据: 凡是 JS 里 `X[obj.字段]` 查中文键表、或 `obj.字段==='中文'` 比较的字段, 一律进本表。 # 显示层 (ui_en.value_script) 负责把渲染出来的中文文本换成英文, 所以人看到的仍是英文。 # tests/test_keep_zh_discriminators.py 扫源码断言本表齐全, 防以后加字段漏登记。 _KEEP_ZH = frozenset(( 'st', 'ostate', 'wlevel', '状态', '设备状态', '证据状态', '部件', 'liveness', 'loc', # 模型本地/云端档位: `v.loc==='本地'` 决定"数据不出本地"这句能不能说 # ★第三类判别用法, 本闸的扫描器 (tests/test_keep_zh_discriminators.py) 抓不到: # 不是比较也不是查表, 而是拼进 URL 路径当 ID (`/problem/${t}/${encodeURIComponent(x)}`)。 # syss 被翻成 'Yaw' 后, 浮层请求 sys=Yaw 永远查不到烤的是 sys=偏航 的快照键, # 点开必是"本窗无记录"——页面不报错, 数据静默消失, 和 st/ostate 那两类同一后果不同机制。 # 2026-09-03 用户实测逮出 (Watch list → 系统名链接)。显示文字仍由 sysName(x) 客户端译。 'syss', )) # ★按**值**豁免 —— 给 `k` 这种一名多用的字段用。 # `k` 在告警排行里是显示串 (`"7122 偏航液压油位低"`, 要翻), # 在控制状态与部件配色里是枚举键 (`'停机'` / `'齿轮箱'`, 不能翻)。 # 键名分不开两者, 只能按值。2026-09-02 实测: 我先把 'k' 整个放进 _KEEP_ZH, # 结果**报警名全部不再翻译** —— 修一个洞挖一个洞, 按值豁免才收得住边界。 # 来源: JS 里 CO / LOSSPLAIN 两张中文键表的键 + `x.k==='停机'` 比较。 _KEEP_ZH_VALS = frozenset(( '齿轮箱', '发电机', '主轴承', '变桨叶片', '偏航', '测风', '其他', # CO 配色键 '限电·绑定', '限电命令·未绑定', '停机(调度令)', '停机', '正常发电', '低风待机', # LOSSPLAIN 键 )) def _proj(o, lang): """把 `key_zh`/`key_en` 投影成裸 `key`。 英文侧取不到 `_en` 时写 `[translation pending]` 而**不回落中文** —— 回落会让缺口永远不被发现, 正是英文版残留三轮没被逮住的机制。 """ if isinstance(o, list): return [_proj(x, lang) for x in o] if not isinstance(o, dict): return o suf = '_' + lang other = '_zh' if lang == 'en' else '_en' # ★裸 `zh` / `en` 成对 (framework.py 的 SYSTEMS/RECORDS/stages 就是这个写法)。 # 它们不带下划线, 走不到下面的后缀规则; 而末尾"残留中文一律写 [translation pending]" # 的兜底会把 zh 打成 pending —— **旁边的 en 明明就是对的**。实测 145 处 pending # 里的大多数是这一种。消费端 JS 读的是 `.zh`, 所以英文版把 en 的值投影到 zh 上。 if lang != 'zh' and isinstance(o, dict) and 'zh' in o and 'en' in o: o = dict(o, zh=o['en']) # ★只有 k_en **有值**时才让裸 k 让位 (2026-09-01 parity 闸逮)。 # 原来无条件让位: 上游给了 action_en=None, 裸 action 就被顶成 None —— # 页面上显示 "None", 比留中文糟得多。空的语言副本等于没给, 不该覆盖原值。 out = {} source_fields = {'name', '步骤', '故障', '排查', '原因', '处置', 'check'} originals = {key: o.get(key + '_zh', o.get(key)) for key in source_fields} if lang == 'en' else {} originals = {key: value for key, value in originals.items() if isinstance(value, str) and _CJK.search(value)} langed = {k[:-len(suf)] for k in o if k.endswith(suf) and o[k] not in (None, '', [])} for k, v in o.items(): if k == '_original': out[k] = dict(v) if isinstance(v, dict) else v continue if k.endswith(other): # 另一语言的副本不出 API continue if k.endswith(suf): if v not in (None, '', []): out[k[:-len(suf)]] = _proj(v, lang) continue if k in langed: # 已有 key_, 裸 key 让位 continue v2 = _proj(v, lang) if k in _KEEP_ZH or (isinstance(v2, str) and v2 in _KEEP_ZH_VALS): out[k] = v2 # 判别值/查表键: 原样留中文 (见 _KEEP_ZH 注释) continue # 枚举层先查码表 — 这些值有确定译名, 不该退化成 [translation pending]。 # 列表型枚举 (如 facets) 逐项查。 if lang != 'zh' and _codes is not None and k in _codes.CODES: if isinstance(v2, str): c = _codes.normalize(k, v2) if c: v2 = _codes.label(k, c, lang) else: v2 = _enum_fallback(k, v2, lang) elif isinstance(v2, list): v2 = [(_codes.label(k, _codes.normalize(k, x), lang) if isinstance(x, str) and _codes.normalize(k, x) else x) for x in v2] # 单数字段名对应复数码表 (facet ← facets) 也走一遍 elif lang != 'zh' and _codes is not None and k.rstrip('s') in _codes.CODES: f = k.rstrip('s') if isinstance(v2, str): c = _codes.normalize(f, v2) if c: v2 = _codes.label(f, c, lang) elif isinstance(v2, list): v2 = [(_codes.label(f, _codes.normalize(f, x), lang) if isinstance(x, str) and _codes.normalize(f, x) else x) for x in v2] # ★这里原来只判 isinstance(v2, str) —— **字符串列表整个绕过了英文投影**。 # _proj 递归进 list, 但 _proj(str) 原样返回, 所以 ["偏航","变桨"] 这种 # 一路穿到 API。实测漏掉 5 条路径 (parts[] / syss[] / srcs[] / # open_items[] / blind[]), 是英文页数据层残留中文的唯一来源。 if lang == 'en': # Action counts use display labels as dictionary keys, so _en_deep # cannot reach them. Only this display map uses the established # work-order action glossary; all other dictionary/URL IDs stay put. if k == '动作统计' and isinstance(v2, dict): from src.windscada.maint.workorder_en import ACTION v2 = {ACTION.get(action) or action: count for action, count in v2.items()} v2 = _en_deep(v2, k) out[k] = v2 for key, original in originals.items(): if key in out and out[key] != original: out.setdefault('_original', {})[key] = original return out def _en_deep(v, field='', _depth=0): """对字符串与**字符串列表/嵌套列表**一律走 _data_text_en。 只对 str 生效的旧写法会让 list-of-str 静默穿透 —— 这类漏法的特征是 "抽查单值全对, 页面上却成片中文", 因为漏的是容器不是值。 """ if isinstance(v, str): return _data_text_en(v, field) if _CJK.search(v) else v if isinstance(v, list) and _depth < 4: return [_en_deep(x, field, _depth + 1) for x in v] return v _DATA_EN_CACHE = {} _DATA_EN_MISS = set() # 查不到的原文集合 (与命中缓存分开: 未命中的返回值随请求模式而异) def _data_text_en(v, field=''): """数据层中文文本 → 英文。取不到才写 [translation pending]。 ★为什么要有这一层: 原实现是"残留中文一律 [translation pending]" —— 干净但过于绝对。 实测 /api/fleet 一次就打出 **2199 处** pending, 而其中大量是厂商报警名、工单原文、 部件名、判级词, 这些在本项目里**已经有译源**: 厂商中英对照表 3834 条、IPC 部件表 2113 条、枚举码表、工单名构词表。不查就直接标 pending, 等于把已有的资产扔掉, 还让英文页看起来像没做完。 查不到仍写 pending —— **不猜译**。这条不能松: 猜译出来的厂商报警名会误导现场。 """ if v in _DATA_EN_CACHE: return _DATA_EN_CACHE[v] if v in _DATA_EN_MISS: return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]' out = None # ⓪⁻ 数据层**整串**直译表 (融合面盲区/未决项长文)。放在最前: 这些是整段人写的 # 分析结论, 走模板反解或分层词表都只会碎成片段。 try: from src.windscada import data_tpl_en as _tplN out = getattr(_tplN, 'NOTES', {}).get(v) if out is None: from src.windscada.en_text import standard_term out = standard_term(v) if out is None: out = getattr(_tplN, 'RECORD_EN', {}).get(v) except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out # ⓪ 「码 + 中文描述」→ 本体 AlarmCode 的权威 name_en (查表, 不翻译) try: from src.windscada import en_text as _et0 out = _et0.alarm_en(v) except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out # ① 服务端拼串走**模板反解** (精确, 非翻译): 抽数字得形态 → 查英文模板 → 数字按序填回 try: from src.windscada import data_tpl_en as _tpl out = _tpl.render(v) except ValueError as e: print('[_data_text_en] 模板占位符对不上, 已跳过不填: %s' % e, file=sys.stderr) out = None except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out try: from src.windscada import en_text as _et # ① 分层查表 (厂商报警 → IPC 部件 → 枚举/文案) for sc in ('fault', 'part', 'ui', None): hit, _src = _et.lookup(v, sc) if sc else _et.lookup(v) if hit: out = hit break # ② 部件自由文本解析 (复合名/带注解/多部件连写) if out is None: out = _et.part_en(v) except Exception: pass # ②b 词表直查 —— 部件名/判级词/系统名作为**独立字段**出现时 (fus.表[].部件 / .振动结论 / # rel.表[].部件), 既不是模板串也不在 en_text 分层表里, 而 data_tpl_en 的 # CHAN/GRADE2/SYSTEM_EN 早就登记了同一批词 (供模板内部用)。不查就整片留中文 # (2026-09-03 实逮 fus.表 328 字)。**同一批词只因出现位置不同就译不出, 是接线漏不是词条漏。** if out is None: try: from src.windscada import data_tpl_en as _t2 out = _t2.CHAN.get(v) or _t2.SYSTEM_EN.get(v) if out is None: g = _t2.GRADE2.get(v) or _t2.GRADE.get(v) if g: # 判级词在表里是小写短语 (模板内嵌用), 独立成字段要首字母大写 out = g[0].upper() + g[1:] except Exception: pass if out is None: try: from src.windscada.maint import workname_en as _wn # ★只用 work_name_en (逐段都命中才拼, 命不中返回 None), **不用 category_en**。 # category_en 是正则归类兜底: 它对看不懂的文本也会给出一个"像样"的答案。 # 实测它把化验样注记译成 work item: # '末样2025-01-09 (距振动现窗>12月, stale: 只作检测链回测)' # → 'Condition monitoring — work item' ← 错, 那不是工单 # '末样2026-07-13: 华标判正常(NB/T宽限); 重点值…' # → 'Generator — work item' ← 错且误导 # 我在本函数的说明里写了"不猜译"然后把猜译器接了进来。 # **看得懂就译, 看不懂就标 pending —— pending 是诚实的, 猜译不是。** out = _wn.work_name_en(v) except Exception: pass if out: _DATA_EN_CACHE[v] = out return out # 查不到: 经典页写 [translation pending] (显示层再藏); v2 (keep=1) **留原文** —— 前端按原文 # 标注为"手册原文", 不丢信息也不冒充英文。命中与未命中分开缓存, 两种模式互不污染。 _DATA_EN_MISS.add(v) return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]' def maint_framework_view(): """运维管理体系三维骨架 —— **唯一实现** (在线 API 与快照预烤共用)。 ★同 dq_findings_view 的教训: 这段原来也是两份, 在线那份我接了 unmapped_top 的 英文化, 预烤那份没有 ⇒ 在线干净、快照里还是中文, 而上云的是快照。 抽出来一次, 消掉这类"改了一半"的可能。 """ import json as _j, pathlib as _p from src.windscada.maint import framework as _fw db = _j.loads(_P.objects_json().read_text(encoding='utf-8')) wo = [(v.get('props') or {}) for k, v in db.items() if k.startswith('workorder/')] r = dict(systems=_fw.system_load(wo)['rows'], tiers=_fw.systems_by_tier(), stages=_fw.stage_matrix(), records=_fw.RECORDS, gaps=_fw.capability_gaps(wo), coverage=_fw.coverage(wo)) # unmapped_top = **未映射到系统码的现场手写部件名**, 不是枚举, 码表投影对它无效。 # 走 part_en 自由文本解析; 空值在这个语境下写 "(not recorded)" 而非破折号。 lang = _lang_of() if lang and lang != 'zh' and (r.get('gaps') or {}).get('unmapped_top'): try: from src.windscada import en_text as _et out = [] for it in r['gaps']['unmapped_top']: if isinstance(it, (list, tuple)) and len(it) == 2: e = _et.part_en(it[0]) or it[0] out.append(['(not recorded)' if e == '\u2014' else e, it[1]]) else: out.append(it) r['gaps'] = dict(r['gaps'], unmapped_top=out) except Exception as e: print('[maint_framework_view] unmapped_top 英文化失败: %s' % e, file=sys.stderr) return r def dq_findings_view(): """数据可信度 finding 行 —— **唯一实现**。 ★2026-08-31: 这段原来存在两份 —— 一份在 /api/dq_findings 处理分支, 一份在快照的 `_dq()` 预烤闭包里。我给 API 那份补了 sensitivity / age_basis 等字段, 预烤那份没动 ⇒ **在线看得到, 快照里没有**。而上云交付的恰恰是快照。 同型教训已在 memory `library-gate-not-wired-to-production`: 落库不等于接上生产路径。 这里直接消掉重复: 两边都调本函数。 """ import json as _j, pathlib as _p db = _j.loads(_P.objects_json().read_text(encoding='utf-8')) KEEP = ('finding/data_quality/', 'finding/caliber/', 'finding/system_coverage/', 'finding/tier_trend/') rows = [] for k, o in db.items(): if not k.startswith(KEEP) or not isinstance(o, dict): continue p2 = o.get('props') or {} rows.append(dict( id=k, kind=k.split('/')[1], name=p2.get('name_zh') or p2.get('name'), name_zh=p2.get('name_zh') or p2.get('name'), name_en=p2.get('name_en'), grade=p2.get('grade'), claim_window=p2.get('claim_window'), evidence=p2.get('evidence_zh') or p2.get('evidence') or [], evidence_zh=p2.get('evidence_zh') or [], evidence_en=p2.get('evidence_en') or [], caveat=p2.get('caveat'), caveat_en=p2.get('caveat_en'), impact=p2.get('discriminator_zh') or p2.get('disposition_zh') or p2.get('implication_zh') or p2.get('corrected_comparison_zh'), impact_en=p2.get('discriminator_en') or p2.get('disposition_en') or p2.get('implication_en') or p2.get('corrected_comparison_en'), got_wrong=p2.get('what_i_got_wrong_zh'), got_wrong_en=p2.get('what_i_got_wrong_en'), quality_flag=p2.get('quality_flag_zh'), quality_flag_en=p2.get('quality_flag_en'), robust=p2.get('robust_zh'), robust_en=p2.get('robust_en'), basis=p2.get('basis_zh'), basis_en=p2.get('basis_en'), sensitivity=(p2.get('sensitivity') or {}).get('note_zh'), sensitivity_en=(p2.get('sensitivity') or {}).get('note_en'), age_basis=p2.get('age_basis_zh'), age_basis_en=p2.get('age_basis_en'), )) rows.sort(key=lambda x: (x['kind'] != 'data_quality', x['id'])) r = dict(rows=rows, n=len(rows)) if not rows: # ★2026-09-18 用户令("页面只能基于输入数据重算")之后实逮: 清过产物再重算的机器上 # `objects.json` 里 **0 条 finding/* 对象** ⇒ 本页只是"空", 不说原因就等于让人以为 # "系统自查没问题"。这四族的生成端确实不在重算链上(见族表 guanlan/ontology 说明), # 所以如实写清, 并明确"不用旧版产出补"。 r['note'] = ('本体对象库里没有 finding/* 对象 (四族: data_quality / caliber / system_coverage / ' 'tier_trend) ⇒ 本页**如实为空**, 不是"自查无发现"。这些 finding 的生成端不在重算链上' '(唯一写方 scripts/ingest_ops_2025.py 的输入 data/raw/工作库 不在现场数据里), ' '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端。') return r def _en_page(page): """lang=en 时把页面文案换成英文。**中文版不经过这里** (lang 空 → 直接返回原页)。 用的是 ui_en 的**整条短语表**, 不是 i18n_en 的逐词词表 —— 后者会产出 "Verification 状态机" 这类混杂串 (已实测作废)。缺条目由 ui_en.audit 报出, 不静默留在页面上。 """ lang = _lang_of() if not lang or lang == 'zh': return page try: from src.windscada import ui_en page = ui_en.apply(page) # ★译后语法闸: 保护规则一松一紧都能把 JS 译坏, 而症状是**空白骨架页** —— # 此时"可见中文=0", 自查全绿。必须用外部解析器判, 不能靠数引号。 for _m in re.finditer(r']*>(.*?)', page, re.S): _code = _m.group(1) if _code.strip() and 'application/json' not in _m.group(0): ui_en.check_js(_code, label='英文页内联脚本') # 数据值 (报警/定论/工单…) 既做比较又要显示。比较照旧用中文, 显示层在 render # 之后替换文本节点 —— 只碰显示不碰逻辑, 所以判断不会失效。 return page.replace('', ui_en.value_script() + '') \ if '' in page else page + ui_en.value_script() except Exception as e: # ★退回中文页比给个坏页安全, 但**必须响亮** —— 静默退回会让"英文版没做完" # 看起来像"英文版做完了只是有中文残留"。守护步骤失败必须可见。 print('[_en_page] 🔴 英文化失败, 已退回中文页: %s: %s' % (type(e).__name__, e), file=sys.stderr) return page def _jdump(obj, **kw): o = _redact(_jclean(obj)) lang = _lang_of() if lang and lang != 'zh': # 中文版走这行以外的路径, 一字不动 o = _proj(o, lang) return json.dumps(o, allow_nan=False, **kw) def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这一块') -> str: r"""产物缺失时的兜底页 (2026-09-17 现场问题驱动)。 原来 `/static/…`、`/turbines/…` 取不到文件就回一个裸 `404` 文本 —— 浏览器里表现为**一块空白**, 用户看到的现象是"这个板块没有信息", 而不是"缺件"。最典型的就是总览页那个 ``); document.getElementById('foot').innerHTML=d.note; return; } if(TAB==='发电性能'){ renderCurves(); return; } if(TAB==='本体'){ renderOntology(); return; } if(TAB==='振动融合分析'&&d.fus&&!d.fus.err&&d.fus.kpi){ const K=d.fus.kpi; // 振动域分布卡·按钮化 (用户令: 点卡进入看该级具体台; FUSF=当前筛选级) const btn=(f,cls,val,label)=>{const on=window.FUSF===f; return `
${val}${label}${on?' · 筛选中,再点清除':' · 点击查看'}
`}; h+=`
`; h+=btn('bad', K.定论>0?'bad':'ok', K.定论, `定论/报警级 (机制链定案 ${K.机制链} 台)`); h+=btn('warn', K.预警>0?'warn':'ok', K.预警, '预警级机组'); h+=btn('note', '', K.候选观察, '候选/监视/观察级'); h+=btn('ok', 'ok', K.销案正常, '销案/无新证'); h+=btn('unlisted', '', `${K.收录台}/${K.全场}`, `振动线收录台 (未列 ${K.未列} 台 ≠ 正常)`); // (可检出系数KPI卡撤 — 2026-08-28 用户令: 能力边界取消, 模型计算隐藏) h+=`
`; // 数据时点 (用户令"以最新导入的报告日期为准, 并说明时间"): 判级转录自振动线, 有自己的出件日期 // 与证据窗末, 与本页所选时间窗无关 — 不标时点会让人以为它跟着窗走 if(d.fus.数据时点) h+=`

判级数据: ${d.fus.数据时点} — 转录自振动线, 不随上方时间窗变; 报警/工单/温度列才跟随所选窗。

`; } else { // (KPI 组已移至总览顶部 — 2026-08-28 用户令: 两页最上端互换) } if(TAB==='部件问题'){ // 九系统入口 (点开看该系统全场详情) — 替代原"问题总览"tab const SD=d.sysdist||{}; h+=`
按系统查看
` +Object.entries(SD).map(([s2,dist])=>{ const a=dist['报警']||0, u=dist['不可判']||0, N=Object.values(dist).reduce((x,y)=>x+y,0)||1; const col=a>=8?'var(--bad)':a>=3?'var(--warn)':a?'var(--info)':'var(--ok)'; return `
${s2}
${[['报警','var(--bad)'],['不可判','var(--unknown)'],['良好','var(--info)'],['优秀','var(--ok)']] .map(([k,c])=>{const n=dist[k]||0; return n?``:''}).join('')}
报警 ${a}${u?` · 不可判 ${u}`:''} · 全场 ${N} 台
`}).join('') +`

点系统卡 → 该系统全场机组与依据; 九系统 = 交付九分册一一对应; 叶片与叶根/塔架与基础无 SCADA 轴 (分册蓝本取 CMS/巡检)

`; if(d.rel&&d.rel.err) h+=blockErr('rel', d.rel.err); if(d.rel&&!d.rel.err){ const R=d.rel, tb=R.表, maxH=Math.max(...tb.map(r=>r.停机时长h)); h+=`
部件可靠性 · ${R.窗}
`; h+=`

部件停机贡献与可靠性 总停机 ${R.总停机h}h = 设备类 ${R.设备类停机h} + 外部类 ${R.外部类停机h}

口径 ≥1h 停机段, 不含调度令 — 与"电量算账"里的停机时长${(function(){var v=d.control.energy&&d.control.energy.items.filter(function(x){return x.k==='停机'}).map(function(x){return x.h})[0];return (v===undefined||v===null)?'':' '+v+'h'})()} 不同, 差额是 短于 1h 的停机段 (本页按段做可靠性指标须成段, 能量账按逐条工况态算电量): 两个数都对, 不可互相相减。

`; tb.forEach(r=>{ const w=Math.round(r.停机时长h/maxH*72); const sev=r.部件==='无记录停机'?'var(--unknown)':r.外部?'var(--info)':(r.占停机>=0.06?'var(--bad)':r.占停机>=0.03?'var(--warn)':'var(--ok)'); h+=``}); h+=`
部件停机时长事件MTBO hMDT h影响台典型失效模式 (按时长)
${r.部件}${r.外部?' 外部':''} ${r.停机时长h}h · ${(r.占停机*100).toFixed(1)}% ${r.事件数}${r.外部?'—':r.MTBF_h}${r.MDT_h} ${r.影响台数}${r.典型失效模式||'—'}

${R.口径}

`; } if(d.fus&&d.fus.err) h+=blockErr('fus', d.fus.err); if(d.fus&&!d.fus.err){ h+=`

振动线结论与四源融合已移至独立板块 → 振动融合 (${d.fus.表.length} 台次收录)

`; } h+=`
多系统交叉机组
`; h+=`

关注机组 (≥2 系统交叉, ${d.watch.length} 台; 点机组号 → 该台独立页)

`; d.watch.forEach(w=>{h+=``}); h+=`
机组系统数命中系统依据
${w.t.replace('WTG','')}#${w.n} ${w.syss.map(x=>`${x}`).join(' · ')} ${w.why}
`; } if(TAB==='振动融合分析'){ if(!d.fus||d.fus.err){ h+=`

融合面数据不可用: ${d.fus&&d.fus.err||'无数据'}

`; } else{ const F=d.fus, CL={bad:'var(--bad)',warn:'var(--warn)',note:'var(--muted)',ok:'var(--ok)',unlisted:'var(--muted)',stale:'var(--muted)'}; if(!window.FUSSUB||window.FUSSUB==='能力边界') window.FUSSUB='融合矩阵'; // 旧持久值兜底 const SUBS=['融合矩阵','端到端闭环','逐台情况','报警工单','振动分析']; h+=`
振动与 SCADA 证据视图 · 展示振动结论、SCADA 指标、报警与工单线索
`; h+=`
`+SUBS.map(s=>{ const on=s===FUSSUB; return ``}).join('')+`
`; if(FUSSUB==='融合矩阵'){ h+=`
两条判读纪律 (随板块常显)

① 同一失效机制的多个观测面 ≠ 多源印证 — 融合禁按源数加权 (29# 柱塞/温度/振动稳态/振动瞬态 = 四个观测面一条机制链)。
② "没报警" ≠ "正常" — 在已知盲区上 (见"能力边界"页), 振动阴性不构成证据; 矩阵"未列"台同理。

`; const spark=(hist,color)=>{ // 记忆轨迹: 旧窗=细线, 现窗=实心点 if(!hist||hist.filter(x=>x!=null).length<3) return ''; const vs=hist.filter(x=>x!=null), mn=Math.min(...vs), mx=Math.max(...vs), sp=(mx-mn)||1; const W=84,H=22,n=hist.length; let dpath='',last=null; hist.forEach((v,i)=>{ if(v==null)return; const x=(i/(n-1))*W, y=H-2-((v-mn)/sp)*(H-4); dpath+=(dpath?'L':'M')+x.toFixed(1)+' '+y.toFixed(1); last=[x,y]; }); return ``; }; h+=`

全场融合矩阵 (38 台 × 6 个证据面)

\n

各列取数窗口不同 (见列头) — 不同窗的两格同时发红不等于"同时发生", 不可当共时印证。

每格右侧曲线 = 逐月记忆轨迹 (${(F.列窗.months||[]).length} 个月, ${(F.列窗.months||['?'])[0]}起; 实心点=现窗) — 现窗快照会丢形态, 轨迹才看得见"塌→回升→翻转"类机制签名。

`; // 等宽固定布局 (用户令): 六证据列各 15.5%, 机组列 7%; 长口径收进列头副行与悬停 const shortWin=w=>{const t=zh(w||''); const m=t.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]:t.slice(0,14)}; const th=(name,win,extra)=>`
${name}
${shortWin(win)}${extra?' · '+extra:''}
`; h+=`
格子颜色 = 设备状态 (业主口径, 与 windcms 报告同一套): ${['危险','报警','良好','优秀','不可判'].map(k=> `${k}`).join('')}
徽章 = 证据状态 (可信度, windcms 九枚举): ${['定论','准定论·预警','候选·新发','候选','候选·记基线','参考','INSUFFICIENT'].map(k=> `${k}`).join('')}
两轴不可互相替代: "候选"说的是可信度不是设备好坏; "良好"说的是设备状态不是证据强度。设备状态只取自振动线的判级栏这一个来源。
`; h+=`${''.repeat(6)}${th('振动',F.列窗.振动,'不随本窗')}${th('温度',F.列窗.温度)}${th('润滑柱塞',F.列窗.润滑,'只展示')}${th('油液',F.列窗.油液)}${th('报警',F.列窗.报警||'')}${th('工单',F.列窗.工单||'')}`; const MAXAL = Math.max(1, ...F.矩阵.map(r=>(r.报警||{}).n||0)); const MAXWO = Math.max(1, ...F.矩阵.map(r=>(r.工单||{}).n||0)); F.矩阵.forEach(r=>{ // 各列只列与问题相关的 (2026-08-28 用户令"不要发散"): 该格非问题级 → 收敛成一词, 全文留悬停; // 有问题才展开证据。矩阵是找问题的面, 不是数据倾倒面。 // 非问题格短词: 优先取 note 里的判断短语 (振动列已把"未收录"细化成 // 已评估无异常 / 数据不足·不可判 / 未评估 三类, 固定字典会把这个区分盖掉) // 格内一律显示明确定级词, 不显示截断的原文 (2026-08-28 用户逮"评估报告 2026-08-2…"这类无意义截断) const shortOf = (c) => { if(c.ostate && c.ostate !== '不可判') return c.ostate; // 明确设备状态优先 if(/数据不足|不可判/.test(String(c.note))) return '数据不足'; if(/未评估/.test(String(c.note))) return '未评估'; if(/无样|未取样/.test(String(c.note))) return '未取样'; if(/样龄|stale/.test(String(c.note))) return '样龄过期'; return {ok:'正常', unlisted:'未收录', stale:'样龄过期'}[c.level] || '—'; }; // ★两套体系并存 (2026-08-28 用户令"参照 windcms 优秀/良好/报警/危险 级别体系"): // ① 业主口径状态等级 (危险>报警>良好>优秀>不可判) = 设备状态轴 → 决定格子颜色 (C 表, 与九系统矩阵同色) // ② 证据状态 九枚举 (定论/准定论·预警/候选/参考/INSUFFICIENT) = 可信度轴 → 作徽章挂在旁边 (windcms 色标) // 两轴不可互相替代: "候选"是可信度不是设备好坏, "良好"是设备状态不是证据强度。 const WC = F.色标 || {}; const cell=(c,lim,icon,tid)=>{ const col = C[c.ostate] || WC[c.wlevel] || CL[c.level] || 'var(--muted)'; const full = vibPlain(zh(c.note)); const isProb = ['bad','warn','note'].includes(c.level); if(!isProb) return ``; const cut = full.length>(lim||46) ? full.slice(0,lim||46)+'…' : full; const hot = ['报警','危险'].includes(c.ostate); const ic = icon ? partGlyph(full.split(/[::]/)[0], hot ? col : 'var(--muted)', hot) : ''; // 图 + 判级短词; 全文走悬停浮层 (2026-08-28 用户令: 列宽有限, 长句在格里会被挤成竖排) // 部件名已由图表达 ⇒ 格内文字只留判级词, 不重复部件名 const parts = full.split(/[::]/); // 明确定级词打头, 后面才是简述; 截断的原文(如"评估报告 2026-08-2…")不作为格内主文案 const brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim(); const cleanBrief = /^[ ]*(评估报告|两口径差异|⚠)/.test(brief) || brief.length > 16 ? '' : brief; // duo 徽章已给定级词 → 主文案只留简述, 无简述时才回退定级词 (避免同格重复两次) const shortCut = cleanBrief || (c.ostate && c.ostate !== '不可判' ? '' : shortOf(c)); // 点部件图 → 浮窗 (问题说明 + 该部件小曲线 + 四源微视图); 悬停只给文字 const clk = (tid && ic) ? ` onclick="popPart('${tid}','${esc(parts[0]).replace(/'/g,'')}')" style="cursor:pointer"` : ''; // 设备状态 | 证据状态 同框 (用户令: 两个 UI 分开显示太乱) const ev = (c.wlevel && c.wlevel !== '—' && c.wlevel !== '正常' && c.ostate) ? `${c.ostate}${c.wlevel}` : ''; return ``}; const al=r.报警||{n:0,fam:[]}, wo=r.工单||{n:0,acts:[]}; const alc=al.rank&&al.rank<=5?'var(--warn)':'var(--line)'; const bu=al.burst; h+=`${cell(r.振动,46,true,r.turbine)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)} `}); h+=`
机组
${shortOf(c)} ${ic || ''}
${shortCut}
${ev} ${spark(c.hist,c.level==='bad'?'var(--bad)':c.level==='warn'?'var(--warn)':'var(--muted)')}
${r.turbine.replace('WTG','')}# ${al.n} 条 ${al.rank?` · 第${al.rank}/${al.tot}`:''} ${bu?`
⚠ ${(bu.share*100).toFixed(0)}% 是码${bu.code}抖动
`:''} ${(al.rank&&al.rank<=10)||bu?famBar(al.fam,al.n,MAXAL):''}
${wo.n?`${wo.n} 单${wo.last?` · ${wo.last}`:''}
${(wo.acts||[]).slice(0,2).map(a=>a.k+'×'+a.n).join(' · ')}
` :`—`}

润滑柱塞各台设定本就不同 (全场单次循环 22~961 秒), 所以此列只展示不判级, 只有已定案的机制链才标色; 油液多数台末次取样距今超 12 个月, 此列看的是"样够不够新", 不代表当前状态。

`; } // (能力边界撤; 端到端闭环替入 — 2026-08-28 用户令: 回恢复的和不恢复的都列, 结果性实证非方法学) if(FUSSUB==='端到端闭环'){ const LP=(F.能力&&F.能力.loop&&F.能力.loop['换件闭环 (检出→换件→恢复)'])?F.能力.loop:null; if(!LP){ h+=`

handoff 无端到端闭环数据

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

维修后验证案例 (检出 → 换件 → 恢复)

有换件记录且同口径指标回落, 可作为检测链已闭合的案例; 指标仍在上升但缺少可匹配工单的机组, 需现场或台账确认后再关闭。

已闭环 · 换件后恢复 (${cl.length} 例)

${cl.map(r=>``).join('')}
机组部件事项换件日换前→换后 (同标尺)恢复比判
${r.turbine.replace('WTG','')}#${dtMini(r.component+(r.title||''),'var(--ok)',null,r.turbine)}${r.title}${r.replace_date}${baBar(r.pre,r.post,'var(--ok)')}${r.pre} → ${r.post}${r.ratio}×${r.verdict}

负面对照: 工单后持平 ${neg} 例 (换件≠必然回落, 说明回落非巧合)。

未闭环 · 指标上升待现场/工单确认 (${up.length} 台)

${up.map(r=>``).join('')}
机组部件早期→近期 (同标尺)倍比状态
${r.turbine.replace('WTG','')}#${dtMini(r.component,'var(--warn)',null,r.turbine)}${baBar(r.early,r.late,'var(--warn)')}${r.early} → ${r.late}${r.ratio}×上升待确认

数据边界: ${(LP['★数据边界']||'').split('**').join('')}

`; } } if(FUSSUB==='逐台情况'){ const lv=v=>/定论|报警/.test(v)?'bad':/预警/.test(v)?'warn':/销案|无新证|保留/.test(v)?'ok':'note'; if(window.FUSF==='unlisted'){ const un=F.矩阵.filter(r=>r.振动.level==='unlisted'); h+=`

未列机组 ${un.length} 台 (振动线未收录 — 未列 ≠ 正常, 盲区见"能力边界"页) ×清除筛选

`; un.forEach(r=>{h+=``}); h+=`
机组温度轴现状润滑柱塞现状
${r.turbine.replace('WTG','')}#${r.温度.note}${r.润滑.note}
`; } else { let rows=F.表; const FLABEL={bad:'定论/报警级',warn:'预警级',note:'候选/监视/观察级',ok:'销案/无新证'}; if(window.FUSF) rows=F.表.filter(r=>lv(r.振动结论)===FUSF); h+=`

逐台情况 (全场 ${new Set(F.表.map(x=>x.turbine)).size} 台 / ${rows.length}${window.FUSF?`/${F.表.length} 台次 · 筛选: ${FLABEL[FUSF]} ×清除`:' 台次'}; 深入谱图/波形 → "振动分析"页)

评价体系 (规范化定义 — 两轴并存, 不可互相替代)
设备状态 (业主口径)证据状态 (可信度)含义
${chip('危险')}定论异常严重, 建议尽快停机检查 (证据: 已定案)
${chip('报警')}准定论·预警 / 候选·新发存在需现场核实或处置的异常 (证据: 基本确认 / 新出现待核实)
${chip('良好')}候选 / 候选·记基线轻微偏离, 记录观察 (证据: 待现场核实 / 已记为基线下窗观察)
${chip('优秀')}参考 / 正常 / 撤回未见异常, 保持常规监视 (证据: 仅记录 / 已取消)
${chip('不可判')}INSUFFICIENT数据不足或监测中断 — 不等于正常 (证据: 数据不足)

设备状态答"这台设备现在怎么样"(危险>报警>良好>优秀>不可判); 证据状态答"这个判断有多可信"(定论→INSUFFICIENT)。 设备状态一律取自振动线的判级栏这一个来源(遇到未登记的定级词直接报错, 不静默当成"优秀")。 本页判级为转录, 不产新判级; 标注为引用同族/谱线级/测量链/未映射的行不作部件级结论消费; claim_window = 该结论的证据窗, 窗外状态该结论不背书。

`; rows.forEach(r=>{ // 判级取后端单源列 (禁前端正则: '销案(定论)'含'定论'会被误染红 — 2026-08-28 同根第2例) const LVC={bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'}; const sev=LVC[r.级]||'var(--info)'; h+=``}); h+=`
机组部件设备状态 / 振动结论证据窗 26-01→09振动信号温度轴 ±6K油样
${r.turbine.replace('WTG','')}# ${dtMini(r.部件,sev,null,r.turbine)} ${r.设备状态||'—'}${plain(r.振动结论)} ${winBar(r.窗区间,r.claim_window)} ${vibPlain(r.振动信号)}${devBar(r.温度通道,r.温度dev,r.温度判,r.温度轴)} ${oilPill(r.油末样,r.油新鲜,r.油样轴)}
`; } } if(FUSSUB==='报警工单'&&F.事件){ // 数据边界长条撤 (2026-08-28 用户令): 两源不重叠的完整说明太长且每页重复; // 关键一句仍在工单卡("0 条=记录缺失非未修过")与汇报定制页第⑧章口径与数据边界。 h+=`

时间窗 ${d.win} · 顶部选择器可换

`; for(const [comp,E] of Object.entries(F.事件)){ h+=`

${comp} — 报警 ${E.报警.总条数} 条/${E.报警.台数} 台 · 工单 ${E.工单.总条数} 条/${E.工单.台数} 台 (窗内)

`; if(E.报警.总条数>0){ h+=``; E.报警.码统计.forEach(c=>{h+=``}); h+=`
码报警内容条数台数累计时长h
${c.code}${c.text}${c.条数}${c.台数}${c.时长h}

台分布 top: ${Object.entries(E.报警.top台).map(([t,n])=>`${t.replace('WTG','')}#×${n}`).join(' · ')}

`; h+=`
最近 ${E.报警.近况.length} 条明细`; E.报警.近况.forEach(r=>{h+=``}); h+=`
机组时间码内容时长min
${r.t.replace('WTG','')}#${r.on}${r.code}${r.text}${r.dur_min??'—'}
`; } else h+=`

窗内无该部件报警

`; if(E.工单.总条数>0){ h+=``; E.工单.明细.forEach(r=>{h+=``}); h+=`
机组日期故障名称处理原因
${r.t.replace('WTG','')}#${r.date}${r.name}${r.act}${r.cause}
`; } else h+=`

窗内无该部件工单 (工单数据止 2024-11, 2026 类窗必空 = 数据边界)

`; h+=`
`; } } if(FUSSUB==='振动分析'){ // 2026-08-28 用户令: 生接改融合 — 只显示结果(windcms 评估报告转录), 模型与计算隐藏, 分析功能留独立 cms if(!window.VIBCMS) { fetch('/api/vibcms').then(r=>r.json()).then(d=>{window.VIBCMS=d; render();}); h+=`

载入振动评估结果…

`; } else if(VIBCMS.error){ h+=`

${VIBCMS.error}

`; } else { const V=VIBCMS; const gc=st=>({'报警':'var(--bad)','危险':'var(--bad)','不可判':'#9aa4b0','良好':'var(--warn2,#8a8f3b)','优秀':'var(--ok)'}[st]||'var(--muted)'); const cp=st=>`${st}`; h+=`

振动状态评估 (windcms 报告 ${V.date} 结果转录)

${V.overall||''} 状态管理判级, 不代表确诊数量; 判据/谱线/计算过程不在本页 — 深潜走底部独立系统。

需行动机组 (报警/不可判)

${V.action.map(r=>{const cm={};String(r[2]).split(/[;;]/).forEach(seg=>{const m=seg.split(/[::]/); if(m.length>1){const k=m[0].trim(); cm[k]=gc(m[1].trim())}}); return ``}).join('')}
机组状态状态焦点 (传动链: 主承前·主承后·齿轮箱·发电机两端)证据强度行动
${r[0]}${cp(r[1])}${dtChainStates(cm,null,r[0])} ${evLadder(r[3])}${/P0/.test(r[4])?`${r[4]}`:r[4]}

38 台部件等级一览

${V.grades.map(g=>``).join('')}
机组部件状态链 (主承前·主承后·齿轮箱三级·发电机DE/NDE; 悬停看逐件等级)综合行动
${g.t} ${dtChainStates({'主轴承前':gc(g.主轴承前),'主轴承后':gc(g.主轴承后),'齿轮箱':gc(g.齿轮箱),'发电机':gc(g.发电机)},null,g.t)} ${cp(g.综合)}${g.行动}
口径说明 本表=windcms 状态管理判级(含标量趋势与数据可判性); 融合矩阵/逐台结论=振动线定谳链转录(候选以上)。两口径差异台: 仅本表列 ${V.diff.only_cms.join(', ')||'无'} (多为标量发作/数据不可判, 未到定谳链); 仅定谳链列 ${V.diff.only_handoff.join(', ')||'无'} (预警级在察, 状态管理未升)。

谱图 · 波形 · 判据 · 工作台等分析功能在独立 windcms 系统 → 打开 windcms ↗ (未启动时 静态版)

`; } } } } if(TAB==='系统维护'){ h+=`
系统维护 · 数据输入与更新端口

盘点中…

`; document.getElementById('root').innerHTML=h; document.getElementById('foot').innerHTML=d.note; fetch('/api/maint_survey').then(r=>r.json()).then(md=>{ const el=document.getElementById('mtOut'); if(el) el.innerHTML=maintCards(md); }).catch(e=>{const el=document.getElementById('mtOut'); if(el) el.innerHTML=`

盘点失败: ${e.message}

`}); return; } if(TAB==='检修助手'){ // 三窗并列 (2026-08-28 用户令): 抢修 / 预防性维护 / 故障树 各有自己的输入与结果, // 不再共用一个框切换 — 实际用法是三者对照着看。 const SYSOPT=['变桨','偏航','主轴承','齿轮箱','发电机','变流器','塔架与基础','主控与传感网']; // ★示例码原来写死 5 个, 其中 64000 在本体里 name 是字符串 "nan" (pandas NaN 漏成字符串, // 555 个码里有 17 个是这样) —— 页面把一条垃圾记录当"常查"推荐, 点了必然空白。 // 改为**按本场实际计数**取: 只从 (本场出现过 ∧ 名字有效) 的 309 个里选。 // 写死清单迟早与数据脱节, 数据驱动才不会推荐自己答不上的东西。 const HOT=(window.__HOTCODES && window.__HOTCODES.length) ? window.__HOTCODES : ['3225','3130','13902','10105','3224']; const btn=m=>``; h+=`
检修助手 · 手册、报警与工单证据查询
`; h+=`

抢修 报警码 → 失效模式 → 手册分步处置 → 历史工单

${btn('fault')} 常查 ${HOT.map(c=> `${c}`).join(' · ')}
`; h+=`

预防性维护 系统 → 失效模式 → 年度任务 → WI 步骤

${btn('prev')}
`; h+=`

故障树 系统失效模式按实证量排 + 部件级原因

${btn('tree')}
`; document.getElementById('root').innerHTML=h; document.getElementById('foot').innerHTML=d.note; for(const m of ['fault','prev','tree']){ const c=(window.ASSTCACHE3||{})[m], el=document.getElementById('asstOut_'+m); if(c&&el) el.innerHTML=c; } return; } if(TAB==='检修决策'){ if(!window.MAINTSTD){ fetch('/api/maint_std').then(r=>r.json()).then(d=>{window.MAINTSTD=d; render()}); } h+=maintStd(window.MAINTSTD); if(!window.MAINTFW){ fetch('/api/maint_framework').then(r=>r.json()).then(d=>{window.MAINTFW=d; render()}); } h+=maintFw(window.MAINTFW); if(!window.DQF){ fetch('/api/dq_findings').then(r=>r.json()).then(d=>{window.DQF=d; render()}); } h+=dqFindings(window.DQF); h+=`
检修决策 · ontology 展现的窗口 (语义本体 + dsh 编排; 推理无界, 结论受闸)
`; const CATS={ '设备类':{hint:'设备状态/机制/判级 — 从机组档案、对象查询与机制检索取证', qs:['29号机组现在什么状态、为什么、该怎么办?','全场哪些机组有报警级或定论级判级?逐台列出并注明审级','失效机制库里有哪些机制族?各涉及哪些机组、状态如何?']}, '检修排程决策':{hint:'排期沙盘/天气窗/验收树 — ont_scenario/ont_get 取证', qs:['29号修泵什么时候做最合适?对比各情景的损失与风险','未来几个月哪些月份适合安排CTV检修和吊装作业?依据历史可作业窗','29号修好之后按什么判据验收?三分支分别是什么条件?']}, '损失分析':{hint:'当前3个月损失/未来损失 — ont_loss/ont_scenario 取证, 金额带电价区间口径', qs:['近3个月全场损失结构如何?分状态和分机组说, 电量与金额都给','29号如果拖到主轴承失效, 未来损失量级是多少?与现在修对比','限电常态化对检修机会成本有什么影响?']}, '安全风险预测':{hint:'风险敞口/劣化趋势/盲区 — ont_risk/ont_mechanism_search 取证, 未列≠正常', qs:['当前哪些机组风险在上升需要重点盯?联动监视读数如何?','监测有哪些盲区?哪些"没报警"不等于正常?','29号轴承正在承受金属接触, 风险敞口怎么评估?']}}; h+=`
`; setTimeout(loadDbase, 200); if(!window.ASKCAT) window.ASKCAT='设备类'; h+=`
`+Object.keys(CATS).map(c=>{ const on=c===ASKCAT; return ``}).join('')+`
`; const cat=CATS[ASKCAT]; h+=`

${ASKCAT} · 常用问题 (点即问, 或下方自由输入)

${cat.hint}。答案句句带[对象id]溯源+接地闸核验; 判级只转述本体原文带审级, 模型零判级权。

`; cat.qs.forEach(q=>{h+=`
${q}
`}); h+=`
空闲 — 点常用问题或输入后提问 (实测: 8b 单台问约 45 秒 / 复合问题约 50~90 秒, 27b/30b 约 2~4 分钟, deepseek 走 dsh 约 2~6 分钟; 串行一次一问; 本地模型零外发)。
`; setTimeout(askPoll, 300); setTimeout(askModelInit, 320); // 填下拉后 askModelInit 内部会调 mdlNote } if(TAB==='故障统计'){ const _pt=(d.faults.partial||[]); h+=`
故障统计与可靠性
` +winBanner('故障统计', d.win, _pt.length?`窗内 ${_pt.join('/')} 数据不满月(覆盖 ${Math.round(d.faults.cov_min*100)}%), 月度图已按天归一`:''); } if(TAB==='电量算账') h+=`
电量算账
`+winBanner('电量算账', d.win, ''); if(TAB==='汇报定制'){ document.getElementById('root').innerHTML=renderReport(d); document.getElementById('foot').innerHTML=d.note; return; } h+=`
`; if(TAB==='故障统计'){ // 排布 (2026-08-28 用户令: 界面优化排布)。原来 9 张卡一条龙堆着, 报警面与停机面混排, // 且三张卡硬设 grid-column:1/-1 — 窄视口本来就单列, 全宽设置只在宽屏起反作用(挡住并排)。 // 现按经典可靠性报告顺序分两节: 报警面 总量→关键少数→结构→台级→季节; 停机面 结构→归因→台级。 // 只有"需要横向空间才读得出结构"的图保留全宽 (四象限散点 / RAM 象限), 其余交给 auto-fit: // 窄屏自然单列(帕累托 720 viewBox 几乎 1:1), 宽屏自动两列。 const F2=d.faults, _pm=(F2.partial||[]); const sec=(t,n)=>`
${t}${n}
`; h+=sec('报警面', `${F2.total.toLocaleString()} 条 · ${F2.n_codes} 个码 · 窗 ${d.win}`); h+=`

月度故障率

${rateChart(F2.monthly)}

柱高=日均条数, 不是月总数 — ${_pm.length?`本窗 ${_pm.join('/')} 数据不满月(灰虚线柱), 直接比月总数会读反`:'各月覆盖完整'}。 ${_pm.length?`实测: ${_pm[0]} 总数最低但日均 ${F2.monthly.per_day[F2.monthly.months.indexOf(_pm[0])]} 条/日 = 全窗前列。`:''}

`; h+=`

帕累托 · 按发生条数

${paretoChart(F2.pareto_n, ' 条', '条数=发生频次, 不反映影响程度 — 高频短时报警码(信号抖动或重复触发)排序靠前')}
`; h+=`

帕累托 · 按持续时长

${paretoChart(F2.pareto_d, ' h', '时长=闩锁口径(复位−激活), 只作排序参考不作停机时长')}
`; h+=`

故障模式四象限 · 条数 × 单次时长

${quadChart(F2.quad)}

窗内共 ${F2.n_codes} 个码, 图上为条数前 ${F2.quad.length} 个。 ${_pm.length?`窗含不满月 ${_pm.join('/')} (覆盖 ${Math.round(F2.cov_min*100)}%), 条数为原始累计未按天归一。 `:''} 点面积表示影响台数, 大点为批次共性、小点为单台; 竖分界取条数中位, 横分界取 10min (一个SCADA采样格)。 右下角的码不要按条数排优先级 — 属重复触发, 非硬件故障。上面两张帕累托对照看即此图的两条投影。

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

台级分布 · 报警条数 top${F2.per_t.length}

${F2.per_t.map(x=>``).join('')}
机组条数占全场
${x.k.replace('WTG','')}# ${x.v.toLocaleString()} ${(x.v/F2.total*100).toFixed(1)}%

全场合计 ${F2.total.toLocaleString()} 条 · 单台占比高须先核查是否为单一报警码重复触发

`; if(d.m8&&d.m8.err) h+=blockErr('m8', d.m8.err); if(m) h+=drawFig({kind:'multiline',title:'故障季节性 · top 报警族 × 月',unit:'条/月',xlab:'月', months:d.m8.seasonal[0].months, series:d.m8.seasonal.slice(0,4).map(x=>({name:x.name.slice(0,16),vals:x.vals})), note:'全程 19 个月 (跨 2 冬 1 夏); 未做 χ² 显著性检验前为【参考级】, 只看形态不作季节归因'}); if(m){ const RT=(d.rel&&!d.rel.err)?d.rel:null; h+=sec('停机面', `${m.停机总时长h?m.停机总时长h.toLocaleString():''}h · ${m.停机事件数||''} 起 · 口径 ≥1h 段, 不含调度令`); if(RT) h+=`

可靠性 × 可维修性 · 部件象限

${ramChart(RT.表)}

按停机时长排序的表看不出这层结构: 同样 500h 的停机, "频发易修"要查根因、"罕发难修"要备件, 处置相反。 设备类停机 ${RT.设备类停机h}h / 外部类 ${RT.外部类停机h}h (外部=电网·风况·运维操作, 不计设备可靠性)。 海上机组 MDT 普遍偏高是交通窗口决定的, 不等于维修本身慢 — 跨场比 MDT 须同为海上。

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

归因覆盖: "无记录停机" ${(RT.表.find(x=>x.部件==='无记录停机')||{}).停机时长h||0}h 是全场最大单项, 因不给 MTBF 而在上图中不出现 — 它不是"没有故障", 是"没归上因"。 口径为段起点±30min 首发码。缺口成因待定: 此前写的"98% 的段在前 72h 内有报警 ⇒ 长停远窗" 不成立 — 前 72h 有报警不等于该报警与本段停机有因果 (双模审 2026-08-28 逮)。 要定成因须改判"段起止是否落在某条报警的持续区间内", 未做之前只报缺口不报原因。

`; h+=`

停机归因帕累托

${paretoChart(d.m8.stop_pareto.map(x=>({k:x.k.split(';')[0].slice(0,26), v:x.h})), ' h', '按停机时长; 事件次数见悬停')}

${m.口径}

`; h+=`

台级停机 top${m.台级top.length}

${(()=>{const mx=Math.max(...m.台级top.map(x=>x.时长h));return m.台级top.map(x=>``).join('')})()}
机组停机时长次数
${x.t.replace('WTG','')}# ${x.时长h}h ${x.次}

停机时长高 ≠ 设备差 — 须先看归因里外部类占比 (调度令已剔除)

`; } } if(TAB==='电量算账'){ const E=d.control.energy; if(E) h+=`
${E.theo.toLocaleString()} MWh理论可发 (实际+损失)
${E.act.toLocaleString()} MWh实际上网 · 等效满发 ${E.eflh.toLocaleString()} h
${E.loss_pct}%损失率 · ${E.loss.toLocaleString()} MWh
${E.cap_ok?'通过':'不通过'}能量守恒核 · 理论 ${E.cap_ok?'<':'≥'} 全额定上限 ${E.cap.toLocaleString()}
`; if(E) h+=`

电量平衡 窗 ${d.win}

${waterfall(E)}
`; if(E) h+=`

损失责任分账

${blameBar(E)}

最大一笔是限电不是故障。 进设备考核的只有"故障与检修停机" ${Math.round((E.items.find(x=>x.k==='停机')||{}).loss||0).toLocaleString()} MWh; 限电属交易与调度侧, 2026 起交易策略主导, 少发未必是弃风。

`; h+=`

工况态明细 窗 ${d.win}

`; const _mxp=Math.max(...d.control.states.map(x=>x.pct)); d.control.states.forEach(x=>{h+=``}); h+=`
工况态时长占比损失MWh
${LOSSPLAIN[x.k]||x.k} ${x.pct}% · ${Math.round(x.h).toLocaleString()}h ${Math.round(x.loss).toLocaleString()}

${d.control.anchors}
控制参数一致性与曲线欠发筛查在固定干净窗判, 已迁入「发电性能」页 — 本页只出当期电量账。

`; } h+=`
`; document.getElementById('root').innerHTML=h; document.getElementById('foot').innerHTML=d.note; } async function renderCurves(){ const root=document.getElementById('root'); if(!CURVES){root.innerHTML='

特性曲线多镜头

正在计算七个镜头 (剥限电 · 逐档中位 · 同型机群残差)…

'; CURVES=await (await fetch('/api/curves')).json();} const d=CURVES; let h=''; // 物理闸: 表格窄屏崩版 (2026-08-28 用户内部设计审逮) → 响应式三卡 const P=d.physics, L=P['λ叶尖速比']; const pg=(name,crit,meas,verdict)=>`
${name} ${crit}
${meas}
${verdict}
`; // (第一性原理硬闸卡撤 — 2026-08-28 用户令: 数据分析原则不出现在交付界面; 校验仍在后端跑, 只是不展示) h+=`
特性曲线与控制参数
`+winBanner('发电性能','',''); // 控制参数一致性与曲线欠发候选自「控制策略」页迁入 (2026-08-28 用户裁): 三者同为固定干净窗, // 放在跟随窗的电量页里会造成一页两窗 — 迁过来后两页各自单窗。 const _D=(typeof D!=='undefined'&&D)?D:null; // D 是块作用域变量, 不挂 window const F=(_D&&_D.control)?_D.control:null, M9=(_D&&_D.m9&&!_D.m9.err)?_D.m9:null; if(_D&&_D.m9&&_D.m9.err) h+=blockErr('m9', _D.m9.err); if(M9){ const p9=M9['P封顶'],w9=M9['ω封顶'],b9=M9['β_schedule']; h+=`

控制参数一致性 窗 ${M9['窗']}

封顶参数落位
${capAxis(p9, w9)}

台数落在几个离散位置上, 不是连成一片 — 这是配置/版本差异的形状; 磨损或工况引起的会是连续散布。两轴各自成组且分组不同台 ⇒ 参数级差异, 成因须配置台账。

桨距调度同档差
${betaBar(b9)}

同一功率档下的桨角与全场中位之差。 ${b9.离群.map(x=>''+x.t.replace('WTG','')+'#').join(' / ')} 偏离达中位的 ${Math.round(Math.max(...b9.离群.map(x=>x.平均偏))/Math.max(b9.全场同档差中位,0.01))} ${TIMES_SFX} → ${b9.判}

尺子巡检 · 极差 ÷ σ
${rulerBar(M9['尺子'])}

判据自身的分辨力。38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰 — 与左图的分组互为印证; 落在期望附近的轴(额定桨角)则是连续分布。

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

曲线欠发筛查 ${F.curve.note} · σ=${F.sigma||F.curve.sigma}%

${F.curve.cands.map(c=>''+c.t.replace('WTG','')+'# '+c.dev+'% '+c.判).join(' | ')||'无候选'}

`; } h+=`
`; d.figs.forEach(f=>{h+=drawFig(f)}); h+=`
`; root.innerHTML=h; document.getElementById('foot').innerHTML=d.note; } reload('2026年'); """ PAGE_PROBLEM = """ %T% %SYS% · windscada

%T% · %SYS%

← 总览本台全部问题
加载中…
单问题视图: 只呈现本台该系统的判据与证据图, 不带出其他系统
""" PAGE_TURBINE = """ %T% · windscada

%T% ← 工作台

只显示有问题的系统; 无问题不出图
加载中…

台内即时查询 (任一契约通道 × 时间窗 × 聚合)

边界: 叶片/塔架无SCADA轴 | 对风误差=wrap(风向−机舱位置) vane口径; 死区/速率需≤30s数据=INSUFFICIENT | 判级窗: 变桨/偏航/蓄能/温度判级=近90日固定, 曲线=2025H2; 图内月序=所选时间窗 | P级归综合报告线
""" class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def _send(self, body, ctype='text/html; charset=utf-8', code=200): if 'text/html' in ctype and not self.path.startswith('/v2'): # 2026-09-06 用户批: /v2 = detail_style_baseline, 不注入 unified-review 样式/导航条 (基线令牌须等于门户) from src.windscada.design import GUANLAN_REVIEW_CSS text = body.decode('utf-8') if isinstance(body, bytes) else body text = text.replace('', '', 1) if '' in text: body = text.replace('', '', 1) b = body.encode('utf-8') if isinstance(body, str) else body self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(b))) self.end_headers() self.wfile.write(b) def do_POST(self): try: u = urllib.parse.urlparse(self.path) n = int(self.headers.get('Content-Length', 0)) body = json.loads(self.rfile.read(n) or b'{}') if u.path == '/api/ask': self._send(_jdump(ask_start(body.get('q', ''), body.get('model', 'qwen3:8b'), body.get('cat', ''), body.get('xrev', False), body.get('lang', 'zh'), body.get('display_q', '')), ensure_ascii=False), 'application/json; charset=utf-8') else: self._send('404', code=404) except Exception as e: self._send(_jdump(dict(err=str(e)[:300]), ensure_ascii=False), 'application/json; charset=utf-8', 500) def do_GET(self): try: u = urllib.parse.urlparse(self.path) # encoding='utf-8' 显式给定 (默认 latin-1 会把中文参数解成乱码 — "齿轮箱"→"齿轮箱", # 浏览器 encodeURIComponent 路径侥幸正常, curl/直传中文即崩; 2026-08-28 四链接口实逮) q = dict(urllib.parse.parse_qsl(u.query, encoding='utf-8', errors='replace')) _REQ.lang = q.get('lang', '') # 本请求的语言, _jdump 出口据此投影文本层 _REQ.keep_orig = q.get('keep') == '1' # v2: 译不出留原文, 不写 [translation pending] win = q.get('win', '2026年') if u.path == '/': _pg = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) self._send(_en_page(_pg)) elif u.path == '/v2': # 新前端 (2026-09-03 用户裁"完全重新做一套, 在原来的基础上"): 同一套 /api/*, # 表现层双语原生 —— 不经 _en_page/ui_en 那三层事后翻译。迁移期与 / 并存, # 页签逐个搬完后再切默认。 from src.windscada.ui import build as _v2 self._send(_v2.render('en' if q.get('lang') == 'en' else 'zh')) elif u.path == '/v2/snapshot': # 新版单文件快照 (静态站用): 接口响应烤进页面, 拦截器实时优先快照兜底。 # 取数函数与在线 API 同一实现; 英文走同一套 _redact → _proj 投影 (keep=1 留原文)。 from src.windscada.ui import snapshot as _snap from src.ontology import mcp_server as _omc import json as _j3, pathlib as _p3 def _codes(): db = _j3.loads(_p3.Path(_omc.OBJ_PATH).read_text(encoding='utf-8')) return sorted(k.split('/')[-1] for k in db if k.startswith('alarmcode/')) _ctx = dict(fleet_view=fleet_view, curve_view=curve_view, vibcms_results=vibcms_results, survey=__import__('src.ontology.maintenance', fromlist=['survey']).survey, ont=_omc, alarm_codes=_codes, turbine_problems=turbine_problems, single_problem=single_problem, _redact=_redact, _jclean=_jclean, _proj=_proj, _REQ=_REQ) _v2_lang = 'en' if q.get('lang') == 'en' else 'zh' _html, _rep = _snap.bake(_v2_lang, _ctx, peer=q.get('peer', ''), noindex=q.get('noindex') == '1', slim=(q.get('slim') != '0' if _v2_lang == 'en' else False)) print('[v2 snapshot] %s' % _j3.dumps(_rep, ensure_ascii=False)[:600], flush=True) self._send(_html) elif u.path.startswith('/turbine/'): t = u.path.rsplit('/', 1)[1] _pg = PAGE_TURBINE.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS).replace('%T%', t) self._send(_en_page(_pg)) elif u.path == '/api/maint_survey': try: from src.ontology.maintenance import survey as _sv r = dict(rows=_sv()) except Exception as e: r = dict(rows=[], error=f'维护面盘点失败: {e}'[:200]) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_chain': try: from src.ontology import mcp_server as _om kind = q.get('kind', 'fault') fn = {'fault': lambda: _om.ont_chain_fault(code=q.get('code', '')), 'prev': lambda: _om.ont_chain_preventive(system=q.get('system', '')), 'tree': lambda: _om.ont_fault_tree(system=q.get('system', '')), 'plan': lambda: _om.ont_maint_plan(top=q.get('top', 10)), 'doc': lambda: _om.ont_doc_search(keyword=q.get('keyword', ''), kind=q.get('dkind', ''))} r = fn[kind]() if kind in fn else dict(text=f'未知链 {kind}', error=True) except Exception as e: r = dict(text=f'本体查询失败: {e}'[:200], error=True) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/maint_std': # 运维标准四判据 + 成熟度评分 (docs/如东运维标准化设计.md) try: import json as _j, pathlib as _p from src.windscada.maint import standards as _st _db = _j.loads(_P.objects_json() .read_text(encoding='utf-8')) _wo = [(v.get('props') or {}) for k, v in _db.items() if k.startswith('workorder/')] _r = _st.run_all(_wo) _r['score'] = _st.score(_wo, _r) except Exception as e: _r = dict(err=f'运维标准判据失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/maint_framework': # 运维管理体系 —— 与快照预烤**同一实现** (maint_framework_view) try: _r = maint_framework_view() except Exception as e: _r = dict(err=f'运维体系读取失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/dq_findings': # 数据可信度发现 —— 与快照预烤**同一实现** (dq_findings_view) try: _r = dq_findings_view() except Exception as e: _r = dict(rows=[], err=f'数据可信度读取失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/vibcms': self._send(_jdump(vibcms_results(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/curves': self._send(_jdump(curve_view(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/fleet': CUR_WIN[0] = win self._send(_jdump(fleet_view(win), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path.startswith('/problem/'): import urllib.parse as _up _, _, t, sysn = u.path.split('/', 3) self._send(PAGE_PROBLEM.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) .replace('%T%', t).replace('%SYS%', _up.unquote(sysn))) elif u.path == '/api/problem': self._send(_jdump(dict(problems=single_problem(q['t'], q['sys'], win)), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/turbine': self._send(_jdump(dict(problems=turbine_problems(q['t'], win)), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/query': agg = q.get('agg', 'med') self._send(json.dumps(adhoc_query(q['t'], q['ch'], win, 'sum' if agg == 'dutyd' else agg), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/snapshot': _lang = urllib.parse.parse_qs(u.query).get('lang', [''])[0] # 单文件快照 (2026-08-28 用户: 我怎么发给别人)。 # 把各窗 fleet / curves / ontology / 维护盘点全部内联进 HTML, 并在页内改写 # window.fetch 直接返回内联数据 —— 前端代码一行不改, 收件人双击即开, 不需要 # 装 Python、不需要连内网、断网也能看。数据按当前受众设置脱敏后再内联。 try: _wins = ['2026年', '2026H1', '2025H2', '全程'] snap, _skip = {}, [] def _nk(path, **kw): """快照 key 归一化: 参数**解码后**排序拼接。 必须与页内 window.__NK 同语义 —— 直接用 URL 字符串做 key 会被 encodeURIComponent 与 urllib quote 的编码差异 (! * ' ( ) 一编一不编) 和参数顺序打掉, 而这种失效是**静默**的: 命中不了就退到 "快照版不含此功能", 看起来像功能没做, 不像 key 错了。 """ a = sorted('%s=%s' % (k, v) for k, v in kw.items() if v not in (None, '')) return path + ('?' + '&'.join(a) if a else '') def _bake(key, fn): """烤一个 API 响应。失败**记名**不静默 —— 守护步骤失败必须响亮。 ★2026-08-31: 此前直接 _redact(_jclean(...)), **绕过 _jdump 里的语言投影**, 于是 ?lang=en 的快照内联的仍是中文数据 (实测 57 万→11 万中文只是 页面文案变了, 数据没变)。这里补上同一套投影。 """ try: # ★顺序是 **_redact 先, _proj 后**, 不要"为了与在线同序"去调它: # 曲线图注等模板的 key 是照**脱敏后**的文本登记的 (含 ▪ 掩码), # 先投影的话字符串里还是真实数字, 模板全部对不上, 整批退回 # [translation pending]。我按"同序"改过一次, 当场被 parity 闸逮住。 # 在线路径没有这个问题是因为脱敏在页面层统一做, 不在数据层。 _v = _redact(_jclean(fn())) if _lang and _lang != 'zh': _v = _proj(_v, _lang) snap[key] = _v return True except Exception as e: _skip.append('%s: %s' % (key, str(e)[:60])) return False for _w in _wins: _bake(_nk('/api/fleet', win=_w), lambda w=_w: fleet_view(w)) _bake('/api/curves', curve_view) _bake('/api/ontology', ontology_view) # ★后加的面板接口也要预烤 —— 漏了会在离线快照里空掉。 # 新增接口时必须回到这里补一行, 否则快照静默少一块。 def _maint_std(): import json as _j2, pathlib as _p2 from src.windscada.maint import standards as _st2 _db2 = _j2.loads(_P.objects_json() .read_text(encoding='utf-8')) _wo2 = [(v.get('props') or {}) for k, v in _db2.items() if k.startswith('workorder/')] _r2 = _st2.run_all(_wo2) _r2['score'] = _st2.score(_wo2, _r2) return _r2 _maint_fw = maint_framework_view # ★与在线 API 同一实现 _dq = dq_findings_view # ★与在线 API 同一实现 _bake('/api/maint_std', _maint_std) _bake('/api/maint_framework', _maint_fw) _bake('/api/dq_findings', _dq) _bake('/api/maint_survey', lambda: dict(rows=__import__('src.ontology.maintenance', fromlist=['survey']).survey())) # ── 扩展预烤 (2026-08-30 用户"完整展示") ─────────────────────── # 原先只烤上面 4 个, 另外 7 个接口在离线版里是死的: 部件问题下钻、 # 本体对象/清单/链、振动详情点开全是"快照版不含此功能" —— 页面能渲染 # 掩盖了功能不可用, 我此前按"十页可交互"汇报是说过头了。 # ask / rpt_compose 要现跑模型, 不烤 (页面另给在线提示), 其余全量枚举。 _bake('/api/vibcms', vibcms_results) # 离线桩: 快照里没有服务端跑模型, 所以这条**有意**与在线不同。 # note 双写 —— 靠翻译层会得到 [translation pending], 而这是给读者看的一句话。 _bake('/api/ask_status', lambda: dict( offline=True, models=[], note_zh='离线版: 模型问答需连接系统', note_en='Offline copy: the model Q&A needs a live connection to the system')) # 部件问题: (机组, 系统) 从各窗 systems 展开, 每个窗各烤一份 _pairs = set() for _w in _wins: _fv = snap.get(_nk('/api/fleet', win=_w)) or {} for _sysn, _rows in (_fv.get('systems') or {}).items(): for _r in (_rows or []): if isinstance(_r, dict) and _r.get('t'): _pairs.add((_r['t'], _sysn)) # ★系统名两种形态都要烤 (2026-09-01 实逮"点开是空浮层")。 # systems 的分组键带**等级后缀** (`塔架与基础·良好`), 而页面上 # `/problem//` 链接发出的是**裸系统名** (`塔架与基础`) —— # 于是 key 差一截, 取不到数, 浮层开了但里面是"本窗无记录"。 # **这种失效最难发现: 浮层确实弹出来了, 看着像"这台这系统没数据", # 而不像"key 错了"。** 两种形态都烤成同一份数据即可。 for _t, _sy in sorted(_pairs): _forms = {_sy} if '·' in _sy: _forms.add(_sy.split('·')[0]) for _w in _wins: for _f in sorted(_forms): # ★每种形态烤**它自己**的数据。上一版这里捕获的是带等级的 _sy, # 于是裸名 key 拿到的是带等级那份 —— 实测差出 0 vs 1 条, # **被对拍闸逮到**。lambda 默认参数必须捕获循环变量 _f 本身。 _bake(_nk('/api/problem', t=_t, sys=_f, win=_w), lambda t=_t, sy=_f, w=_w: dict(problems=single_problem(t, sy, w))) # 整机视图: 页面上机组号链到 /turbine/, 弹层要用 for _t in sorted({t for t, _ in _pairs}): for _w in _wins: _bake(_nk('/api/turbine', t=_t, win=_w), lambda t=_t, w=_w: dict(problems=turbine_problems(t, w))) # 本体清单 + 每个对象详情 (清单里出现的 id 才可能被点开) _oids = set() for _ty in sorted((ontology_view().get('stats') or {})): _k = _nk('/api/ont_list', type=_ty, limit=60) if _bake(_k, lambda ty=_ty: dict(rows=ontology_list(ty, '', 60))): for _row in (snap[_k].get('rows') or []): if isinstance(_row, dict) and _row.get('id'): _oids.add(_row['id']) for _oid in sorted(_oids): _bake(_nk('/api/ont_obj', id=_oid), lambda i=_oid: ontology_obj(i)) # 本体链: kind 五种, 参数取页面下拉的实际取值域 try: from src.ontology import mcp_server as _omc _bake(_nk('/api/ont_chain', kind='plan', top='12'), lambda: _omc.ont_maint_plan(top=12)) _syslist = sorted({sy for _, sy in _pairs}) or ['齿轮箱'] for _sy in _syslist: _bake(_nk('/api/ont_chain', kind='prev', system=_sy), lambda sy=_sy: _omc.ont_chain_preventive(system=sy)) _bake(_nk('/api/ont_chain', kind='tree', system=_sy), lambda sy=_sy: _omc.ont_fault_tree(system=sy)) # ★原来码源是**另一处烤好的** /api/ont_list?limit=60 的行, 于是全场 555 个码 # 只进了按码号排序的前 60 个; 而页面上"点一下就查"的示例码 # (3225/64000/64001/4110/13121) 恰好都不在那 60 个里 # ⇒ **页面在推荐自己答不上的码**, 点下去整条抢修链没反应。 # "排序后取前 N"的裁剪最容易把真正要用的挡在外面。 # ★改为**直接从本体读**, 不再依赖别处烤没烤、烤了多少 —— # 跨模块借数据是这次截断的根因, 借来的上限管不着。 # (中途踩坑: 我先写成 _omc.ont_list(...), 而 mcp_server 根本没有这个函数, # AttributeError 被外层 try 吞掉 ⇒ 整段 fault 一条都没烤, 还静默。) import json as _js _db = _js.loads(pathlib.Path(_omc.OBJ_PATH).read_text(encoding='utf-8')) _codes = sorted(k.split('/')[-1] for k in _db if k.startswith('alarmcode/')) for _c in [c for c in _codes if c]: _bake(_nk('/api/ont_chain', kind='fault', code=_c), lambda c=_c: _omc.ont_chain_fault(code=c)) except Exception as _e: _skip.append('ont_chain: %s' % str(_e)[:60]) if _skip: print('[snapshot] 未烤成 %d 项: %s' % (len(_skip), '; '.join(_skip[:6])), file=sys.stderr) print('[snapshot] 预烤 %d 键 (%s)' % ( len(snap), ', '.join(sorted({k.split('?')[0] for k in snap}))), file=sys.stderr) if _lang and _lang != 'zh': # ★key 统一在**全部烤完之后**加 lang: 英文页的 fetch 会自动追加 # ?lang=en, 预烤 key 不带就永远对不上 (miss 是静默的, 退到 err 提示, # 看着像功能没做)。 # ★不能在 _bake 内部改 —— 后续代码还要用原 key 从 snap 取值, # 改了就 KeyError (本次实测踩到)。 # ★存双 key: 带 lang 与不带。页面首帧的 fetch 可能早于 fetch patch # 生效, 那一发不带 lang —— 只存带 lang 的会 miss 一次 (实测首屏 fleet)。 # # ★★ 加 lang 必须走**同一个归一化器**, 不能字符串拼接 (2026-09-01 实逮)。 # __NK 把参数**按字母排序**, 所以正确形态是 `?lang=en&sys=…&t=…&win=…`; # 而拼接得到的是 `?sys=…&t=…&win=…&lang=en` —— 差一个顺序, 全部 miss。 # 后果极隐蔽: 十个标签页照常显示 (它们命中的是**不带 lang** 的那份), # 只有**点开下钻**才走带 lang 的 key ⇒ 浮层弹出来却是"本窗无记录", # 看着像"这台这系统没数据", 不像 key 错了。用户报的"有些点不开"就是它。 def _with_lang(k): base, _, q = k.partition('?') kv = dict(x.split('=', 1) for x in q.split('&') if '=' in x) if q else {} kv['lang'] = _lang return base + '?' + '&'.join(sorted('%s=%s' % (a, b) for a, b in kv.items())) snap = dict(snap, **{_with_lang(k): v for k, v in snap.items()}) page = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) if _lang and _lang != 'zh': # 快照页面本身也走英文化 (文案表 + 显示层脚本), 并改 lang 属性 —— # 此前只投影了 API 路径, 快照页漏了, 导出的 index.html 一直是 lang="zh" page = _en_page(page).replace('window.__SNAP__=' + json.dumps(snap, ensure_ascii=False, allow_nan=False) + ';\n' '/* 快照模式: 拦截 fetch 从内联数据取。key 走归一化(参数解码后排序)而非 */' '/* 原样 URL —— 编码差异(encodeURIComponent 不编 !*\'() 而 quote 编)与参数 */' '/* 顺序都会让字符串比对落空, 且落空是静默的: 退到 err 提示, 看着像功能没做。 */' 'window.__NK=function(u){var s=String(u),i=s.indexOf("?");if(i<0)return s;' 'var p=s.slice(0,i),a=s.slice(i+1).split("&").filter(Boolean).map(function(kv){' 'var j=kv.indexOf("=");if(j<0)return kv;var k=kv.slice(0,j),v=kv.slice(j+1);' 'try{v=decodeURIComponent(v.replace(/\\+/g," "))}catch(e){}' 'return v===""?null:k+"="+v}).filter(function(x){return x!==null}).sort();' 'return p+(a.length?"?"+a.join("&"):"")};' 'window.__HIT={ok:0,miss:[]};window.__RF=window.fetch;' # ★没烤的窗按钮直接摘掉: 让用户点一个必然空白的选项, 比少给几个窗更糟。 # 默认窗取**已烤窗里最新的那个**, 供上面的回落候选用。 'window.__WINS=(function(){var s={};Object.keys(window.__SNAP__).forEach(' 'function(k){var m=/[?&]win=([^&]*)/.exec(k);if(m)s[decodeURIComponent(m[1])]=1});' 'return Object.keys(s)})();' 'window.__DEFWIN=window.__WINS[0]||"";' 'window.__WINLABEL=' + json.dumps( 'Latest completed build' if _lang == 'en' else '最新一次完整分析') + ';' # ★快照里**去掉窗选择器**, 换成固定标签 (2026-09-01 用户令 # "设定不随时间窗动, 就显示最新的")。 # 理由: 选择器给约 20 个窗而只烤了 4 个, 选中其余任何一个都空白; # 而这 4 个窗的内容**完全一样** (fus 40 行 / 需跟踪 76 台次 逐窗相同), # 所以留着选择器只会让人以为能切、切了还空。 # ★先前那版是"摘掉没烤的 option" —— 摘过头了: 选项显示文本已英文化 # ("2026 full year"), 而 __WINS 是中文键, 匹配不上就把有效的也摘了。 # **文本匹配在译过的 UI 上天然不可靠**, 所以改为整体替换而非逐项筛。 'window.__PINWIN=function(){try{' 'var sel=document.querySelector("select#win");if(!sel)return;' 'var lab=document.createElement("span");lab.id="winpin";' 'lab.style.cssText="font-size:12.5px;color:var(--muted);padding:5px 9px;' 'border:1px solid var(--line);border-radius:7px;background:var(--panel)";' 'lab.textContent=window.__WINLABEL||"Latest build";' 'lab.title="This snapshot is fixed to the latest completed build; ' 'the panels below do not vary by window.";' 'sel.parentNode.replaceChild(lab,sel)}catch(e){}};' 'try{new MutationObserver(function(){window.__PINWIN()})' '.observe(document.documentElement,{childList:true,subtree:true})}catch(e){}' 'document.addEventListener("DOMContentLoaded",function(){' 'setTimeout(window.__PINWIN,300)});' # 问答接口不能被离线拦截: 云上 nginx /api/ 后面有真服务在跑, # 其余接口才走预烤。放行名单写死, 免得日后新增接口误穿。 'window.fetch=function(u,o){var k=window.__NK(u);' 'if(/^\\/api\\/(ask|ask_status|ask_models|rpt_compose|rpt_export)\\b/.test(String(u)))return window.__RF(u,o);' # ★第 4 个候选: 把 win 换成默认窗 (2026-09-01 用户令"不随时间窗动, 就显示最新的")。 # 窗选择器给了约 20 个选项而快照只烤了 4 个 —— 选中其余任何一个 # 都会整页空白 (表现为"该系统全场无异常"、振动页空), 看着像没数据, # 实则是没烤。这些面板的内容本就不随窗变 (四个窗 fus 完全一样), # 所以回落到默认窗是**正确答案**而不是遮丑。 # ★同 src/windscada/ui/snapshot.py 的坑: 这里不能 encodeURIComponent —— k 与 # __SNAP__ 键都是原文(未编码)中文, 编码后拼出的键谁都不匹配, 兜底静默失效 # (2026-09-03 实逮: 换窗点浮层报"本窗无记录", 默认窗其实有数据)。 'var c=[k,k.replace(/&?win=[^&]*/,"").replace(/\\?$/,""),String(u).split("?")[0],' 'k.replace(/win=[^&]*/,"win="+(window.__DEFWIN||""))];' 'for(var i=0;i') # ★英文版改走**源码级生成**, 不再注入运行时翻译 hook # (2026-08-31 用户令: 不要打补丁式翻译, 整个系统重新生成一遍)。 # hook 的本质是给中文系统套一层英文 —— 只译写入 DOM 的那一刻, # JS 逻辑与 JSON 键仍是中文, 长尾补不完且源码里满是中文。 # 改为在页面拼好后整页替换 (见本函数末尾与 i18n_en.to_english_source)。 # ★中文版完全不受影响: 不带 lang=en 时这条分支不执行, 国内照常用。 # ★ 单文件版的下钻: 页面上 74 个链接指向 /problem// 与 /turbine/, # 那是服务端独立页面 —— 快照里点了就是死链 (404/白页)。此前"十页可交互" # 的说法没覆盖这一层。改法: 拦截这两类链接, 用已预烤的同一份数据在页内 # 开浮层, 图表复用共享的 drawFig, 与在线版看到的是同一批图。 # ★注入块里的文案要**自己**按语言选: 它加在 _en_page 之后, # 英文化那一遍已经跑完了, 这里的中文字面量永远不会被翻译。 # 实逮: 英文页浮层里弹出"本窗无记录"。 # ('状态'/'依据' 是 JSON 键, '年' 是窗名数据值 —— 都不能动。) inject += ('') page = page.replace('', page, re.S): if 'application/json' in _m.group(1) or not _m.group(2).strip(): continue _uie.check_js(_m.group(2), label='快照脚本#%d' % (_nchk + 1)) _nchk += 1 print('[snapshot] 成品页语法闸: %d 个脚本全部通过 node --check' % _nchk, file=sys.stderr) if _nchk == 0: print('[snapshot] 🔴 语法闸切出 0 个脚本 —— **本次未校验**, 非通过', file=sys.stderr) except Exception as _e: print('[snapshot] 🔴 成品页语法闸失败: %s' % _e, file=sys.stderr) raise if _lang == 'en': _n1 = len(re.findall(r'[\u4e00-\u9fff]', page)) print('[snapshot] 英文快照: 页面残留中文 %d 字 (含内联数据)' % _n1, file=sys.stderr) # ★脱敏必须是**最后一步**, 且只对上云那一版做 (2026-09-01 用户令)。 # 中文快照是内部工作副本, 要保留真实场名/机型, 不脱敏。 # 英文快照是要发到公网的, 而它此前带着 rudong×366 / Siemens×194 / # SWT×89 —— **同时页面上写着 "site, owner and OEM removed"**, # 产物与自己的声明不符, 这比不声明更糟。 # 放最后: 对象 id 与引用它的地方必须换成同一个词, 只能整体做。 if _lang == 'en': from src.windscada import deid_public as _dp page = _dp.scrub_or_die(page, label='英文快照 (上云件)') print('[snapshot] 脱敏回扫: 干净 (rudong/OEM/机型/省市/业主/坐标 全零)', file=sys.stderr) fn = (f'Guanlan_Snapshot_{CUR_WIN[0]}_EN.html' if _lang == 'en' else f'观澜快照_{CUR_WIN[0]}_{"上报版" if not INTERNAL else "内部版"}.html') data = page.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(data))) self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + urllib.parse.quote(fn)) self.end_headers() self.wfile.write(data) except Exception as e: import traceback self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) elif u.path == '/api/rpt_export': # 导出 Word/Excel (2026-08-28 用户令: 导出 demo)。图表块转数据表, 不放位图 — # Word 里的位图既不能编辑也无法核对, 上报集团要的是能核的数字。 q = urllib.parse.parse_qs(u.query) fmt = q.get('fmt', ['docx'])[0] blocks = [x for x in q.get('blocks', [''])[0].split(',') if x] aud = q.get('aud', ['in'])[0] w = q.get('win', ['2026年'])[0] narr = q.get('narr', [''])[0] or None try: from src.windscada import report_export as _rx fl = fleet_view(w) if aud == 'up': # 上报版走同一套脱敏规则 fl = _redact(_jclean(fl)) data = (_rx.to_xlsx if fmt == 'xlsx' else _rx.to_docx)(fl, blocks, w, aud, narr) ext = 'xlsx' if fmt == 'xlsx' else 'docx' mime = ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' if fmt == 'xlsx' else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') fn = f'运行分析报告_{w}_{"上报版" if aud == "up" else "内部版"}.{ext}' self.send_response(200) self.send_header('Content-Type', mime) self.send_header('Content-Length', str(len(data))) self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + urllib.parse.quote(fn)) self.end_headers() self.wfile.write(data) except Exception as e: import traceback self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-400:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) elif u.path in ('/api/facts', '/api/facts/claim'): # 结构化事实契约 (用户批 2026-09-06 "8033 /api 切到契约生成"): 只读 derived/ 产物, 不算数不改写; # 生成器 = scripts/guanlan_facts_contract.py render, 契约 sha 随体返回供前端/CI 对拍. import json as _json _dd = _P.guanlan() / 'derived' try: if u.path == '/api/facts': _body = _dd.joinpath('detail_cards.json').read_text(encoding='utf-8') else: _cid = urllib.parse.parse_qs(u.query).get('id', [''])[0] _qa = _json.loads(_dd.joinpath('qa_refs.json').read_text(encoding='utf-8')) _hit = next((r for r in _qa.get('refs', []) if r.get('claim_id') == _cid), None) _body = _jdump(dict(contract_sha256=_qa.get('contract_sha256'), claim=_hit, err=None if _hit else 'unknown claim_id'), ensure_ascii=False) self._send(_body, 'application/json; charset=utf-8') except FileNotFoundError as e: self._send(_jdump(dict(err='facts derived 缺失: %s' % e), ensure_ascii=False), 'application/json; charset=utf-8', 503) elif u.path == '/api/rpt_compose': q = urllib.parse.parse_qs(u.query) if q.get('probe', [''])[0]: # 前端只探"编排接口是否存在" (v2 bindReport probe=1); 原先照跑 rpt_compose('', [], model) 走模型, # 汇报定制页首屏空等 ~23 s (2026-09-06 实测) —— 探针只答存在性, 不算数. self._send(_jdump(dict(ok=True, probe=True), ensure_ascii=False), 'application/json; charset=utf-8'); return r = rpt_compose(q.get('need', [''])[0], [x for x in q.get('canvas', [''])[0].split(',') if x], q.get('model', ['qwen3:8b'])[0]) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ask_status': self._send(_jdump(ask_status(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ask_models': # 与云端问答微服务 (windscada_ask_serve.py) 同形: {models: {id: {...}}}。此前本地没有 # 这条路由, 经典页的模型下拉在本地永远是"无可用模型" —— v2 与经典页同源取清单。 self._send(_jdump(dict(models={k: {} for k in ASK_MODELS}), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ontology': self._send(_jdump(ontology_view(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_list': self._send(_jdump(dict(rows=ontology_list(q.get('type', ''), q.get('t', ''), q.get('limit', 60))), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_obj': self._send(_jdump(ontology_obj(q.get('id', '')), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/channels': self._send(_jdump(channel_list(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path.startswith('/static/'): f = ST / u.path[len('/static/'):] if f.is_file(): self._send(f.read_bytes(), 'text/html; charset=utf-8') else: # 裸 404 会让总览 iframe 变成"一块空白"(用户 2026-09-17 报的正是这个现象) ⇒ 结构化缺件页 self._send(_gap_page(u.path, f, '总览(全场状态)'), 'text/html; charset=utf-8') elif u.path.startswith('/turbines/'): f = ST / u.path.lstrip('/') if f.is_file(): raw = f.read_text(encoding='utf-8', errors='ignore') light = '' raw = raw.replace('', light + '', 1) if '' in raw else light + raw self._send(raw) else: self._send(_gap_page(u.path, f, '机组详情页'), 'text/html; charset=utf-8') elif u.path.startswith('/windcms/'): # windcms 振动分析本体嵌入 (用户令: 原振动分析板块进入振动融合板块; 静态挂载非复制, # 正本仍在 outputs/rudong/windcms 由振动线维护, 本路由只读) base = (ST.parent / 'windcms').resolve() f = (base / u.path[len('/windcms/'):]).resolve() if base in f.parents or f == base: if f.is_dir(): f = f / 'index.html' if f.is_file(): ext = f.suffix.lower() mime = {'.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css', '.js': 'application/javascript', '.md': 'text/plain; charset=utf-8'}.get(ext, 'application/octet-stream') self._send(f.read_bytes(), mime) else: self._send('404', code=404) else: self._send('403', code=403) elif u.path == '/api/reload': # 产物重载 (2026-09-16 用户令: 重算产物即时可用于页面呈现)。 # 平时**不需要**调它 —— _load() 每次请求都会比产物指纹, 变了自动重载; # 这个端点给"重算结束想立刻生效"的显式动作, 也便于运维界面/脚本确认当前指纹。 _old = reload_products(urllib.parse.parse_qs(u.query).get('why', ['manual'])[0]) self._send(_jdump(dict(ok=True, old_stamp=_old, stamp=products_stamp(force=True), files=len(_product_files())), ensure_ascii=False), 'application/json; charset=utf-8') else: self._send('404', code=404) except ProductsMissing as e: # 产物缺失不是"程序坏了": 回 200 + 结构化无数据, 前端按正常渲染显示原因 (2026-09-11 空状态检查) self._send(_jdump(dict(kind='multiline', title='无产物', unit='', months=[], series=[], err='no_products', note=f'{e.what} 不存在 → {_P.rel(e.path)}; 先放原始件再跑 ' f'scripts/rebuild_from_raw.py (SCADA 侧加 --scada) 或 ' f'scripts/rebuild_all.py 生成产物。' f'"包内没有生成端"的件按用户令 2026-09-17 **运行期不由交付包补齐**: ' f'要么等研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6), ' f'要么由人离线补救(products_restore_missing.py --stash <…>, 不在运行链里); ' f'2026-09-16 起清除产物不留备份, 故没有 --on 还原'), ensure_ascii=False), 'application/json; charset=utf-8') except Exception as e: import traceback self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) if __name__ == '__main__': # 统一日志口径 (用户令 2): 之后每一行都带 时间戳/级别/组件(详情见 src/logfile.py) import pathlib as _pl, sys as _sys0 _sys0.path.insert(0, str(_pl.Path(__file__).resolve().parents[1])) from src import logfile as _lf; _lf.prefix_stdout('detail') # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛 # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩; # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。 import sys as _sys for _s in (_sys.stdout, _sys.stderr): try: _s.reconfigure(errors='replace') except Exception: pass import argparse ap = argparse.ArgumentParser() import os ap.add_argument('--port', type=int, default=int(os.environ.get('PORT', 8033))) # PORT 环境变量优先, 供 preview 分配端口 ap.add_argument('--host', default='127.0.0.1', help='0.0.0.0 = 对局域网开放 (演示用, 结束请关)') ap.add_argument('--farm', default=None, help='场名 (默认 rudong; 可用场见 configs/farms/)') a = ap.parse_args() print(f'windscada serve :{a.port}', flush=True) # --host 0.0.0.0 供局域网演示 (2026-08-28 用户: 怎么发给别人)。 # 默认仍只绑本机 — 内部数据不该因为一次演示就对整个网段开放, 要开是显式动作。 if getattr(a, 'farm', None): # ★CFG/ST 在模块加载时就绑定了 (第 81 行), 这里必须显式重绑而不是只切 current — # 否则 --farm 只改了配置模块里的指针, 服务用的仍是启动时那个场 (静默不生效)。 # 用模块字典赋值而非 global: main 里更早处已引用过 CFG, global 声明会触发 # "used prior to global declaration"。改模块属性等效且不受声明位置约束。 from src.windscada import config as _cfg import sys as _sysmod _me = _sysmod.modules[__name__] _cfg.set_current(a.farm) _me.CFG = _cfg.farm() _me.ST = pathlib.Path(_me.CFG['store']) _CACHE.clear() _STAMP['val'] = None # 换场后产物指纹必须重算, 否则新场的缓存判为"未变化" print(f'场: {_me.CFG["name"]} ({a.farm}, {_me.CFG["n_turbines"]} 台, 仓 {_me.ST})') else: print(f'场: {CFG["name"]} (默认; 可用 {list(__import__("src.windscada.config", fromlist=["available"]).available())})') _host = getattr(a, 'host', '127.0.0.1') if _host != '127.0.0.1': import socket as _sk try: _ip = _sk.gethostbyname(_sk.gethostname()) except Exception: _ip = _host print(f'⚠ 已对局域网开放: http://{_ip}:{a.port} (同网段任何人可访问, 演示结束请关闭)') ThreadingHTTPServer((_host, a.port), H).serve_forever()