Bladeren bron

按用户令去掉 VBScript: 无窗口启动改为 pythonw.exe + Python 启动器 + 安装时建快捷方式; 并修两处入口脚本编码事故 (install.ps1 掉 BOM / install.sh 是 CRLF), 编码守则 + .gitattributes 机器强制 (只钉 .bat/.ps1/.sh, 不制造 EOL 全仓改动)

zhouyang.xie 3 weken geleden
bovenliggende
commit
4866f71f27
15 gewijzigde bestanden met toevoegingen van 589 en 329 verwijderingen
  1. 28 0
      .gitattributes
  2. 5 1
      README_先读我.txt
  3. 13 13
      check.bat
  4. 30 14
      docs/系统设计说明.md
  5. 8 3
      guanlan.py
  6. 6 6
      install.bat
  7. 152 106
      install.ps1
  8. 77 77
      install.sh
  9. 3 1
      scripts/check_transferable.py
  10. 30 4
      scripts/guanlan_start_hidden.py
  11. 63 10
      scripts/pack_dist.py
  12. 73 8
      src/entry_refs.py
  13. 96 81
      start.bat
  14. BIN
      start_hidden.vbs
  15. 5 5
      stop.bat

+ 28 - 0
.gitattributes

@@ -0,0 +1,28 @@
+# 观澜 · 只钉"换行/编码本身就是功能"的那几个入口文件
+#
+# 为什么需要它(2026-09-16/17 一天内实炸两次):
+#   · `install.ps1` 掉 UTF-8 BOM → Windows PowerShell 5.1 按 ANSI/GBK 解码 → 中文乱码 + 级联 ParserError,
+#     目标机 `install` 1 秒即退出;
+#   · `install.sh` 从写出来起就是 CRLF(git blob 就是 CRLF)→ POSIX 语义下每个词尾粘 `\r`:
+#     `set -e` 变成非法选项、`RT=""` 变成 `RT="\r"` 把后面的 `-n "$RT"` 判断翻面
+#     → 此前所有交付包的 Linux/macOS 安装脚本都是坏的。
+#   机器侧的强制执行在 `src/entry_refs.py::encoding_problems()`(打包 / 开箱验证 / 装机自检各查一遍);
+#   这里把 checkout 也钉死,免得"某次 git checkout / clone 之后又变回去"。
+#
+# ★ 刻意**不**声明 *.py / *.md / *.json / *.yaml 的 eol:
+#   仓库里这些文件历来混着 CRLF(实测 .py 245 件里 42 件是 CRLF,且 2dad1a2 时就如此),
+#   Python/JSON 对两种换行都能读;为纯 EOL 去改 40+ 个文件、把历史 diff 冲掉,不划算。
+#   只有"解释器/解析器会因此报错"的文件才值得钉死。
+*.sh   text eol=lf
+*.bat  text eol=crlf
+*.ps1  text eol=crlf
+
+# 二进制/特殊编码:禁止任何换行转换
+*.parquet -text
+*.npz     -text
+*.zip     -text
+*.png     -text
+*.jpg     -text
+*.ico     -text
+*.docx    -text
+*.pdf     -text

+ 5 - 1
README_先读我.txt

@@ -17,9 +17,13 @@
   模型(可选, 只影响问答与本地审核页): ollama pull qwen3:8b / deepseek-r1:14b / bge-m3
 
 二、启动 / 停止
