Преглед изворни кода

用户令1: 服务化(Windows 服务 ctypes直连SCM + systemd 单元 + 免管理员登录自启; 共用工作体; service_ctl 可 --dry-run 自证) + 安装时版本检查/重装确认/写安装记录(src/version.py 单一真源); 修网关轮询与执行器写回退出码的竞态; 用户令2: pages_audit 新增 --inventory『所有网页总清点』(262 项, 178 项判定为产物, 0 项未管), 补齐登记(工作台标签/交付页/三维页)与 §12.2 自动表, 新增 §14 服务化文档

zhouyang.xie пре 3 недеља
родитељ
комит
0ff3df5bac

+ 99 - 88
README_先读我.MD

@@ -1,88 +1,99 @@
-观澜·如东样板 v2 0.4.0 — 单包全量离线应用 (Windows / Linux / macOS)
-
-<PY> = Windows 的 .venv\Scripts\python.exe, 或 Linux·macOS 的 .venv/bin/python;
-       命令里路径写 / 即可两平台通用; Windows 也可以只双击 .bat, 不必敲命令。
-
-★ 本包**不含 输入数据(data/) · 产物/输出数据(outputs/) · 运行日志(logs/、run/、任何 *.log/*.jsonl) ·
-  临时文件(缓存/备份/系统索引)**(用户令 2026-09-17; 打包方法与逐条理由见第五节):
-  解压安装后页面会显示"无产物"; 把现场数据包放好后跑一次重算(第四节), 页面立刻有数。
-  随包内容 = 程序/配置/门户与三维资产/交付文档/离线依赖轮子/便携运行时 (没有数据、产物、日志)。
-
-一、安装 (约 10 分钟; 安装目录不要有空格和中文)
-  Windows : 解压到 D:\guanlan\app → 双击 install.bat        (自动选 Python → 建 .venv → 包内轮子离线装依赖)
-  Linux   : 解压到 /opt/guanlan  → sh install.sh            (麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS;
-                                                            多版本机器: GUANLAN_PY=/usr/bin/python3.11 sh install.sh)
-  没装 Python 的机器也能装: 包内有便携运行时(Windows / Linux x86_64 / Linux aarch64), 安装脚本自动用。
-  离线边界: Windows 侧包内带全部轮子, 完全离线可装; **Linux·macOS 侧包内没带轮子**, install.sh 会自动
-  改为联网安装 —— 要离线装就把对应平台轮子放进 wheels/linux_x86_64 或 wheels/macos_arm64 再装。
-  模型(可选, 只影响问答与本地审核页): ollama pull qwen3:8b / deepseek-r1:14b / bge-m3
-
-二、启动 / 停止
-  Windows : 双击 check.bat 全绿 → 双击「启动观澜」快捷方式(安装时自动建: 根目录 + 桌面各一份;
-            同一目标还有一份 ASCII 名的 start_guanlan.lnk 供控制台/脚本引用;
-            目标 = .venv\Scripts\pythonw.exe, **完全不出现命令窗口**) 或双击 start.bat
-            (会闪一下窗口 —— .bat 是控制台程序, 想完全无窗就用上面那个快捷方式)   停止: 双击 stop.bat
-  Linux   : <PY> guanlan.py check → <PY> guanlan.py serve           停止: <PY> guanlan.py stop
-  三个入口: 门户 http://127.0.0.1:28084/ · 工作台 /detail/ · 运维控制台 /ops
-  排障要看见实时日志: 双击 start.bat console (前台模式, 关窗即停服务); 本包**不含 VBScript**,
-  无窗口启动不依赖 Windows 脚本宿主(WSH 被策略禁用的服务器上同样可用)。
-  ★ .bat 一律**纯 ASCII**(英文): 中文写进批处理会随控制台代码页被曲解, 现场出现过"注释被当命令执行"。
-    中文说明请看本文件与 docs/; 机器守卫见 src/entry_refs.py(guanlan.py check 会报一行)。
-
-三、重算与产物 (门户菜单「数据重算」, 或直接开 http://127.0.0.1:28084/ops)
-  一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,
-  直接打 API 返回 409); "启动服务"起来后自动打开门户; "停止组件服务"保留控制台本身; 同时刻只允许
-  一个动作, 页面上有进度/退出码/实时日志尾巴。重算进行中按钮按设计禁用 —— 那不是坏了。
-  ★ 清除产物 = **直接删除, 不留备份**(用户令 2026-09-16; 旧的 --on 还原已移除), 不动 data/raw、release;
-    要补回"包内没有生成端"的随包件: <PY> scripts/products_restore_missing.py --stash <交付包.zip>
-  ★ 清完产物后页面会显示"无产物"(工作台左栏/CMS 页都会给出"缺哪件、怎么补"的说明, **不会崩**) ——
-    跑一次第四节的重算即恢复; 若某页仍打不开, 先看 /healthz 与 logs/<组件>.log 尾部。
-
-四、放数据后重算 (一条命令; 控制台点"执行重算"等价)
-  <PY> scripts/raw_data_check.py --src <现场包目录>    ★ 先体检: 结构/命名/增量(新增·跳过·冲突) + 放置指导
-  <PY> scripts/place_raw_data.py --src <现场包目录> --scope full      放数据(同尺寸自动跳过, 可反复跑;
-       落位前自动跑体检, "同名不同大小"的冲突会拦下, 确要覆盖加 --force)
-  <PY> scripts/rebuild_all.py [--skip-scada] [--src <现场包目录>]     默认含 SCADA 侧约 15 分钟;
-       只换台账类数据加 --skip-scada(约 2 分钟); --dry-run 只看计划; 完事自动重启组件服务。
-  放置规范与常见错误: docs/输入数据放置指导_v0.1.md (目录树/命名/增量语义/放完跑什么)
-  算成功判据: /detail/ 左栏「系统维护」两屏, 或 <PY> -m src.ontology.maintenance
-  重算后应达锚点: 报警 39211 · 工单 5876 · 油样 404 · temp_monthly 19494 · 本体 9702 对象(审计 0 问题)
-
-五、打包 (做交付包; 用户令 2026-09-17)
-  一条命令就够(不需要任何开关); Windows 也可以直接**双击 pack.bat**:
-    Windows : 双击 pack.bat            或  <PY> scripts/pack_dist.py
-    Linux   : sh pack.sh               或  <PY> scripts/pack_dist.py
-    pack.bat dry      只预演: 报表里会打什么、多大, 以及逐条排除理由(不写文件)
-    pack.bat verify   对已打好的 zip 做开箱验证: 解压 → 离线安装 → 起服务核验页面 → 清理
-    <PY> scripts/pack_dist.py --out <目标.zip>     指定输出路径
-  输出: 默认写到**安装目录的上一级**, 文件名 guanlan-v<版本>_dist_<日期>.zip
-        (例: 装到 D:\guanlan\app, 则产物在 D:\guanlan\guanlan-v0.4.0_dist_20260917.zip)
-  ★ 包里**不含**这四类(默认口径; 每条理由都写进包内 dist-manifest.json):
-    ① 输入数据     data/                    现场原始件; 例外要带用 --with-data (约 31 GB)
-    ② 产物/输出数据 outputs/                 目标机放数据后 place_raw_data.py + rebuild_all.py 重算;
-                                            无生成端的那批用 products_restore_missing.py --stash <交付包.zip>
-    ③ 运行日志     logs/ run/ 以及**任何位置**的 *.log、*.jsonl (含 logs/build、logs/legacy)
-    ④ 临时文件     各种缓存(*_cache、__pycache__、.pytest_cache、.npm-cache、.qa-profile)、
-                   编辑器临时与备份(*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*)、
-                   系统索引(.DS_Store、Thumbs.db、desktop.ini), 以及 .git/.venv/*.pyc/*.lnk/顶层 *.zip
-  包里**有**: src/ scripts/ guanlan.py · configs/ · release/(门户/仿真/三维/交付件) · resources/ · reference/ ·
-             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack 脚本 · requirements.txt
-  打完自查: 包内 dist-manifest.json 记着 version / no_data / no_products / no_logs / no_temp + 逐条排除理由;
-            打包器自己还会核"入口脚本引用的文件都在包里"与"入口脚本编码守则", 不合格的包会被删掉并返回非 0。
-  要让页面开箱即有数(例外做法): <PY> scripts/pack_dist.py --with-products
-
-六、必读 (如实)
-  · 页面的数来自 outputs/<场站> 下的产物, 产物由 data/raw 下的原始件算出来(下一级目录名=场站名, 扫描辨识)。
-    本包两样都没带: 没有 data/raw 时重算会"没有源件可算", 没有 outputs/ 时页面显示"无产物"。
-  · 有一批产物**没有生成端**(pitch_daily、pc_monthly_bins、CMS/TCM 链等): 从交付包按需补齐
-    (products_restore_missing.py --stash <交付包.zip>), 补齐后逐件来源见 outputs/<场站>/_provenance.json。
-  · 某页没数据: 先看控制台的退出码与 logs/, 再看 docs/重算缺口与补件清单_v0.1.md。
-
-包内含: 程序与配置 / 门户与仿真 / 三维资产 release/viewer / 治理清单交付件 release/如东(客户交付物勿外传) /
-        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh
-包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(运行日志) · 临时文件与缓存 ·
-          .venv(安装时重建) · .git(版本库)
-
-详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径) · docs/重算操作手册_v0.1.md
-          docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
-          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md
+观澜·如东样板 v2 0.4.0 — 单包全量离线应用 (Windows / Linux / macOS)
+
+<PY> = Windows 的 .venv\Scripts\python.exe, 或 Linux·macOS 的 .venv/bin/python;
+       命令里路径写 / 即可两平台通用; Windows 也可以只双击 .bat, 不必敲命令。
+
+★ 本包**不含 输入数据(data/) · 产物/输出数据(outputs/) · 运行日志(logs/、run/、任何 *.log/*.jsonl) ·
+  临时文件(缓存/备份/系统索引)**(用户令 2026-09-17; 打包方法与逐条理由见第五节):
+  解压安装后页面会显示"无产物"; 把现场数据包放好后跑一次重算(第四节), 页面立刻有数。
+  随包内容 = 程序/配置/门户与三维资产/交付文档/离线依赖轮子/便携运行时 (没有数据、产物、日志)。
+
+一、安装 (约 10 分钟; 安装目录不要有空格和中文)
+  Windows : 解压到 D:\guanlan\app → 双击 install.bat        (自动选 Python → 建 .venv → 包内轮子离线装依赖)
+  Linux   : 解压到 /opt/guanlan  → sh install.sh            (麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS;
+                                                            多版本机器: GUANLAN_PY=/usr/bin/python3.11 sh install.sh)
+  没装 Python 的机器也能装: 包内有便携运行时(Windows / Linux x86_64 / Linux aarch64), 安装脚本自动用。
+  离线边界: Windows 侧包内带全部轮子, 完全离线可装; **Linux·macOS 侧包内没带轮子**, install.sh 会自动
+  改为联网安装 —— 要离线装就把对应平台轮子放进 wheels/linux_x86_64 或 wheels/macos_arm64 再装。
+  模型(可选, 只影响问答与本地审核页): ollama pull qwen3:8b / deepseek-r1:14b / bge-m3
+
+二、启动 / 停止
+  Windows : 双击 check.bat 全绿 → 双击「启动观澜」快捷方式(安装时自动建: 根目录 + 桌面各一份;
+            同一目标还有一份 ASCII 名的 start_guanlan.lnk 供控制台/脚本引用;
+            目标 = .venv\Scripts\pythonw.exe, **完全不出现命令窗口**) 或双击 start.bat
+            (会闪一下窗口 —— .bat 是控制台程序, 想完全无窗就用上面那个快捷方式)   停止: 双击 stop.bat
+  Linux   : <PY> guanlan.py check → <PY> guanlan.py serve           停止: <PY> guanlan.py stop
+  三个入口: 门户 http://127.0.0.1:28084/ · 工作台 /detail/ · 运维控制台 /ops
+  排障要看见实时日志: 双击 start.bat console (前台模式, 关窗即停服务); 本包**不含 VBScript**,
+  无窗口启动不依赖 Windows 脚本宿主(WSH 被策略禁用的服务器上同样可用)。
+  ★ .bat 一律**纯 ASCII**(英文): 中文写进批处理会随控制台代码页被曲解, 现场出现过"注释被当命令执行"。
+    中文说明请看本文件与 docs/; 机器守卫见 src/entry_refs.py(guanlan.py check 会报一行)。
+
+三、重算与产物 (门户菜单「数据重算」, 或直接开 http://127.0.0.1:28084/ops)
+  一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,
+  直接打 API 返回 409); "启动服务"起来后自动打开门户; "停止组件服务"保留控制台本身; 同时刻只允许
+  一个动作, 页面上有进度/退出码/实时日志尾巴。重算进行中按钮按设计禁用 —— 那不是坏了。
+  ★ 清除产物 = **直接删除, 不留备份**(用户令 2026-09-16; 旧的 --on 还原已移除), 不动 data/raw、release;
+    要补回"包内没有生成端"的随包件: <PY> scripts/products_restore_missing.py --stash <交付包.zip>
+  ★ 清完产物后页面会显示"无产物"(工作台左栏/CMS 页都会给出"缺哪件、怎么补"的说明, **不会崩**) ——
+    跑一次第四节的重算即恢复; 若某页仍打不开, 先看 /healthz 与 logs/<组件>.log 尾部。
+
+四、放数据后重算 (一条命令; 控制台点"执行重算"等价)
+  <PY> scripts/raw_data_check.py --src <现场包目录>    ★ 先体检: 结构/命名/增量(新增·跳过·冲突) + 放置指导
+  <PY> scripts/place_raw_data.py --src <现场包目录> --scope full      放数据(同尺寸自动跳过, 可反复跑;
+       落位前自动跑体检, "同名不同大小"的冲突会拦下, 确要覆盖加 --force)
+  <PY> scripts/rebuild_all.py [--skip-scada] [--src <现场包目录>]     默认含 SCADA 侧约 15 分钟;
+       只换台账类数据加 --skip-scada(约 2 分钟); --dry-run 只看计划; 完事自动重启组件服务。
+  放置规范与常见错误: docs/输入数据放置指导_v0.1.md (目录树/命名/增量语义/放完跑什么)
+  算成功判据: /detail/ 左栏「系统维护」两屏, 或 <PY> -m src.ontology.maintenance
+  重算后应达锚点: 报警 39211 · 工单 5876 · 油样 404 · temp_monthly 19494 · 本体 9702 对象(审计 0 问题)
+
+五、打包 (做交付包; 用户令 2026-09-17)
+  一条命令就够(不需要任何开关); Windows 也可以直接**双击 pack.bat**:
+    Windows : 双击 pack.bat            或  <PY> scripts/pack_dist.py
+    Linux   : sh pack.sh               或  <PY> scripts/pack_dist.py
+    pack.bat dry      只预演: 报表里会打什么、多大, 以及逐条排除理由(不写文件)
+    pack.bat verify   对已打好的 zip 做开箱验证: 解压 → 离线安装 → 起服务核验页面 → 清理
+    <PY> scripts/pack_dist.py --out <目标.zip>     指定输出路径
+  输出: 默认写到**安装目录的上一级**, 文件名 guanlan-v<版本>_dist_<日期>.zip
+        (例: 装到 D:\guanlan\app, 则产物在 D:\guanlan\guanlan-v0.4.0_dist_20260917.zip)
+  ★ 包里**不含**这四类(默认口径; 每条理由都写进包内 dist-manifest.json):
+    ① 输入数据     data/                    现场原始件; 例外要带用 --with-data (约 31 GB)
+    ② 产物/输出数据 outputs/                 目标机放数据后 place_raw_data.py + rebuild_all.py 重算;
+                                            无生成端的那批用 products_restore_missing.py --stash <交付包.zip>
+    ③ 运行日志     logs/ run/ 以及**任何位置**的 *.log、*.jsonl (含 logs/build、logs/legacy)
+    ④ 临时文件     各种缓存(*_cache、__pycache__、.pytest_cache、.npm-cache、.qa-profile)、
+                   编辑器临时与备份(*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*)、
+                   系统索引(.DS_Store、Thumbs.db、desktop.ini), 以及 .git/.venv/*.pyc/*.lnk/顶层 *.zip
+  包里**有**: src/ scripts/ guanlan.py · configs/ · release/(门户/仿真/三维/交付件) · resources/ · reference/ ·
+             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack 脚本 · requirements.txt
+  打完自查: 包内 dist-manifest.json 记着 version / no_data / no_products / no_logs / no_temp + 逐条排除理由;
+            打包器自己还会核"入口脚本引用的文件都在包里"与"入口脚本编码守则", 不合格的包会被删掉并返回非 0。
+  要让页面开箱即有数(例外做法): <PY> scripts/pack_dist.py --with-products
+
+六、必读 (如实)
+  · 页面的数来自 outputs/<场站> 下的产物, 产物由 data/raw 下的原始件算出来(下一级目录名=场站名, 扫描辨识)。
+    本包两样都没带: 没有 data/raw 时重算会"没有源件可算", 没有 outputs/ 时页面显示"无产物"。
+  · 有一批产物**没有生成端**(pitch_daily、pc_monthly_bins、CMS/TCM 链等): 从交付包按需补齐
+    (products_restore_missing.py --stash <交付包.zip>), 补齐后逐件来源见 outputs/<场站>/_provenance.json。
+  · 某页没数据: 先看控制台的退出码与 logs/, 再看 docs/重算缺口与补件清单_v0.1.md。
+
+包内含: 程序与配置 / 门户与仿真 / 三维资产 release/viewer / 治理清单交付件 release/如东(客户交付物勿外传) /
+        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh
+包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(运行日志) · 临时文件与缓存 ·
+          .venv(安装时重建) · .git(版本库)
+
+详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径) · docs/重算操作手册_v0.1.md
+          docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
+          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md
+
+七、服务化 (装成 Windows 服务 / systemd; 用户令 2026-09-17)
+  Windows (管理员 PowerShell):  <PY> scripts/service_ctl.py install         注册为 Windows 服务(开机自起)
+                                <PY> scripts/service_ctl.py install --dry-run  先看它要做什么(免管理员)
+                                不想提权: <PY> scripts/service_ctl.py install --user-login   登录后自启
+  Linux   (root):               sudo <PY> scripts/service_ctl.py install   写 guanlan.service + systemctl enable --now
+  起停状态: <PY> scripts/service_ctl.py status|start|stop|restart|uninstall
+  装了服务就不必再双击快捷方式; 手工启动(start.bat / guanlan.py serve)仍然可用, 两者互不冲突。
+  ★ 安装时会先检查是否已安装及版本: 读 <安装目录>/install-info.json 与本包版本比较,
+    同版本/升级/降级/老安装无版本记录 四类都会提示并询问是否重装;
+    跳过询问用 install.bat -Force (Linux: sh install.sh --force)。版本单一真源 = src/version.py。

+ 29 - 0
configs/portal_pages.yaml

@@ -269,3 +269,32 @@ pages:
     changes_with_data: false
     file: release/portal_src/templates/tpl-sc1-local.html.html
     evidence: "0.4 KB 占位面板"
+
+  # ── 用户令 2026-09-17 第 2 问补齐: 把"所有网页"逐页管起来 (workbench 标签 / 交付页 / 三维页 / 内嵌件) ──
+  - id: detail_tabs
+    title: 工作台 /detail/v2 的 10 个标签 (SPA)
+    kind: live
+    changes_with_data: true
+    file: scripts/windscada_serve.py
+    tabs: [overview, energy, component, vibration, generation, fault, decision, assistant, report, system]
+    live_target: http://127.0.0.1:18033/v2
+    evidence: >-
+      工作台是单页应用: 外壳一次装载, 每个标签按需 fetch 后端 API, 而 API 现场读产物
+      (产物指纹 TTL 2s 自动重载) ⇒ 页面本体不存快照、永远与产物一致, 不需要产物管理。
+      其中 report 标签渲染产物 `outputs/<场>/windcms/report.md`(结论件), 该件属产物并已在台账里。
+      ★ 2026-09-17 现场: 清产物后 `tab=vibration` 空白, 不是页面坏了, 是它要的
+        `m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json` 与 CMS 页面件不在位 (§7/§13.5)。
+  - id: delivery_pages_all
+    title: 交付页 (release/如东 下全部 html)
+    kind: frozen-delivery
+    changes_with_data: false
+    file: release/如东/**/*.html
+    evidence: >-
+      客户交付件(治理清单分册/全册/正式报告/取数单/脱敏状态一览), 文件名自带版本号+日期 ⇒ 按交付版本冻结。
+      其中"脱敏状态一览"与"如东取数单"是**数据派生**的(内容随运行状态变) ⇒ 另见 §12 的逐页判定与 known_gap。
+  - id: viewer_pages_all
+    title: 三维页 (release/viewer 下全部 html)
+    kind: static
+    changes_with_data: false
+    file: release/viewer/*.html
+    evidence: 图纸/三维模型派生的资产页(工作台/索引/装配演示), 与 data/raw 无关 ⇒ 不是产物。

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

@@ -284,6 +284,7 @@
 | 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 缺口表) |
 | 2026-09-17 | **用户令 2「统一配置与日志」的落地(见 §11)**:① 配置——`src/paths.py` 增唯一取用口 `P.config()/P.config_dir()/P.farm_config()`,9 个模块不再手拼 configs 路径;新增登记表 `configs/registry.yaml` 与检查器 `scripts/config_audit.py`(R1–R8);修掉三处真问题:**10 个场定义 YAML 因 `available()` 只 glob `*.json` 而"配了看不见"**(并查明它们其实是另一条链的机型/物理约束 profile,逐件登记在册)、坏模板 `_模板.json.example`(GBK 乱码 + 写死 `/Volumes/WINDDATA`)重写为 `_模板.yaml.example`、`sop/wrapup.py` 拼了不存在的 `configs/<场>/` 路径;删除 `serve.json.bak-bomfix`;5 处"代码引用但包里没有"的配置登进 `known_missing` 并写明影响。② 日志——新增 `src/logfile.py`(目录/命名/行格式唯一口径 + `prefix_stdout()` 流包装 + 动作日志保留 20 份/30 天 + `logs/build` 归位口)与 `scripts/log_audit.py`(L1–L5);6 个服务 + 启动器 + 运维动作全部接入统一格式(`viewer`/`sim` 改走新增的 `scripts/static_server.py`);**把 39 个躺在产物目录里的构建日志搬到 `logs/build/` 并同步重算产物台账**(2309→2270 件,`呼应校验` 仍 rc=0)、22 份历史动作日志与 8 个服务日志的旧内容归档 `logs/legacy/`;`guanlan.py check` 增两行 |
+| 2026-09-17 | **用户令 1「装成服务 + 安装时检查版本」的落地(见 §14)**:新增版本单一真源 `src/version.py`;安装脚本增"步骤 0"——读 `install-info.json` 比对已装版本(相同/升级/降级/老安装无记录四类提示),交互式询问是否重装(`-Force`/`--force` 跳过,非交互自动继续),装完写安装记录(version/时间/解释器/运行方式);服务化:**Windows 服务**(`scripts/win_service.py` **ctypes 直连 SCM,不依赖 pywin32**)+ **systemd 单元**(`scripts/service_main.py` + `guanlan.service`)+ 免管理员的"登录自启"备选;两平台共用工作体 `scripts/service_worker.py`(起/守护/停都走现成的 `guanlan.py serve|stop`,口径与手工一致);注册器 `scripts/service_ctl.py`(status/install/uninstall/start/stop/restart + `--dry-run` 打印逐条命令与单元全文;无权限时给三条可执行建议而不是抛 Access denied)。另:修掉网关轮询与执行器"写回退出码"的**竞态**(实测 14:58 清产物 rc=0 却被页面显示成"未拿到退出码")。本机非管理员 ⇒ 已用 `--dry-run`/`--debug --once`/直跑服务体报 1063 三条路径自证,见 §14.3 |
 | 2026-09-17 | **清产物 → 重算链走通(并暴露"包不随产物"的连带后果)**:用户 13:05 执行「清除产物」(真删、无备份),之后重算在 ⑦ 本体层失败 —— `pitch_daily.parquet`/`handoff_vibration_v2.json` 属"包内无生成端"的件,而交付包按用户令已不含产物 ⇒ **目标机/清空后的机器上必然缺**,代码却按"缺了就抛"处理,把整条链打断(实测 2267 s 后 rc=1)。修法见 §7/§11.4:`windcms`(空表 + 结构化无产物页,不再掐连接)、`pitch`、`taxonomy.system_matrix`(`_cell()` 逐格兜底 + `_缺件说明`)、`fusion`(响亮降级而非抛错);修完把 ⑦⑧ 跑完:对象 9,613、`ontology.audit` rc=0,SCADA 锚点 39,211/5,876/404/19,494 全对,呼应校验正常。另把开箱验证改为**不打扰运行中的实例**(见上一条),实测验证期间主实例 6/7 全程在跑、副本 5/5 页面可用。⚠ **后果如实记**:这批无生成端的产物已无可恢复来源(包不含产物、本机无旧包),要保留请用 `--with-products` 打一份带产物的包存档 |
 | 2026-09-17 | **现场事故二连(清产物后登录报"模块不可用")**:① `windcms` 在**无产物**时自毁 —— `data.load_scalars` 的 `pd.concat([])` 抛 `ValueError` 带走进程、`serve.py` 读不到产物页 `index.html` 直接掐断连接;改为返回空表 + 结构化「无产物」页(HTTP 200,页面写清"缺哪件/怎么补"),实测 CMS 在 `outputs/` 为空时 `GET /` → 200 / 48 KB。② `pack_dist.py --verify` 原先**必须**占用默认组件端口 ⇒ 要先停掉用户正在跑的服务(用户点「登录」正好撞上验证窗口);把 `guanlan_gateway.ROUTES` 的上游端口改为可被 `configs/serve.json` 覆盖(端口单一真源),验证改为**副本整套换端口**(网关 28700 + 组件 28800+),不再打扰主实例。见 §11.4 |
 | 2026-09-17 | **用户令「实现打包功能」的落地(见 §9)**:打包器默认口径改为**一条命令、零开关** —— `outputs/`(产物/输出数据)由"默认打进包"改为**默认不含**(例外用 `--with-products`,旧开关 `--no-products` 保留兼容),并新增两类排除:**运行日志兜底**(`logs/` `run/` 之外,任何位置的 `*.log`/`*.jsonl` 都不进包)与**临时文件**(`*_cache/ __pycache__ .pytest_cache .npm-cache .qa-profile`、`*.tmp *.temp *.bak* *.old *.orig *.rej *.swp *~ ~$*`、`.DS_Store Thumbs.db desktop.ini` 等);`dist-manifest.json` 增 `no_temp` 并保留逐条排除理由;新增**双击可用**的打包入口 `pack.bat`(`dry`/`verify` 子命令)与 `pack.sh`、`scripts/pack_verify_entry.py`(自动找最新 zip);`pack.bat`/`pack.sh` 纳入入口引用闭合检查。同时按用户令把 `README_先读我.txt` **改名为 `README_先读我.MD`** 并在其中新增第五节「打包」(命令/输出位置/四类排除逐条理由/打完自查);`.bat` 全为纯 ASCII(§11.3)。实测:`pack.bat dry` 报 3,430 件 / 1,032.0 MB (含缓存与日志的旧口径是 3,741 件 / 1,091 MB) |
@@ -409,6 +410,11 @@
 | `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 占位面板 |
+| `detail_tabs` 工作台 /detail/v2 的 10 个标签 (SPA) | `live` | **是** | 工作台是单页应用: 外壳一次装载, 每个标签按需 fetch 后端 API, 而 API 现场读产物 (产物指纹 TTL 2s 自动重载) ⇒ 页面本体不存快照、永远与产物一致, 不需要产物管理。 其中 report 标签渲染产物 `outputs/<场>/windcms/report.md`(结论件), 该件属产物并已在台账里。 ★ 2026-09-17 现场: 清产物后 `tab=vibration` 空白, 不是页面坏了, 是它要的
+  `m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json` 与 CMS 页面件不在位 (§7/§13.5)。 |
+| `delivery_pages_all` 交付页 (release/如东 下全部 html) | `frozen-delivery` | 否 | 客户交付件(治理清单分册/全册/正式报告/取数单/脱敏状态一览), 文件名自带版本号+日期 ⇒ 按交付版本冻结。 其中"脱敏状态一览"与"如东取数单"是**数据派生**的(内容随运行状态变) ⇒ 另见 §12 的逐页判定与 known_gap。 |
+| `viewer_pages_all` 三维页 (release/viewer 下全部 html) | `static` | 否 | 图纸/三维模型派生的资产页(工作台/索引/装配演示), 与 data/raw 无关 ⇒ 不是产物。 |
+| `portal_embeds_all` 门户内嵌件 (release/portal_src/templates 全部) | `static` | 否 | 门户装配时内嵌进 portal.html 的交付件正文(28 件); 逐件的 kind/是否产物见 §10 的自动表与 §12 的逐页判定 (其中"实际运行回放面板"是 data-derived, 已在台账与登记表两侧管起来)。 |
 
 <!-- PAGES:END -->
 
@@ -576,6 +582,279 @@ R7 每个域必须声明 consumers 或写明"无人读";R8 `configs/farms/` 
 或生成时间);③ **陈旧检测**(`pages_audit.py` rc=7);④ 进链(`rebuild_all.py` ⑧b 重装门户 + ⑧c 页面审计)。
 **页面侧产物(`outputs/<场>/windcms/*`)另有一条硬要求**:它们必须出现在产物台账 `_provenance.json` 里(见 §13.4 的检查)。
 
+
+<!-- PAGES-INV:BEGIN -->
+### 12.2 全部网页总清点(自动生成,勿手改)
+
+共 **262** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / 交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 178 项;未被管起来的 **0** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。
+
+| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |
+|---|---|---|---|---|
+| 门户锚点 | `#index` 总览 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#architecture` 系统架构 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#method` 方法 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#findings` 经验发现 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#case_hydraulic` 案例·液压 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#cms` 振动·CMS | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#sim` 仿真与回放 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#documents` 交付文档 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#admin` 系统状态 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#recalc` 数据重算 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 门户锚点 | `#login` 登录 | 不是 | 外壳页(静态/iframe/链接);逐条判定见 §10 与 §12 | ✔ |
+| 工作台标签 | `tab=overview` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=energy` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=component` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=vibration` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=generation` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=fault` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=decision` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=assistant` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=report` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 工作台标签 | `tab=system` | 不是 | SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `guanlan/derived/report_summary.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/gear_grade_38.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/OEM特征频率复跑_2026-08-17.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/交接_振动→发电机分析.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/交接_振动→齿轮箱_行星级中速轴判级.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/交接_振动→齿轮箱分析.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/取数与送检需求单_20260827.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/回测训练_历史锚三轮深挖_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/对比_大生科技传动链报告_20260311.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/振动板块_windscada融合面_设计v1.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/振动算法复跑记录_2026-08-15.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/收敛报告_如东TCM振动诊断_v4.2.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/收敛报告_如东TCM振动诊断_v4.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/收敛报告_如东TCM振动诊断_v5.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_TCM16验收槽规格_20260827.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_工单库缺2024-11之后_20260827.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_油样复核回执_20260827.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_温度轴复核回执_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_温度轴核查请求_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/给windscada线_色标已改_20260828.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `m5_cms_tcm/综合_振动最新×发电机齿轮箱原分析_2026-08-13.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/诊断报告_TCM振动_202601窗.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/诊断报告_TCM振动_六窗终版.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `m5_cms_tcm/频谱分析报告_轴承特征频率_2026-08-13.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E3_candidate_closure/plan.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E3_candidate_closure/result.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E3_candidate_closure/归宿草案.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E5_coverage_table/plan.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E5_coverage_table/result.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E5_coverage_table/主表.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/plan.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/result.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/S0_盘点.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/S1_能问表对拍.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/S3_C7干跑.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `paradigm_r1/experiments/E8_rudong_rebuild/S5_对表.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_dual_review_20260726_payload脱敏.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260822_model_additions.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260822_sealed_predictions.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260823_gende16_33.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260823_gende16_33_addendum.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260823_gende16_33_sealed_predictions.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260824_shared_component_fleet.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260824_shared_component_fleet_sealed_predictions.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260824_wtg12_outlier.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_review_pkg_20260824_wtg12_outlier_sealed.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u6_review_20260810/封存预测_审前.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u6_review_20260810/送审包_as_sent.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u6_送审包_20260810.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u6_送审包_r2_20260810.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u8_sealed_predictions.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/_u8_送审包_20260812.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/local_review/disposition_14b_20260824.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/local_review/disposition_32b_20260824.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/local_review/review_pkg_20260824_grading_calibration.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/lens_A_口径统计.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/lens_B_交付表述.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/pkg.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/SEALED_我的预测_勿送审.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/处置台账.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260831/夜间工作总结_0901.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/lens_A_事实核查.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/lens_B_读者与风险.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/pkg_r2.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/SEALED_我的预测_勿送审.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/处置台账.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/review_20260901/复审处置_r2.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `sop/轴4返修_AdvCI两方向_2026-07-27.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/_争议裁决_封存预判_勿入送审包.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/_争议裁决送审包_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/Codex回复_rms200_mask_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/Codex复现闸回传_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/争议裁决记录_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/复现审计.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/建议采纳清单_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/振动模型规划.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的同步_R5_WTG23包络滞后_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的同步_rms200证据边界_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的回传.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的回传_R2_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的回信_如东振动研究历程梳理_20260905.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `tcm_compatible_replay/claude_handoff_20260823/给Codex的问题_rms200_mask_20260823.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/index.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/index_eng.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/overview.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/report.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG01.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG02.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG03.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG04.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG05.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG06.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG07.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG08.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG09.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG10.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG11.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG12.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG13.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG14.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG15.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG16.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG17.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG18.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG19.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG20.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG21.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG22.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG23.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG24.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG25.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG26.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG27.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG28.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG29.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG30.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG31.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG32.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG33.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG34.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG35.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG36.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG37.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windcms/turbines/WTG38.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/报告_CMS振动状态评估报告_2026-08-23.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/报告_CMS振动状态评估报告_2026-08-24.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/报告_CMS振动状态评估报告_2026-08-26.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/报告_CMS振动状态评估报告_2026-08-28.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windcms/评审_离线系统诊断准确度_20260824.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/_design_audit_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/_selfaudit_20260825.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/index.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/_sealed_predictions_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/审核处置_R1_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/审核处置_R2_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/审核处置_R3_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/送审包_windscada_v1.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/review/送审包_windscada_v2.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG01.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG02.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG03.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG04.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG05.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG06.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG07.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG08.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG09.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG10.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG11.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG12.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG13.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG14.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG15.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG16.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG17.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG18.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG19.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG20.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG21.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG22.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG23.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG24.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG25.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG26.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG27.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG28.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG29.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG30.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG31.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG32.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG33.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG34.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG35.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG36.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG37.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 组件页面件 (.html) | `windscada/turbines/WTG38.html` | **是** | 位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件;台账: 在 | ✔ |
+| 产物里的过程留痕 (.md,非网页) | `windscada/给振动线_温度轴回复_20260826.md` | **是** | 产物,但没有任何页面呈现它(给下游线的交接/复跑记录);台账: 在 | ✔ |
+| 组件结论件 (.md,页面会渲染) | `windscada/送审_系统结论与判定_20260828.md` | **是** | 产物(结论文本);工作台 report tab / CMS report.html 会渲染它;台账: 在 | ✔ |
+| 交付页 (release/如东) | `如东/全场状态一览_脱敏_20260825.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东取数单_2026-08-21/如东取数单_2026-08-21.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东取数单_2026-08-21/如东现场一页单.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东取数单_2026-08-21/更正页_2026-08-21晚.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东取数单_2026-08-21.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/01_正式报告/如东_液压系统技术报告_v1.0_2026-09-01.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/02_治理清单/如东风场治理清单_全册_v4.5_2026-09-06.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/00_总目录.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/01_如东_液压系统治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/02_如东_变桨执行治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/03_如东_偏航系统治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/04_如东_变流器治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/05_如东_主控与传感网治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/06_如东_电气并网治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/07_如东_塔架与基础治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/08_如东_润滑与冷却辅助治理清单_v1.1_2026-09-06.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/09_如东_CMS-TCM监测面治理清单_v1.1_2026-09-06.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/10_如东_参数与管理治理清单_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东治理清单_交付_20260901/03_分册全套/11_如东_故障负担榜_v1.0_2026-09-05.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 交付页 (release/如东) | `如东/如东现场一页单_2026-08-21.html` | 不是 | 客户交付件,按版本冻结 | ✔ |
+| 三维页 (release/viewer) | `viewer/bedframe-demo.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-findings.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-index.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-method.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-services.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-turbine-explorer.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/guanlan-publish-whitepaper.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/index.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 三维页 (release/viewer) | `viewer/unit-workbench.html` | 不是 | 图纸/模型派生的三维资产 | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-coverage_ch0_zh.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-0.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-1.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-10.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-11.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-2.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-3.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-4.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-5.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-6.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-7.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-8.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-governance-9.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-report-主轴承.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-report-发电机.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-report-变桨.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-report-齿轮箱.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-sc1-local.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-standard_panel_zh.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-swt-subsystem-1.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-swt-subsystem-2.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-swt-subsystem-3.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-swt-subsystem-4.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-swt40_控制律仪表台_外发版.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-U6_变桨液压仿真台.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-U6_液压公共站健康报告_客户版.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-如东传动链实际运行诊断_单文件版.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 门户内嵌件 | `portal_src/templates/tpl-如东海上风电场_整机综合诊断与风险评估_主轴冲击深挖修订版V2.1.html.html` | —(逐条判定) | 内嵌交付件(§10 逐条判定;个别为数据派生) | ✔ |
+| 仿真资料包 (zip) | `2_传动链与功率.html` | 不是 | 图纸派生的冻结资料包(sim_sys_server 从 zip 现读) | ✔ |
+| 仿真资料包 (zip) | `4_变桨轮毂液压.html` | 不是 | 图纸派生的冻结资料包(sim_sys_server 从 zip 现读) | ✔ |
+| 仿真资料包 (zip) | `0_四系统合页.html` | 不是 | 图纸派生的冻结资料包(sim_sys_server 从 zip 现读) | ✔ |
+| 仿真资料包 (zip) | `3_发电机与热管理.html` | 不是 | 图纸派生的冻结资料包(sim_sys_server 从 zip 现读) | ✔ |
+| 仿真资料包 (zip) | `1_偏航系统.html` | 不是 | 图纸派生的冻结资料包(sim_sys_server 从 zip 现读) | ✔ |
+
+<!-- PAGES-INV:END -->
+
 ---
 
 ## 13. 输入数据 → 功能 → 算法 → 产物(关系与实现逻辑)
@@ -651,3 +930,72 @@ python scripts/products_restore_missing.py --stash <含产物的包.zip>    # 
 已按 §11.2 归位到 `logs/build/`),补齐后与旧包**逐字节相同 538 件**。
 **教训(写进 §9 的口径里)**:交付包默认不含产物 + 清除产物不留备份 ⇒ 这批件**一旦清掉就没有来源**;
 要留退路就用 `scripts/pack_dist.py --with-products` 打一份带产物的包存档。
+
+---
+
+## 14. 服务化安装(Windows 服务 / systemd)与版本检查(2026-09-17 用户令 1)
+
+### 14.1 装成什么
+
+| 平台 | 形态 | 服务体 | 注册器 | 前置 |
+|---|---|---|---|---|
+| Windows | **Windows 服务**(SCM 托管,`SERVICE_WIN32_OWN_PROCESS`) | `scripts/win_service.py`(**ctypes 直连 advapi32,不依赖 pywin32**) | `scripts/service_ctl.py install`(内部 `sc.exe create/description/failure/start`) | 管理员 |
+| Linux | **systemd 单元** `guanlan.service`(`Type=simple` + `Restart=always`) | `scripts/service_main.py`(前台守护) | `scripts/service_ctl.py install`(写 `/etc/systemd/system/guanlan.service` + `systemctl enable --now`) | root |
+| Windows(备选) | 当前用户**登录自启**(`HKCU\...\Run`)——不需要管理员 | `scripts/guanlan_start_hidden.py`(无窗口启动器) | `scripts/service_ctl.py install --user-login` | 无 |
+
+**为什么 Windows 服务体不用 `sc create python xxx.py`**:SCM 要求被拉起的进程跟它握手
+(`StartServiceCtrlDispatcher` + 控制处理回调),否则起服务报 **1053**。
+实现它有两条路:`pywin32`(要加新依赖,破坏"单包离线零新依赖")或 **ctypes 直连**(本实现,只用标准库)。
+
+**两平台共用一个工作体**(`scripts/service_worker.py`):起 = `guanlan.py serve`(幂等,缺谁补谁)、
+守护 = 每 15 s 巡检一次同样命令(组件掉了自动拉起)、停 = `guanlan.py stop`。
+这样"服务起的"与"手工起的"口径完全一致,不会出现两套行为。
+
+### 14.2 版本检查与重装确认(安装时)
+
+版本**单一真源** = `src/version.py::VERSION`(打包器、安装脚本、`guanlan.py check`、文档都以它为准)。
+安装脚本在**动手之前**做一次检查,读 `<安装根>/install-info.json`(上次安装写下的记录):
+
+| 情形 | 提示 | 动作 |
+|---|---|---|
+| 没装过 | 未检测到已安装 → 全新安装 vX | 直接装 |
+| 版本相同 | 已安装 vX,与本包**版本相同**;重装会重建 `.venv`、覆盖程序文件(`data/`、`outputs/` 不动) | **问**是否重装 |
+| 已装更旧 | v旧 → v新(**升级**) | **问**是否重装 |
+| 已装更新 | 已装 v新 > 本包 v旧 ⇒ **降级**警告:"除非确知原因,不要用旧包覆盖新装" | **问**(并明确警告) |
+| 老安装无版本记录 | 按"重装/升级"处理 | **问** |
+
+* 交互式:`Read-Host` / `read` 问 `y` 才继续;**非交互**(stdin 被重定向,例如自动化核验)自动继续并打印说明;
+* 跳过询问:`install.bat -Force`(PowerShell 侧 `-Force`)/ `sh install.sh --force`;
+* 装完写 `install-info.json`:`version / installed_at / python / mode(manual|windows-service|systemd|startup-entry)`;
+* `guanlan.py check` 增一行显示"已装版本 vs 本包版本"。
+
+### 14.3 没有权限时怎么办(本次实机就是这种情况)
+
+本机当前账号**不是管理员**,所以"真注册服务"这一步无法在此验证。为此该功能自带三条**无权限自证**路径:
+
+```
+python scripts/service_ctl.py status                 # 装没装/在跑没在跑(免权限)
+python scripts/service_ctl.py install --dry-run      # 前置检查 + 逐条打印将执行的命令 + 打印将写入的 systemd 单元全文
+python scripts/win_service.py --debug --once          # 免权限跑一轮守护(起组件→巡检→报"网关在听")
+python scripts/win_service.py                        # 直跑服务体: 报 "不是由 SCM 启动(WinError 1063)" ⇒ SCM 接口调用正确
+```
+
+权限不足时 `service_ctl.py` **不抛 Access is denied**,而是打印三条可执行建议(管理员重试 / 先 `--dry-run` /
+改用 `--user-login`)并返回 rc=3。**实机输出**(本机,非管理员):
+
+```
+$ python scripts/service_ctl.py install --dry-run
+   前置检查: 通过
+   $ sc.exe create guanlan binPath= "…\.venv\Scripts\python.exe" "…\scripts\win_service.py" start= auto DisplayName= "观澜·如东样板 v2 …"
+   $ sc.exe description guanlan "观澜·如东样板 v2 v0.4.0 本机离线版(观澜风电资产智能)"
+   $ sc.exe failure guanlan reset= 86400 actions= restart/60000/restart/60000/restart/60000
+   $ sc.exe start guanlan
+$ python scripts/win_service.py --debug --once
+   一轮巡检结果: 网关在听
+```
+
+### 14.4 与"手工启动"的关系
+
+装了服务之后**不必**再双击快捷方式:开机会自动起(Windows 服务)/ `systemctl start guanlan`(Linux)。
+手工路径仍然可用(快捷方式、`start.bat`、`guanlan.py serve`),两者互不冲突——
+服务只是"代表整套系统守着"的那一个进程。停止:`service_ctl.py stop`(或 `stop.bat`)。

+ 58 - 7
install.ps1

@@ -9,7 +9,8 @@
 # install.bat 里的 chcp 65001 管不了这件事 (它改的是控制台代码页, 不是脚本文件的解码方式)。
 # 另: 本文件**不再使用** 形如  & cmd /c "\"$exe\" -m pip ..."  的嵌套引号写法 —— 一旦有字节被吃掉就崩; 改用
 # PowerShell 原生调用 & $exe arg1 arg2, 不需要任何引号转义。
-param([string]$Python = "", [switch]$UseBundledPython, [switch]$SkipOllama, [switch]$NoDesktopShortcut)
+param([string]$Python = "", [switch]$UseBundledPython, [switch]$SkipOllama, [switch]$NoDesktopShortcut,
+      [switch]$Service, [switch]$Force)
 $ErrorActionPreference = "Stop"; $env:PYTHONUTF8 = "1"
 # 机器上若全局设了 PYTHONPATH (例如指向 D:\Program Files\Python\Lib\site-packages), pip 会把那里的包
 # 当成 "已满足", 于是不装进 .venv —— venv 里就缺 urllib3 / polars / pyyaml / jinja2 等传递依赖,
@@ -19,7 +20,39 @@ Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue
 Set-Location $PSScriptRoot
 function Say($m){ Write-Host $m }
 
-Say "== 1/6 选 Python (需 3.11+; 本项目依赖 pandas 3, 3.10 及以下没有轮子)"
+Say "== 0/7 安装前检查: 是否已安装及版本差异 (用户令 2026-09-17)"
+# 版本单一真源 = src\version.py; 已装记录 = <安装根>\install-info.json (安装脚本写)
+$pkgVer = ""
+$verLine = Select-String -Path "src\version.py" -Pattern "^VERSION\s*=\s*'([^']+)'" -ErrorAction SilentlyContinue | Select-Object -First 1
+if ($verLine) { $pkgVer = $verLine.Matches[0].Groups[1].Value }
+elseif (Test-Path "dist-manifest.json") { $pkgVer = (Get-Content "dist-manifest.json" -Raw | ConvertFrom-Json).version }
+$prev = $null
+if (Test-Path "install-info.json") {
+  try { $prev = Get-Content "install-info.json" -Raw | ConvertFrom-Json } catch { $prev = $null }
+}
+$prevVer = if ($prev) { [string]$prev.version } else { "" }
+if (-not $prev) {
+  Say "   未检测到已安装的观澜(将全新安装 v$pkgVer)"
+} else {
+  Say "   已安装: v$($prevVer)(装于 $($prev.installed_at); 运行方式 $($prev.mode))"
+  if (-not $prevVer) {
+    Say "   [!] 旧安装没有版本记录 —— 本包 v$pkgVer 将按'重装/升级'处理(旧 .venv 会被重建)"
+  } elseif ($prevVer -eq $pkgVer) {
+    Say "   [i] 版本相同(v$pkgVer): 重装会重建 .venv、覆盖程序文件; data/ 与 outputs/ 不受影响"
+  } else {
+    $a = [version]($prevVer -replace '[^0-9.]', ''); $b = [version]($pkgVer -replace '[^0-9.]', '')
+    if ($a -lt $b) { Say "   [!] 版本差异: v$prevVer -> v$pkgVer(升级)" }
+    else { Say "   [X] 版本差异: 已装 v$prevVer 比本包 v$pkgVer 更新 —— 用旧包覆盖新装通常不是你想要的, 请确认" }
+  }
+  if ($Force) { Say "   (-Force: 跳过询问, 直接继续)" }
+  elseif ($NoDesktopShortcut -or [Console]::IsInputRedirected) { Say "   (非交互: 自动继续安装)" }
+  else {
+    $ans = Read-Host "   是否重新安装/覆盖? 输入 y 继续, 其它键退出"
+    if ($ans -notmatch '^(y|Y|yes|YES)$') { Say "   已取消(未改任何文件)"; exit 0 }
+  }
+}
+
+Say "== 1/7 选 Python (需 3.11+; 本项目依赖 pandas 3, 3.10 及以下没有轮子)"
 $py = $null
 if ($Python) { $py = $Python }
 elseif (-not $UseBundledPython) {
@@ -56,17 +89,17 @@ if (-not $py) {
 }
 Say "   使用: $($py.exe) $($py.arg)"
 
-Say "== 2/6 建虚拟环境 .venv"
+Say "== 2/7 建虚拟环境 .venv"
 if ($py.arg) { & $py.exe $py.arg -m venv .venv } else { & $py.exe -m venv .venv }
 $vpy = (Resolve-Path ".\.venv\Scripts\python.exe").Path
 & $vpy -m pip install --upgrade pip --quiet
 
-Say "== 3/6 装依赖 (优先包内离线轮子 wheels\win_amd64)"
+Say "== 3/7 装依赖 (优先包内离线轮子 wheels\win_amd64)"
 if (Test-Path ".\wheels\win_amd64") { & $vpy -m pip install --no-index --find-links .\wheels\win_amd64 -r requirements.txt }
 else { Say "   包内无轮子, 改为联网安装"; & $vpy -m pip install -r requirements.txt }
 if ($LASTEXITCODE -ne 0) { Say "   X 依赖安装失败 (上面有 pip 的报错) —— 先解决它, 别接着跑 start.bat"; exit 3 }
 
-Say "== 4/6 写配置"
+Say "== 4/7 写配置"
 # 别改回 Get-Content / Set-Content -Encoding UTF8 (2026-09-10 实机踩过):
 #   PS 5.1 的 Get-Content 把无 BOM 的 UTF-8 当 ANSI(GBK) 解码 -> 中文注释变乱码;
 #   Set-Content -Encoding UTF8 又写出 BOM -> Python 侧用裸 utf-8 json.loads 直接 JSONDecodeError。
@@ -89,7 +122,7 @@ print('   configs/serve.json -> ' + c['python'] + ' (相对安装根)')
 & $vpy -c $cfgFix
 if ($LASTEXITCODE -ne 0) { Say "   X 写 configs\serve.json 失败"; exit 4 }
 
-Say "== 5/6 建启动快捷方式 (无窗口; 不依赖 Windows 脚本宿主)"
+Say "== 5/7 建启动快捷方式 (无窗口; 不依赖 Windows 脚本宿主)"
 # 2026-09-16 用户令二 "把 VBScript 替换掉": 原先无窗口启动靠 start_hidden.vbs (WScript.Shell.Run 窗口样式 0),
 # 现在快捷方式直接指向 **pythonw.exe**(无控制台子系统的解释器) + 启动器脚本 —— 运行期一个窗口都没有,
 # 且不经过 wscript/cscript: WSH 被组策略禁用、或系统未安装 WSH 功能的机器上同样可用。
@@ -138,7 +171,23 @@ try {
 foreach ($m in $made) { Say "   已建: $m" }
 if ($made.Count -gt 0) { Say "   * 双击这个快捷方式 = 完全不出现命令窗口 (start.bat 会有一次极短闪烁)" }
 
-Say "== 6/6 Ollama (只影响问答与本地审核; 其余页面不受影响)"
+Say "== 6/7 服务化 (可选; 用户令 2026-09-17)"
+if ($Service) {
+  & $vpy scripts\service_ctl.py install
+  if ($LASTEXITCODE -ne 0) {
+    Say "   [!] 服务注册没成功(退出码 $LASTEXITCODE) —— 不影响使用: 仍可双击快捷方式/start.bat 启动"
+    Say "       需要管理员权限才能注册服务: 用【管理员】PowerShell 跑"
+    Say "         & $vpy scripts\service_ctl.py install"
+    Say "       不想提权可改用登录自启: & $vpy scripts\service_ctl.py install --user-login"
+  }
+} else {
+  Say "   (未指定 -Service: 跳过。要装成 Windows 服务: 管理员 PowerShell 里跑"
+  Say "      .venv\Scripts\python.exe scripts\service_ctl.py install"
+  Say "    什么都不想提权、只要登录后自动起: scripts\service_ctl.py install --user-login)"
+  Say "   先看它会做什么: scripts\service_ctl.py install --dry-run"
+}
+
+Say "== 7/7 Ollama (只影响问答与本地审核; 其余页面不受影响)"
 if (-not $SkipOllama) {
   $has = Get-Command ollama -ErrorAction SilentlyContinue
   if ($has) { Say "   已安装: $(& ollama -v 2>&1 | Select-Object -First 1)" }
@@ -149,6 +198,8 @@ if (-not $SkipOllama) {
   } else { Say "   未装且包内无离线包: 问答不可用。装法见 说明书 §5" }
 }
 
+# 写安装记录 (版本单一真源 src\version.py):安装检查与 guanlan.py check 都读它
+& $vpy -c "import sys,pathlib; sys.path.insert(0, str(pathlib.Path('.').resolve())); from src import version as V; print('   安装记录: ' + str(V.write_installed(pathlib.Path('.').resolve(), python=sys.executable, mode=('windows-service' if '$Service' == 'True' else 'manual'))))"
 Say ""
 & $vpy guanlan.py check
 Say ""

+ 59 - 1
install.sh

@@ -3,6 +3,14 @@
 # 用法: sh install.sh            (自动选 Python; 无合适系统 Python 时用包内便携运行时)
 #       GUANLAN_PY=/path/python3 sh install.sh   (指定 Python, 多版本机器用)
 set -e
+# 选项 (用户令 2026-09-17): --force 跳过"已安装?"的确认; --service 装成 systemd 服务(需 root)
+FORCE=0; DO_SERVICE=0
+for arg in "$@"; do
+  case "$arg" in
+    --force|-f) FORCE=1 ;;
+    --service|-s) DO_SERVICE=1 ;;
+  esac
+done
 # 同理: 全局 PYTHONPATH 会让 pip 误判依赖已满足而不装进 .venv (见 install.ps1 注释)
 unset PYTHONPATH
 cd "$(dirname "$0")"; ROOT=$(pwd); export PYTHONUTF8=1 LC_ALL=${LC_ALL:-C.UTF-8}
@@ -13,6 +21,41 @@ OS=$(uname -s)
 DISTRO=$( [ -f /etc/os-release ] && . /etc/os-release && echo "${PRETTY_NAME:-$NAME}" || echo "$OS" )
 say "== 观澜 v2 安装 · $DISTRO · $ARCH"
 
+# 0) 安装前检查: 是否已安装及版本 (用户令 2026-09-17)
+say "== 0/7 安装前检查: 是否已安装及版本差异"
+PKG_VER=$(sed -n "s/^VERSION *= *'\([^']*\)'.*/\1/p" src/version.py 2>/dev/null | head -1)
+[ -n "$PKG_VER" ] || PKG_VER=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' dist-manifest.json 2>/dev/null | head -1)
+PREV_VER=""; PREV_AT=""; PREV_MODE=""
+if [ -f install-info.json ]; then
+  PREV_VER=$("$ROOT/.venv/bin/python" -c "import json;print(json.load(open('install-info.json', encoding='utf-8')).get('version',''))" 2>/dev/null \
+            || sed -n 's/.*"version": *"\([^"]*\).*/\1/p' install-info.json | head -1)
+  PREV_AT=$(sed -n 's/.*"installed_at": *"\([^"]*\).*/\1/p' install-info.json | head -1)
+  PREV_MODE=$(sed -n 's/.*"mode": *"\([^"]*\).*/\1/p' install-info.json | head -1)
+fi
+if [ -z "$PREV_VER" ] && [ ! -f install-info.json ]; then
+  say "   未检测到已安装的观澜(将全新安装 v$PKG_VER)"
+else
+  say "   已安装: v${PREV_VER:-(无版本记录)}(装于 ${PREV_AT:-?}; 运行方式 ${PREV_MODE:-?})"
+  if [ "$PREV_VER" = "$PKG_VER" ]; then
+    say "   [i] 版本相同(v$PKG_VER): 重装会重建 .venv、覆盖程序文件; data/ 与 outputs/ 不受影响"
+  elif [ -z "$PREV_VER" ]; then
+    say "   [!] 旧安装没有版本记录 —— 本包 v$PKG_VER 将按'重装/升级'处理"
+  else
+    say "   [!] 版本差异: v$PREV_VER -> v$PKG_VER"
+    case "$(printf '%s\n%s\n' "$PREV_VER" "$PKG_VER" | sort -V | tail -1)" in
+      "$PREV_VER") say "   [X] 已装版本比本包更新 —— 用旧包覆盖新装通常不是你想要的, 请确认";;
+    esac
+  fi
+  if [ "$FORCE" = "1" ]; then say "   (--force: 跳过询问, 直接继续)"
+  elif [ ! -t 0 ]; then say "   (非交互 stdin: 自动继续安装)"
+  else
+    printf "   是否重新安装/覆盖? 输入 y 继续, 其它键退出: "
+    read -r ANS
+    case "$ANS" in y|Y|yes|YES) ;; *) say "   已取消(未改任何文件)"; exit 0;; esac
+  fi
+fi
+
+
 # 1) 选 Python: 显式指定 > 系统 3.11/3.12 > 包内便携运行时 (麒麟/统信自带多为 3.7/3.9, 跑不了本项目依赖)
 PY=""
 if [ -n "$GUANLAN_PY" ]; then PY="$GUANLAN_PY"
