Преглед на файлове

修现场报「重算后 振动页 / 总览全场状态 没数据」—— 根因是缺"包内没有生成端"的随包件, 并修被它放大的三处缺陷

用户令现象: ① /detail/v2#tab=vibration 没有数据; ② #tab=overview 的「全场状态 一格一台 · 点格定位」没有信息。

根因(实测, 不是重算坏了):
· 用户在 14:58 点「清除产物」= 真删; 而"包内没有生成端"的那批随包件交付包按用户令不含产物
  ⇒ 总览「全场状态」iframe 的正本 outputs/rudong/windscada/index.html(101 KB) 与振动融合面要的
  m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json、tcm_index.parquet、
  windcms/ 页面件、sop/ paradigm_r1/ guanlan/ pitch/ tcm_compatible_replay/ 全不在位。
  iframe 取 /static/index.html → detail 服务缺件回裸 404 ⇒ 浏览器里就是"一块空白";
  振动页 /api/vibcms 亦无数据源。
· 重算链 16/17 步 ⑧c 页面归口审计因这些缺件判 rc=5 → 整条链中断(rc=1, 1367 s),
  连最后的 ⑧台账等价验收都没跑 —— "重算失败"这件事本身是被缺件放大的。
· 第 ⑤ 步「补齐随包件」只找 _products_off*/ 暂存区, 而含产物的包就在仓库根目录却没人去看 → 打印"跳过"。

修复:
1) 从含产物的交付包补齐 567 件(products_restore_missing.py --stash guanlan-rudong-v2_0.2.0_test_win64.zip),
   逐件来源记进 _provenance.json 的 shipped(1725 raw-derived / 567 shipped)。
   复检: pages_audit 由 rc=5 → **rc=0 全部一致**; /detail/static/index.html 404 → **HTTP 200 / 94 KB**;
   /detail/api/vibcms 恢复真实数据(全场 38 台判级 / 8 台报警); /detail/api/fleet 138 KB。
2) products_restore_missing.stash_dir(): 暂存区不在时**自动找含产物的包**(本仓根与上一级 .zip 里
   挑真含 outputs/<场>/windscada/temp_monthly.parquet 的那个, 取件数最多者)。实测自动命中 586 件。
3) rebuild_all: ⑧c 容忍 rc=5(缺件属预期, 口径与 ④/⑤/⑥/⑧b 一致: 缺的是"随包件"这一路, 不是重算本身),
   不再中断整条链、不再跳过最后的验收步; 缺件仍由 guanlan.py check 报 FAIL(那是给人看的告警位)。
