Bläddra i källkod

现场事故修复: start.bat 注释里的中文被 cmd 当命令执行 + echo 里写 <<shortcut>> 触发重定向错 —— 包里 .bat 一律纯 ASCII(英文), 中文说明移入 README/docs; install.ps1 增设 ASCII 名快捷方式 start_guanlan.lnk; 编码守则新增 .bat 纯 ASCII/无 << / 重定向符三条(变异测试两类可拦); 顺带把 portability 门禁的 3 处绝对路径清零

zhouyang.xie 3 veckor sedan
förälder
incheckning
6fb9dcc88d
7 ändrade filer med 611 tillägg och 574 borttagningar
  1. 3 0
      README_先读我.txt
  2. 2 1
      docs/系统设计说明.md
  3. 2 2
      guanlan.py
  4. 7 3
      install.ps1
  5. 346 343
      scripts/pack_dist.py
  6. 216 201
      src/entry_refs.py
  7. 35 24
      start.bat

+ 3 - 0
README_先读我.txt

@@ -18,12 +18,15 @@
 
 二、启动 / 停止
   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)
   一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 2 - 1
docs/系统设计说明.md


+ 2 - 2
guanlan.py

@@ -179,8 +179,8 @@ def cmd_check(c):
         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 解错)")
+        row("入口脚本编码守则 (.ps1 = UTF-8 BOM + CRLF; .bat = 纯 ASCII/CRLF/无 BOM/无 `<<`; .sh = LF)", not _enc,
+            "; ".join(_enc[:2]) or "编码与字符都对 (PS 5.1 才不会把中文按 GBK 解错; 批处理里不放中文/重定向符)")
     except Exception as _e:
         row("入口脚本引用闭合", False, f"{type(_e).__name__}: {_e}")
     # 页面归口 (2026-09-17 用户令 1): 门户里哪些页面是"数据派生快照"(属于产物)、它们陈旧没陈旧。

+ 7 - 3
install.ps1

