Pārlūkot izejas kodu

用户令1: 页面归口 —— 门户页面/子页是否随数据变、是否属于产物; 新增 configs/portal_pages.yaml 登记表 + scripts/pages_audit.py 五条机器规则(含陈旧检测 rc=7), 接入 check 与重算链(⑧b 重装门户/⑧c 审计), 文档 §10 + §7 三条缺口

zhouyang.xie 3 nedēļas atpakaļ
vecāks
revīzija
3e73a20b9c
5 mainītis faili ar 725 papildinājumiem un 0 dzēšanām
  1. 245 0
      configs/portal_pages.yaml
  2. 71 0
      docs/系统设计说明.md
  3. 19 0
      guanlan.py
  4. 378 0
      scripts/pages_audit.py
  5. 12 0
      scripts/rebuild_all.py

+ 245 - 0
configs/portal_pages.yaml

@@ -0,0 +1,245 @@
+# 门户页面与子页的"内容归口"登记表  (2026-09-17, 用户令 1)
+#
+# 回答两个问题:
+#   ① 这一页/这一件 **该不该随输入数据(data/raw)变化**?
+#   ② 它 **算不算产物**? 算的话归到哪、由谁生成、怎么查它陈旧没陈旧?
+#
+# 五个 kind (每个都有机器可查的规则, 见 scripts/pages_audit.py):
+#   static          受管静态: 外壳正文(方法论/架构/案例叙述)。不随输入数据变, 进 git, 不引用产物。
+#   live            实时取数: iframe/链接到组件服务或 API。永远与产物一致, 不需要快照, 也不进产物管理。
+#   data-citing     静态正文 + **产物引用脚注**(如"判据全量见 outputs/<场>/…/findings.json")。
+#                   正文不随数据变; 但被引用的产物路径必须**存在**, 否则引用悬空(挂羊头)。
+#   data-derived    数据派生快照: 把产物/输入烘进页面(内嵌 JSON / 指纹 / 生成时间)。
+#                   ★**属于产物**: 必须登记 source + (source_sha256 或 generated_at), 并做陈旧检测。
+#   frozen-delivery 冻结交付件: 带版本号与日期的客户交付件(治理清单/报告/资料包)。
+#                   按**交付版本**变, 不随数据自动变; 必须能从文件名或正文读到版本号与日期。
+#
+# 判定依据(evidence)一律写清"凭什么这么判", 可复核: 看的是页面里有没有 iframe/fetch/内嵌数据/产物引用,
+# 以及内容是不是由 data/raw 算出来的。要改分类, 先改证据。
+version: 1
+farm: rudong
+
+# 产物路径写法统一: 一律以 outputs/<场>/… 记录(相对安装根), 便于陈旧检测直接定位文件
+pages:
+
+  # ── 门户外壳里的静态叙述页 (受管, 不随数据变) ───────────────────────────────
+  - id: index
+    title: 总览
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "外壳 #index 段 2,519 B: 0 iframe / 0 fetch / 0 产物引用; 正文是产品承诺与入口导航"
+  - id: architecture
+    title: 系统架构
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "外壳 #architecture 段 3,037 B: 无数据引用; 描述分层架构"
+  - id: method
+    title: 方法
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "外壳 #method 段 2,959 B: 无数据引用; 描述方法论"
+  - id: findings
+    title: 经验发现
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "★用户问的三页之一。外壳 #findings 段 2,322 B: 0 iframe / 0 fetch / 0 产物引用; 正文是公司级经验叙述(59 项内部检查 → 8 条公开教训, 60+ 个风场), 不是本场站数据的函数 ⇒ 不该随输入数据变, 也不是产物。若将来要放本场站计数, 必须改成 data-derived 并登记 source"
+  - id: case_hydraulic
+    title: 案例·液压
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "外壳 #case_hydraulic 段 6,187 B: 无数据引用; 案例叙述"
+
+  # ── 实时取数页 (永远与产物一致) ──────────────────────────────────────────
+  - id: cms
+    title: 振动·CMS
+    kind: live
+    changes_with_data: true
+    file: release/portal_src/shell.html
+    live_target: http://127.0.0.1:18020/
+    evidence: "外壳 #cms 段里 1 个 iframe + 2 个链接指向组件 :18020 (src/windcms/serve.py), 组件按需读产物 ⇒ 页面本身不存快照, 数据变了刷新即变"
+  - id: recalc
+    title: 数据重算
+    kind: live
+    changes_with_data: true
+    file: release/portal_src/shell.html
+    live_target: http://127.0.0.1:28084/ops
+    evidence: "外壳 #recalc 段只有 1 个到 /ops 的链接(网关提供, 后端即真实状态)"
+  - id: login
+    title: 登录
+    kind: live
+    changes_with_data: true
+    file: release/portal_src/shell.html
+    live_target: http://127.0.0.1:18033/v2
+    evidence: "外壳 #login 段链接到工作台 :18033/v2"
+  - id: admin
+    title: 系统状态
+    kind: live
+    changes_with_data: true
+    file: release/portal_src/shell.html
+    live_target: http://127.0.0.1:28084/healthz
+    evidence: "外壳 #admin 段由外壳 JS 拉网关 /healthz 渲染(段内无静态数字)"
+
+  # ── #sim 仿真与回放 ────────────────────────────────────────────────────
+  - id: sim
+    title: 仿真与回放
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "★用户问的三页之一。外壳 #sim 段 50,767 B 是**静态叙述**(机理/图纸说明), 段内 0 iframe / 0 fetch; 6 个链接指向 :18792 仿真台页与 :64292 三维工作台。仿真台的输入是**图纸/机型参数**(configs/machine_packs), 不是 data/raw ⇒ 页面本体不该随输入数据变"
+    children:
+      - name: 控制律仿真台资料包 (5 页)
+        file: release/如东SWT40_控制律仿真台_20260906.zip
+        members_in_zip: ["0_四系统合页.html", "1_偏航系统.html", "2_传动链与功率.html", "3_发电机与热管理.html", "4_变桨轮毂液压.html"]
+        served_by: release/sim_sys_server.py
+        kind: frozen-delivery
+        changes_with_data: false
+        version: "20260906"
+        evidence: "release/sim_sys_server.py 直接从 zip 里读这 5 页 ⇒ 冻结资料包(日期在包名里); 内容为图纸/机理仿真, 不随输入数据变"
+      - name: 三维拆装工作台
+        file: release/viewer/unit-workbench.html
+        kind: static
+        changes_with_data: false
+        evidence: "release/viewer/** 由图纸/三维模型构建(build-*.py + esbuild), 与 data/raw 无关; rev=hub-review-v1 是评审版本号"
+      - name: 控制律仪表台面板 (门户内嵌)
+        file: release/portal_src/templates/tpl-swt40_控制律仪表台_外发版.html.html
+        kind: data-citing
+        changes_with_data: false
+        cites: [outputs/rudong/yaw_verify/findings.json]
+        known_gap:
+          missing: [outputs/rudong/yaw_verify/findings.json]
+          why: 引用的产物目录 yaw_verify/ 不在本包(振动线/偏航专项产物未随包); 本包同类台账在 outputs/<场>/sop/findings.json。不改交付件正文(那是客户手里的冻结版本), 但按「引用悬空」如实记账, 见 docs §7
+        evidence: "面板正文是固定的判据/仪表说明, 只把产物当**脚注引用**('判据与证伪条件全量见 outputs/rudong/yaw_verify/findings.json') ⇒ 正文不随数据变, 但被引用的产物必须在位"
+      - name: 四系统判据面板 (门户内嵌, 4 份)
+        file: release/portal_src/templates/tpl-swt-subsystem-*.html.html
+        kind: data-citing
+        changes_with_data: false
+        cites: [outputs/rudong/yaw_verify/findings.json]
+        known_gap:
+          missing: [outputs/rudong/yaw_verify/findings.json]
+          why: "同仪表台: 引用 yaw_verify/ 专项产物, 该目录不在本包"
+        evidence: "同仪表台: 静态正文 + findings 引用脚注"
+
+  # ── #documents 交付文档 ───────────────────────────────────────────────
+  - id: documents
+    title: 交付文档
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/shell.html
+    evidence: "★用户问的三页之一。外壳 #documents 段 5,690 B 是导航页(0 fetch), 1 个 iframe 指向网关下发的治理清单 HTML(release/如东/**)。段内不含数据快照"
+    children:
+      - name: 治理清单交付包 (分册 + 全册 + 正式报告)
+        file: "release/如东/如东治理清单_交付_20260901/**/*.html"
+        kind: frozen-delivery
+        changes_with_data: false
+        evidence: "文件名自带版本号与日期(如 如东_液压系统治理清单_v1.2_2026-09-01.html / 全册_v4.5_2026-09-06.html) ⇒ 客户交付件, 随交付版本变; 内容虽由分析产出, 但**冻结发布**, 不该随输入数据自动改"
+      - name: 全场状态一览 (脱敏)
+        file: release/如东/全场状态一览_脱敏_20260825.html
+        kind: data-derived
+        changes_with_data: true
+        source: null
+        known_gap:
+          why: "内容是实际运行状态的函数(脱敏快照), 属产物性质; 但页面里没有任何指纹/生成时间/来源登记, 全仓也搜不到生成端 ⇒ 现在**无法做陈旧检测**。处置: 要么补生成端并改成带 source_sha256 的产物(推荐), 要么在登记表里把它降级为 frozen-delivery 并接受它会过期(需用户裁定)"
+        evidence: "★这是问题所在: 1.5 MB 的场站状态快照(脱敏版), 内容是实际运行状态的函数 ⇒ **属于产物**, 但页面里**没有任何指纹/生成时间/来源登记**, 全仓也搜不到生成端 ⇒ 目前游离在产物管理之外(无溯源、无陈旧检测、无重算入口)"
+      - name: 如东取数单
+        file: release/如东/如东取数单_2026-08-21.html
+        kind: data-derived
+        changes_with_data: true
+        cites:
+          - outputs/rudong/cleaned/native_scturbine.parquet
+          - outputs/rudong/m5_cms_tcm/tcm_index.parquet
+          - outputs/rudong/structured/events/fastlog.parquet
+        known_gap:
+          missing:
+            - outputs/rudong/cleaned/native_scturbine.parquet
+            - outputs/rudong/structured/events/fastlog.parquet
+            - outputs/rudong/structured/events/fault_scada_xml.parquet
+          why: "取数单里列的 cleaned/、structured/ 两棵产物树不在本包(属缺失的六层链/结构化产物线, 见 docs §7); 本包在位的同类是 outputs/<场>/windscada/ 与 m5_cms_tcm/"
+        evidence: 取数单是「要哪些数据」的清单页, 明列产物路径 ⇒ 随产物结构变; 现在只有日期没有指纹, 也没有生成端随包
+
+  # ── 门户内嵌的数据派生页 (28 个模板里的) ──────────────────────────────
+  - id: embed_replay_chain
+    title: 传动链实际运行回放 (门户内嵌单文件版)
+    kind: data-derived
+    changes_with_data: true
+    file: release/portal_src/templates/tpl-如东传动链实际运行诊断_单文件版.html.html
+    embedded_json_var: RUDONG_ACTUAL_REPLAY
+    source_key: source
+    source_sha_key: source_sha256
+    json_escaped: true
+    source: outputs/rudong/cleaned/turbine_1min.parquet
+    known_gap:
+      missing:
+        - outputs/rudong/cleaned/turbine_1min.parquet
+        - outputs/rudong/cleaned/turbine_1min_fingerprint.json
+        - outputs/rudong/gearbox_life/load_spectrum_summary.csv
+        - outputs/rudong/gearbox_life/tcm_dynamic_severity.csv
+      why: "页面烘入的 cleaned/ 与 gearbox_life/ 两棵产物树不在本包(六层链未随包, docs §7) ⇒ 内嵌快照**无法与当前产物比对**(拿不到当前值)。这恰恰说明它属于产物: 有 source+sha256 的机制已经齐了, 缺的是那份产物本身"
+    evidence: "内嵌 window.RUDONG_ACTUAL_REPLAY 带 source=outputs/rudong/cleaned/turbine_1min.parquet 与 source_sha256 ⇒ 明明白白的产物烘入快照; **已可做陈旧检测**(产物在位时)"
+  - id: embed_standard_panel
+    title: 标准面板 (门户内嵌)
+    kind: data-derived
+    changes_with_data: true
+    file: release/portal_src/templates/tpl-standard_panel_zh.html.html
+    generated_at_key: 生成
+    source: outputs/rudong/paradigm_r1/releases/E1_measure_replay_20260905/release.yaml
+    cites:
+      - outputs/rudong/structured/reference/turbine_master.parquet
+    known_gap:
+      missing:
+        - outputs/rudong/paradigm_r1/releases/E1_measure_replay_20260905/release.yaml
+        - outputs/rudong/structured/reference/turbine_master.parquet
+      why: "引用的 paradigm 发布件与 structured/ 参考表不在本包(paradigm_r1 目录在, 但没有该 release 子目录) ⇒ 只能按正文里的生成时间判断新旧"
+    evidence: "正文带 '生成 2026-09-05 16:28' 与产物引用 ⇒ 某次生成的数据快照; 缺 source_sha ⇒ 只能按生成时间+引用在位判断"
+  - id: embed_coverage_ch0
+    title: 覆盖度报告 ch0 (门户内嵌)
+    kind: data-derived
+    changes_with_data: true
+    file: release/portal_src/templates/tpl-coverage_ch0_zh.html.html
+    generated_at_key: 生成
+    source: null
+    known_gap:
+      why: "正文写 '源: e1_base.parquet @e3b531b8eddf8197' —— 那份 e1_base.parquet 不在本包 ⇒ 指纹虽有, 但对不上任何在位文件, 溯源链断在包外; 只能按生成时间 2026-09-05 16:28 判新旧"
+    evidence: "正文写 '生成 2026-09-05 16:28 · 源: e1_base.parquet @e3b531b8eddf8197 (10-min, 2,973,638 格, 38 台)' ⇒ 数据派生; 但 e1_base.parquet **不在本包** ⇒ 溯源链断在包外"
+  - id: embed_u6_sim
+    title: U6 变桨液压仿真台 (门户内嵌)
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-U6_变桨液压仿真台.html.html
+    evidence: "图纸派生的物理回路仿真台面板; 无内嵌数据/指纹/产物引用"
+  - id: embed_u6_health
+    title: U6 液压公共站健康报告 (客户版, 门户内嵌)
+    kind: frozen-delivery
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-U6_液压公共站健康报告_客户版.html.html
+    version: "客户版"
+    evidence: "984 KB 客户交付报告; 文件名标 '客户版'; 无数据指纹 ⇒ 按交付件冻结(缺版本号/日期, 见 audit 提示)"
+  - id: embed_gearbox_report
+    title: 整机综合诊断与风险评估 (主轴冲击深挖 V2.1, 门户内嵌)
+    kind: frozen-delivery
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-如东海上风电场_整机综合诊断与风险评估_主轴冲击深挖修订版V2.1.html.html
+    version: "V2.1"
+    evidence: "3.7 MB 综合诊断报告, 文件名带修订版号 V2.1; 报告类交付件按版本发布"
+  - id: embed_reports_4
+    title: 分系统评估报告 (主轴承/齿轮箱/发电机/变桨, 门户内嵌 4 份)
+    kind: frozen-delivery
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-report-*.html.html
+    evidence: "4 份系统评估报告(0.7–3.4 MB), 无内嵌数据/指纹; 属交付报告(缺版本号/日期登记)"
+  - id: embed_governance_12
+    title: 治理清单分册 (门户内嵌 12 份)
+    kind: frozen-delivery
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-governance-*.html.html
+    evidence: "12 份治理分册(0–11), 与 release/如东 下的分册同源; 冻结交付件"
+  - id: embed_sc1_local
+    title: SC1 本地占位 (门户内嵌)
+    kind: static
+    changes_with_data: false
+    file: release/portal_src/templates/tpl-sc1-local.html.html
+    evidence: "0.4 KB 占位面板"