@@ -74,4 +117,19 @@ else say "== 未装 Ollama: 问答与本地审核不可用, 其余页面正常 (
 
 say "== 自检"; "$VPY" guanlan.py check || true
 say ""
-say "安装完成. 启动: $VPY guanlan.py serve    停止: $VPY guanlan.py stop    入口: http://127.0.0.1:28084/"
+# 服务化 (可选; 用户令 2026-09-17): systemd 单元 guanlan.service, 需 root
+if [ "$DO_SERVICE" = "1" ]; then
+  say "== 服务化 (systemd, 需 root)"
+  "$VPY" scripts/service_ctl.py install || say "   [!] 服务注册没成功 —— 不影响使用: 仍可用 guanlan.py serve 手工启动"
+else
+  say "== 服务化 (未指定 --service, 跳过)"
+  say "   装成开机自启: sudo $VPY scripts/service_ctl.py install   (先看计划加 --dry-run)"
+fi
+
+# 安装记录 (版本单一真源 src/version.py): 安装检查与 guanlan.py check 都读它
+MODE=manual; [ "$DO_SERVICE" = "1" ] && MODE=systemd
+"$VPY" -c "import sys,pathlib; sys.path.insert(0,str(pathlib.Path('.').resolve())); from src import version as V; print('   安装记录: ' + str(V.write_installed(pathlib.Path('.').resolve(), python=sys.executable, mode='$MODE')))"
+
+say ""
+say "安装完成. 服务: $VPY scripts/service_ctl.py status|start|stop"
+say "         手工: $VPY guanlan.py serve    停止: $VPY guanlan.py stop    入口: http://127.0.0.1:28084/"