@@ -111,9 +111,13 @@ function New-GuanlanShortcut([string]$Path) {
 }
 $made = @()
 try {
-  $lnkRoot = Join-Path $rootDir "启动观澜.lnk"
-  New-GuanlanShortcut $lnkRoot
-  $made += $lnkRoot
+  # 中文名那份给人双击 (资源管理器里好认); ASCII 名那份给控制台/CI/脚本引用
+  # (用户令 2 之后 .bat 一律纯 ASCII, 所以 start.bat 的帮助文字只能指向 ASCII 名 —— 2026-09-17)。
+  foreach ($nm in @("启动观澜.lnk", "start_guanlan.lnk")) {
+    $lnkRoot = Join-Path $rootDir $nm
+    New-GuanlanShortcut $lnkRoot
+    $made += $lnkRoot
+  }
   try {
     if ($NoDesktopShortcut) {
       Say "   (-NoDesktopShortcut: 跳过桌面快捷方式 —— 自动核验用, 免得在别人桌面上留一个指向临时目录的死链)"

+ 346 - 343
scripts/pack_dist.py

@@ -1,343 +1,346 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
-
-## 为什么会需要单独一个打包器
-
-直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍:
-`.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID)
-· `data/raw`(31 GB 现场原始件, 按约定不随包分发)。
-本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。
-
-## 包含 / 排除 (规则即文档)
-
-**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
-· 页面与交付(`release/` `resources/` `reference/`) · **产物**(`outputs/`, 页面取数靠它; 加 `--no-products`
-则不含) · 文档(`docs/` `README_先读我.txt` `测试须知.txt`) · 安装/起停脚本(`install.bat` `install.sh`
-`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 换机必失效, 目标机安装时重建
-    _products_off*/                  历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
-    logs/ run/                       本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
-    data/raw/                        现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
-    __pycache__/ *.pyc               解释器缓存
-    *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
-    *.zip(顶层)                      旧的交付压缩包; 避免包中包
-
-## 用法
-
-    python scripts/pack_dist.py --dry-run                  # 只报会打什么/多大, 不写文件
-    python scripts/pack_dist.py                            # 打包 → <父目录>/guanlan-v<VERSION>_dist_<日期>.zip
-    python scripts/pack_dist.py --no-products              # **不含产物**(用户令 2026-09-16): 开箱页面显示"无产物"
-    python scripts/pack_dist.py --with-data                # 连 data/raw 一起打 (30 GB, 慎用)
-    python scripts/pack_dist.py --out D:\out\x.zip         # 指定输出
-    python scripts/pack_dist.py --verify <zip>             # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
-
-## 排除结果写进包
-
-包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs 与逐条排除理由;
-`guanlan.py check` 读它 —— 打了"不含产物"的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
-"""
-from __future__ import annotations
-
-import argparse
-import importlib.util
-import json
-import os
-import pathlib
-import shutil
-import sys
-import tempfile
-import time
-import zipfile
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-
-INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
-                'wheels', 'vendor']
-PRODUCTS_DIR = 'outputs'          # 产物仓: 默认打进包 (页面开箱有数); --no-products 时排除
-# ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
-#   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
-#   `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',
-                 # ★ 无窗口启动: 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;
-#   前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
-#   (实测 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():
-    """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
-    spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
-    m = importlib.util.module_from_spec(spec)
-    sys.modules['check_transferable'] = m
-    spec.loader.exec_module(m)
-    return m
-
-
-def plan(with_data=False, no_products=False):
-    """→ (要打包的顶层条目 list, 排除说明 list)
-
-    2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
-      · 输入数据 `data/`   —— 一直默认排除 (要用 --with-data 才带);
-      · 产物     `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
-      · 日志     `logs/`    —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
-    排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
-    目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
-    """
-    inc = []
-    for d in INCLUDE_DIRS:
-        p = ROOT / d
-        if p.is_dir():
-            inc.append(p)
-    if not no_products:
-        p = ROOT / PRODUCTS_DIR
-        if p.is_dir():
-            inc.append(p)
-    if with_data:
-        inc.append(ROOT / 'data')
-    for f in INCLUDE_FILES:
-        p = ROOT / f
-        if p.is_file():
-            inc.append(p)
-        else:
-            print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
-    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
-    #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
-    #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
-    from src.entry_refs import auto_include, referenced_tree_files
-    got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
-    extra = auto_include(ROOT, got)
-    if extra:
-        for p in extra:
-            print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
-        inc.extend(extra)
-    skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
-    _glob_why = {
-        '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
-        'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
-    }
-    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 的同一口径)')]
-    return inc, skipped
-
-
-def keep(f: pathlib.Path) -> bool:
-    """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
-
-    ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
-    而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
-    现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
-    """
-    parts = f.relative_to(ROOT).parts
-    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])
-
-
-def size_of(p: pathlib.Path):
-    if p.is_file():
-        return 1, p.stat().st_size
-    n = s = 0
-    for f in p.rglob('*'):
-        if f.is_file() and keep(f):
-            n += 1; s += f.stat().st_size
-    return n, s
-
-
-def build(out: pathlib.Path, with_data=False, progress=True, no_products=False) -> int:
-    inc, skipped = plan(with_data, no_products)
-    tot_n = tot_s = 0
-    print('== 要打进包的内容 ==')
-    for p in inc:
-        n, s = size_of(p)
-        tot_n += n; tot_s += s
-        print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
-    print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
-    print('== 排除 ==')
-    for name, why in skipped:
-        print(f'   {name:26s} {why}')
-
-    out.parent.mkdir(parents=True, exist_ok=True)
-    man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
-               includes=[p.relative_to(ROOT).as_posix() for p in inc],
-               excluded={n: w for n, w in skipped}, files=tot_n + 1,   # +1 = dist-manifest.json 自身
-               bytes=tot_s,
-               no_data=not with_data, no_products=bool(no_products), no_logs=True,
-               note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
-                    '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
-                    '再 check → start。要让页面有数: 把现场包放好后跑 '
-                    '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
-                    '.venv 不在包内(换机必失效, 安装时重建)。')
-    t0 = time.time()
-    with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
-        z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
-        done = 0
-        for p in inc:
-            if p.is_file():
-                z.write(p, p.relative_to(ROOT).as_posix())
-                done += 1
-            else:
-                for f in p.rglob('*'):
-                    if not f.is_file() or not keep(f):
-                        continue
-                    z.write(f, f.relative_to(ROOT).as_posix())
-                    done += 1
-            if progress and done % 500 < 1:
-                print(f'   … 已写入 {done} 件', flush=True)
-    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 报错)
-    # + 入口编码守则 (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('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
-    print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
-    return 0
-
-
-def verify(zip_path: pathlib.Path, keep=False) -> int:
-    """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
-    tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
-    if tmp.exists():
-        shutil.rmtree(tmp, ignore_errors=True)
-    print(f'① 解压到 {tmp}')
-    t0 = time.time()
-    with zipfile.ZipFile(zip_path) as z:
-        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 → 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] 引用全部在位, 编码守则通过 (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('④ 收尾')
-    if keep:
-        print(f'   解压目录保留在 {tmp} (--keep)')
-    else:
-        shutil.rmtree(tmp, ignore_errors=True)
-        print('   已清理')
-    print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}')
-    return 0 if ok >= 4 else 1
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--out', default=None)
-    ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)')
-    ap.add_argument('--no-products', action='store_true',
-                    help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
-    ap.add_argument('--dry-run', action='store_true')
-    ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
-    ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
-    a = ap.parse_args()
-    if a.verify:
-        return verify(pathlib.Path(a.verify), a.keep)
-    out = (pathlib.Path(a.out) if a.out
-           else ROOT.parent / f'guanlan-v{VERSION}_dist_{time.strftime("%Y%m%d")}.zip')
-    if a.dry_run:
-        inc, skipped = plan(a.with_data, a.no_products)
-        tot_n = tot_s = 0
-        print(f'== 会打进包 (version {VERSION}) ==')
-        for p in inc:
-            n, s = size_of(p); tot_n += n; tot_s += s
-            print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
-        print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
-        print('== 会排除 ==')
-        for name, why in skipped:
-            print(f'   {name:26s} {why}')
-        print('\n(dry-run, 未写文件)')
-        return 0
-    return build(out, a.with_data, no_products=a.no_products)
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
+
+## 为什么会需要单独一个打包器
+
+直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍:
+`.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID)
+· `data/raw`(31 GB 现场原始件, 按约定不随包分发)。
+本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。
+
+## 包含 / 排除 (规则即文档)
+
+**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
+· 页面与交付(`release/` `resources/` `reference/`) · **产物**(`outputs/`, 页面取数靠它; 加 `--no-products`
+则不含) · 文档(`docs/` `README_先读我.txt` `测试须知.txt`) · 安装/起停脚本(`install.bat` `install.sh`
+`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 换机必失效, 目标机安装时重建
+    _products_off*/                  历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
+    logs/ run/                       本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
+    data/raw/                        现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
+    __pycache__/ *.pyc               解释器缓存
+    *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
+    *.zip(顶层)                      旧的交付压缩包; 避免包中包
+
+## 用法
+
+    python scripts/pack_dist.py --dry-run                  # 只报会打什么/多大, 不写文件
+    python scripts/pack_dist.py                            # 打包 → <父目录>/guanlan-v<VERSION>_dist_<日期>.zip
+    python scripts/pack_dist.py --no-products              # **不含产物**(用户令 2026-09-16): 开箱页面显示"无产物"
+    python scripts/pack_dist.py --with-data                # 连 data/raw 一起打 (30 GB, 慎用)
+    python scripts/pack_dist.py --out D:\out\x.zip         # 指定输出 # portability-allow: 只是用法示例里的 Windows 路径写法, 不是本机路径
+    python scripts/pack_dist.py --verify <zip>             # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
+
+## 排除结果写进包
+
+包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs 与逐条排除理由;
+`guanlan.py check` 读它 —— 打了"不含产物"的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import os
+import pathlib
+import shutil
+import sys
+import tempfile
+import time
+import zipfile
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
+                'wheels', 'vendor']
+PRODUCTS_DIR = 'outputs'          # 产物仓: 默认打进包 (页面开箱有数); --no-products 时排除
+# ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
+#   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
+#   `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',
+                 # ★ 无窗口启动: 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;
+#   前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
+#   (实测 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():
+    """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
+    spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
+    m = importlib.util.module_from_spec(spec)
+    sys.modules['check_transferable'] = m
+    spec.loader.exec_module(m)
+    return m
+
+
+def plan(with_data=False, no_products=False):
+    """→ (要打包的顶层条目 list, 排除说明 list)
+
+    2026-09-16 用户令: 打包**不含 输入数据 / 产物 / 日志**。
+      · 输入数据 `data/`   —— 一直默认排除 (要用 --with-data 才带);
+      · 产物     `outputs/` —— 新增 --no-products 排除 (默认仍带: 页面开箱即有数);
+      · 日志     `logs/`    —— 一直在 EXCLUDE_DIRS 里 (连同 run/ 旧 PID)。
+    排除产物时, 包内 `dist-manifest.json` 会记 `no_products: true`,
+    目标机 `guanlan.py check` 据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
+    """
+    inc = []
+    for d in INCLUDE_DIRS:
+        p = ROOT / d
+        if p.is_dir():
+            inc.append(p)
+    if not no_products:
+        p = ROOT / PRODUCTS_DIR
+        if p.is_dir():
+            inc.append(p)
+    if with_data:
+        inc.append(ROOT / 'data')
+    for f in INCLUDE_FILES:
+        p = ROOT / f
+        if p.is_file():
+            inc.append(p)
+        else:
+            print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
+    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
+    #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
+    #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
+    from src.entry_refs import auto_include, referenced_tree_files
+    got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
+    extra = auto_include(ROOT, got)
+    if extra:
+        for p in extra:
+            print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
+        inc.extend(extra)
+    skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
+    _glob_why = {
+        '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
+        'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
+    }
+    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 的同一口径)')]
+    return inc, skipped
+
+
+def keep(f: pathlib.Path) -> bool:
+    """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
+
+    ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
+    而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
+    现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
+    """
+    parts = f.relative_to(ROOT).parts
+    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])
+
+
+def size_of(p: pathlib.Path):
+    if p.is_file():
+        return 1, p.stat().st_size
+    n = s = 0
+    for f in p.rglob('*'):
+        if f.is_file() and keep(f):
+            n += 1; s += f.stat().st_size
+    return n, s
+
+
+def build(out: pathlib.Path, with_data=False, progress=True, no_products=False) -> int:
+    inc, skipped = plan(with_data, no_products)
+    tot_n = tot_s = 0
+    print('== 要打进包的内容 ==')
+    for p in inc:
+        n, s = size_of(p)
+        tot_n += n; tot_s += s
+        print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
+    print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
+    print('== 排除 ==')
+    for name, why in skipped:
+        print(f'   {name:26s} {why}')
+
+    out.parent.mkdir(parents=True, exist_ok=True)
+    man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
+               includes=[p.relative_to(ROOT).as_posix() for p in inc],
+               excluded={n: w for n, w in skipped}, files=tot_n + 1,   # +1 = dist-manifest.json 自身
+               bytes=tot_s,
+               no_data=not with_data, no_products=bool(no_products), no_logs=True,
+               note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
+                    '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
+                    '再 check → start。要让页面有数: 把现场包放好后跑 '
+                    '`scripts/place_raw_data.py --src <现场包目录> --scope full` + `scripts/rebuild_all.py`。'
+                    '.venv 不在包内(换机必失效, 安装时重建)。')
+    t0 = time.time()
+    with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
+        z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
+        done = 0
+        for p in inc:
+            if p.is_file():
+                z.write(p, p.relative_to(ROOT).as_posix())
+                done += 1
+            else:
+                for f in p.rglob('*'):
+                    if not f.is_file() or not keep(f):
+                        continue
+                    z.write(f, f.relative_to(ROOT).as_posix())
+                    done += 1
+            if progress and done % 500 < 1:
+                print(f'   … 已写入 {done} 件', flush=True)
+    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 报错)
+    # + 入口编码守则 (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('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
+    print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
+    return 0
+
+
+def verify(zip_path: pathlib.Path, keep=False) -> int:
+    """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
+    tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
+    if tmp.exists():
+        shutil.rmtree(tmp, ignore_errors=True)
+    print(f'① 解压到 {tmp}')
+    t0 = time.time()
+    with zipfile.ZipFile(zip_path) as z:
+        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 → 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] 引用全部在位, 编码守则通过 (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':
+        # 2026-09-17: 安装脚本现在建**两份**快捷方式 —— 中文名给人双击, ASCII 名 (start_guanlan.lnk)
+        # 给控制台/脚本引用 (纯 ASCII 的 start.bat 只能引用 ASCII 名)。两份都要在。
+        for nm in ('启动观澜.lnk', 'start_guanlan.lnk'):
+            lnk = tmp / nm
+            if lnk.is_file() and lnk.stat().st_size > 0:
+                print(f'   [OK] 安装时已生成快捷方式: {nm} ({lnk.stat().st_size} B)')
+            else:
+                bad.append(f'安装后没有 {nm} (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('④ 收尾')
+    if keep:
+        print(f'   解压目录保留在 {tmp} (--keep)')
+    else:
+        shutil.rmtree(tmp, ignore_errors=True)
+        print('   已清理')
+    print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}')
+    return 0 if ok >= 4 else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--out', default=None)
+    ap.add_argument('--with-data', action='store_true', help='连 data/raw 一起打(约 31 GB, 慎用)')
+    ap.add_argument('--no-products', action='store_true',
+                    help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
+    ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
+    a = ap.parse_args()
+    if a.verify:
+        return verify(pathlib.Path(a.verify), a.keep)
+    out = (pathlib.Path(a.out) if a.out
+           else ROOT.parent / f'guanlan-v{VERSION}_dist_{time.strftime("%Y%m%d")}.zip')
+    if a.dry_run:
+        inc, skipped = plan(a.with_data, a.no_products)
+        tot_n = tot_s = 0
+        print(f'== 会打进包 (version {VERSION}) ==')
+        for p in inc:
+            n, s = size_of(p); tot_n += n; tot_s += s
+            print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
+        print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
+        print('== 会排除 ==')
+        for name, why in skipped:
+            print(f'   {name:26s} {why}')
+        print('\n(dry-run, 未写文件)')
+        return 0
+    return build(out, a.with_data, no_products=a.no_products)
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 216 - 201
src/entry_refs.py

@@ -1,201 +1,216 @@
-# -*- coding: utf-8 -*-
-r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
-
-## 为什么要这个模块
-
-交付包 v0.4.0 第一次打出来时, 目标机 (D:\产品\app) 双击 `start.bat` 报:
-
-    无法找到脚本文件 "D:\产品\app\start_hidden.vbs"
-
-原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
-`start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
-不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
-
-    凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
-    它就**必须**出现在包里(或被打包器自动补进去)。
-
-· 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
-  按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
-· `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
-· 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
-
-★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
-  但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
-
-单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
-"""
-from __future__ import annotations
-
-import pathlib
-import re
-
-# 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
-# ★ 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/域名当文件)
-_REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
-                  r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
-                  r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
-
-_STRIP = ('dp0', 'DP0')          # %~dp0 / %dp0 展开后的残前缀
-
-
-def _candidates(tok: str):
-    """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
-    t = tok.replace('\\', '/').lstrip('./')
-    cands = [t]
-    for p in _STRIP:
-        if t.startswith(p) and len(t) > len(p):
-            cands.append(t[len(p):])
-    parts = t.split('/')
-    cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
-    return [c for c in dict.fromkeys(cands) if c]
-
-
-def _norm(name: str) -> str:
-    return name.replace('\\', '/').lstrip('./').lower()
-
-
-def entry_refs(path: pathlib.Path) -> set[str]:
-    """一个入口脚本里出现的全部"像文件"的路径 token。"""
-    try:
-        txt = path.read_text(encoding='utf-8-sig', errors='replace')
-    except OSError:
-        return set()
-    return {m.group(0) for m in _REF.finditer(txt)}
-
-
-def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
-    """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
-
-    本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
-    """
-    root = pathlib.Path(root)
-    out: dict[str, set[str]] = {}
-    for name in ENTRY_FILES:
-        p = root / name
-        if not p.is_file():
-            continue
-        found: set[str] = set()
-        for tok in entry_refs(p):
-            for cand in _candidates(tok):
-                if (root / cand).is_file():
-                    found.add(_norm(cand))
-                    break
-        if found:
-            out[name] = found
-    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 下的真实文件判断。
-
-    provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
-    """
-    have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
-    bad: list[tuple[str, str]] = []
-    root = pathlib.Path(root)
-    for entry, refs in sorted(referenced_tree_files(root).items()):
-        for rel in sorted(refs):
-            ok = (rel in have) if have is not None else (root / rel).is_file()
-            if not ok:
-                bad.append((entry, rel))
-    return bad
-
-
-def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
-    """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
-
-    只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
-    缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
-    """
-    got = {_norm(x) for x in already}
-    root = pathlib.Path(root)
-    add: list[pathlib.Path] = []
-    for _, refs in referenced_tree_files(root).items():
-        for rel in refs:
-            if '/' in rel or rel in got:
-                continue
-            p = root / rel
-            if p.is_file():
-                add.append(p)
-                got.add(rel)
-    return sorted(set(add), key=lambda p: p.name)
-
-
-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())} 条引用全部在位)')
-    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)
+# -*- coding: utf-8 -*-
+r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
+
+## 为什么要这个模块
+
+交付包 v0.4.0 第一次打出来时, 目标机 (现场安装目录 <安装目录>) 双击 `start.bat` 报:
+
+    无法找到脚本文件 "<安装目录>\start_hidden.vbs"
+
+原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
+`start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
+不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
+
+    凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
+    它就**必须**出现在包里(或被打包器自动补进去)。
+
+· 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
+  按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
+· `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
+· 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
+
+★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
+  但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
+
+单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
+"""
+from __future__ import annotations
+
+import pathlib
+import re
+
+# 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
+# ★ 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/域名当文件)
+_REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
+                  r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
+                  r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
+
+_STRIP = ('dp0', 'DP0')          # %~dp0 / %dp0 展开后的残前缀
+
+
+def _candidates(tok: str):
+    """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
+    t = tok.replace('\\', '/').lstrip('./')
+    cands = [t]
+    for p in _STRIP:
+        if t.startswith(p) and len(t) > len(p):
+            cands.append(t[len(p):])
+    parts = t.split('/')
+    cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
+    return [c for c in dict.fromkeys(cands) if c]
+
+
+def _norm(name: str) -> str:
+    return name.replace('\\', '/').lstrip('./').lower()
+
+
+def entry_refs(path: pathlib.Path) -> set[str]:
+    """一个入口脚本里出现的全部"像文件"的路径 token。"""
+    try:
+        txt = path.read_text(encoding='utf-8-sig', errors='replace')
+    except OSError:
+        return set()
+    return {m.group(0) for m in _REF.finditer(txt)}
+
+
+def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
+    """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
+
+    本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
+    """
+    root = pathlib.Path(root)
+    out: dict[str, set[str]] = {}
+    for name in ENTRY_FILES:
+        p = root / name
+        if not p.is_file():
+            continue
+        found: set[str] = set()
+        for tok in entry_refs(p):
+            for cand in _candidates(tok):
+                if (root / cand).is_file():
+                    found.add(_norm(cand))
+                    break
+        if found:
+            out[name] = found
+    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, 否则第一行失效)')
+        # ★ 2026-09-17: .bat 必须**纯 ASCII**。原因: 批处理的解析与输出都跟控制台代码页绑在一起
+        #   (cp936 / cp65001 / 系统区域设置)。现场实测: 中文注释行被 cmd 拆开当成命令执行
+        #   ("'本身是控制台程序' 不是内部或外部命令") —— 放在注释里都不安全。
+        #   所有中文说明改放 README / docs; .bat 只留英文。
+        nonascii = [x for x in b if x > 0x7F]
+        if nonascii:
+            bad.append(f'{rel} 含 {len(nonascii)} 个非 ASCII 字节 (.bat 必须纯 ASCII: 中文在批处理里会随代码页被曲解, '
+                       f'现场出现过"注释被当命令执行"; 中文说明放 README/docs)')
+        txt = b.decode('utf-8', 'replace')
+        if '<<' in txt:
+            bad.append(f'{rel} 含 `<<` —— 在批处理里是重定向, `echo <x>` 这种写法会直接报错')
+        for m in re.finditer(r'>{1,2}(?!\s|"|nul\b|&)', txt):
+            bad.append(f'{rel} 第 {txt[:m.start()].count(chr(10)) + 1} 行的 `>` 后面不是重定向目标 '
+                       f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)')
+            break
+    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 下的真实文件判断。
+
+    provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
+    """
+    have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
+    bad: list[tuple[str, str]] = []
+    root = pathlib.Path(root)
+    for entry, refs in sorted(referenced_tree_files(root).items()):
+        for rel in sorted(refs):
+            ok = (rel in have) if have is not None else (root / rel).is_file()
+            if not ok:
+                bad.append((entry, rel))
+    return bad
+
+
+def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
+    """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
+
+    只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
+    缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
+    """
+    got = {_norm(x) for x in already}
+    root = pathlib.Path(root)
+    add: list[pathlib.Path] = []
+    for _, refs in referenced_tree_files(root).items():
+        for rel in refs:
+            if '/' in rel or rel in got:
+                continue
+            p = root / rel
+            if p.is_file():
+                add.append(p)
+                got.add(rel)
+    return sorted(set(add), key=lambda p: p.name)
+
+
+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())} 条引用全部在位)')
+    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)

+ 35 - 24
start.bat

@@ -1,24 +1,31 @@
 @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  Guanlan start.bat
 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  ASCII ONLY ON PURPOSE. Chinese text inside a .bat is codepage-dependent
+rem  (console cp936 / cp65001 / system locale), and cmd.exe has been seen to
+rem  mis-parse such lines and then try to EXECUTE parts of them
+rem  ("'...' is not recognized as an internal or external command").
+rem  So every .bat in this package is pure ASCII; all Chinese guidance lives in
+rem  README (see "README" text file at the install root) and docs\.
+rem  The rule is enforced by src\entry_refs.py (check.bat reports it) and by the
+rem  packer: a .bat with non-ASCII bytes never ships.
 rem
-rem  为什么分开: 各组件服务本来就是 DETACHED_PROCESS 起的 (不弹窗), 唯一会弹的就是
-rem  "在控制台里前台跑 guanlan.py serve" 这件事本身。排障又确实需要看见实时输出,
-rem  所以保留一个显式的 console 档, 而不是把日志彻底藏掉。
+rem  Default (double-click) : no-window start via pythonw.exe + the launcher.
+rem      A .bat is itself a console program, so Windows opens a window for a
+rem      moment; this script exits at once and the window closes by itself.
+rem      A .bat cannot avoid that flash. For a truly windowless start use the
+rem      shortcut created by install.bat / install.ps1:
+rem          [install dir]\start_guanlan.lnk     (ASCII name: console / CI / scripts)
+rem          [install dir] and Desktop: a .lnk with the Chinese product name
+rem      Both point straight at .venv\Scripts\pythonw.exe, so neither cmd.exe nor
+rem      Windows Script Host is involved.
+rem
+rem  start.bat console      : foreground mode (old behaviour). Keeps this window
+rem                           open and prints live logs; closing it stops serve.
+rem  start.bat help         : this text.
+rem
+rem  Logs (docs section 11.2): logs\serve.log and logs\start_hidden.log.
 rem ============================================================================
 chcp 65001 >nul
 set PYTHONUTF8=1
@@ -36,13 +43,14 @@ if not exist ".venv\Scripts\pythonw.exe" (
 )
 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^).
+  echo     The package is incomplete - re-extract it. check.bat reports this too.
   pause
   exit /b 3
 )
 
-rem 无窗口启动: pythonw.exe 无控制台子系统, 运行期间不出现任何窗口。
-rem (/min 是双保险; 启动器起完 serve 就退出, 不挂窗口。)
+rem No-window start: pythonw.exe has no console subsystem, so no window appears
+rem while the services run. /min is a belt-and-braces flag; the launcher exits
+rem as soon as serve is up.
 start "" /min ".venv\Scripts\pythonw.exe" "scripts\guanlan_start_hidden.py"
 exit /b 0
 
@@ -55,7 +63,8 @@ if not exist ".venv\Scripts\python.exe" (
   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     Close it to stop the server; for no-window start run start.bat with no argument.
+echo     For a truly windowless start use the shortcut start_guanlan.lnk.
 echo.
 ".venv\Scripts\python.exe" guanlan.py serve
 if errorlevel 1 (
@@ -75,7 +84,7 @@ if errorlevel 1 (
   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.
+  echo     Details: logs\ for the degraded module.
 )
 start "" http://127.0.0.1:28084/
 echo.
@@ -87,10 +96,12 @@ 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            no-window start (default): pythonw + browser. This cmd window
+echo                        blinks for a fraction of a second, then closes itself.
+echo   start_guanlan.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)
+echo Docs: README text file at the install root; docs\ folder (design, runbook, data placement)
 exit /b 0

Vissa filer visades inte eftersom för många filer har ändrats