+ 71 - 0
docs/系统设计说明.md

@@ -261,6 +261,9 @@
 | 油样 2026-07 批 102 行(华标合并报告) | 数据层「油液化验」 | 源件 `BG-2026-07-YP013 ….pdf` 不在现场包 | 补那份 PDF 后 `rebuild_from_raw.py --verify` 无人工项 |
 | 无生成端的组级产物(`pc_monthly_bins` / `duty_monthly` / `thermal_monthly` / `sector_power` / `yaw_*` / `genbearing_monthly` / `mblub_monthly` / `pitch_daily` 等) | 趋势件、热链、扇区、偏航/润滑面 | 全库只有读取方、0 处写入方 | 研发补口径(照 `temp_monthly` 的办法反推 + 逐值验证) |
 | `configs/farms/*.yaml` 无代码读者;`available()` 只认 `*.json` | 多场部署 | `src/windscada/config.py::available()` | 二选一(见 §3.3) |
+| 门户/交付件引用的产物树 **不在本包**:`cleaned/`、`gearbox_life/`、`yaw_verify/`、`structured/` | 传动链回放面板、四系统判据面板、控制律仪表台、如东取数单 | `scripts/pages_audit.py` 报 19 条「引用悬空(已知)」;本包在位的是 `outputs/<场>/{windscada,m5_cms_tcm,sop,ontology,paradigm_r1,pitch,guanlan,tcm_compatible_replay,windcms}` | 六层链/结构化产物随包,**或**把页面里的引用改到本包在位路径(需研发确认是同一件东西;不改交付件正文) |
+| 2 件数据派生页面**没有生成端**:`release/如东/全场状态一览_脱敏_20260825.html`(1.5 MB 场站状态快照)、`release/如东/如东取数单_2026-08-21.html` | 交付文档页里这两个子页 | 全仓搜不到生成端;页面里也没有 `source`/指纹 ⇒ 无法做陈旧检测(§10) | 补生成端并写成带 `source_sha256` 的产物(推荐);或明确降级为 `frozen-delivery` 并接受它会过期(需用户裁定) |
+| 3 件内嵌快照**缺指纹**:标准面板、覆盖度 ch0、如东取数单 | 门户内嵌页 | 只有「生成 2026-09-05 16:28」这类时间戳;覆盖度页的源 `e1_base.parquet` 不在本包 | 生成时把 `source` + `source_sha256` 写进页面(机制已就绪,见 §10 的 `data-derived` 规则) |
 
 ---
 