4) windscada_serve: /static/* 与 /turbines/* 缺件不再回裸 404, 改 _gap_page() 结构化缺件页
   (HTTP 200 + 缺哪件/哪条产物线/怎么补 + 运维控制台与 healthz 入口), 与 CMS 的"无产物"页同口径(docs §11.4)。
   实测 /detail/static/不存在的页.html → 200 / 1,684 B。

打包器两个缺陷(用户令 2 的连带, 本次实逮):
5) **install-info.json 被原样打进交付包**: INCLUDE_FILES 里没有它, 但入口引用闭合兜底把
   "install.ps1 读它"当成"入口引用的文件"自动补入 ⇒ 目标机第一次装就读到打包机的安装记录,
   安装前检查误报"已安装 v2.5.0(装于 16:45, F:\temp\app) → 版本相同, 是否重装" —— 版本检查当场失灵。
   处置: 打包器按名字排除 + 自动补入过滤 + **打包后复查**(包内有 install-info.json 或 *.lnk 即删包 rc=1);
   entry_refs: 闭合检查放行"装机产物"(它本来就该不在, 装之前不存在正是版本检查的判据), 否则守卫会
   反过来把合格的包判成不合格; .gitignore 挡住这两类。
6) **--with-products / --with-data 是摆设**: keep() 无条件套用 EXCLUDE_GLOBS(含 data/outputs),
   于是 plan() 打印"例外打进包"却逐件被滤掉 —— 实测开关打出来的包与默认包同为 3441 件 / 879 MB,
   里面一件产物都没有("以为有存档、其实没有", 要补随包件时才发现)。
   修后: 默认 3440 件 / 1032 MB; --with-products 5697 件 / 4701 MB。

交付: F:\temp\app_guanlang_v2.5.0.zip(3440 件, 装机产物 0, 产物 0)
      F:\temp\app_guanlang_v2.5.0_with_products.zip(5697 件, 含产物 —— 随包件的可恢复存档)
开箱验证: 5/5 页面 + 卸载核验通过(独立端口, 主实例未打扰)。
zhouyang.xie преди 3 седмици
родител
ревизия
14c938aeba
променени са 7 файла, в които са добавени 188 реда и са изтрити 23 реда
  1. 6 0
      .gitignore
  2. 0 0
      docs/系统设计说明.md
  3. 54 15
      scripts/pack_dist.py
  4. 48 3
      scripts/products_restore_missing.py
  5. 10 2
      scripts/rebuild_all.py
  6. 49 2
      scripts/windscada_serve.py
  7. 21 1
      src/entry_refs.py

+ 6 - 0
.gitignore

@@ -52,6 +52,12 @@ release/portal.html
 release/portal_src/templates/
 release/portal_src/governance_sources.json
 
+# ── 装机产物 (2026-09-17) ────────────────────────────────────────────────────
+# `install-info.json` = **本机**装没装、装的哪一版 (含安装时间与本机绝对路径); `.lnk` = 安装时建的快捷方式。
+# 两者都只在本机有效, 换机即失效: 不该进版本库, 更不该进交付包 (打包器已按名字排除 + 打包后复查)。
+install-info.json
+*.lnk
+
 # 归档 zip: 本仓**只保留一个**入库的旧交付包(补救源, 见 旧包_补救源_说明.md);
 # 其余 .zip 一律不入库 —— 交付包每次重打, 往仓库里塞会把 clone 拖到 GB 级 (用户令 2026-09-17)
 *.zip

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
docs/系统设计说明.md


+ 54 - 15
scripts/pack_dist.py

@@ -113,8 +113,20 @@ EXCLUDE_GLOBS = ['_products_off*', 'data', 'outputs']
 #   .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**;  .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份
 EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl',
                   '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'}
-# 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件
-EXCLUDE_NAMES = ('*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.crdownload', '*.partial')
+# 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件 + **本机安装记录**
+# ★ `install-info.json` 必须排除 (2026-09-17 实逮: 它被原样打进了交付包): 那是**本机装没装、装的哪一版**
+#   的凭据, 里面写着安装时间与本机绝对路径; 随包发出去, 目标机第一次装就会读到它, 于是安装前检查
+#   报"已安装 v2.5.0(装于 <打包机时间>)", 明明是新机器却提示"版本相同, 是否重装" —— 用户令 1
+#   要的版本检查当场失灵。它是装机产物, 不是交付件。
+EXCLUDE_NAMES = ('install-info.json', '*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini',
+                 '*.crdownload', '*.partial')
+
+
+def is_machine_artifact(p: pathlib.Path) -> bool:
+    """装机产物(本机装没装 / 本机快捷方式)—— 只在本机有效, 不随包、不入库 (2026-09-17)。"""
+    import fnmatch
+    return (p.name == 'install-info.json' or p.suffix.lower() in EXCLUDE_SUFFIX
+            or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
 
 
 def _xfer():
@@ -159,10 +171,19 @@ def plan(with_data=False, with_products=False):
     from src.entry_refs import auto_include, referenced_tree_files
     got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
     extra = auto_include(ROOT, got)
-    if extra:
-        for p in extra:
+    # ★ 装机产物不许被"引用闭合"兜底补进包 (2026-09-17 实逮): `install.ps1`/`install.sh` 的版本检查那步
+    #   会读 `install-info.json`, 于是自动补入把它当"入口引用的文件"补了进来 —— 而它是**本机装没装**的
+    #   凭据(含安装时间与本机路径), 目标机第一次装就会误报"已安装、版本相同"。引用闭合管的是"入口点名的
+    #   文件必须在位", 不管"这个文件该不该随包", 所以这里按排除规则再滤一遍并如实说一句。
+    keepable, dropped = [], []
+    for p in extra:
+        (dropped if is_machine_artifact(p) else keepable).append(p)
+    if keepable:
+        for p in keepable:
             print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
-        inc.extend(extra)
+        inc.extend(keepable)
+    for p in dropped:
+        print(f'   [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
     skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')]
     if (ROOT / PRODUCTS_DIR).is_dir():
         skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
@@ -186,13 +207,19 @@ def plan(with_data=False, with_products=False):
     return inc, skipped
 
 
-def keep(f: pathlib.Path) -> bool:
+def keep(f: pathlib.Path, with_data: bool = False, with_products: bool = False) -> bool:
     """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
 
     ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
     而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
     现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
     ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
+    ★ 2026-09-17 修: `--with-products` / `--with-data` 原先**是个摆设** —— `keep()` 无条件套用
+      `EXCLUDE_GLOBS`(里面有 `data`/`outputs`), 于是 `plan()` 把 outputs/ 加进要打清单、打印
+      "本次给了 --with-products, 例外打进包", 而写盘循环又把它们逐件滤掉: 实测 `--with-products`
+      打出来的包与默认包**同样 3441 件 / 879 MB**, 里面一件产物都没有。后果不是"少带东西",
+      而是"以为有存档、其实没有" —— 要补随包件时才发现在包里找不到。现在两个开关**真的**生效:
+      例外目录一旦显式要带, 就不再被 EXCLUDE_GLOBS 拦下。
     """
     import fnmatch
     parts = f.relative_to(ROOT).parts
@@ -202,16 +229,22 @@ def keep(f: pathlib.Path) -> bool:
         return False
     if len(parts) == 1 and f.suffix.lower() == '.zip':      # 顶层旧交付包, 避免包中包
         return False
-    return not any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1])
+    allow = set()
+    if with_data:
+        allow.add('data')
+    if with_products:
+        allow.add(PRODUCTS_DIR)
+    globs = tuple(g.rstrip('*') for g in EXCLUDE_GLOBS if g.rstrip('*') not in allow)
+    return not any(x.startswith(globs) for x in parts[:1])
 
 
-def size_of(p: pathlib.Path):
+def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = False):
     if p.is_file():
         return 1, p.stat().st_size
     n = s = 0
     for f in p.rglob('*'):
-        if f.is_file() and keep(f):
-            n += 1; s += f.stat().st_size
+        if f.is_file() and keep(f, with_data, with_products):
+            n += 1; s = s + f.stat().st_size
     return n, s
 
 
@@ -221,7 +254,7 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
     print('== 要打进包的内容 ==')
     print('   (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
     for p in inc:
-        n, s = size_of(p)
+        n, s = size_of(p, with_data, with_products)
         tot_n += n; tot_s += s
         print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
     print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
@@ -252,7 +285,7 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
                 done += 1
             else:
                 for f in p.rglob('*'):
-                    if not f.is_file() or not keep(f):
+                    if not f.is_file() or not keep(f, with_data, with_products):
                         continue
                     z.write(f, f.relative_to(ROOT).as_posix())
                     done += 1
@@ -272,11 +305,17 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
             print(f'   [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
     for m in enc:
         print(f'   [X] 入口编码守则: {m}')
-    if miss or enc:
+    # 装机产物不得进包 (2026-09-17 实逮: 本机的 install-info.json 被原样打进包 ⇒ 目标机安装前检查
+    # 会误报"已安装 vx.y.z, 版本相同" —— 版本检查当场失灵)。这类"看着无害、后果是静默误报"的东西
+    # 必须由机器守: 包里出现一件就删包。
+    machine = [n for n in names if n.split('/')[-1] in ('install-info.json',) or n.endswith('.lnk')]
+    for n in machine[:5]:
+        print(f'   [X] 包里有"装机产物" {n} —— 它只在本机有效 (安装记录/快捷方式), 不该随包分发')
+    if miss or enc or machine:
         out.unlink()
         print(f'   [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
         return 1
-    print(f'   [OK] 入口引用闭合 + 编码守则: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
+    print(f'   [OK] 入口引用闭合 + 编码守则 + 无装机产物: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
     print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
     print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
     return 0
@@ -381,7 +420,7 @@ def main() -> int:
         tot_n = tot_s = 0
         print(f'== 会打进包 (version {VERSION}) ==')
         for p in inc:
-            n, s = size_of(p); tot_n += n; tot_s += s
+            n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
             print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
         print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
         print('== 会排除 ==')

+ 48 - 3
scripts/products_restore_missing.py

@@ -70,13 +70,52 @@ RAW_DERIVED = {
 }
 
 
+def zip_candidates() -> list[pathlib.Path]:
+    """可能含产物的包(本仓根 + 上一级)—— 没有暂存区时用它自动找随包件来源。
+
+    2026-09-17 现场问题驱动:用户「清除产物」后点重算,页面开始缺件(总览 iframe 空白、振动页没数),
+    而本步打印的却是"跳过:找不到随包产物原件"。可**含产物的包明明就在旁边**
+    (`guanlan-rudong-v2_0.2.0_test_win64.zip`)—— 只是没人告诉脚本去看它。
+    于是这里自动扫一遍:谁的 `outputs/<场>/windscada/temp_monthly.parquet` 在,谁就是随包件来源
+    (那件是"包内无生成端"的典型件,只有真正的随包整份产物里才有)。挑件数最多的那个。
+    """
+    out = []
+    for base in (ROOT, ROOT.parent):
+        try:
+            out += [p for p in sorted(base.glob('*.zip')) if p.is_file()]
+        except OSError:
+            pass
+    return list(dict.fromkeys(out))
+
+
+def find_stash_zip() -> tuple[pathlib.Path, int] | None:
+    """→ (含产物的包, 该场产物件数);找不到就 None。"""
+    import zipfile
+    marker = f'outputs/{P.farm()}/windscada/temp_monthly.parquet'
+    pref = f'outputs/{P.farm()}/'
+    best = None
+    for z in zip_candidates():
+        try:
+            with zipfile.ZipFile(z) as zf:
+                names = set(zf.namelist())
+        except Exception:                                    # 不是 zip / 读不动 / 加密: 跳过, 不中断
+            continue
+        if marker not in names:
+            continue
+        n = sum(1 for x in names if x.startswith(pref))
+        if best is None or n > best[1]:
+            best = (z, n)
+    return best
+
+
 def stash_dir(explicit=None) -> pathlib.Path:
     """随包产物原件在哪。
 
-    三个地方都可能是它(取决于用过哪些开关), 所以按"像不像随包整份产物"打分挑:
+    四个地方都可能是它(取决于用过哪些开关), 所以按"像不像随包整份产物"打分挑:
       · `_products_off/<场>/`                     = products_state --off 挪走的当前产物;
       · `_products_off_prev_<时间戳>/<场>/`         = --off --archive-old 存档的上一代(= 随包原件);
-      · `--stash` 显式指定。
+      · `--stash` 显式指定 (目录或交付包 zip);
+      · **含产物的交付包 zip, 自动找** (2026-09-17 加: 本仓根与上一级 `.zip` 里挑真含随包件的那个)。
     判据用**只在随包里有的件**(windscada/temp_monthly.parquet 这类"包内无生成端"的产物):
     含它的目录才是"标准答案"来源。原先只看第一个候选, 于是 --on 还原后暂存区空了 → 台账被写成 0/0。
     """
@@ -92,9 +131,15 @@ def stash_dir(explicit=None) -> pathlib.Path:
         n = sum(1 for _ in c.rglob('*') if _.is_file())
         scored.append(((1 if (c / marker).is_file() else 0), n, c))
     scored.sort(key=lambda x: (-x[0], -x[1]))
+    if scored and scored[0][0]:                       # 只有真含随包件的目录才算数
+        return scored[0][2]
+    found = find_stash_zip()
+    if found:
+        print(f'  [自动] 暂存区不在, 但找到含产物的交付包: {P.rel(found[0])} ({found[1]} 件)')
+        return found[0]
     if scored:
         return scored[0][2]
-    raise StashMissing('找不到随包产物原件 (_products_off/** 或 _products_off_prev_**); 用 --stash 指定')
+    raise StashMissing('找不到随包产物原件 (暂存区 _products_off/**、含产物的交付包 .zip、或 --stash 指定)')
 
 
 class StashMissing(SystemExit):

+ 10 - 2
scripts/rebuild_all.py

@@ -106,10 +106,18 @@ def build_plan(a) -> list:
                          note='rc=1 = 缺契约产物(产物被清除时属预期) ⇒ 门户保留原样, 不是装配坏了'))
     # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。
     #     rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。
+    #     ★ rc=5 也容忍 (2026-09-17 实逮): rc=5 = "登记的文件/被引产物不在位", 而**包内没有生成端**的那批
+    #       随包件(如 windscada/index.html、m5_cms_tcm/tcm_index.parquet、windcms 页面件)在
+    #       "清过产物 + 交付包不含产物"的机器上必然不在位 ⇒ 第 16 步判失败、整条链 rc=1, 而且把最后的
+    #       ⑧台账等价验收也跳过了(实测 2026-09-17: 用户在页面上点重算, 1367 s 后 rc=1 断在这里)。
+    #       这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由
+    #       `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。
     plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
-                         tolerate=(7,),
+                         tolerate=(5, 7),
                          note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
-                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期'))
+                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '
+                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期) '
+                              '⇒ 不打断整条链, 补齐办法见 docs §7 与「数据重算」页的缺件提示'))
     if a.with_verify:
         # 只有 4 = "有需人工看的差异"(本包已知项: 油样 102 行的源件不在现场包里) 才容忍;
         # 5 = 找不到基线(等于没验收) 必须当失败 —— 2026-09-12 就是因为放过了它, 出现过"验收没做却报通过"。

+ 49 - 2
scripts/windscada_serve.py

@@ -467,6 +467,52 @@ def _jdump(obj, **kw):
     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` 文本 —— 浏览器里表现为**一块空白**,
+    用户看到的现象是"这个板块没有信息", 而不是"缺件"。最典型的就是总览页那个
+    `<iframe src="/static/index.html">`(全场状态总览页): 这件产物属"包内没有生成端"的随包件,
+    清过产物之后重算也生不出来 ⇒ iframe 里是空白, 页面上却找不出任何原因。
+
+    现在改成结构化说明: 缺哪一件、它归哪条产物线、**怎么补**、去哪儿看全局状态。
+    口径与 CMS 的"无产物"页一致 (docs §11.4: 无产物时必须还能打开页面, 且要写明缺件与补齐办法)。
+    """
+    import html as _h
+    rel = ''
+    try:
+        rel = pathlib.Path(missing).relative_to(pathlib.Path(_P.ROOT)).as_posix() if missing else ''
+    except Exception:
+        rel = str(missing or '')
+    return ('<!doctype html><meta charset="utf-8"><title>产物不在位</title>'
+            '<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;color:#183548;background:#f2f6f8;'
+            'margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh}'
+            '.b{background:#fff;border:1px solid #d4e3e7;border-radius:10px;padding:22px 26px;max-width:800px;'
+            'box-shadow:0 6px 20px rgba(14,74,87,.08)}h1{font-size:17px;margin:0 0 10px}'
+            'code{background:#eef4f6;border-radius:4px;padding:1px 5px}.s{color:#667f8e;font-size:13px}'
+            'a{color:#087f89}li{margin:3px 0}</style>'
+            f'<div class="b"><h1>{_h.escape(what)}的数据产物不在位</h1>'
+            f'<p class="s">请求路径 <code>{_h.escape(path)}</code></p>'
+            + (f'<p>缺的是 <code>{_h.escape(rel)}</code>(属 <code>outputs/&lt;场站&gt;/</code> 下的产物)。</p>'
+               if rel else '')
+            + '<p>两种情形分开补:</p><ul>'
+            '<li><b>有生成端的产物</b>(alarms / workorders / temp_monthly / 振动窗索引谱 …): 放好 '
+            '<code>data/raw/&lt;场站&gt;/</code> 后跑 <code>python scripts/rebuild_all.py</code>, '
+            '或点运维控制台的「数据重算」。</li>'
+            '<li><b>包内没有生成端的随包件</b>(如本页 <code>windscada/index.html</code>、'
+            '<code>m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json</code>、'
+            '<code>tcm_index.parquet</code>、<code>windcms/</code> 页面件、<code>sop/</code>、'
+            '<code>paradigm_r1/</code>): 只能从**含产物的交付包**补齐 —— '
+            '<code>python scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>。'
+            '重算链的第 ⑤ 步做的就是这件事, 但交付包默认不含产物, 没有包就补不回来。</li></ul>'
+            '<p class="s">逐件来源见 <code>outputs/&lt;场站&gt;/_provenance.json</code>;'
+            '缺口清单见 <code>docs/系统设计说明.md</code> §7 与 docs/重算缺口与补件清单_v0.1.md。</p>'
+            '<p><a href="/v2" target="_top">← 返回工作台</a> · '
+            '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台 · 数据重算</a> · '
+            '<a href="http://127.0.0.1:28084/healthz" target="_top">全部模块状态</a></p></div>')
+
 from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
 from src.windscada.config import farm
 from src.windscada import taxonomy
@@ -5079,7 +5125,8 @@ class H(BaseHTTPRequestHandler):
                 if f.is_file():
                     self._send(f.read_bytes(), 'text/html; charset=utf-8')
                 else:
-                    self._send('404', code=404)
+                    # 裸 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():
@@ -5088,7 +5135,7 @@ class H(BaseHTTPRequestHandler):
                     raw = raw.replace('</head>', light + '</head>', 1) if '</head>' in raw else light + raw
                     self._send(raw)
                 else:
-                    self._send('404', code=404)
+                    self._send(_gap_page(u.path, f, '机组详情页'), 'text/html; charset=utf-8')
             elif u.path.startswith('/windcms/'):
                 # windcms 振动分析本体嵌入 (用户令: 原振动分析板块进入振动融合板块; 静态挂载非复制,
                 # 正本仍在 outputs/rudong/windcms 由振动线维护, 本路由只读)

+ 21 - 1
src/entry_refs.py

@@ -169,16 +169,35 @@ def encoding_problems(root: pathlib.Path) -> list[str]:
     return bad
 
 
+# 装机时才有 / 只在本机有效的文件 —— 入口脚本**可以**引用它们, 但它们**不该在包里**
+# (所以闭合检查必须放行, 否则这条守卫会得出相反的结论, 见 missing_refs 的注释)。
+MACHINE_ARTIFACTS = ('install-info.json',)      # 安装时写的"本机装没装/哪一版"凭据
+
+
+def _is_machine_artifact(rel: str) -> bool:
+    name = rel.replace('\\', '/').split('/')[-1].lower()
+    return name in MACHINE_ARTIFACTS or name.endswith('.lnk')
+
+
 def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
     """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
 
     provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
+
+    ★ 2026-09-17: **放行"装机产物"** (见 MACHINE_ARTIFACTS)。这条守卫的本意是"包内不许有指向空气的
+      入口"(曾经漏带 start_hidden.vbs, 目标机双击即报错), 而 `install-info.json` 是**故意不发**的:
+      它是安装时才写的本机凭据, 装之前本来就该不在, 而 `install.ps1`/`install.sh` 的版本检查那一步
+      正是靠"文件在不在"判断装没装的。原先这两件事会打架 —— 把 install-info.json 排除出包之后,
+      闭合检查反过来报"install.ps1 引用了它但包里没有 ⇒ 不能交付", 于是包被闸删掉。
+      判据修正为: 引用的文件必须"在包内**或在目标机上必然存在**(装机产物/安装时自建)"。
     """
     have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
     bad: list[tuple[str, str]] = []
     root = pathlib.Path(root)
     for entry, refs in sorted(referenced_tree_files(root).items()):
         for rel in sorted(refs):
+            if _is_machine_artifact(rel):
+                continue
             ok = (rel in have) if have is not None else (root / rel).is_file()
             if not ok:
                 bad.append((entry, rel))
@@ -190,13 +209,14 @@ def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
 
     只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
     缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
+    ★ 装机产物 (`install-info.json` / `*.lnk`) 不在此列 —— 它们本机有, 但**不该随包**(见 MACHINE_ARTIFACTS)。
     """
     got = {_norm(x) for x in already}
     root = pathlib.Path(root)
     add: list[pathlib.Path] = []
     for _, refs in referenced_tree_files(root).items():
         for rel in refs:
-            if '/' in rel or rel in got:
+            if '/' in rel or rel in got or _is_machine_artifact(rel):
                 continue
             p = root / rel
             if p.is_file():

Някои файлове не бяха показани, защото твърде много файлове са промени