-  Windows : 双击 check.bat 全绿 → 双击 start.bat (自动开浏览器)    停止: 双击 stop.bat
+  Windows : 双击 check.bat 全绿 → 双击「启动观澜」快捷方式(安装时自动建: 根目录 + 桌面各一份;
+            目标 = .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 被策略禁用的服务器上同样可用)。
 
 三、重算与产物 (门户菜单「数据重算」, 或直接开 http://127.0.0.1:28084/ops)
   一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,

+ 13 - 13
check.bat

@@ -1,13 +1,13 @@
-@echo off
-chcp 65001 >nul
-set PYTHONUTF8=1
-cd /d "%~dp0"
-if not exist ".venv\Scripts\python.exe" (
-  echo [X] Not installed yet: .venv\Scripts\python.exe not found
-  echo     Run install.bat first. If install printed errors, send that screen back
-  echo     instead of continuing - the server cannot start without .venv.
-  pause
-  exit /b 2
-)
-".venv\Scripts\python.exe" guanlan.py check
-pause
+@echo off
+chcp 65001 >nul
+set PYTHONUTF8=1
+cd /d "%~dp0"
+if not exist ".venv\Scripts\python.exe" (
+  echo [X] Not installed yet: .venv\Scripts\python.exe not found
+  echo     Run install.bat first. If install printed errors, send that screen back
+  echo     instead of continuing - the server cannot start without .venv.
+  pause
+  exit /b 2
+)
+".venv\Scripts\python.exe" guanlan.py check
+pause

+ 30 - 14
docs/系统设计说明.md

@@ -160,18 +160,28 @@
 
 ### 4.2 用户怎么启动(不弹命令窗口)
 
-| 入口 | 行为 |
-|---|---|
-| `start_hidden.vbs`(推荐,可建桌面快捷方式) | `WScript.Shell.Run(..., 0, False)` 以 `pythonw.exe` 跑启动器 —— **完全不出现命令窗口** |
-| `start.bat`(双击) | 默认走上面的隐藏路径(自己立刻退出);`start.bat console` = 旧的前台模式(排障看实时日志) |
-| `scripts/guanlan_start_hidden.py` | 隐藏启动器本体: 端口已在 → 只开浏览器(幂等);否则无窗口起 `guanlan.py serve`、等 `/healthz`、开浏览器 |
-| 运维控制台/门户「数据重算」 | 动作进程同样无窗口;进度与日志尾巴在页面里看 |
+★ **2026-09-16 用户令"把 VBScript 替换掉"**:原先靠 `start_hidden.vbs`(`WScript.Shell.Run(..., 0, False)`)
+隐藏启动。VBScript/Windows 脚本宿主(WSH)已被微软列为弃用特性,且服务器上常被组策略整体禁用,
+所以现在**包里没有任何 `.vbs`**,无窗口启动由 `pythonw.exe`(无控制台子系统的解释器)+ Python 启动器承担:
+
+| 入口 | 行为 | 窗口 |
+|---|---|---|
+| `启动观澜.lnk`(**推荐**;`install.ps1` 安装时生成,根目录一份 + 桌面一份) | 快捷方式直接指向 `.venv\Scripts\pythonw.exe` + `scripts\guanlan_start_hidden.py`(工作目录=安装根,窗口样式 7) | **一个窗口都不出现**(不经过 cmd.exe,也不经过 WSH) |
+| `start.bat`(双击) | 默认 `start "" /min ".venv\Scripts\pythonw.exe" "scripts\guanlan_start_hidden.py"` | 会**闪一下**(.bat 是控制台程序,Windows 必定先开窗口,脚本内无法消除);`start.bat console` = 旧前台模式(排障看实时日志,关窗=停服务) |
+| `scripts/guanlan_start_hidden.py` | 启动器本体:端口已在 → 只开浏览器(幂等);否则无窗口起 `guanlan.py serve`、等 `/healthz`、开浏览器 | 无窗口(`--selftest` 自证:`GetConsoleWindow()==0`) |
+| 运维控制台/门户「数据重算」 | 动作进程同样无窗口;进度与日志尾巴在页面里看 | 无窗口 |
 
-**日志去哪了**: `logs/serve.log`(隐藏模式下 serve 的输出)、`logs/start_hidden.log`(启动器轨迹:就绪耗时/失败原因/是否已开浏览器)、
-各组件 `logs/<name>.log`、运维动作 `logs/ops_<动作>_<时间>.log`。失败时隐藏启动器还会弹一个**消息框**(无窗口模式下唯一能让人看见的通道)。
+**日志去哪了**: `logs/serve.log`(无窗口模式下 serve 的输出)、`logs/start_hidden.log`(启动器轨迹:窗口状态/就绪耗时/失败原因/是否已开浏览器)、
+各组件 `logs/<name>.log`、运维动作 `logs/ops_<动作>_<时间>.log`。失败时启动器还会弹一个**消息框**(无窗口模式下唯一能让人看见的通道)。
 
 **实测**(2026-09-16): 全停后可见窗口 0;`start.bat` 2.3 s 返回;启动后可见窗口 **0**,6 个服务端口全开,
-7 s 内 `/healthz` 就绪。
+7 s 内 `/healthz` 就绪。VBScript 移除后复测(2026-09-17,就在这一版源码上):
+① 全新副本 `install.ps1` **rc=0**(172 s),安装后目录里生成 `启动观澜.lnk`(1,683 B);
+② `pythonw.exe scripts\guanlan_start_hidden.py --selftest` → `{"console_hwnd": 0, "windowless": true}`;
+③ 全停后按快捷方式的方式启动(`pythonw.exe scripts\guanlan_start_hidden.py`):命令**立即返回**,
+`logs/start_hidden.log` 记下 `窗口状态: 无控制台窗口 (= 无窗口启动成立)`,7 s 内 `/healthz` 就绪,
+`/`·`/detail/`·`/ops`·`/ops/recalc` 全部 200;
+④ 自动化核验用 `install.ps1 -NoDesktopShortcut`,避免在桌面留一个指向临时副本的死链。
 
 ---
 
@@ -229,7 +239,7 @@
 | 状态 | 入口 | 说明 |
 |---|---|---|
 | **系统运行中** | 门户「数据重算」或 `/ops` 的「执行重算」按钮;等价命令行 `python scripts/rebuild_all.py` | 动作经 `_ops_launch.py` 二次启动(不挂网关的父子树,避免 `taskkill /T` 把自己杀掉);跑完**不需要重启**就能在页面看到新数(§5.1 指纹重载)。重算期间按钮全灰、并发动作被后端拒(HTTP 409) |
-| **系统未运行** | 同一套命令行(先 `guanlan.py stop` 或本就关机状态) | 全部构建器都是普通 CLI,不依赖服务;跑完再 `start.bat`/`start_hidden.vbs` 起来,页面直接读新产物。**实测**: 全停后跑 `rebuild_from_raw.py` rc=0、`inventory_products.py --check` rc=0 |
+| **系统未运行** | 同一套命令行(先 `guanlan.py stop` 或本就关机状态) | 全部构建器都是普通 CLI,不依赖服务;跑完再双击 `启动观澜` 快捷方式(或 `start.bat`)起来,页面直接读新产物。**实测**: 全停后跑 `rebuild_from_raw.py` rc=0、`inventory_products.py --check` rc=0 |
 
 一键顺序(`rebuild_all.py`,14 步): ① 放数据(`--src` 才跑)/② 三门台账/③ SCADA 侧 10 构建器/④ 月度派生件/
 **④b 振动侧摄入**/⑤ 补齐随包件(★2026-09-16 用户令"清除产物不留备份"之后, 随包件不再有 `_products_off/` 暂存区
@@ -263,7 +273,10 @@
 | 2026-09-16 | 振动侧接入(详见 `docs/振动数据接入_v0.1.md`): `data/raw/<场>/{windcms,m5_cms_tcm}` 两类源件、`rudong_tcm_index.py`/`rudong_tcm_spectra.py`/`vib_raw_build.py`/`vib_reports_build.py`、窗 `w0316` |
 | 2026-09-16 | 用户令"清除产物不留备份": `products_state.py --off --yes` 改为**真删除**(不再产生 `_products_off*/`)、`--on` 与门户「恢复产物」按钮移除;随包件的唯一来源改为**交付包 zip**(`products_restore_missing.py --stash <交付包.zip>`);`derived_manifest.prune()` 清掉陈旧自登记(`raw-derived` 台账 3444 → 1740 件,回到真实) |
 | 2026-09-16 | 用户令"打包不含 输入数据/产物/日志" → 交付包 **v0.4.0**(见 §9): `pack_dist.py` 增 `--no-products`、`VERSION='0.4.0'`、`dist-manifest.json` 记 `no_data/no_products/no_logs` 与逐条排除理由;`guanlan.py check` 读该清单,产物缺失显示 `[--] 待重算` 而非 FAIL |
-| 2026-09-16 | **现场实炸一次**: 目标机 `D:\产品\app` 双击 `start.bat` 报"无法找到脚本文件 `start_hidden.vbs`" —— 打包器 `INCLUDE_FILES` 是**手工清单**,新加的根目录文件(无窗口启动的 `.vbs`)忘了写进去。修法不是"再补一行",而是**从引用反推**:新增 `src/entry_refs.py`,凡入口脚本(`.bat/.vbs/.ps1/.sh`)引用的、本机真实存在的文件,打包时**自动补入**、打包后**对着包内条目复核**(缺一个就删掉这个包并 rc=1)、开箱验证(`--verify` 第①b 步)与装机自检(`guanlan.py check` 一行)各再查一遍。同时修正 `size_of()` 把 `__pycache__/*.pyc` 也数进"预演件数"的虚报(src 少报 129 件) |
+| 2026-09-16 | **现场实炸一次**: 目标机 `D:\产品\app` 双击 `start.bat` 报"无法找到脚本文件 `start_hidden.vbs`" —— 打包器 `INCLUDE_FILES` 是**手工清单**,新加的根目录文件(无窗口启动的 `.vbs`)忘了写进去。修法不是"再补一行",而是**从引用反推**:新增 `src/entry_refs.py`,凡入口脚本(`.bat/.ps1/.sh`)引用的、本机真实存在的文件,打包时**自动补入**、打包后**对着包内条目复核**(缺一个就删掉这个包并 rc=1)、开箱验证(`--verify` 第①b 步)与装机自检(`guanlan.py check` 一行)各再查一遍。同时修正 `size_of()` 把 `__pycache__/*.pyc` 也数进"预演件数"的虚报(src 少报 129 件) |
+| 2026-09-16 | 用户令"**把 VBScript 替换掉**"(见 §4.2): 删除 `start_hidden.vbs`;无窗口启动改为 `pythonw.exe` + `scripts/guanlan_start_hidden.py`(不经过 wscript/WSH —— 服务器上 WSH 常被组策略禁用/未安装);`install.ps1` 安装时用 PowerShell 原生 COM 建 `启动观澜.lnk`(根目录 + 桌面,带 `-NoDesktopShortcut` 供自动化核验);`start.bat` 默认改为 `start "" /min pythonw.exe …`(.bat 会闪一次,快捷方式才是零窗口);`guanlan_start_hidden.py --selftest` 用 `GetConsoleWindow()==0` **自证无窗口**;打包器新增 `*.lnk`(安装时才产生、内含绝对路径)与顶层 `*.zip` 排除(后者原先只写在文档里没实现) |
+| 2026-09-17 | **入口脚本的换行/编码事故两连** —— 这类问题都不会在开发机上暴露,只在目标机双击时炸:<br>① `install.ps1` 的 UTF-8 BOM 被编辑工具吃掉(我在规范化之后又改了一次文件)⇒ PS 5.1 按 GBK 解码 ⇒ 中文乱码 + 级联 ParserError,目标机 `install` **1 秒即退出**。开箱验证当场逮到(`install 退出码 1, 耗时 1s` + `The '<' operator is reserved for future use`)。<br>② `install.sh` **从写出来那天起就是 CRLF**(git HEAD 的 blob 就是 CRLF,不是某次编辑造成的)⇒ POSIX 语义下每个词尾粘 `\r`:`set -e` 变非法选项、`RT=""` 变 `RT="\r"` 让后续 `-n "$RT"` 判断翻面 ⇒ **此前所有交付包的 Linux/macOS 安装脚本都是坏的**。<br>加固:`src/entry_refs.py::encoding_problems()` 成文守则(`.ps1` = UTF-8 BOM + CRLF;`.bat` = CRLF 无 BOM;`.sh` = LF),在**打包、开箱验证第①b 步、装机自检**三处强制执行(违反就不出包 / 直接判 FAIL);`guanlan.py check` 增一行;新增 `.gitattributes` 把 checkout 也钉死(`*.bat/*.ps1 eol=crlf`、`*.sh eol=lf`)。变异测试:去掉 BOM / 改 CRLF / .bat 存成 LF 三种改法都被逐条报出 |
+| 2026-09-17 | 打包后**对着 zip 条目逐件复核**(3,733 件全部与工作树逐字节相同)+ 开箱验证:解压 3,734 件 → ①b 闭合与编码守则通过 → 离线安装 rc=0(172 s)→ ②b 快捷方式在位 + `console_hwnd=0` 无窗口成立 + 包内无 `.vbs` → 页面 4/5(`/` 与主包同字节;`/cms/` 503 属无产物预期) |
 
 ---
 
@@ -278,7 +291,7 @@
 | `release/` | 门户、仿真页、三维资产 `viewer/`、治理清单交付件 `release/如东/`(客户交付物,勿外传) |
 | `docs/` `README_先读我.txt` `测试须知.txt` | 交付文档与说明书 |
 | `wheels/win_amd64/`(42 件)、`vendor/python/`(3 平台便携运行时) | 离线安装件:Windows 完全离线可装;Linux/macOS 走联网安装(要离线就把轮子放进 `wheels/linux_x86_64` / `wheels/macos_arm64`) |
-| `install.bat/.ps1/.sh`、`check/start/stop.bat`、**`start_hidden.vbs`**、`requirements.txt` | 安装与起停入口(`start.bat` 默认调 `start_hidden.vbs` 无窗口启动,缺它则双击即报错) |
+| `install.bat/.ps1/.sh`、`check/start/stop.bat`、`requirements.txt` | 安装与起停入口(**无 `.vbs`**:`start.bat` 默认调 `pythonw.exe` + `scripts/guanlan_start_hidden.py`;`install.ps1` 安装时另建 `启动观澜.lnk` 供零窗口启动) |
 
 | 不进包 | 理由(同时写进包内 `dist-manifest.json` 的 `excluded`) |
 |---|---|
@@ -287,6 +300,7 @@
 | `logs/` `run/` | **用户令**:日志不进包(`run/pids.json` 里的 PID 到新机器上是无效引用) |
 | `.venv/` `.git/` `.github/` | venv 换机必失效(安装时重建);版本库不随交付件分发 |
 | `_products_off*/` | 旧设计的"清除产物"暂存档(2026-09-16 起清除=真删,不再产生;老机器上若有可手工删) |
+| `*.lnk` | 安装时建的快捷方式:内含绝对路径,换机必失效,由目标机的 `install.ps1` 重建 |
 | `__pycache__/` `*.pyc`、顶层 `*.zip` | 解释器缓存、旧的交付压缩包(避免包中包) |
 
 开箱验证(一条命令给出"能不能装、能不能跑"的证据):`python scripts/pack_dist.py --verify <zip>` —— 解压到
@@ -300,10 +314,12 @@
 根因是打包器的 `INCLUDE_FILES` 是**手工清单**,新加根目录文件时漏了一项 —— 这类清单必然漏,所以改成**从引用反推**:
 
 ```
-凡是入口脚本 (.bat/.vbs/.ps1/.sh) 里出现的一个路径, 且该路径在本机源码树里真实存在 → 它必须在包里
+凡是入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在本机源码树里真实存在 → 它必须在包里
 (本机不存在的 `.venv\Scripts\pythonw.exe` / 系统 exe / `%~dp0` 前缀残片, 按"装机后才有的东西"忽略)
 ```
 
-四处落地:打包时**自动补入**(`plan()` 打印 `[+] 自动补入 (被入口脚本引用)`)→ 打包后**对着 zip 条目复核**(缺一个就删包 + rc=1,不留"看起来能用"的坏包)→ `--verify` 第①b 步在解压目录再查 → `guanlan.py check` 输出一行 `入口脚本引用闭合 (23 条: start.bat→start_hidden.vbs 等)`,装机后 `check.bat` 也能看出来。
+四处落地:打包时**自动补入**(`plan()` 打印 `[+] 自动补入 (被入口脚本引用)`)→ 打包后**对着 zip 条目复核**(缺一个就删包 + rc=1,不留"看起来能用"的坏包)→ `--verify` 第①b 步在解压目录再查 → `guanlan.py check` 输出一行 `入口脚本引用闭合 (24 条: start.bat→guanlan_start_hidden.py 等)`,装机后 `check.bat` 也能看出来。
+
+★ 那个 `.vbs` 已按用户令删除(§4.2),但这份守卫保留:它守的是"入口引用的文件必须齐全"这条性质,与用什么语言实现无关 —— 现在它盯的是 `start.bat`/`install.ps1` → `scripts\guanlan_start_hidden.py` 这类引用(这次替换正是它自动发现的:去掉 `.vbs` 后入口列表变为 6 个、引用 24 条,全部在位)。
 
 

+ 8 - 3
guanlan.py

@@ -165,13 +165,18 @@ def cmd_check(c):
         row("子进程口径自检", False, f"{type(_e).__name__}: {_e}")
     # 入口引用闭合 (2026-09-16 现场实炸后加): 目标机 (D:\产品\app) 双击 start.bat 报
     # "无法找到脚本文件 start_hidden.vbs" —— 包少带了一个被引用的文件。这条守卫在**装机后**复查一遍:
-    # start.bat/check.bat/stop.bat/start_hidden.vbs/install.* 引用的每个文件都必须在位, 缺一个就 FAIL。
+    # start.bat/check.bat/stop.bat/install.* 引用的每个文件都必须在位, 缺一个就 FAIL。
+    # (那个 .vbs 已按用户令删除, 无窗口启动改用 pythonw.exe + scripts\guanlan_start_hidden.py;
+    #  守卫与实现语言无关, 照样生效 —— 现在它盯的是 start.bat → scripts\guanlan_start_hidden.py 这类引用。)
     try:
-        from src.entry_refs import missing_refs, referenced_tree_files
+        from src.entry_refs import encoding_problems, missing_refs, referenced_tree_files
         _miss = missing_refs(ROOT)
         _nref = sum(len(v) for v in referenced_tree_files(ROOT).values())
-        row(f"入口脚本引用闭合 ({_nref} 条: start.bat→start_hidden.vbs 等)", not _miss,
+        row(f"入口脚本引用闭合 ({_nref} 条: start.bat→guanlan_start_hidden.py 等)", not _miss,
             "; ".join(f"{e} 缺 {r}" for e, r in _miss[:3]) or "每个入口引用的文件都在位")
+        _enc = encoding_problems(ROOT)
+        row("入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM)", not _enc,
+            "; ".join(_enc[:2]) or "编码都对 (PS 5.1 才不会把中文按 GBK 解错)")
     except Exception as _e:
         row("入口脚本引用闭合", False, f"{type(_e).__name__}: {_e}")
     for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"):

+ 6 - 6
install.bat

@@ -1,6 +1,6 @@
-@echo off
-chcp 65001 >nul
-set PYTHONUTF8=1
-cd /d "%~dp0"
-powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
-pause
+@echo off
+chcp 65001 >nul
+set PYTHONUTF8=1
+cd /d "%~dp0"
+powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
+pause

+ 152 - 106
install.ps1

@@ -1,106 +1,152 @@
-# 观澜·如东样板 v2 — Windows 离线安装
-# 用法: powershell -ExecutionPolicy Bypass -File install.ps1
-#       ... -File install.ps1 -Python "C:\Python312\python.exe"   # 多版本机器指定
-#       ... -File install.ps1 -UseBundledPython                    # 强制用包内便携运行时
-#
-# 编码约定 (2026-09-10, 同事 xzy 实机报 ParserError 后定): 本文件必须存成 **UTF-8 带 BOM + CRLF**。
-# Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI 代码页 (中文机 = GBK) 解码, 文件里的中文会被
-# 错读, 相邻的转义反引号可能被一起吞掉 → 引号不配对 → 级联 ParserError (报在看起来没问题的行上)。
-# install.bat 里的 chcp 65001 管不了这件事 (它改的是控制台代码页, 不是脚本文件的解码方式)。
-# 另: 本文件**不再使用** 形如  & cmd /c "\"$exe\" -m pip ..."  的嵌套引号写法 —— 一旦有字节被吃掉就崩; 改用
-# PowerShell 原生调用 & $exe arg1 arg2, 不需要任何引号转义。
-param([string]$Python = "", [switch]$UseBundledPython, [switch]$SkipOllama)
-$ErrorActionPreference = "Stop"; $env:PYTHONUTF8 = "1"
-# 机器上若全局设了 PYTHONPATH (例如指向 D:\Program Files\Python\Lib\site-packages), pip 会把那里的包
-# 当成 "已满足", 于是不装进 .venv —— venv 里就缺 urllib3 / polars / pyyaml / jinja2 等传递依赖,
-# 换个没设 PYTHONPATH 的 shell 立刻 import 失败; 且 PYTHONPATH 排在 venv 之前会顶掉 pin 住的版本。
-# 先把环境变量摘掉, 让下面 pip 老老实实按 requirements.txt 装进 .venv。
-Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue
-Set-Location $PSScriptRoot
-function Say($m){ Write-Host $m }
-
-Say "== 1/5 选 Python (需 3.11+; 本项目依赖 pandas 3, 3.10 及以下没有轮子)"
-$py = $null
-if ($Python) { $py = $Python }
-elseif (-not $UseBundledPython) {
-  # 列出机器上所有 Python, 选第一个 >=3.11 (多版本共存时不猜, 打印出来让人看见)
-  $cands = @()
-  foreach ($c in @("py -3.13","py -3.12","py -3.11","python","python3")) {
-    $exe, $arg = $c.Split(" ", 2)
-    try {
-      $v = if ($arg) { & $exe $arg -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
-           else      { & $exe      -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
-      if ($LASTEXITCODE -eq 0 -and $v) { $cands += [pscustomobject]@{cmd=$c; exe=$exe; arg=$arg; ver=$v} }
-    } catch {}
-  }
-  if ($cands) { Say ("   机器上的 Python: " + (($cands | ForEach-Object { "$($_.cmd)=$($_.ver)" }) -join ", ")) }
-  $ok = $cands | Where-Object { [version]$_.ver -ge [version]"3.11" } | Select-Object -First 1
-  if ($ok) { $py = $ok }
-}
-if (-not $py) {
-  $rt = Get-ChildItem "vendor\python\cpython-3.12*windows*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
-  if ($rt) {
-    Say "   系统无 3.11+, 解包内便携运行时: $($rt.Name)"
-    if (Test-Path runtime) { Remove-Item runtime -Recurse -Force }
-    New-Item -ItemType Directory runtime | Out-Null
-    tar -xzf $rt.FullName -C runtime           # Windows 10 1803+ 自带 tar
-    $py = [pscustomobject]@{ exe = (Resolve-Path "runtime\python\python.exe").Path; arg = "" }
-  } else {
-    Say "   X 没有 3.11+ 的 Python, 包内也没有便携运行时。"
-    Say "     办法一: 装 https://www.python.org/downloads/windows/ 的 3.12.x (勾 Add to PATH)"
-    Say "     办法二: -Python <python.exe 的完整路径> 指定"
-    exit 2
-  }
-} elseif ($py -is [string]) {
-  $py = [pscustomobject]@{ exe = $py; arg = "" }
-}
-Say "   使用: $($py.exe) $($py.arg)"
-
-Say "== 2/5 建虚拟环境 .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/5 装依赖 (优先包内离线轮子 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/5 写配置"
-# 别改回 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。
-#   交给 venv 里的 Python 读写: 读 utf-8-sig (有 BOM 也认), 写无 BOM 的 UTF-8。
-#   下面 Python 代码故意只用单引号, 避开 PS 5.1 传参时的引号转义坑。
-$cfgFix = @'
-import json, pathlib, sys
-p = pathlib.Path('configs/serve.json')   # portability-allow: 安装脚本已 Set-Location 到安装根
-c = json.loads(p.read_text(encoding='utf-8-sig'))
-# 写**相对**路径 (相对安装根): 整个目录拷到别的电脑/别的盘后仍然有效 (绝对路径会在换机后失效)
-exe = pathlib.Path(sys.executable).resolve()
-root = pathlib.Path('.').resolve()
-try:
-    c['python'] = exe.relative_to(root).as_posix()
-except ValueError:
-    c['python'] = exe.as_posix()
-p.write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
-print('   configs/serve.json -> ' + c['python'] + ' (相对安装根)')
-'@
-& $vpy -c $cfgFix
-if ($LASTEXITCODE -ne 0) { Say "   X 写 configs\serve.json 失败"; exit 4 }
-
-Say "== 5/5 Ollama (只影响问答与本地审核; 其余页面不受影响)"
-if (-not $SkipOllama) {
-  $has = Get-Command ollama -ErrorAction SilentlyContinue
-  if ($has) { Say "   已安装: $(& ollama -v 2>&1 | Select-Object -First 1)" }
-  elseif (Test-Path "vendor\ollama\OllamaSetup.exe") {
-    Say "   包内有离线安装包, 现在静默安装 (约 1.5 GB, 需几分钟)…"
-    Start-Process -FilePath "vendor\ollama\OllamaSetup.exe" -ArgumentList "/SILENT" -Wait
-    Say "   安装完成; 模型请把有网机器的 %USERPROFILE%\.ollama\models 整个目录拷到本机同位置"
-  } else { Say "   未装且包内无离线包: 问答不可用。装法见 说明书 §5" }
-}
-
-Say ""
-& $vpy guanlan.py check
-Say ""
-Say "安装完成. 启动: start.bat   停止: stop.bat   自检: check.bat   入口: http://127.0.0.1:28084/"
+# 观澜·如东样板 v2 — Windows 离线安装
+# 用法: powershell -ExecutionPolicy Bypass -File install.ps1
+#       ... -File install.ps1 -Python "C:\Python312\python.exe"   # 多版本机器指定
+#       ... -File install.ps1 -UseBundledPython                    # 强制用包内便携运行时
+#
+# 编码约定 (2026-09-10, 同事 xzy 实机报 ParserError 后定): 本文件必须存成 **UTF-8 带 BOM + CRLF**。
+# Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI 代码页 (中文机 = GBK) 解码, 文件里的中文会被
+# 错读, 相邻的转义反引号可能被一起吞掉 → 引号不配对 → 级联 ParserError (报在看起来没问题的行上)。
+# install.bat 里的 chcp 65001 管不了这件事 (它改的是控制台代码页, 不是脚本文件的解码方式)。
+# 另: 本文件**不再使用** 形如  & cmd /c "\"$exe\" -m pip ..."  的嵌套引号写法 —— 一旦有字节被吃掉就崩; 改用
+# PowerShell 原生调用 & $exe arg1 arg2, 不需要任何引号转义。
+param([string]$Python = "", [switch]$UseBundledPython, [switch]$SkipOllama, [switch]$NoDesktopShortcut)
+$ErrorActionPreference = "Stop"; $env:PYTHONUTF8 = "1"
+# 机器上若全局设了 PYTHONPATH (例如指向 D:\Program Files\Python\Lib\site-packages), pip 会把那里的包
+# 当成 "已满足", 于是不装进 .venv —— venv 里就缺 urllib3 / polars / pyyaml / jinja2 等传递依赖,
+# 换个没设 PYTHONPATH 的 shell 立刻 import 失败; 且 PYTHONPATH 排在 venv 之前会顶掉 pin 住的版本。
+# 先把环境变量摘掉, 让下面 pip 老老实实按 requirements.txt 装进 .venv。
+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 及以下没有轮子)"
+$py = $null
+if ($Python) { $py = $Python }
+elseif (-not $UseBundledPython) {
+  # 列出机器上所有 Python, 选第一个 >=3.11 (多版本共存时不猜, 打印出来让人看见)
+  $cands = @()
+  foreach ($c in @("py -3.13","py -3.12","py -3.11","python","python3")) {
+    $exe, $arg = $c.Split(" ", 2)
+    try {
+      $v = if ($arg) { & $exe $arg -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
+           else      { & $exe      -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
+      if ($LASTEXITCODE -eq 0 -and $v) { $cands += [pscustomobject]@{cmd=$c; exe=$exe; arg=$arg; ver=$v} }
+    } catch {}
+  }
+  if ($cands) { Say ("   机器上的 Python: " + (($cands | ForEach-Object { "$($_.cmd)=$($_.ver)" }) -join ", ")) }
+  $ok = $cands | Where-Object { [version]$_.ver -ge [version]"3.11" } | Select-Object -First 1
+  if ($ok) { $py = $ok }
+}
+if (-not $py) {
+  $rt = Get-ChildItem "vendor\python\cpython-3.12*windows*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
+  if ($rt) {
+    Say "   系统无 3.11+, 解包内便携运行时: $($rt.Name)"
+    if (Test-Path runtime) { Remove-Item runtime -Recurse -Force }
+    New-Item -ItemType Directory runtime | Out-Null
+    tar -xzf $rt.FullName -C runtime           # Windows 10 1803+ 自带 tar
+    $py = [pscustomobject]@{ exe = (Resolve-Path "runtime\python\python.exe").Path; arg = "" }
+  } else {
+    Say "   X 没有 3.11+ 的 Python, 包内也没有便携运行时。"
+    Say "     办法一: 装 https://www.python.org/downloads/windows/ 的 3.12.x (勾 Add to PATH)"
+    Say "     办法二: -Python <python.exe 的完整路径> 指定"
+    exit 2
+  }
+} elseif ($py -is [string]) {
+  $py = [pscustomobject]@{ exe = $py; arg = "" }
+}
+Say "   使用: $($py.exe) $($py.arg)"
+
+Say "== 2/6 建虚拟环境 .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)"
+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 写配置"
+# 别改回 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。
+#   交给 venv 里的 Python 读写: 读 utf-8-sig (有 BOM 也认), 写无 BOM 的 UTF-8。
+#   下面 Python 代码故意只用单引号, 避开 PS 5.1 传参时的引号转义坑。
+$cfgFix = @'
+import json, pathlib, sys
+p = pathlib.Path('configs/serve.json')   # portability-allow: 安装脚本已 Set-Location 到安装根
+c = json.loads(p.read_text(encoding='utf-8-sig'))
+# 写**相对**路径 (相对安装根): 整个目录拷到别的电脑/别的盘后仍然有效 (绝对路径会在换机后失效)
+exe = pathlib.Path(sys.executable).resolve()
+root = pathlib.Path('.').resolve()
+try:
+    c['python'] = exe.relative_to(root).as_posix()
+except ValueError:
+    c['python'] = exe.as_posix()
+p.write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+print('   configs/serve.json -> ' + c['python'] + ' (相对安装根)')
+'@
+& $vpy -c $cfgFix
+if ($LASTEXITCODE -ne 0) { Say "   X 写 configs\serve.json 失败"; exit 4 }
+
+Say "== 5/6 建启动快捷方式 (无窗口; 不依赖 Windows 脚本宿主)"
+# 2026-09-16 用户令二 "把 VBScript 替换掉": 原先无窗口启动靠 start_hidden.vbs (WScript.Shell.Run 窗口样式 0),
+# 现在快捷方式直接指向 **pythonw.exe**(无控制台子系统的解释器) + 启动器脚本 —— 运行期一个窗口都没有,
+# 且不经过 wscript/cscript: WSH 被组策略禁用、或系统未安装 WSH 功能的机器上同样可用。
+# 注意: 这里是 PowerShell 自己的 COM 调用, 不是 .vbs 文件; 建不出来也不让安装失败 (提示手工建/用 start.bat)。
+$rootDir  = (Get-Location).Path
+$pywPath  = (Resolve-Path ".\.venv\Scripts\pythonw.exe").Path
+$launcher = Join-Path $rootDir "scripts\guanlan_start_hidden.py"
+function New-GuanlanShortcut([string]$Path) {
+  $sh  = New-Object -ComObject WScript.Shell
+  $lnk = $sh.CreateShortcut($Path)
+  $lnk.TargetPath       = $pywPath
+  $lnk.Arguments        = '"' + $launcher + '"'
+  $lnk.WorkingDirectory = $rootDir
+  $lnk.WindowStyle      = 7                # 最小化; pythonw 本身无窗口, 这里是双保险
+  $lnk.Description      = "观澜·如东样板 v2 - 无窗口启动 (pythonw.exe)"
+  $ico = Join-Path $rootDir "resources\guanlan.ico"
+  if (Test-Path $ico) { $lnk.IconLocation = $ico }
+  $lnk.Save()
+}
+$made = @()
+try {
+  $lnkRoot = Join-Path $rootDir "启动观澜.lnk"
+  New-GuanlanShortcut $lnkRoot
+  $made += $lnkRoot
+  try {
+    if ($NoDesktopShortcut) {
+      Say "   (-NoDesktopShortcut: 跳过桌面快捷方式 —— 自动核验用, 免得在别人桌面上留一个指向临时目录的死链)"
+    } else {
+      $desk = [Environment]::GetFolderPath('Desktop')
+      if ($desk) {
+        $lnkDesk = Join-Path $desk "观澜·如东样板 v2.lnk"
+        New-GuanlanShortcut $lnkDesk
+        $made += $lnkDesk
+      }
+    }
+  } catch { Say "   (桌面快捷方式没建成, 不影响使用: $($_.Exception.Message))" }
+} catch {
+  Say "   [!] 快捷方式没建成: $($_.Exception.Message)"
+  Say "       不影响使用: 双击 start.bat 一样起得来(会有一次极短的窗口闪烁);"
+  Say "       或手工建: 目标填 $pywPath  参数填 `"$launcher`""
+}
+foreach ($m in $made) { Say "   已建: $m" }
+if ($made.Count -gt 0) { Say "   * 双击这个快捷方式 = 完全不出现命令窗口 (start.bat 会有一次极短闪烁)" }
+
+Say "== 6/6 Ollama (只影响问答与本地审核; 其余页面不受影响)"
+if (-not $SkipOllama) {
+  $has = Get-Command ollama -ErrorAction SilentlyContinue
+  if ($has) { Say "   已安装: $(& ollama -v 2>&1 | Select-Object -First 1)" }
+  elseif (Test-Path "vendor\ollama\OllamaSetup.exe") {
+    Say "   包内有离线安装包, 现在静默安装 (约 1.5 GB, 需几分钟)…"
+    Start-Process -FilePath "vendor\ollama\OllamaSetup.exe" -ArgumentList "/SILENT" -Wait
+    Say "   安装完成; 模型请把有网机器的 %USERPROFILE%\.ollama\models 整个目录拷到本机同位置"
+  } else { Say "   未装且包内无离线包: 问答不可用。装法见 说明书 §5" }
+}
+
+Say ""
+& $vpy guanlan.py check
+Say ""
+Say "安装完成. 启动: 双击「启动观澜」快捷方式 (无窗口) 或 start.bat; 停止: stop.bat; 自检: check.bat"
+Say "          入口: http://127.0.0.1:28084/"

+ 77 - 77
install.sh

@@ -1,77 +1,77 @@
-#!/bin/sh
-# 观澜·如东样板 v2 — Linux/macOS 离线安装 (支持 银河麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS)
-# 用法: sh install.sh            (自动选 Python; 无合适系统 Python 时用包内便携运行时)
-#       GUANLAN_PY=/path/python3 sh install.sh   (指定 Python, 多版本机器用)
-set -e
-# 同理: 全局 PYTHONPATH 会让 pip 误判依赖已满足而不装进 .venv (见 install.ps1 注释)
-unset PYTHONPATH
-cd "$(dirname "$0")"; ROOT=$(pwd); export PYTHONUTF8=1 LC_ALL=${LC_ALL:-C.UTF-8}
-say(){ printf '%s\n' "$*"; }
-
-ARCH=$(uname -m); case "$ARCH" in x86_64|amd64) A=x86_64;; aarch64|arm64) A=aarch64;; *) A=$ARCH;; esac
-OS=$(uname -s)
-DISTRO=$( [ -f /etc/os-release ] && . /etc/os-release && echo "${PRETTY_NAME:-$NAME}" || echo "$OS" )
-say "== 观澜 v2 安装 · $DISTRO · $ARCH"
-
-# 1) 选 Python: 显式指定 > 系统 3.11/3.12 > 包内便携运行时 (麒麟/统信自带多为 3.7/3.9, 跑不了本项目依赖)
-PY=""
-if [ -n "$GUANLAN_PY" ]; then PY="$GUANLAN_PY"
-else
-  for c in python3.12 python3.13 python3.11 python3; do
-    p=$(command -v $c 2>/dev/null) || continue
-    "$p" -c 'import sys; sys.exit(0 if sys.version_info[:2] >= (3,11) else 1)' 2>/dev/null && { PY="$p"; break; }
-  done
-fi
-if [ -z "$PY" ]; then
-  RT=$(ls vendor/python/cpython-3.12*-$A-*linux-gnu-install_only*.tar.gz 2>/dev/null | head -1)
-  [ "$OS" = Darwin ] && RT=""
-  if [ -n "$RT" ]; then
-    say "   系统无 Python 3.11+, 用包内便携运行时: $(basename "$RT")"
-    rm -rf runtime && mkdir -p runtime && tar -xzf "$RT" -C runtime
-    PY="$ROOT/runtime/python/bin/python3"
-  else
-    say "   ✗ 未找到 Python 3.11+ , 包内也没有本架构 ($A) 的便携运行时。"
-    say "     办法一: 装系统 Python 3.11+ ; 办法二: GUANLAN_PY=/路径/python3 sh install.sh"
-    exit 2
-  fi
-fi
-say "   Python: $PY ($("$PY" -V 2>&1))"
-
-# 2) 虚拟环境 + 离线依赖 (轮子在 wheels/linux_<arch> 或 wheels/macos_arm64)
-"$PY" -m venv .venv 2>/dev/null || "$PY" -m venv --without-pip .venv
-VPY=$ROOT/.venv/bin/python
-"$VPY" -m ensurepip --upgrade >/dev/null 2>&1 || true
-"$VPY" -m pip install --upgrade pip --quiet 2>/dev/null || true
-case "$OS" in Darwin) W=wheels/macos_arm64;; *) W=wheels/linux_$A;; esac
-if [ -d "$W" ] && [ "$(ls -A "$W" 2>/dev/null | wc -l)" -gt 0 ]; then
-  say "== 离线安装依赖 ($W)"; "$VPY" -m pip install --no-index --find-links "$W" -r requirements.txt --quiet
-else
-  say "== 包内无本平台轮子 ($W), 改为联网安装"; "$VPY" -m pip install -r requirements.txt --quiet
-fi
-
-# 3) 写配置 (python 指向本 venv)
-"$VPY" - <<'PYEOF'
-import json, pathlib, sys
-p = pathlib.Path('configs/serve.json')   # portability-allow: 安装脚本已 cd 到安装根
-c = json.loads(p.read_text(encoding='utf-8'))
-# 写**相对**路径 (相对安装根): 整个目录拷到别的电脑/别的盘后仍然有效 (绝对路径会在换机后失效)
-exe = pathlib.Path(sys.executable).resolve(); root = pathlib.Path('.').resolve()
-try:
-    c['python'] = exe.relative_to(root).as_posix()
-except ValueError:
-    c['python'] = exe.as_posix()
-p.write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
-print('   configs/serve.json -> ' + c['python'] + ' (相对安装根)')
-PYEOF
-
-# 4) Ollama (可选; 只影响问答与本地审核)
-if command -v ollama >/dev/null 2>&1; then say "== Ollama 已安装: $(ollama -v 2>&1 | head -1)"
-elif [ -f vendor/ollama/ollama-linux-$( [ "$A" = x86_64 ] && echo amd64 || echo arm64 ).tar.zst ]; then
-  say "== 检测到包内 Ollama 离线包; 安装需 root:"
-  say "   sudo tar -I zstd -C /usr -xf vendor/ollama/ollama-linux-*.tar.zst && sudo useradd -r -s /bin/false -m -d /usr/share/ollama ollama 2>/dev/null; ollama serve &"
-  say "   (麒麟/统信若无 zstd: sudo yum install zstd 或 sudo apt install zstd)"
-else say "== 未装 Ollama: 问答与本地审核不可用, 其余页面正常 (装法见 说明书 §5)"; fi
-
-say "== 自检"; "$VPY" guanlan.py check || true
-say ""
-say "安装完成. 启动: $VPY guanlan.py serve    停止: $VPY guanlan.py stop    入口: http://127.0.0.1:28084/"
+#!/bin/sh
+# 观澜·如东样板 v2 — Linux/macOS 离线安装 (支持 银河麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS)
+# 用法: sh install.sh            (自动选 Python; 无合适系统 Python 时用包内便携运行时)
+#       GUANLAN_PY=/path/python3 sh install.sh   (指定 Python, 多版本机器用)
+set -e
+# 同理: 全局 PYTHONPATH 会让 pip 误判依赖已满足而不装进 .venv (见 install.ps1 注释)
+unset PYTHONPATH
+cd "$(dirname "$0")"; ROOT=$(pwd); export PYTHONUTF8=1 LC_ALL=${LC_ALL:-C.UTF-8}
+say(){ printf '%s\n' "$*"; }
+
+ARCH=$(uname -m); case "$ARCH" in x86_64|amd64) A=x86_64;; aarch64|arm64) A=aarch64;; *) A=$ARCH;; esac
+OS=$(uname -s)
+DISTRO=$( [ -f /etc/os-release ] && . /etc/os-release && echo "${PRETTY_NAME:-$NAME}" || echo "$OS" )
+say "== 观澜 v2 安装 · $DISTRO · $ARCH"
+
+# 1) 选 Python: 显式指定 > 系统 3.11/3.12 > 包内便携运行时 (麒麟/统信自带多为 3.7/3.9, 跑不了本项目依赖)
+PY=""
+if [ -n "$GUANLAN_PY" ]; then PY="$GUANLAN_PY"
+else
+  for c in python3.12 python3.13 python3.11 python3; do
+    p=$(command -v $c 2>/dev/null) || continue
+    "$p" -c 'import sys; sys.exit(0 if sys.version_info[:2] >= (3,11) else 1)' 2>/dev/null && { PY="$p"; break; }
+  done
+fi
+if [ -z "$PY" ]; then
+  RT=$(ls vendor/python/cpython-3.12*-$A-*linux-gnu-install_only*.tar.gz 2>/dev/null | head -1)
+  [ "$OS" = Darwin ] && RT=""
+  if [ -n "$RT" ]; then
+    say "   系统无 Python 3.11+, 用包内便携运行时: $(basename "$RT")"
+    rm -rf runtime && mkdir -p runtime && tar -xzf "$RT" -C runtime
+    PY="$ROOT/runtime/python/bin/python3"
+  else
+    say "   ✗ 未找到 Python 3.11+ , 包内也没有本架构 ($A) 的便携运行时。"
+    say "     办法一: 装系统 Python 3.11+ ; 办法二: GUANLAN_PY=/路径/python3 sh install.sh"
+    exit 2
+  fi
+fi
+say "   Python: $PY ($("$PY" -V 2>&1))"
+
+# 2) 虚拟环境 + 离线依赖 (轮子在 wheels/linux_<arch> 或 wheels/macos_arm64)
+"$PY" -m venv .venv 2>/dev/null || "$PY" -m venv --without-pip .venv
+VPY=$ROOT/.venv/bin/python
+"$VPY" -m ensurepip --upgrade >/dev/null 2>&1 || true
+"$VPY" -m pip install --upgrade pip --quiet 2>/dev/null || true
+case "$OS" in Darwin) W=wheels/macos_arm64;; *) W=wheels/linux_$A;; esac
+if [ -d "$W" ] && [ "$(ls -A "$W" 2>/dev/null | wc -l)" -gt 0 ]; then
+  say "== 离线安装依赖 ($W)"; "$VPY" -m pip install --no-index --find-links "$W" -r requirements.txt --quiet
+else
+  say "== 包内无本平台轮子 ($W), 改为联网安装"; "$VPY" -m pip install -r requirements.txt --quiet
+fi
+
+# 3) 写配置 (python 指向本 venv)
+"$VPY" - <<'PYEOF'
+import json, pathlib, sys
+p = pathlib.Path('configs/serve.json')   # portability-allow: 安装脚本已 cd 到安装根
+c = json.loads(p.read_text(encoding='utf-8'))
+# 写**相对**路径 (相对安装根): 整个目录拷到别的电脑/别的盘后仍然有效 (绝对路径会在换机后失效)
+exe = pathlib.Path(sys.executable).resolve(); root = pathlib.Path('.').resolve()
+try:
+    c['python'] = exe.relative_to(root).as_posix()
+except ValueError:
+    c['python'] = exe.as_posix()
+p.write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+print('   configs/serve.json -> ' + c['python'] + ' (相对安装根)')
+PYEOF
+
+# 4) Ollama (可选; 只影响问答与本地审核)
+if command -v ollama >/dev/null 2>&1; then say "== Ollama 已安装: $(ollama -v 2>&1 | head -1)"
+elif [ -f vendor/ollama/ollama-linux-$( [ "$A" = x86_64 ] && echo amd64 || echo arm64 ).tar.zst ]; then
+  say "== 检测到包内 Ollama 离线包; 安装需 root:"
+  say "   sudo tar -I zstd -C /usr -xf vendor/ollama/ollama-linux-*.tar.zst && sudo useradd -r -s /bin/false -m -d /usr/share/ollama ollama 2>/dev/null; ollama serve &"
+  say "   (麒麟/统信若无 zstd: sudo yum install zstd 或 sudo apt install zstd)"
+else say "== 未装 Ollama: 问答与本地审核不可用, 其余页面正常 (装法见 说明书 §5)"; fi
+
+say "== 自检"; "$VPY" guanlan.py check || true
+say ""
+say "安装完成. 启动: $VPY guanlan.py serve    停止: $VPY guanlan.py stop    入口: http://127.0.0.1:28084/"

+ 3 - 1
scripts/check_transferable.py

@@ -216,7 +216,9 @@ IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs',
 def install_in(dst: pathlib.Path) -> pathlib.Path | None:
     """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
     print(f'   在 {dst} 里跑安装 (离线) …')
-    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1')]
+    # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
+    # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
+    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
            if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
     t0 = time.time()
     r = subprocess.run(cmd, cwd=str(dst))

+ 30 - 4
scripts/guanlan_start_hidden.py

@@ -14,12 +14,15 @@
   4. 全过程写 `logs/start_hidden.log` (含退出码与失败原因) —— **不静默**:
      失败时不但写日志, 还会弹一个消息框 (无窗口模式下唯一能让人看见的方式)。
 
-由 `start_hidden.vbs` 以 `pythonw.exe` 调用 (窗口样式 0 = 完全不显示)。
+由 `pythonw.exe`(无控制台子系统的解释器) 调用 —— 2026-09-16 用户令"把 VBScript 替换掉"之后,
+不再经 `start_hidden.vbs` / Windows 脚本宿主: `start.bat` 与安装时生成的 `启动观澜.lnk` 都直接
+指向 `.venv\\Scripts\\pythonw.exe` + 本脚本, WSH 被禁用或未安装也照样无窗口启动。
 
 用法:
-    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py          # 隐藏启动 + 开浏览器
-    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --no-open  # 隐藏启动, 不开浏览器
+    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py          # 无窗口启动 + 开浏览器
+    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --no-open  # 无窗口启动, 不开浏览器
     .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
+    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py --selftest # 只打印"有没有控制台"就退出
 """
 from __future__ import annotations
 
@@ -78,6 +81,17 @@ def notify(title: str, text: str) -> None:
         pass
 
 
+def console_hwnd() -> int:
+    """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
+    if os.name != 'nt':
+        return -1
+    try:
+        import ctypes
+        return int(ctypes.windll.kernel32.GetConsoleWindow())
+    except Exception:
+        return -2
+
+
 def main() -> int:
     ap = argparse.ArgumentParser()
     ap.add_argument('--host', default='127.0.0.1')
@@ -85,8 +99,17 @@ def main() -> int:
     ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
     ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
     ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
+    ap.add_argument('--selftest', action='store_true',
+                    help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
     a = ap.parse_args()
 
+    if a.selftest:
+        h = console_hwnd()
+        print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
+                              interpreter=sys.executable, argv0=sys.argv[0]),
+                         ensure_ascii=False))
+        return 0
+
     gw = a.gateway
     if gw is None:
         try:
@@ -95,7 +118,10 @@ def main() -> int:
         except Exception:
             gw = 28084
     url = f'http://{a.host}:{gw}/'
-    log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s) ---')
+    _h = console_hwnd()
+    _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
+    log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
+    log(f'窗口状态: {_w}')
 
     if port_up(a.host, gw):
         log(f'网关 {gw} 已在运行 → 只打开页面')

+ 63 - 10
scripts/pack_dist.py

@@ -17,6 +17,8 @@ r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
 `install.ps1` `check.bat` `start.bat` `stop.bat`) · `requirements.txt`。
 
 **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
+**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
+`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
 
 **排除**(每条都写了理由):
     .git/ .venv/ .github/            版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
@@ -24,6 +26,7 @@ r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
     logs/ run/                       本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
     data/raw/                        现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
     __pycache__/ *.pyc               解释器缓存
+    *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
     *.zip(顶层)                      旧的交付压缩包; 避免包中包
 
 ## 用法
@@ -64,9 +67,10 @@ PRODUCTS_DIR = 'outputs'          # 产物仓: 默认打进包 (页面开箱有
 #   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
 VERSION = '0.4.0'                 # 包版本 (2026-09-16): 写进 dist-manifest.json 与默认文件名
 INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
-                 'start_hidden.vbs',      # ★ 无窗口启动: start.bat 会调它。v0.4.0 第一版漏了它 → 目标机 start.bat 报
-                                          #   "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。
-                                          #   现在除了补进本清单, 还由 src/entry_refs.py **从引用反推**兜底(漏不掉)。
+                 # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报
+                 #   "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript
+                 #   整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`,
+                 #   安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
                  'requirements.txt', 'README_先读我.txt', '测试须知.txt']
 EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
 # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
@@ -74,6 +78,10 @@ EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
 #   (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
 #   为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
 EXCLUDE_GLOBS = ['_products_off*', 'data']
+# 进包**只**看目录/名的顶部前缀, 这些则按后缀排除 —— 都是"本机/装机时才产生"的东西:
+#   .pyc  解释器缓存;  .lnk 安装时建的快捷方式(里面是绝对路径, 换机必失效, 由 install.ps1 重建);
+#   .zip  顶层旧交付包(避免包中包; 原先只写在文档里, 2026-09-16 补上实现)
+EXCLUDE_SUFFIX = {'.pyc', '.lnk'}
 
 
 def _xfer():
@@ -112,7 +120,7 @@ def plan(with_data=False, no_products=False):
             inc.append(p)
         else:
             print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
-    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.vbs/.ps1/.sh) 引用到的、本机真实存在的
+    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
     #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
     #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
     from src.entry_refs import auto_include, referenced_tree_files
@@ -129,6 +137,9 @@ def plan(with_data=False, no_products=False):
     }
     skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
     skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
+    skipped += [('__pycache__/ *.pyc', '解释器缓存')]
+    skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
+    skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
     if no_products and (ROOT / PRODUCTS_DIR).is_dir():
         skipped += [('outputs/', '用户令: 产物不进包 (目标机放数据后 rebuild_all.py 重算; '
                                  '或在目标机也用 --no-products 的同一口径)')]
@@ -143,7 +154,9 @@ def keep(f: pathlib.Path) -> bool:
     现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
     """
     parts = f.relative_to(ROOT).parts
-    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc':
+    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX:
+        return False
+    if len(parts) == 1 and f.suffix.lower() == '.zip':      # 顶层旧交付包, 避免包中包
         return False
     return not any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1])
 
@@ -201,17 +214,22 @@ def build(out: pathlib.Path, with_data=False, progress=True, no_products=False)
     print(f'\n完成: {out}  {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
 
     # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
-    from src.entry_refs import missing_refs
+    # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
+    from src.entry_refs import encoding_problems, missing_refs
     with zipfile.ZipFile(out) as z:
         names = z.namelist()
+    enc = encoding_problems(ROOT)
     miss = missing_refs(ROOT, names)
     if miss:
         for e, rel in miss:
             print(f'   [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
+    for m in enc:
+        print(f'   [X] 入口编码守则: {m}')
+    if miss or enc:
         out.unlink()
         print(f'   [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
         return 1
-    print(f'   [OK] 入口引用闭合: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
+    print(f'   [OK] 入口引用闭合 + 编码守则: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
     print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
     print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
     return 0
@@ -228,19 +246,54 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
         z.extractall(tmp)
     n = sum(1 for _ in tmp.rglob('*') if _.is_file())
     print(f'   {n} 件, 耗时 {time.time()-t0:.0f}s')
-    print('①b 入口引用闭合核对 (包内 start.bat → start_hidden.vbs 之类)')
-    from src.entry_refs import missing_refs
+    print('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)')
+    from src.entry_refs import encoding_problems, missing_refs
     miss = missing_refs(tmp)
+    enc = encoding_problems(tmp)
     if miss:
         for e, rel in miss:
             print(f'   [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
+    for m in enc:
+        print(f'   [X] 入口编码守则: {m}')
+    if miss or enc:
         return 1
-    print('   [OK] 全部在位')
+    print('   [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
     print('② 离线安装')
     xt = _xfer()
     py = xt.install_in(tmp)
     if py is None:
         return 1
+    print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)')
+    bad = []
+    if os.name == 'nt':
+        lnk = tmp / '启动观澜.lnk'
+        if lnk.is_file() and lnk.stat().st_size > 0:
+            print(f'   [OK] 安装时已生成快捷方式: {lnk.name} ({lnk.stat().st_size} B)')
+        else:
+            bad.append('安装后没有 启动观澜.lnk (install.ps1 第 5/6 步没生效)')
+        import json as _json
+        import subprocess as _sp
+        pyw = tmp / '.venv/Scripts/pythonw.exe'
+        r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'],
+                    capture_output=True, text=True, errors='replace', timeout=120)
+        try:
+            info = _json.loads((r.stdout or '').strip().splitlines()[-1])
+        except Exception:
+            info = {}
+        if info.get('windowless') is True:
+            print(f'   [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立')
+        else:
+            bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }')
+        if list(tmp.glob('*.vbs')):
+            bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}')
+        else:
+            print('   [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)')
+    else:
+        print('   (非 Windows: 无窗口/快捷方式不适用)')
+    for b in bad:
+        print(f'   [X] {b}')
+    if bad:
+        return 1
     print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
     ok = xt.run_and_probe(tmp, py)
     print('④ 收尾')

+ 73 - 8
src/entry_refs.py

@@ -8,17 +8,20 @@ r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引
     无法找到脚本文件 "D:\产品\app\start_hidden.vbs"
 
 原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
-`start_hidden.vbs`(无窗口启动) 时忘了把它写进去, 于是包里 `start.bat` 指向一个不存在的文件。
-手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
+`start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
+不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
 
-    凡是包内入口脚本 (.bat/.vbs/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
+    凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
     它就**必须**出现在包里(或被打包器自动补进去)。
 
 · 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
   按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
-· `%~dp0start_hidden.vbs` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
+· `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
 · 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
 
+★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
+  但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
+
 单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
 """
 from __future__ import annotations
@@ -27,7 +30,9 @@ import pathlib
 import re
 
 # 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
-ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'start_hidden.vbs',
+# ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
+#   `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
+ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat',
                'install.bat', 'install.ps1', 'install.sh')
 
 # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
@@ -85,6 +90,61 @@ def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
     return out
 
 
+_SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
+              'outputs', 'data', 'logs', 'run'}
+
+
+def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
+    """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
+    import os
+    for dp, dn, fns in os.walk(root):
+        dn[:] = [d for d in dn if d not in _SKIP_WALK]
+        for fn in fns:
+            if fn.lower().endswith(exts):
+                yield pathlib.Path(dp) / fn
+
+
+def encoding_problems(root: pathlib.Path) -> list[str]:
+    r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
+
+    · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
+      代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
+      (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
+      `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
+      ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
+        所以这条必须由机器守, 不能靠"我记得"。
+    · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
+      (`@echo off`) 失效并被当命令执行。
+    · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
+      POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
+      `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
+      2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
+      也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
+    """
+    root = pathlib.Path(root)
+    bad: list[str] = []
+    for p in _iter_scripts(root, ('.ps1',)):
+        b = p.read_bytes()
+        rel = p.relative_to(root).as_posix()
+        if b[:3] != b'\xef\xbb\xbf':
+            bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
+        if b.count(b'\n') != b.count(b'\r\n'):
+            bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
+    for p in _iter_scripts(root, ('.bat',)):
+        b = p.read_bytes()
+        rel = p.relative_to(root).as_posix()
+        if b.count(b'\n') != b.count(b'\r\n'):
+            bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
+        if b[:3] == b'\xef\xbb\xbf':
+            bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
+    for p in _iter_scripts(root, ('.sh',)):
+        b = p.read_bytes()
+        if b'\r\n' in b:
+            bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
+                       f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
+    return bad
+
+
 def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
     """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
 
@@ -104,7 +164,7 @@ def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
 def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
     """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
 
-    只自动补**根目录**文件 (如 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
+    只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
     缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
     """
     got = {_norm(x) for x in already}
@@ -121,16 +181,21 @@ def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
     return sorted(set(add), key=lambda p: p.name)
 
 
-if __name__ == '__main__':      # 直接跑 = 对当前安装目录做一次闭合检查
+if __name__ == '__main__':      # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
     import sys
     r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
     refs = referenced_tree_files(r)
     miss = missing_refs(r)
+    enc = encoding_problems(r)
     for e in sorted(refs):
         print(f'   {e:18s} → ' + ', '.join(sorted(refs[e])))
     for e, rel in miss:
         print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
+    for m in enc:
+        print(f'[X] 编码守则: {m}')
     if not miss:
         print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
               f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
-    sys.exit(1 if miss else 0)
+    if not enc:
+        print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
+    sys.exit(1 if (miss or enc) else 0)

+ 96 - 81
start.bat

@@ -1,81 +1,96 @@
-@echo off
-rem ============================================================================
-rem  观澜 start.bat
-rem  2026-09-16 用户令: 启动观澜系统时"弹出的命令窗口"改为不弹出方式。
-rem
-rem  默认 (双击本文件)      : 走**无窗口**启动 —— 交给 start_hidden.vbs (窗口样式 0),
-rem                           后台起服务、等就绪、自动打开浏览器, 不留任何命令窗口。
-rem  start.bat console      : 前台模式 (旧行为) —— 在当前窗口里跑 serve 并实时打印日志,
-rem                           只在排障时用; 关掉这个窗口等于停掉 serve。
-rem  start.bat help         : 说明。
-rem
-rem  为什么分开: 各组件服务本来就是 DETACHED_PROCESS 起的 (不弹窗), 唯一会弹的就是
-rem  "在控制台里前台跑 guanlan.py serve" 这件事本身。排障又确实需要看见实时输出,
-rem  所以保留一个显式的 console 档, 而不是把日志彻底藏掉。
-rem ============================================================================
-chcp 65001 >nul
-set PYTHONUTF8=1
-cd /d "%~dp0"
-
-if /i "%~1"=="help" goto help
-if /i "%~1"=="console" goto console
-if /i "%~1"=="-c" goto console
-
-if not exist ".venv\Scripts\pythonw.exe" (
-  echo [X] Not installed yet: .venv\Scripts\pythonw.exe not found
-  echo     Run install.bat first.
-  pause
-  exit /b 2
-)
-
-rem 无窗口启动: wscript 以隐藏窗口方式调用 pythonw.exe 跑启动器, 本窗口立即退出。
-wscript.exe //nologo "%~dp0start_hidden.vbs"
-exit /b 0
-
-:console
-if not exist ".venv\Scripts\python.exe" (
-  echo [X] Not installed yet: .venv\Scripts\python.exe not found
-  echo     Run install.bat first. If install printed errors, send that screen back
-  echo     instead of continuing - the server cannot start without .venv.
-  pause
-  exit /b 2
-)
-echo [i] Foreground mode - this window stays open and shows live logs.
-echo     Close it to stop the server; for no-window start just run start.bat with no argument.
-echo.
-".venv\Scripts\python.exe" guanlan.py serve
-if errorlevel 1 (
-  rem serve exits 1 in two different cases: the gateway never came up, or the gateway is
-  rem up with at least one degraded module, e.g. Ollama not running. Probe healthz to tell
-  rem them apart, otherwise a working server gets reported as "did not start".
-  ".venv\Scripts\python.exe" -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:28084/healthz',timeout=6).status==200 else 1)" 2>nul
-  if errorlevel 1 (
-    echo.
-    echo [X] Gateway did not come up ^(reason above^). Not opening the browser -
-    echo     a "site cannot be reached" page would look like a network problem.
-    echo     See logs\gateway.log
-    pause
-    exit /b 1
-  )
-  echo.
-  echo [!] Gateway is up, but at least one module is degraded ^(the [DOWN] lines above^).
-  echo     Usual cause: local Ollama is not running or has no models pulled, which only
-  echo     disables the Q^&A / local-review pages. Every other page works.
-  echo     Details: logs/ for the degraded module.
-)
-start "" http://127.0.0.1:28084/
-echo.
-echo [i] Ops console - stop/start services, rebuild, clear products:
-echo     http://127.0.0.1:28084/ops
-echo     It stays available while component services are stopped (only the gateway must be up);
-echo     buttons follow real state and the backend refuses impossible actions (HTTP 409).
-exit /b 0
-
-:help
-echo Usage:
-echo   start.bat            no-window start (default, recommended): hidden services + browser
-echo   start.bat console    foreground start with live logs (troubleshooting)
-echo   stop.bat             stop everything
-echo   check.bat            self-check
-echo Logs: logs\serve.log (no-window mode), logs\start_hidden.log (launcher trace)
-exit /b 0
+@echo off
+rem ============================================================================
+rem  观澜 start.bat
+rem  2026-09-16 用户令: 启动观澜系统时"弹出的命令窗口"改为不弹出方式。
+rem  2026-09-16 用户令二: **把 VBScript 替换掉** —— 原先无窗口启动靠 start_hidden.vbs
+rem                      (WScript.Shell.Run 窗口样式 0)。现改为直接用 pythonw.exe(无控制台子系统的
+rem                      解释器) 跑启动器: 不经过 Windows 脚本宿主, WSH 被组策略禁用/未安装的
+rem                      服务器上照样能用。
+rem
+rem  默认 (双击本文件)      : pythonw.exe 无窗口起服务 + 等就绪 + 自动开浏览器。
+rem                           ★ .bat 本身是控制台程序, 双击时 Windows 必定先开一个窗口; 本脚本会让它
+rem                             在零点几秒内自己关掉 (脚本内无法消除这个闪窗)。要**完全不闪窗**:
+rem                             用安装时生成的 `启动观澜.lnk`(目标 = .venv\Scripts\pythonw.exe),
+rem                             它不经过 cmd.exe, 一个窗口都不会出现。
+rem  start.bat console      : 前台模式 (旧行为) —— 在当前窗口里跑 serve 并实时打印日志,
+rem                           只在排障时用; 关掉这个窗口等于停掉 serve。
+rem  start.bat help         : 说明。
+rem
+rem  为什么分开: 各组件服务本来就是 DETACHED_PROCESS 起的 (不弹窗), 唯一会弹的就是
+rem  "在控制台里前台跑 guanlan.py serve" 这件事本身。排障又确实需要看见实时输出,
+rem  所以保留一个显式的 console 档, 而不是把日志彻底藏掉。
+rem ============================================================================
+chcp 65001 >nul
+set PYTHONUTF8=1
+cd /d "%~dp0"
+
+if /i "%~1"=="help" goto help
+if /i "%~1"=="console" goto console
+if /i "%~1"=="-c" goto console
+
+if not exist ".venv\Scripts\pythonw.exe" (
+  echo [X] Not installed yet: .venv\Scripts\pythonw.exe not found
+  echo     Run install.bat first.
+  pause
+  exit /b 2
+)
+if not exist "scripts\guanlan_start_hidden.py" (
+  echo [X] Missing launcher: scripts\guanlan_start_hidden.py
+  echo     The package is incomplete - re-extract it ^(check.bat reports this too^).
+  pause
+  exit /b 3
+)
+
+rem 无窗口启动: pythonw.exe 无控制台子系统, 运行期间不出现任何窗口。
+rem (/min 是双保险; 启动器起完 serve 就退出, 不挂窗口。)
+start "" /min ".venv\Scripts\pythonw.exe" "scripts\guanlan_start_hidden.py"
+exit /b 0
+
+:console
+if not exist ".venv\Scripts\python.exe" (
+  echo [X] Not installed yet: .venv\Scripts\python.exe not found
+  echo     Run install.bat first. If install printed errors, send that screen back
+  echo     instead of continuing - the server cannot start without .venv.
+  pause
+  exit /b 2
+)
+echo [i] Foreground mode - this window stays open and shows live logs.
+echo     Close it to stop the server; for no-window start just run start.bat with no argument.
+echo.
+".venv\Scripts\python.exe" guanlan.py serve
+if errorlevel 1 (
+  rem serve exits 1 in two different cases: the gateway never came up, or the gateway is
+  rem up with at least one degraded module, e.g. Ollama not running. Probe healthz to tell
+  rem them apart, otherwise a working server gets reported as "did not start".
+  ".venv\Scripts\python.exe" -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:28084/healthz',timeout=6).status==200 else 1)" 2>nul
+  if errorlevel 1 (
+    echo.
+    echo [X] Gateway did not come up ^(reason above^). Not opening the browser -
+    echo     a "site cannot be reached" page would look like a network problem.
+    echo     See logs\gateway.log
+    pause
+    exit /b 1
+  )
+  echo.
+  echo [!] Gateway is up, but at least one module is degraded ^(the [DOWN] lines above^).
+  echo     Usual cause: local Ollama is not running or has no models pulled, which only
+  echo     disables the Q^&A / local-review pages. Every other page works.
+  echo     Details: logs/ for the degraded module.
+)
+start "" http://127.0.0.1:28084/
+echo.
+echo [i] Ops console - stop/start services, rebuild, clear products:
+echo     http://127.0.0.1:28084/ops
+echo     It stays available while component services are stopped (only the gateway must be up);
+echo     buttons follow real state and the backend refuses impossible actions (HTTP 409).
+exit /b 0
+
+:help
+echo Usage:
+echo   start.bat            no-window start (default): pythonw + browser (this cmd window blinks ~0.2s)
+echo   <<shortcut>>         启动观澜.lnk - zero window at all (created by install; target = pythonw.exe)
+echo   start.bat console    foreground start with live logs (troubleshooting)
+echo   stop.bat             stop everything
+echo   check.bat            self-check
+echo Logs: logs\serve.log (no-window mode), logs\start_hidden.log (launcher trace)
+exit /b 0

BIN
start_hidden.vbs


+ 5 - 5
stop.bat

@@ -1,5 +1,5 @@
-@echo off
-chcp 65001 >nul
-cd /d "%~dp0"
-".venv\Scripts\python.exe" guanlan.py stop
-pause
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+".venv\Scripts\python.exe" guanlan.py stop
+pause