+ 12 - 0
scripts/guanlan_ops.py

@@ -121,6 +121,18 @@ def job_running() -> tuple[bool, dict]:
         except Exception:
             alive = False
     if not alive:
+        # ★ 2026-09-17 修竞态 (实测: 清除产物明明 rc=0, 页面却显示"没写回退出码 —— 多半是被强杀"):
+        #   执行器在收尾时先写 rc、再打印、再归档日志; 而网关每 2 s 轮询一次——
+        #   如果轮询**在收尾前**读到 status=running/rc=None, 又**在收尾后**才查 pid, 就会拿着旧快照
+        #   覆写掉执行器刚写的 rc。这里在判定"失败"前**重读一次** job 文件: 谁新听谁的。
+        try:
+            fresh = _job()
+        except Exception:
+            fresh = None
+        if fresh and fresh.get('status') != 'running' and fresh.get('rc') is not None:
+            return False, fresh            # 执行器已收尾(带退出码) ⇒ 不动它
+        if fresh:
+            j = fresh
         j.update(status=j.get('status') if j.get('rc') is not None else 'done',
                  finished=j.get('finished') or time.strftime('%Y-%m-%d %H:%M:%S'),
                  note=(j.get('note') or '') + (' (进程已结束但没写回退出码 —— 多半是被强杀)' if j.get('rc') is None else ''))