@@ -277,6 +280,7 @@
 | 2026-09-16 | 用户令"**把 VBScript 替换掉**"(见 §4.2): 删除 `start_hidden.vbs`;无窗口启动改为 `pythonw.exe` + `scripts/guanlan_start_hidden.py`(不经过 wscript/WSH —— 服务器上 WSH 常被组策略禁用/未安装);`install.ps1` 安装时用 PowerShell 原生 COM 建 `启动观澜.lnk`(根目录 + 桌面,带 `-NoDesktopShortcut` 供自动化核验);`start.bat` 默认改为 `start "" /min pythonw.exe …`(.bat 会闪一次,快捷方式才是零窗口);`guanlan_start_hidden.py --selftest` 用 `GetConsoleWindow()==0` **自证无窗口**;打包器新增 `*.lnk`(安装时才产生、内含绝对路径)与顶层 `*.zip` 排除(后者原先只写在文档里没实现) |
 | 2026-09-17 | **入口脚本的换行/编码事故两连** —— 这类问题都不会在开发机上暴露,只在目标机双击时炸:<br>① `install.ps1` 的 UTF-8 BOM 被编辑工具吃掉(我在规范化之后又改了一次文件)⇒ PS 5.1 按 GBK 解码 ⇒ 中文乱码 + 级联 ParserError,目标机 `install` **1 秒即退出**。开箱验证当场逮到(`install 退出码 1, 耗时 1s` + `The '<' operator is reserved for future use`)。<br>② `install.sh` **从写出来那天起就是 CRLF**(git HEAD 的 blob 就是 CRLF,不是某次编辑造成的)⇒ POSIX 语义下每个词尾粘 `\r`:`set -e` 变非法选项、`RT=""` 变 `RT="\r"` 让后续 `-n "$RT"` 判断翻面 ⇒ **此前所有交付包的 Linux/macOS 安装脚本都是坏的**。<br>加固:`src/entry_refs.py::encoding_problems()` 成文守则(`.ps1` = UTF-8 BOM + CRLF;`.bat` = CRLF 无 BOM;`.sh` = LF),在**打包、开箱验证第①b 步、装机自检**三处强制执行(违反就不出包 / 直接判 FAIL);`guanlan.py check` 增一行;新增 `.gitattributes` 把 checkout 也钉死(`*.bat/*.ps1 eol=crlf`、`*.sh eol=lf`)。变异测试:去掉 BOM / 改 CRLF / .bat 存成 LF 三种改法都被逐条报出 |
 | 2026-09-17 | 打包后**对着 zip 条目逐件复核**(3,733 件全部与工作树逐字节相同)+ 开箱验证:解压 3,734 件 → ①b 闭合与编码守则通过 → 离线安装 rc=0(172 s)→ ②b 快捷方式在位 + `console_hwnd=0` 无窗口成立 + 包内无 `.vbs` → 页面 4/5(`/` 与主包同字节;`/cms/` 503 属无产物预期) |
+| 2026-09-17 | **用户令 1「页面是否属于产物」的落地(见 §10)**:新建登记表 `configs/portal_pages.yaml`(20 个页面/子页 + 五类 kind + 逐条判定依据)与检查器 `scripts/pages_audit.py`(五条机器规则;**陈旧检测** rc=7:内嵌快照的 `source_sha256` 与当前产物不符即报"数据变了页面没变");`guanlan.py check` 增一行;`rebuild_all.py` 增 ⑧b「重装门户(把新产物灌进门户结论段)」与 ⑧c「页面归口审计」;文档增 §10(含用户问的三页逐页判定)。**结论**:`#findings` 静态、非产物;`#sim` 本体是图纸派生的冻结资料包、其中"实际运行回放"面板属产物;`#documents` 里治理清单/报告是冻结交付件,而脱敏状态一览与取数单属产物且缺生成端与溯源(已入 §7 缺口表) |
 
 ---
 
@@ -322,4 +326,71 @@
 
 ★ 那个 `.vbs` 已按用户令删除(§4.2),但这份守卫保留:它守的是"入口引用的文件必须齐全"这条性质,与用什么语言实现无关 —— 现在它盯的是 `start.bat`/`install.ps1` → `scripts\guanlan_start_hidden.py` 这类引用(这次替换正是它自动发现的:去掉 `.vbs` 后入口列表变为 6 个、引用 24 条,全部在位)。
 
+---
+
+## 10. 页面归口(哪些页面是产物、该不该随数据变)
 