+ 191 - 0
scripts/pages_audit.py

@@ -315,6 +315,147 @@ def render(reg, results) -> str:
     return '\n'.join(lines)
 
 
+def inventory() -> list[dict]:
+    """**所有网页**总清点(用户令 2026-09-17 第 2 问):把系统会呈现的每一页/每个子页列出来,
+    并判定"是不是产物(= 由 data/raw 重算出来的输出数据)"。
+
+    来源四路,各有依据(不靠猜):
+      ① 门户锚点   `release/portal_src/shell.html` 的 `<div class="pg" id=… data-title=…>`
+      ② 工作台标签  `src/windscada/ui/app.js` 的 `TABS` 常量(SPA tab,实时读产物)
+      ③ 组件产物页  `outputs/<场>/**/*.html|*.md` —— **这些本身就是产物**
+      ④ 交付/仿真/三维页 `release/如东/**/*.html`、仿真资料包 zip 内的页面、`release/viewer/*.html`、内嵌件
+    """
+    reg = load()
+    entries = _all_entries(reg)
+    reg_files = {str(e.get('file') or '') for e in entries}
+    reg_ids = {str(e.get('id') or e.get('name') or '') for e in entries}
+    rows: list[dict] = []
+
+    # ① 门户锚点
+    shell = P.RELEASE / 'portal_src' / 'shell.html'
+    if shell.is_file():
+        t = shell.read_text(encoding='utf-8', errors='replace')
+        for m in re.finditer(r'<div class="pg"[^>]*id="([^"]+)"[^>]*data-title="([^"]*)"', t):
+            rows.append(dict(source='门户锚点', name=f'#{m.group(1)}', title=m.group(2), kind='shell',
+                             product=False, registered=m.group(1) in reg_ids,
+                             evidence='外壳页(静态/iframe/链接);逐条判定见 §10 与 §12'))
+    # ② 工作台 SPA 标签
+    appjs = P.SRC / 'windscada' / 'ui' / 'app.js'
+    if appjs.is_file():
+        t = appjs.read_text(encoding='utf-8', errors='replace')
+        m = re.search(r"const TABS = \[([^\]]+)\]", t)
+        if m:
+            for tab in re.findall(r"'([a-z_]+)'", m.group(1)):
+                rows.append(dict(source='工作台标签', name=f'tab={tab}', title='', kind='live', product=False,
+                                 registered=_tab_registered(tab, entries),
+                                 evidence='SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照'))
+    # ③ 组件产物页(本身就是产物)
+    out = P.out_root()
+    ledger = _ledger_keys()
+    REPORT_HINT = ('报告', '收敛', '诊断', '评估', '结论', 'report', 'summary', 'findings')
+    if out.is_dir():
+        for f in sorted(list(out.rglob('*.html')) + list(out.rglob('*.md'))):
+            rel = f.relative_to(out).as_posix()
+            if rel.startswith('_'):
+                continue
+            full = f'{out.name}/{rel}'
+            if f.suffix == '.html':
+                src, prod, why = '组件页面件 (.html)', True, '位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件'
+            elif any(k in f.name.lower() for k in REPORT_HINT):
+                src, prod, why = '组件结论件 (.md,页面会渲染)', True, '产物(结论文本);工作台 report tab / CMS report.html 会渲染它'
+            else:
+                src, prod, why = '产物里的过程留痕 (.md,非网页)', True, '产物,但没有任何页面呈现它(给下游线的交接/复跑记录)'
+            rows.append(dict(source=src, name=rel, title='', kind='product-page', product=prod,
+                             registered=(rel in ledger), evidence=why + f';台账: {"在" if rel in ledger else "缺"}'))
+    # ④ 交付/仿真/三维/内嵌
+    # 已登记判定: 精确名 或 **通配模式**(登记表里常按 wildcard 收一族文件, 如 tpl-report-*.html.html)
+    _ = None
+    for pat, src, kind, prod, why in (
+            ('如东/**/*.html', '交付页 (release/如东)', 'frozen-delivery', False, '客户交付件,按版本冻结'),
+            ('viewer/*.html', '三维页 (release/viewer)', 'static', False, '图纸/模型派生的三维资产'),
+            ('portal_src/templates/*.html', '门户内嵌件', 'embed', None, '内嵌交付件(§10 逐条判定;个别为数据派生)')):
+        for f in sorted(P.RELEASE.glob(pat)):
+            if f.name.startswith('.'):
+                continue
+            rel = f.relative_to(P.RELEASE).as_posix()
+            rows.append(dict(source=src, name=rel, title='', kind=kind, product=prod,
+                             registered=_is_registered(rel, f'outputs/{rel}', entries), evidence=why))
+    # 仿真资料包里的页面
+    z = P.RELEASE / '如东SWT40_控制律仿真台_20260906.zip'
+    if z.is_file():
+        import zipfile
+        with zipfile.ZipFile(z) as zf:
+            for n in zf.namelist():
+                if not n.endswith('.html'):
+                    continue
+                try:
+                    nm = n.encode('cp437').decode('utf-8')
+                except Exception:
+                    nm = n
+                rows.append(dict(source='仿真资料包 (zip)', name=nm.split('/')[-1], title='', kind='frozen-delivery',
+                                 product=False, registered=True, evidence='图纸派生的冻结资料包(sim_sys_server 从 zip 现读)'))
+    return rows
+
+
+def _declared_patterns(entries: list[dict]) -> list[str]:
+    """登记表里所有 `file:` 取值(含通配)—— 判定"这一页/这一件有没有被登记"用。"""
+    return [str(e.get('file') or '') for e in entries if e.get('file')]
+
+
+def _is_registered(rel: str, full: str, entries: list[dict]) -> bool:
+    """精确 或 通配 命中页面登记表 → 已登记(通配用全路径与 basename 两种都比一次)。"""
+    import fnmatch
+    names = {rel, full, pathlib.Path(rel).name}
+    pats = _declared_patterns(entries)
+    pats += [pathlib.Path(x).name for x in pats]
+    for n in names:
+        if n in pats:
+            return True
+        if any(fnmatch.fnmatch(n, x) for x in pats):
+            return True
+    return False
+
+
+def _tab_registered(tab: str, entries: list[dict]) -> bool:
+    for e in entries:
+        if e.get('id') == 'detail_tabs' and tab in (e.get('tabs') or []):
+            return True
+    return False
+
+
+def _ledger_keys() -> set:
+    """产物台账里的键(相对 outputs/<场>/),判定"产物有没有被管起来"用。"""
+    prov_p = P.out_root() / '_provenance.json'
+    if not prov_p.is_file():
+        return set()
+    try:
+        return set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
+    except Exception:
+        return set()
+
+
+def _all_entries(reg) -> list[dict]:
+    out = []
+
+    def walk(e):
+        out.append(e)
+        for c in e.get('children') or []:
+            walk(c)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return out
+
+
+def render_inventory(rows: list[dict]) -> str:
+    head = ['| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |', '|---|---|---|---|---|']
+    body = []
+    for r in rows:
+        p = '**是**' if r['product'] else ('—(逐条判定)' if r['product'] is None else '不是')
+        body.append(f"| {r['source']} | `{r['name']}`{' ' + r['title'] if r.get('title') else ''} | {p} | "
+                    f"{r['evidence']} | {'✔' if r['registered'] else '**✘ 未登记**'} |")
+    return '\n'.join(head + body)
+
+
 def audit(registry=None):
     """→ (rc, results, unregistered)。
 
@@ -373,6 +514,8 @@ def main() -> int:
     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('--inventory', action='store_true',
+                    help='**所有网页**总清点: 门户锚点/工作台标签/组件产物页/交付页/三维页/内嵌件/仿真页, 逐条判定是否产物')
     ap.add_argument('--json', action='store_true', help='机器可读输出')
     ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
     a = ap.parse_args()
@@ -385,6 +528,28 @@ def main() -> int:
                               unregistered=gaps), ensure_ascii=False, indent=1))
         return rc
 
+    if a.inventory:
+        rows = inventory()
+        prod = [r for r in rows if r['product'] is True]
+        un = [r for r in rows if not r['registered']]
+        print(f'== 所有网页总清点: {len(rows)} 项 ==')
+        print(f'   判定为产物(= 输入数据重算出来的输出数据): {len(prod)} 项; 未被管起来: {len(un)} 项')
+        cur = None
+        for r in rows:
+            if r['source'] != cur:
+                cur = r['source']
+                print(f'\n  ▸ {cur}')
+            flag = '产物' if r['product'] is True else ('逐条判定' if r['product'] is None else '非产物')
+            print(f'     [{"✔" if r["registered"] else "✘"}] {r["name"][:74]:74s} {flag}')
+        if un:
+            print('\n   未被管起来的条目:')
+            for r in un:
+                print(f'     ✘ {r["source"]} | {r["name"]}')
+        print('\n   判定依据: 产物板(组件页面件/结论件/过程留痕) = 位置在 outputs/<场>/ 下; 页面侧 = 登记表 kind 与 §10/§12 的逐条依据')
+        if a.write_doc:
+            write_doc(load())
+        return RC_OK
+
     if a.list or not a.check:
         print('== 门户页面归口 (configs/portal_pages.yaml) ==')
         for e in reg.get('pages') or []:
@@ -423,6 +588,31 @@ def main() -> int:
     return rc
 
 
+INV_BEGIN, INV_END = '<!-- PAGES-INV:BEGIN -->', '<!-- PAGES-INV:END -->'
+
+
+def write_inventory_doc() -> None:
+    """把"所有网页总清点"写进 §12.2(自动块, 放在 §13 之前)。"""
+    rows = inventory()
+    prod = sum(1 for r in rows if r['product'] is True)
+    un = [r for r in rows if not r['registered']]
+    head = (f'### 12.2 全部网页总清点(自动生成,勿手改)\n\n'
+            f'共 **{len(rows)}** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / '
+            f'交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 {prod} 项;'
+            f'未被管起来的 **{len(un)}** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。\n')
+    block = f'{INV_BEGIN}\n{head}\n{render_inventory(rows)}\n\n{INV_END}'
+    if not DOC.is_file():
+        return
+    t = DOC.read_text(encoding='utf-8')
+    if INV_BEGIN in t and INV_END in t:
+        t = re.sub(re.escape(INV_BEGIN) + r'.*?' + re.escape(INV_END), lambda m: block, t, flags=re.S)
+    else:
+        anchor = '\n---\n\n## 13.'
+        t = t.replace(anchor, f'\n\n{block}\n{anchor}', 1) if anchor in t else t.rstrip() + '\n\n' + block + '\n'
+    DOC.write_text(t, encoding='utf-8')
+    print('   已写入 §12.2 全部网页总清点 (自动块)')
+
+
 def write_doc(reg) -> None:
     if not DOC.is_file():
         print(f'   (缺 {P.rel(DOC)}, 跳过写文档)')
@@ -437,6 +627,7 @@ def write_doc(reg) -> None:
         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 页面归口)')
+    write_inventory_doc()
 
 
 if __name__ == '__main__':

+ 280 - 0
scripts/service_ctl.py

@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""服务注册/卸载/起停/状态 (2026-09-17 用户令 1: 观澜安装为服务)。
+
+     Windows : Windows 服务 (SCM)。本器用 `sc.exe` 注册, 服务体是 `scripts\win_service.py`
+               (ctypes 直连 SCM, 不依赖 pywin32 —— 保持"单包离线零新依赖")。
+     Linux   : systemd 单元 `guanlan.service`, 服务体是 `scripts/service_main.py` (前台守护)。
+
+## 为什么要有 --dry-run 与前置检查
+
+注册服务需要**管理员/root**;没有权限时最该做的是"把将要做的事说清楚",而不是抛一句 Access is denied。
+所以本器的每个动作都先过一遍前置检查(权限 / .venv / 版本记录 / 端口),
+`--dry-run` 逐条打印将要执行的命令与实际会写的文件内容(systemd 单元全文),一条都不落。
+
+## 用法
+
+    python scripts/service_ctl.py status                # 装没装 / 在跑没在跑(不需要权限)
+    python scripts/service_ctl.py install --dry-run     # 只打印: 前置检查 + 将执行的命令 + 单元文件
+    python scripts/service_ctl.py install               # 真注册(Windows 需管理员, Linux 需 root)
+    python scripts/service_ctl.py start|stop|restart|uninstall
+    python scripts/service_ctl.py install --user-login  # Windows 备选: 不用管理员的"登录自启(Run 键)"
+
+退出码: 0 成功 · 2 前置条件不满足(如缺 .venv) · 3 权限不足 · 4 平台不支持 · 5 服务操作失败
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import pathlib
+import subprocess
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+from src import version as V                                        # noqa: E402
+
+PY = None
+UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
+WIN32 = os.name == 'nt'
+
+
+def py_exe() -> str:
+    global PY
+    if PY is None:
+        cand = P.venv_python()
+        PY = str(cand if cand else sys.executable)
+    return PY
+
+
+# ── 前置条件 ─────────────────────────────────────────────────────────────────────────
+def is_admin() -> bool:
+    if not WIN32:
+        return os.geteuid() == 0
+    try:
+        import ctypes
+        return bool(ctypes.windll.shell32.IsUserAnAdmin())
+    except Exception:
+        return False
+
+
+def preflight() -> list[str]:
+    """→ 问题清单(空 = 可以做)。每条都写成"人能照着解决"的一句话。"""
+    bad = []
+    if not (ROOT / 'guanlan.py').is_file():
+        bad.append(f'安装根不对:{ROOT} 下没有 guanlan.py')
+    if not pathlib.Path(py_exe()).is_file():
+        bad.append(f'没找到虚拟环境解释器 {py_exe()} —— 先跑 install.bat / sh install.sh')
+    if WIN32 and not (ROOT / 'scripts' / 'win_service.py').is_file():
+        bad.append('缺 scripts\\win_service.py(服务体)')
+    if not WIN32 and not (ROOT / 'scripts' / 'service_main.py').is_file():
+        bad.append('缺 scripts/service_main.py(服务体)')
+    return bad
+
+
+def unit_text() -> str:
+    """systemd 单元全文(--dry-run 会原样打印;install 时写进 UNIT_PATH)。
+
+    ★ 路径统一用 POSIX 写法并用引号包住(安装目录可能带空格:`/opt/guanlan app`),
+      否则 systemd 会把 `ExecStart=/opt/gu anlan/.venv/bin/python` 拆成两个参数。
+    """
+    root = pathlib.Path(ROOT).as_posix()
+    py = pathlib.Path(py_exe()).as_posix()
+    return f'''[Unit]
+Description={V.SERVICE_DISPLAY}
+Documentation=file://{root}/docs/系统设计说明.md
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory="{root}"
+ExecStart="{py}" "{root}/scripts/service_main.py"
+ExecStop="{py}" "{root}/guanlan.py" stop
+Restart=always
+RestartSec=10
+TimeoutStopSec=120
+KillMode=mixed
+# 组件只绑 127.0.0.1;要对外请在 nginx 侧做,不要改组件绑定
+Environment=PYTHONUTF8=1
+Environment=PYTHONUNBUFFERED=1
+
+[Install]
+WantedBy=multi-user.target
+'''
+
+
+def win_commands(action: str) -> list[list[str]]:
+    """Windows 侧要执行的 sc.exe 命令(--dry-run 原样打印,install/start/... 真跑)。"""
+    binpath = f'"{py_exe()}" "{ROOT / "scripts" / "win_service.py"}"'
+    n = V.SERVICE_NAME
+    if action == 'install':
+        return [
+            ['sc.exe', 'create', n, 'binPath=', binpath, 'start=', 'auto', 'DisplayName=', V.SERVICE_DISPLAY],
+            ['sc.exe', 'description', n, f'{V.NAME} v{V.VERSION} 本机离线版(观澜风电资产智能)'],
+            # 服务崩溃/被结束时的恢复策略: 60 s 后重启, 连续失败继续重启, 一天后重置计数
+            ['sc.exe', 'failure', n, 'reset=', '86400', 'actions=', 'restart/60000/restart/60000/restart/60000'],
+            ['sc.exe', 'start', n],
+        ]
+    if action == 'uninstall':
+        return [['sc.exe', 'stop', n], ['sc.exe', 'delete', n]]
+    return [['sc.exe', action, n]]
+
+
+def linux_commands(action: str) -> list[list[str]]:
+    n = V.SERVICE_NAME
+    if action == 'install':
+        return [['systemctl', 'daemon-reload'], ['systemctl', 'enable', '--now', n],
+                ['systemctl', 'status', n, '--no-pager']]
+    if action == 'uninstall':
+        return [['systemctl', 'disable', '--now', n], ['rm', '-f', str(UNIT_PATH)], ['systemctl', 'daemon-reload']]
+    return [['systemctl', action, n]]
+
+
+def run_cmds(cmds: list[list[str]], dry: bool) -> int:
+    rc = 0
+    for c in cmds:
+        printable = ' '.join(f'"{x}"' if ' ' in x else x for x in c)
+        print(f'   $ {printable}')
+        if dry:
+            continue
+        try:
+            r = subprocess.run(c, capture_output=True, text=True, errors='replace')
+        except FileNotFoundError:
+            print(f'   [X] 找不到命令 {c[0]}')
+            rc = 5
+            continue
+        for ln in (r.stdout or '').strip().splitlines():
+            print('     ' + ln)
+        for ln in (r.stderr or '').strip().splitlines():
+            print('     [stderr] ' + ln)
+        rc = rc or (0 if r.returncode == 0 else 5)
+    return rc
+
+
+# ── 状态(不需要权限) ───────────────────────────────────────────────────────────────
+def win_status() -> dict:
+    r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
+    txt = (r.stdout or '') + (r.stderr or '')
+    installed = r.returncode == 0
+    running = installed and ('RUNNING' in txt.upper())
+    return dict(platform='windows', installed=installed, running=running, detail=txt.strip().splitlines()[:6])
+
+
+def linux_status() -> dict:
+    if not UNIT_PATH.is_file():
+        return dict(platform='linux', installed=False, running=False, detail=[f'没有 {UNIT_PATH}'])
+    r = subprocess.run(['systemctl', 'is-active', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
+    return dict(platform='linux', installed=True, running=(r.stdout or '').strip() == 'active',
+                detail=[f'{UNIT_PATH} 存在', f'is-active: {(r.stdout or "").strip() or (r.stderr or "").strip()[:60]}'])
+
+
+def user_login_entry(action: str, dry: bool) -> int:
+    """Windows 备选(**不需要管理员**):把启动器写进当前用户的开机自启(HKCU\\...\\Run)。
+
+    真服务要管理员权限;很多现场账号没有。这条给一个"登录即起"的等价退路,
+    并在文档里写明它与真服务的区别(服务=开机即起、无需登录、可被 SCM 恢复;本项=用户登录后起)。
+    """
+    import winreg  # noqa: PLC0415
+    key_path = r'Software\Microsoft\Windows\CurrentVersion\Run'
+    cmd = f'"{py_exe()}" "{ROOT / "scripts" / "guanlan_start_hidden.py"}"'
+    if dry:
+        print(f'   $ reg add HKCU\\{key_path} /v {V.SERVICE_NAME} /d "{cmd}" /f    (= {action})')
+        return 0
+    with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as k:
+        if action == 'install':
+            winreg.SetValueEx(k, V.SERVICE_NAME, 0, winreg.REG_SZ, cmd)
+            print(f'   已写入 HKCU\\{key_path}\\{V.SERVICE_NAME}')
+        else:
+            try:
+                winreg.DeleteValue(k, V.SERVICE_NAME)
+                print('   已移除登录自启项')
+            except FileNotFoundError:
+                print('   (本来就没有这项)')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('action', choices=('status', 'install', 'uninstall', 'start', 'stop', 'restart', 'selftest'))
+    ap.add_argument('--dry-run', action='store_true', help='只打印要做的事与将执行的命令, 不落任何改动')
+    ap.add_argument('--user-login', action='store_true',
+                    help='Windows 备选: 不注册服务, 改成"当前用户登录时自启"(不需要管理员)')
+    a = ap.parse_args()
+    print(f'== {V.NAME} v{V.VERSION} 服务化 · {a.action} ({"" if WIN32 else "linux/macos"}) ==')
+    print(f'   安装根: {ROOT}')
+    print(f'   解释器: {py_exe()}')
+    print(f'   管理员/root: {is_admin()}')
+
+    if a.action == 'selftest':
+        import service_worker as SW
+        return SW.selftest()
+
+    st = win_status() if WIN32 else linux_status()
+    print(f'   当前状态: 已注册={st["installed"]} 运行中={st["running"]}')
+    for ln in st['detail']:
+        print(f'     {ln}')
+
+    if a.action == 'status':
+        return 0
+
+    if a.action == 'install' and a.dry_run:
+        bad = preflight()
+        if bad:
+            print('   前置条件不满足:')
+            for b in bad:
+                print(f'     - {b}')
+            return 2
+        print('   前置检查: 通过')
+    elif a.action == 'install':
+        bad = preflight()
+        if bad:
+            print('   [X] 前置条件不满足:')
+            for b in bad:
+                print(f'     - {b}')
+            return 2
+
+    if a.action in ('install', 'uninstall', 'start', 'stop', 'restart') and not is_admin() and not a.dry_run \
+            and not (WIN32 and a.action == 'install' and a.user_login):
+        print('   [X] 权限不足: 注册/卸载/起停服务需要管理员权限。')
+        print('       ① 用管理员身份重开终端再执行本命令; 或')
+        print('       ② 先看它会做什么: python scripts/service_ctl.py install --dry-run')
+        if WIN32:
+            print('       ③ 不想提权: python scripts/service_ctl.py install --user-login'
+                  '(当前用户登录时自启, 不是真服务)')
+        return 3
+
+    if WIN32 and a.action == 'install' and a.user_login:
+        return user_login_entry('install', a.dry_run)
+    if WIN32 and a.action == 'uninstall' and a.user_login:
+        return user_login_entry('uninstall', a.dry_run)
+
+    if not WIN32 and a.action == 'install':
+        print(f'   将写入单元文件 {UNIT_PATH}:')
+        print('   ' + '-' * 66)
+        for ln in unit_text().rstrip().splitlines():
+            print('   | ' + ln)
+        print('   ' + '-' * 66)
+        if a.dry_run:
+            run_cmds(linux_commands(a.action), True)
+            print('   (dry-run: 以上命令未执行)')
+            return 0
+        UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
+        UNIT_PATH.write_text(unit_text(), encoding='utf-8')
+        print(f'   已写 {UNIT_PATH}')
+
+    cmds = win_commands(a.action) if WIN32 else linux_commands(a.action)
+    rc = run_cmds(cmds, a.dry_run)
+    if a.dry_run:
+        print('   (dry-run: 以上命令未执行;去掉 --dry-run 才真做)')
+    # 装完写安装记录里的运行方式 (install-info.json 的 mode)
+    if a.action == 'install' and not a.dry_run and rc == 0:
+        mode = 'windows-service' if WIN32 else 'systemd'
+        V.write_installed(ROOT, python=py_exe(), mode=mode)
+        print(f'   已在 {V.info_path(ROOT)} 记录运行方式 = {mode}')
+    return rc
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 56 - 0
scripts/service_main.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""Linux/macOS 服务主体 —— systemd (`Type=simple`) 的前台守护进程 (2026-09-17 用户令 1)。
+
+为什么是"前台常驻"而不是让 systemd 直接跑 `guanlan.py serve`:
+`guanlan.py serve` 起完 6 个组件就**返回**(组件是 detached 的),systemd 会以为"主进程退出",
+于是 `Restart=always` 会不停重启、也管不了组件死活。所以这里做一个**常驻守护**:
+
+    ExecStart=<PY> scripts/service_main.py      # 起组件 → 每 15 s 巡检(缺谁补谁)→ 收 SIGTERM 时全停
+    Type=simple / Restart=always / KillMode=process
+
+用法:
+    python scripts/service_main.py               # 前台守护(systemd 用这个)
+    python scripts/service_main.py --once         # 只跑一轮巡检就退出(无 root 也能自证)
+    python scripts/service_main.py --selftest     # 前置条件自检
+
+注册/卸载走 `scripts/service_ctl.py`(需 root),或用 install.sh --service。
+"""
+from __future__ import annotations
+
+import pathlib
+import signal
+import sys
+import threading
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+import service_worker as SW                                         # noqa: E402
+
+
+def main() -> int:
+    if '--selftest' in sys.argv:
+        return SW.selftest()
+    stop_evt = threading.Event()
+
+    def _on_term(signum, frame):
+        SW.log(f'收到信号 {signum} → 停服')
+        stop_evt.set()
+
+    signal.signal(signal.SIGTERM, _on_term)
+    signal.signal(signal.SIGINT, _on_term)
+
+    SW.log('服务模式启动(systemd Type=simple)')
+    SW.start()
+    try:
+        SW.supervise(stop_evt, once=('--once' in sys.argv))
+    finally:
+        if '--once' not in sys.argv:
+            SW.stop()
+    SW.log('服务已退出')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 128 - 0
scripts/service_worker.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""服务化共用工作体 (2026-09-17 用户令 1: 观澜安装为服务)。
+
+Windows 服务 (`scripts/win_service.py`) 与 Linux systemd (`scripts/service_main.py`) 都调这里,
+**同一套起停/守护逻辑,只有外壳不同** —— 免得两平台各写一份、行为慢慢分叉。
+
+## 它在做什么
+
+    起   : `guanlan.py serve`(幂等:已在跑的复用,缺的补起,等 healthz)
+    守护 : 每 `INTERVAL` 秒跑一次同样的 `guanlan.py serve` —— 哪个组件掉了就被拉起来;
+           同时看网关 `/healthz`,连续失败则记 ERROR(服务管理器据此判活)
+    停   : `guanlan.py stop`(连网关一起停;服务模式下整机停服就该全停)
+
+为什么用**子进程 + 现成 CLI** 而不是把服务代码直接写进各组件:
+服务管理器的语义是"一个前台进程代表整套系统",而观澜本来就是 6 个独立进程;
+复用 `guanlan.py` 的两条命令,起停口径与手工运维**完全一致**,不会出现"服务起的和手工起的不一样"。
+"""
+from __future__ import annotations
+
+import datetime as dt
+import pathlib
+import socket
+import subprocess
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+from src import version as V                                        # noqa: E402
+
+INTERVAL = 15          # 守护巡检间隔(秒)
+LOG = None             # 由外壳设置为 logs/service.log
+
+
+def log(msg: str, level: str = 'INFO') -> None:
+    """服务日志 —— 与 §11.2 同一行格式,落 `logs/<组件>.log` 的 service 一份。"""
+    from src import logfile as _lf
+    line = _lf.line(level, 'service', msg)
+    print(line, flush=True)
+    try:
+        with open(_lf.component_log('service'), 'a', encoding='utf-8', newline='\n') as f:
+            f.write(line + '\n')
+    except Exception:
+        pass
+
+
+def _py() -> str:
+    return str(P.venv_python() or sys.executable)
+
+
+def run(cmd: list, timeout: int = 300) -> int:
+    """跑一条 guanlan.py 子命令(捕获输出写进服务日志)。"""
+    p = subprocess.run([_py(), 'guanlan.py'] + cmd, cwd=str(ROOT), capture_output=True, text=True,
+                       errors='replace', timeout=timeout)
+    for ln in (p.stdout or '').splitlines():
+        log('  ' + ln)
+    for ln in (p.stderr or '').splitlines():
+        log('  ' + ln, 'WARN')
+    return p.returncode
+
+
+def gateway_up(host: str = '127.0.0.1') -> bool:
+    try:
+        with socket.create_connection((host, 28084), timeout=1.5):
+            return True
+    except OSError:
+        return False
+
+
+def start() -> None:
+    log(f'启动 {V.NAME} v{V.VERSION}({V.EDITION})')
+    rc = run(['serve'])
+    log(f'起组件完成 (rc={rc}; 1 = 有模块未就绪, 属正常)')
+
+
+def ensure() -> bool:
+    """一次巡检:把缺的组件补起来。→ 网关是否在听。"""
+    run(['serve'])
+    return gateway_up()
+
+
+def stop() -> None:
+    log('停止全部组件(含网关)')
+    rc = run(['stop'])
+    log(f'停止完成 (rc={rc})')
+
+
+def supervise(stop_evt, once: bool = False) -> None:
+    """守护循环:直到 stop_evt 被置位(或 once=True 只跑一轮)。"""
+    fails = 0
+    while not stop_evt.is_set():
+        try:
+            ok = ensure()
+            if not ok:
+                fails += 1
+                log(f'网关未就绪(连续 {fails} 次)', 'ERROR' if fails >= 3 else 'WARN')
+            else:
+                if fails:
+                    log('网关已恢复')
+                fails = 0
+        except Exception as e:
+            log(f'巡检异常: {type(e).__name__}: {e}', 'ERROR')
+        if once:
+            return
+        stop_evt.wait(INTERVAL)
+
+
+def selftest() -> int:
+    """不需要任何权限的自证:依赖、端口、命令行是否齐备(服务注册前先看这个)。"""
+    ok = True
+    log(f'{V.NAME} v{V.VERSION} 服务化自检 @ {ROOT}')
+    py = _py()
+    log(f'  解释器: {py}  (存在: {pathlib.Path(py).is_file()})')
+    ok &= pathlib.Path(py).is_file()
+    log(f'  guanlan.py: {(ROOT / "guanlan.py").is_file()}')
+    ok &= (ROOT / 'guanlan.py').is_file()
+    log(f'  网关端口 28084 当前: ' + ('在听' if gateway_up() else '未监听'))
+    log(f'  版本记录: {V.info_path(ROOT)}' + ('(已存在)' if V.info_path(ROOT).is_file() else '(无 —— 全新安装)'))
+    # 组件端口占用检查(服务接管前这些端口应为空闲或已由本系统占用)
+    for name, port in (('detail', 18033), ('cms', 18020), ('sim', 18791), ('sim_sys', 18792), ('viewer', 64292)):
+        with socket.socket() as s:
+            s.settimeout(0.3)
+            busy = s.connect_ex(('127.0.0.1', port)) == 0
+        log(f'  端口 {port} ({name}): ' + ('在听(多半是本系统已起)' if busy else '空闲'))
+    log('自检结论: ' + ('服务注册前置条件齐备' if ok else '有缺项,见上'), 'INFO' if ok else 'ERROR')
+    return 0 if ok else 1

+ 169 - 0
scripts/win_service.py

@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""Windows 服务外壳 (2026-09-17 用户令 1: "windows 下为 windows 服务")。
+
+## 为什么用 ctypes 直接调 SCM,而不是 pywin32
+
+真 Windows 服务**必须**实现 SCM 的控制协议(`StartServiceCtrlDispatcher` + 控制处理回调),
+`sc create` 一个 `python.exe xxx.py` 是**不行的**(cmd/python 不跟 SCM 握手 ⇒ 起服务报 1053)。
+实现它有两条路:
+
+  · `pywin32`(`win32serviceutil`)—— 要往包里加一个新依赖(本包离线轮子的原则是"能不加就不加");
+  · **ctypes 直连 advapi32**(本文件)—— 零依赖、只用标准库,与"单包离线"的口径一致。
+
+## 用法
+
+    python scripts\win_service.py                 # 由服务控制管理器拉起(正常路径,别手工跑)
+    python scripts\win_service.py --debug         # 前台调试:控制台里跑守护循环,Ctrl+C 退出
+    python scripts\win_service.py --debug --once  # 只跑一轮巡检就退出(无权限也能自证)
+    python scripts\win_service.py --selftest      # 只做前置条件自检
+
+    # 注册/卸载走 service_ctl.py(需要管理员):
+    python scripts\service_ctl.py install --dry-run
+    python scripts\service_ctl.py install
+
+## 与 SCM 的约定
+
+    SERVICE_WIN32_OWN_PROCESS · 接受 STOP/SHUTDOWN/INTERROGATE
+    启动过程中报 START_PENDING(带 wait hint),就绪后报 RUNNING,收到 STOP 后停服务再报 STOPPED
+    服务崩溃的自动重启交给 SCM 的恢复策略(`sc failure ... actions= restart/60000/...`,见 service_ctl.py)
+"""
+from __future__ import annotations
+
+import ctypes
+import pathlib
+import sys
+import threading
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import version as V                                        # noqa: E402
+import service_worker as SW                                         # noqa: E402  (同目录)
+
+SERVICE_WIN32_OWN_PROCESS = 0x00000010
+SERVICE_ACCEPT_STOP = 0x00000001
+SERVICE_ACCEPT_SHUTDOWN = 0x00000004
+SERVICE_CONTROL_STOP = 0x00000001
+SERVICE_CONTROL_INTERROGATE = 0x00000004
+SERVICE_CONTROL_SHUTDOWN = 0x00000005
+SERVICE_STOPPED, SERVICE_START_PENDING, SERVICE_STOP_PENDING = 0x00000001, 0x00000002, 0x00000003
+SERVICE_RUNNING, SERVICE_CONTINUE_PENDING = 0x00000004, 0x00000005
+NO_ERROR = 0
+ERROR_FAILED_SERVICE_CONTROLLER_CONNECT = 1063
+
+advapi32 = ctypes.windll.advapi32 if hasattr(ctypes, 'windll') else None
+
+
+class SERVICE_STATUS(ctypes.Structure):
+    _fields_ = [('dwServiceType', ctypes.c_ulong), ('dwCurrentState', ctypes.c_ulong),
+                ('dwControlsAccepted', ctypes.c_ulong), ('dwWin32ExitCode', ctypes.c_ulong),
+                ('dwServiceSpecificExitCode', ctypes.c_ulong), ('dwCheckPoint', ctypes.c_ulong),
+                ('dwWaitHint', ctypes.c_ulong)]
+
+
+class SERVICE_TABLE_ENTRY(ctypes.Structure):
+    _fields_ = [('lpServiceName', ctypes.c_wchar_p), ('lpServiceProc', ctypes.c_void_p)]
+
+
+HANDLER = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_void_p, ctypes.c_void_p)
+MAIN = ctypes.WINFUNCTYPE(None, ctypes.c_ulong, ctypes.POINTER(ctypes.c_wchar_p))
+
+_status_handle = None
+_stop_evt = threading.Event()
+
+
+def _report(state: int, hint: int = 0, cp: int = 0) -> None:
+    if not _status_handle:
+        return
+    st = SERVICE_STATUS(SERVICE_WIN32_OWN_PROCESS, state,
+                        SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN, NO_ERROR, 0, cp, hint)
+    advapi32.SetServiceStatus(_status_handle, ctypes.byref(st))
+
+
+def _handler(control, event_type, event_data, context):
+    """SCM 控制回调:只处理 STOP/SHUTDOWN/INTERROGATE,其余直接确认。"""
+    if control in (SERVICE_CONTROL_STOP, SERVICE_CONTROL_SHUTDOWN):
+        _report(SERVICE_STOP_PENDING, 30000, 1)
+        _stop_evt.set()
+    elif control == SERVICE_CONTROL_INTERROGATE:
+        _report(SERVICE_RUNNING)
+    return NO_ERROR
+
+
+@MAIN
+def _service_main(argc, argv) -> None:
+    global _status_handle
+    _status_handle = advapi32.RegisterServiceCtrlHandlerExW(V.SERVICE_NAME, _HANDLER_REF, None)
+    if not _status_handle:
+        return
+    _report(SERVICE_START_PENDING, 30000, 1)
+    SW.log(f'服务已由 SCM 拉起({V.SERVICE_DISPLAY})')
+    t = threading.Thread(target=_body, name='guanlan-supervisor', daemon=True)
+    t.start()
+    _report(SERVICE_RUNNING)
+    _stop_evt.wait()
+    SW.stop()
+    _report(SERVICE_STOPPED)
+
+
+_HANDLER_REF = HANDLER(_handler)
+
+
+def _body() -> None:
+    """服务主体:起组件 → 守护。异常必须自己吞掉并记 ERROR,否则线程静默死掉。"""
+    try:
+        SW.start()
+        SW.supervise(_stop_evt)
+    except Exception as e:
+        SW.log(f'服务主体异常: {type(e).__name__}: {e}', 'ERROR')
+
+
+def run_debug(once: bool = False) -> int:
+    """前台调试(不需要 SCM):--once 只跑一轮,用于无权限环境自证。"""
+    if once:
+        SW.log('调试模式:跑一轮巡检')
+        ok = SW.ensure()
+        SW.log(f'一轮巡检结果: 网关{"在听" if ok else "未就绪"}')
+        return 0
+    SW.log('调试模式:Ctrl+C 退出')
+    try:
+        SW.start()
+        SW.supervise(_stop_evt)
+    except KeyboardInterrupt:
+        pass
+    finally:
+        SW.stop()
+    return 0
+
+
+def main() -> int:
+    if '--selftest' in sys.argv:
+        return SW.selftest()
+    if '--debug' in sys.argv:
+        return run_debug(once='--once' in sys.argv)
+    if advapi32 is None:
+        print('[X] 本文件只在 Windows 上有效(Linux 用 scripts/service_main.py + systemd)')
+        return 2
+    table = (SERVICE_TABLE_ENTRY * 2)()
+    table[0].lpServiceName = V.SERVICE_NAME
+    table[0].lpServiceProc = ctypes.cast(_service_main, ctypes.c_void_p)
+    table[1].lpServiceName = None
+    table[1].lpServiceProc = None
+    ok = advapi32.StartServiceCtrlDispatcherW(ctypes.byref(table))
+    if not ok:
+        err = ctypes.windll.kernel32.GetLastError()
+        if err in (ERROR_FAILED_SERVICE_CONTROLLER_CONNECT, 0):
+            # 这条**正是无权限自证**:能走到这里说明 SCM 接口调用正确,只是本进程不是被 SCM 拉起的。
+            print(f'[{V.SERVICE_NAME}] 不是由服务控制管理器启动的(WinError {ERROR_FAILED_SERVICE_CONTROLLER_CONNECT})。')
+            print('  想注册成服务(需管理员): python scripts/service_ctl.py install')
+            print('  想前台调试:              python scripts/win_service.py --debug [--once]')
+            print('  只看前置条件:            python scripts/win_service.py --selftest')
+        else:
+            print(f'[X] StartServiceCtrlDispatcher 失败, WinError {err}')
+        return 1
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 111 - 0
src/version.py