+**用户问的三页,结论(依据见下表与 `configs/portal_pages.yaml`)**:
+
+| 页面 | 该不该随输入数据变 | 算不算产物 | 现状与处置 |
+|---|---|---|---|
+| `#findings` 经验发现 | **不该** | **不是** | 外壳里的静态叙述(2,322 B,0 iframe/fetch/产物引用),内容是公司级方法论而非本场站数据的函数 ⇒ 留在受管外壳。若将来要放本场站计数,必须改成 `data-derived` 并登记 `source` |
+| `#sim` 仿真与回放 | 页面本体**不该** | 本体**不是** | 叙述+6 个链接:仿真台是**图纸/机型**派生的冻结资料包(`release/如东SWT40_控制律仿真台_20260906.zip`,由 `release/sim_sys_server.py` 从 zip 现读),三维工作台是图纸派生。**但**其中"实际运行回放"面板(门户内嵌 `tpl-如东传动链实际运行诊断_单文件版`)把 `outputs/<场>/cleaned/turbine_1min.parquet` 连 `source_sha256` 一起烘进了页面 ⇒ **那一件属于产物** |
+| `#documents` 交付文档 | 交付件**按版本变**,不随数据自动变 | 治理清单/报告**不是**(冻结交付件);**取数单与脱敏状态一览是** | iframe 指向的治理清单分册自带版本号+日期(v1.2_2026-09-01 / v4.5_2026-09-06)⇒ 冻结交付件。但 `全场状态一览_脱敏_20260825.html`(1.5 MB 场站状态快照)与 `如东取数单`(明列产物路径)是**数据派生**的 ⇒ 属产物,且**现在既无生成端也无溯源** |
+
+**五种 kind 与机器规则**(登记表头部有完整说明):
+
+| kind | 该随数据变? | 机器查什么 |
+|---|---|---|
+| `static` | 否 | 正文不得引用产物(引用了说明分类错了) |
+| `live` | **是**(永远一致) | iframe/链接的端口必须在 `configs/serve.json` 的已知服务里 |
+| `data-citing` | 否(正文不变) | 被引用的产物路径**必须存在**(悬空引用 = 挂羊头) |
+| `data-derived` | **是** | 必须有 `source` + (`source_sha256` 或生成时间);有 sha 就跟当前产物**逐字节比对 ⇒ 陈旧检测** |
+| `frozen-delivery` | 否(按交付版本变) | 文件名或正文必须能读到**版本号 + 日期** |
+
+**"纳入产物管理"落到本系统的四件事**:① 登记(`configs/portal_pages.yaml`:kind/来源/生成端/依据);
+② 溯源(`source` + `source_sha256` 或生成时间);③ **陈旧检测**(`scripts/pages_audit.py`,rc=7 = 数据换了页面没换);
+④ 进链(`rebuild_all.py` ⑧b 重装门户把新产物灌进结论段、⑧c 页面归口审计;`guanlan.py check` 一行)。
+实时读产物的页面(`/detail/`、`/cms/`、`/ops`)**不需要**快照,靠 §5.1 的产物指纹热重载即时更新。
+
+**当前如实状态**:19 条**已知缺口**(登记表里逐条写了理由,不装作通过)—— 都是"页面引用了本包没有的产物树"
+(`cleaned/`、`gearbox_life/`、`yaw_verify/`、`structured/`,属未随包的六层链/结构化产物线,见 §7),
+因此这些页面**拿不到当前值做比对**;另有 2 件数据派生页面**没有生成端**(脱敏状态一览、取数单),
+以及 3 件内嵌快照**缺指纹只能按生成时间判断**(标准面板、覆盖度 ch0、取数单)。这三类都记在 §7 缺口表里。
+
+<!-- PAGES:BEGIN -->
+### 10.1 门户页面归口(自动生成,勿手改)
+
+判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。
+
+| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |
+|---|---|---|---|
+| `index` 总览 | `static` | 否 | 外壳 #index 段 2,519 B: 0 iframe / 0 fetch / 0 产物引用; 正文是产品承诺与入口导航 |
+| `architecture` 系统架构 | `static` | 否 | 外壳 #architecture 段 3,037 B: 无数据引用; 描述分层架构 |
+| `method` 方法 | `static` | 否 | 外壳 #method 段 2,959 B: 无数据引用; 描述方法论 |
+| `findings` 经验发现 | `static` | 否 | ★用户问的三页之一。外壳 #findings 段 2,322 B: 0 iframe / 0 fetch / 0 产物引用; 正文是公司级经验叙述(59 项内部检查 → 8 条公开教训, 60+ 个风场), 不是本场站数据的函数 ⇒ 不该随输入数据变, 也不是产物。若将来要放本场站计数, 必须改成 data-derived 并登记 source |
+| `case_hydraulic` 案例·液压 | `static` | 否 | 外壳 #case_hydraulic 段 6,187 B: 无数据引用; 案例叙述 |
+| `cms` 振动·CMS | `live` | **是** | 外壳 #cms 段里 1 个 iframe + 2 个链接指向组件 :18020 (src/windcms/serve.py), 组件按需读产物 ⇒ 页面本身不存快照, 数据变了刷新即变 |
+| `recalc` 数据重算 | `live` | **是** | 外壳 #recalc 段只有 1 个到 /ops 的链接(网关提供, 后端即真实状态) |
+| `login` 登录 | `live` | **是** | 外壳 #login 段链接到工作台 :18033/v2 |
+| `admin` 系统状态 | `live` | **是** | 外壳 #admin 段由外壳 JS 拉网关 /healthz 渲染(段内无静态数字) |
+| `sim` 仿真与回放 | `static` | 否 | ★用户问的三页之一。外壳 #sim 段 50,767 B 是**静态叙述**(机理/图纸说明), 段内 0 iframe / 0 fetch; 6 个链接指向 :18792 仿真台页与 :64292 三维工作台。仿真台的输入是**图纸/机型参数**(configs/machine_packs), 不是 data/raw ⇒ 页面本体不该随输入数据变 |
+| &nbsp;&nbsp;└ `控制律仿真台资料包 (5 页)` | `frozen-delivery` | 否 | release/sim_sys_server.py 直接从 zip 里读这 5 页 ⇒ 冻结资料包(日期在包名里); 内容为图纸/机理仿真, 不随输入数据变 |
+| &nbsp;&nbsp;└ `三维拆装工作台` | `static` | 否 | release/viewer/** 由图纸/三维模型构建(build-*.py + esbuild), 与 data/raw 无关; rev=hub-review-v1 是评审版本号 |
+| &nbsp;&nbsp;└ `控制律仪表台面板 (门户内嵌)` | `data-citing` | 否 | 面板正文是固定的判据/仪表说明, 只把产物当**脚注引用**('判据与证伪条件全量见 outputs/rudong/yaw_verify/findings.json') ⇒ 正文不随数据变, 但被引用的产物必须在位 |
+| &nbsp;&nbsp;└ `四系统判据面板 (门户内嵌, 4 份)` | `data-citing` | 否 | 同仪表台: 静态正文 + findings 引用脚注 |
+| `documents` 交付文档 | `static` | 否 | ★用户问的三页之一。外壳 #documents 段 5,690 B 是导航页(0 fetch), 1 个 iframe 指向网关下发的治理清单 HTML(release/如东/**)。段内不含数据快照 |
+| &nbsp;&nbsp;└ `治理清单交付包 (分册 + 全册 + 正式报告)` | `frozen-delivery` | 否 | 文件名自带版本号与日期(如 如东_液压系统治理清单_v1.2_2026-09-01.html / 全册_v4.5_2026-09-06.html) ⇒ 客户交付件, 随交付版本变; 内容虽由分析产出, 但**冻结发布**, 不该随输入数据自动改 |
+| &nbsp;&nbsp;└ `全场状态一览 (脱敏)` | `data-derived` | **是** | ★这是问题所在: 1.5 MB 的场站状态快照(脱敏版), 内容是实际运行状态的函数 ⇒ **属于产物**, 但页面里**没有任何指纹/生成时间/来源登记**, 全仓也搜不到生成端 ⇒ 目前游离在产物管理之外(无溯源、无陈旧检测、无重算入口) |
+| &nbsp;&nbsp;└ `如东取数单` | `data-derived` | **是** | 取数单是「要哪些数据」的清单页, 明列产物路径 ⇒ 随产物结构变; 现在只有日期没有指纹, 也没有生成端随包 |
+| `embed_replay_chain` 传动链实际运行回放 (门户内嵌单文件版) | `data-derived` | **是** | 内嵌 window.RUDONG_ACTUAL_REPLAY 带 source=outputs/rudong/cleaned/turbine_1min.parquet 与 source_sha256 ⇒ 明明白白的产物烘入快照; **已可做陈旧检测**(产物在位时) |
+| `embed_standard_panel` 标准面板 (门户内嵌) | `data-derived` | **是** | 正文带 '生成 2026-09-05 16:28' 与产物引用 ⇒ 某次生成的数据快照; 缺 source_sha ⇒ 只能按生成时间+引用在位判断 |
+| `embed_coverage_ch0` 覆盖度报告 ch0 (门户内嵌) | `data-derived` | **是** | 正文写 '生成 2026-09-05 16:28 · 源: e1_base.parquet @e3b531b8eddf8197 (10-min, 2,973,638 格, 38 台)' ⇒ 数据派生; 但 e1_base.parquet **不在本包** ⇒ 溯源链断在包外 |
+| `embed_u6_sim` U6 变桨液压仿真台 (门户内嵌) | `static` | 否 | 图纸派生的物理回路仿真台面板; 无内嵌数据/指纹/产物引用 |
+| `embed_u6_health` U6 液压公共站健康报告 (客户版, 门户内嵌) | `frozen-delivery` | 否 | 984 KB 客户交付报告; 文件名标 '客户版'; 无数据指纹 ⇒ 按交付件冻结(缺版本号/日期, 见 audit 提示) |
+| `embed_gearbox_report` 整机综合诊断与风险评估 (主轴冲击深挖 V2.1, 门户内嵌) | `frozen-delivery` | 否 | 3.7 MB 综合诊断报告, 文件名带修订版号 V2.1; 报告类交付件按版本发布 |
+| `embed_reports_4` 分系统评估报告 (主轴承/齿轮箱/发电机/变桨, 门户内嵌 4 份) | `frozen-delivery` | 否 | 4 份系统评估报告(0.7–3.4 MB), 无内嵌数据/指纹; 属交付报告(缺版本号/日期登记) |
+| `embed_governance_12` 治理清单分册 (门户内嵌 12 份) | `frozen-delivery` | 否 | 12 份治理分册(0–11), 与 release/如东 下的分册同源; 冻结交付件 |
+| `embed_sc1_local` SC1 本地占位 (门户内嵌) | `static` | 否 | 0.4 KB 占位面板 |
+
+<!-- PAGES:END -->

+ 19 - 0
guanlan.py

@@ -179,6 +179,25 @@ def cmd_check(c):
             "; ".join(_enc[:2]) or "编码都对 (PS 5.1 才不会把中文按 GBK 解错)")
     except Exception as _e:
         row("入口脚本引用闭合", False, f"{type(_e).__name__}: {_e}")
+    # 页面归口 (2026-09-17 用户令 1): 门户里哪些页面是"数据派生快照"(属于产物)、它们陈旧没陈旧。
+    # 用户问过"#findings/#sim/#documents 该不该随输入数据变、算不算产物" —— 答案落在
+    # configs/portal_pages.yaml 登记表 + scripts/pages_audit.py 的检查里, 这里只报结论。
+    # 退出码: 0 一致 / 5 缺文件或未登记 / 6 溯源缺失 / 7 **页面陈旧**(数据变了页面没变) / 8 交付件缺版本 / 9 分类错。
+    try:
+        import importlib.util as _ilu
+        _spec = _ilu.spec_from_file_location('_pages_audit', ROOT / "scripts" / "pages_audit.py")
+        _m = _ilu.module_from_spec(_spec)
+        _spec.loader.exec_module(_m)
+        _rc, _res, _gaps = _m.audit()
+        _bad = [r for r in _res if r[0] not in ("OK", "i")]
+        _known = [r for r in _res if r[0] == "i"]
+        _note = (f"检查 {len(_res)} 项: 不一致 {len(_bad)}"
+                 + (f" ({'; '.join(f'{r[1]}: {r[2][:60]}' for r in _bad[:2])})" if _bad else "")
+                 + f" · 已知缺口 {len(_known)} (引用悬空/无溯源, 登记表明写理由)"
+                 + (f" · 未登记页 {len(_gaps)}" if _gaps else ""))
+        row("页面归口: 页面是否随数据变 / 是否陈旧 (configs/portal_pages.yaml)", _rc == 0, _note)
+    except Exception as _e:
+        row("页面归口审计", False, f"{type(_e).__name__}: {_e}")
     for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"):
         try: __import__(m); row(f"依赖 {m}", True)
         except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)")

+ 378 - 0
scripts/pages_audit.py

@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。
+
+## 背景(用户的问题)
+
+    http://127.0.0.1:28084/#findings  #sim  #documents 及它们包含的子页,
+    "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。
+
+答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告),
+还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。
+本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是:
+
+  · static            —— 正文不得引用产物(setting 变了就该改分类);
+  · live              —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务;
+  · data-citing       —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉);
+  · data-derived      —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物
+                         逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来);
+  · frozen-delivery   —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。
+
+## 为什么要有"陈旧检测"
+
+页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。
+数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。
+
+## 退出码 (给 rebuild_all / check 用)
+
+    0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误
+
+## 用法
+
+    python scripts/pages_audit.py                 # 表 + 检查(默认)
+    python scripts/pages_audit.py --list          # 只打表
+    python scripts/pages_audit.py --check         # 只检查(安静模式, 只打结论)
+    python scripts/pages_audit.py --write-doc     # 把表写进 docs/系统设计说明.md (标记区间内)
+    python scripts/pages_audit.py --json          # 机器可读结果
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import glob as globmod
+import hashlib
+import html as htmllib
+import json
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+
+REG = P.CONFIGS / 'portal_pages.yaml'
+DOC = P.ROOT / 'docs' / '系统设计说明.md'
+DOC_BEGIN, DOC_END = '<!-- PAGES:BEGIN -->', '<!-- PAGES:END -->'
+
+RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9
+KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery')
+
+RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+')
+# 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。
+# ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。
+RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版')
+RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}')
+RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)')
+
+
+def load(path=None):
+    import yaml
+    reg_p = pathlib.Path(path) if path else REG
+    if not reg_p.is_file():
+        raise SystemExit(f'缺登记表 {P.rel(reg_p)}')
+    reg = yaml.safe_load(reg_p.read_text(encoding='utf-8'))
+    reg['_file'] = str(reg_p)
+    return reg
+
+
+def sha256(p: pathlib.Path) -> str | None:
+    try:
+        return hashlib.sha256(p.read_bytes()).hexdigest()
+    except OSError:
+        return None
+
+
+def expand(entry) -> list[pathlib.Path]:
+    """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。"""
+    pat = P.ROOT / entry['file']
+    s = str(pat)
+    if any(c in s for c in '*?['):
+        return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True))
+    return [pat] if pat.is_file() else []
+
+
+def texts(files):
+    for f in files:
+        try:
+            yield f, f.read_text(encoding='utf-8', errors='replace')
+        except OSError:
+            continue
+
+
+def read_embedded_json(path: pathlib.Path, var: str):
+    """抓页面里内嵌的 window.<var> = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。"""
+    try:
+        t = path.read_text(encoding='utf-8', errors='replace')
+    except OSError:
+        return None
+    m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t)
+    if not m:
+        return None
+    raw, depth = '', 0
+    for ch in t[m.start(1):]:
+        raw += ch
+        if ch == '{':
+            depth += 1
+        elif ch == '}':
+            depth -= 1
+            if depth == 0:
+                break
+    for cand in (raw, htmllib.unescape(raw)):
+        try:
+            return json.loads(cand)
+        except Exception:
+            continue
+    return None
+
+
+def check_entry(e, results):
+    """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。
+
+    level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。
+    ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life),
+      页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处,
+      所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。
+    """
+    eid = e.get('id') or e.get('name') or '?'
+    kind = e.get('kind')
+    gap = e.get('known_gap') or {}
+    if kind not in KINDS:
+        results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND))
+        return
+    files = expand(e)
+    if not files and not e.get('members_in_zip'):
+        results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS))
+        return
+
+    # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查)
+    for f, t in texts(files):
+        cited = set(e.get('cites') or [])
+        if kind in ('data-derived', 'data-citing'):
+            cited |= set(RE_OUT.findall(t))
+        for c in sorted(cited):
+            if (P.ROOT / c).exists():
+                continue
+            if gap and c in (gap.get('missing') or []):
+                results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK))
+            else:
+                results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS))
+
+    # ② 分类规则
+    if kind == 'static':
+        for f, t in texts(files):
+            if RE_OUT.search(t):
+                results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived',
+                                RC_KIND))
+
+    elif kind == 'live':
+        svc = {}
+        try:
+            svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
+        except Exception:
+            pass
+        ports = {int(v) for k, v in svc.items() if isinstance(v, int)}
+        tgt = e.get('live_target') or ''
+        m = re.search(r':(\d{2,5})', tgt)
+        if m and ports and int(m.group(1)) not in ports:
+            results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS))
+
+    elif kind == 'data-derived':
+        src = e.get('source')
+        var = e.get('embedded_json_var')
+        skey, shkey = e.get('source_key'), e.get('source_sha_key')
+        found_sha, found_src, found_gen = None, src, None
+        if var and skey:
+            for f in files:
+                obj = read_embedded_json(f, var)
+                if isinstance(obj, dict):
+                    found_src = obj.get(skey) or found_src
+                    found_sha = obj.get(shkey) if shkey else None
+                    break
+        for f, t in texts(files):
+            if found_gen is None:
+                g = RE_GEN.search(t)
+                if g:
+                    found_gen = g.group(1)
+        if not found_src:
+            if gap:
+                results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK))
+            else:
+                results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV))
+        if not (found_sha or found_gen or e.get('generated_at_key')):
+            if not gap:
+                results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV))
+        if found_src:
+            sp = P.ROOT / found_src
+            if not sp.exists():
+                if gap and found_src in (gap.get('missing') or []):
+                    results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK))
+                else:
+                    results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS))
+            elif found_sha:
+                cur = sha256(sp)
+                if cur and cur.lower() != str(found_sha).lower():
+                    results.append(('X', eid,
+                                    f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 '
+                                    f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成',
+                                    RC_STALE))
+                else:
+                    results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK))
+            else:
+                results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 '
+                                          f'(记录: {found_gen or "无"})', RC_PROV))
+
+    elif kind == 'frozen-delivery':
+        ok_ver = ok_date = False
+        for f in files:
+            name = f.name
+            ok_ver |= bool(RE_VER.search(name))
+            ok_date |= bool(RE_DATE.search(name))
+            if not (ok_ver and ok_date):
+                t = f.read_text(encoding='utf-8', errors='replace')      # 版本号/日期可能在正文深处
+                ok_ver |= bool(RE_VER.search(t))
+                ok_date |= bool(RE_DATE.search(t))
+        if e.get('version'):
+            ok_ver = True
+        if not ok_ver:
+            results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版',
+                            RC_VER))
+        if not ok_date and not e.get('version'):
+            results.append(('?', eid, '冻结交付件没有日期标记', RC_VER))
+
+    # ③ 子项递归
+    for c in e.get('children') or []:
+        check_entry(c, results)
+
+
+def declared_patterns(reg) -> list[str]:
+    """登记表里所有 file 字段 (含通配) —— 覆盖检查用。"""
+    pats = []
+
+    def walk(e):
+        f = e.get('file')
+        if f:
+            pats.append(str(f))
+        for c in e.get('children') or []:
+            walk(c)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return pats
+
+
+def coverage_gaps(reg) -> list[str]:
+    """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。"""
+    pats = declared_patterns(reg)
+    gaps = []
+    tdir = P.RELEASE / 'portal_src' / 'templates'
+    for f in sorted(tdir.glob('*.html')):
+        rel = f.relative_to(P.ROOT).as_posix()
+        if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats):
+            gaps.append(rel)
+    return gaps
+
+
+def render(reg, results) -> str:
+    lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|']
+    def walk(e, depth=0):
+        eid = e.get('id') or e.get('name') or '?'
+        title = e.get('title') or ''
+        pre = '&nbsp;&nbsp;└ ' * depth
+        lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | "
+                     f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |")
+        for c in e.get('children') or []:
+            walk(c, depth + 1)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return '\n'.join(lines)
+
+
+def audit(registry=None):
+    """→ (rc, results, unregistered)。
+
+    供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程:
+    子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。
+    """
+    reg = load(registry)
+    results: list[tuple[str, str, str, int]] = []
+    for e in reg.get('pages') or []:
+        check_entry(e, results)
+    rc_map = {r[3] for r in results if r[0] not in ('OK', 'i') and r[3]}
+    return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--list', action='store_true', help='只打表')
+    ap.add_argument('--check', action='store_true', help='只检查')
+    ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md')
+    ap.add_argument('--json', action='store_true', help='机器可读输出')
+    ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
+    a = ap.parse_args()
+    reg = load(a.registry)
+    rc, results, gaps = audit(a.registry)
+
+    if a.json:
+        print(json.dumps(dict(rc=rc,
+                              results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results],
+                              unregistered=gaps), ensure_ascii=False, indent=1))
+        return rc
+
+    if a.list or not a.check:
+        print('== 门户页面归口 (configs/portal_pages.yaml) ==')
+        for e in reg.get('pages') or []:
+            def walk(x, d=0):
+                eid = x.get('id') or x.get('name')
+                print(f'   {"  " * d}{eid:34s} {x.get("kind"):16s} '
+                      f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}')
+                for c in x.get('children') or []:
+                    walk(c, d + 1)
+            walk(e)
+
+    if not a.check or True:
+        lvl = {}
+        for level, item, note, r in results:
+            lvl[level] = lvl.get(level, 0) + 1
+        print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, '
+              f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==')
+        for level, item, note, r in results:
+            if level not in ('OK', 'i'):
+                print(f'   [{level}] {item}: {note}')
+        if lvl.get('i'):
+            print(f'   [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):')
+            seen = set()
+            for level, item, note, r in results:
+                if level == 'i' and item not in seen:
+                    seen.add(item)
+                    print(f'        · {item}')
+        if gaps:
+            print(f'   [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):')
+            for g in gaps[:8]:
+                print(f'        · {g}')
+        print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}')
+        if a.write_doc:
+            write_doc(reg)
+        return rc
+    return rc
+
+
+def write_doc(reg) -> None:
+    if not DOC.is_file():
+        print(f'   (缺 {P.rel(DOC)}, 跳过写文档)')
+        return
+    t = DOC.read_text(encoding='utf-8')
+    block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n'
+             f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n'
+             f'{render(reg, None)}\n\n{DOC_END}')
+    if DOC_BEGIN in t and DOC_END in t:
+        t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S)
+    else:
+        t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n'
+    DOC.write_text(t, encoding='utf-8')
+    print(f'   已写入 {P.rel(DOC)} (§10 页面归口)')
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 12 - 0
scripts/rebuild_all.py

@@ -98,6 +98,18 @@ def build_plan(a) -> list:
     plan.append(step_cmd('⑦ 本体: 检索索引', [PY, '-c', 'from src.ontology import retrieval as R; R.build(use_vec=False)']))
     plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
     plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
+    # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py
+    #     从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。
+    #     装配是"先写临时文件再原子替换", 失败不会破坏正在被网关读的 portal.html (见 portal_build 注释)。
+    plan.append(step_cmd('⑧b 重装门户 (把新产物灌进门户结论段)', [PY, 'scripts/portal_build.py'],
+                         tolerate=(1,),
+                         note='rc=1 = 缺契约产物(产物被清除时属预期) ⇒ 门户保留原样, 不是装配坏了'))
+    # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。
+    #     rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。
+    plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
+                         tolerate=(7,),
+                         note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
+                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期'))
     if a.with_verify:
         # 只有 4 = "有需人工看的差异"(本包已知项: 油样 102 行的源件不在现场包里) 才容忍;
         # 5 = 找不到基线(等于没验收) 必须当失败 —— 2026-09-12 就是因为放过了它, 出现过"验收没做却报通过"。