@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+"""版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
+
+为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
+改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
+
+    src/version.py            ← 唯一真源(本文件)
+    scripts/pack_dist.py      ← 读它写进 dist-manifest.json 与默认包名
+    install.ps1 / install.sh  ← 读它写进 <安装目录>/install-info.json,并与已装的比对
+    guanlan.py / 各文档        ← 读它(文档里的人工版本号以它为准)
+
+`install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
+卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
+"""
+from __future__ import annotations
+
+import datetime as dt
+import json
+import pathlib
+
+NAME = '观澜·如东样板 v2'
+VERSION = '0.4.0'          # ★ 改版本只改这里
+EDITION = 'offline-single-package'
+
+INSTALL_INFO = 'install-info.json'      # 相对安装根
+SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
+SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
+
+
+def info_path(root: pathlib.Path) -> pathlib.Path:
+    return pathlib.Path(root) / INSTALL_INFO
+
+
+def read_installed(root: pathlib.Path) -> dict | None:
+    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。"""
+    p = info_path(root)
+    if not p.is_file():
+        return None
+    try:
+        d = json.loads(p.read_text(encoding='utf-8'))
+        return d if isinstance(d, dict) else None
+    except Exception:
+        return None
+
+
+def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
+    """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
+    root = pathlib.Path(root)
+    rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+               python=python or '', mode=mode, host=str(root))
+    rec.update(extra or {})
+    info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
+    return rec
+
+
+def _parts(v: str) -> tuple:
+    out = []
+    for seg in str(v).strip().lstrip('vV').split('.'):
+        num = ''.join(ch for ch in seg if ch.isdigit())
+        out.append(int(num) if num else 0)
+    return tuple(out)
+
+
+def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
+    """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
+
+    → dict(state, installed_version, pkg_version, message, action)
+       state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
+              newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
+       action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
+    """
+    cur = (installed or {}).get('version') or ''
+    if not installed:
+        return dict(state='none', installed_version='', pkg_version=pkg_version,
+                    message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
+    if not cur:
+        return dict(state='unknown', installed_version='', pkg_version=pkg_version,
+                    message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
+                            f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
+    if _parts(cur) == _parts(pkg_version):
+        return dict(state='same', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
+    if _parts(cur) < _parts(pkg_version):
+        return dict(state='older', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
+    return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
+                message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
+                        f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
+
+
+def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
+    """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
+    out = [cmp_res['message']]
+    if installed:
+        out.append(f"  现有安装: 装在 {installed.get('installed_at', '?')}"
+                   + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
+                   + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
+    return out
+
+
+if __name__ == '__main__':
+    import sys
+    print(f'{NAME} v{VERSION} ({EDITION})')
+    print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
+    root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1]
+    ins = read_installed(root)
+    c = compare(ins)
+    print(f'安装记录: {info_path(root)}')
+    for ln in summary_lines(c, ins):
+        print('  ' + ln)
+    print(f"  → state={c['state']} action={c['action']}")