Ver Fonte

用户令: ① 增加卸载脚本 uninstall.bat/uninstall.sh; ② 版本号规则与打包版本管理 (本次打包 v2.5.0)

① 卸载 (新增 3 个文件 + 文档)
   uninstall.bat (纯 ASCII/CRLF) + uninstall.sh (LF) → 共用同一份实现 scripts/guanlan_uninstall.py
   (与 install.bat/install.sh→install.ps1 同构, 免得两平台各写一遍行为不一致)。
   默认只拆"本程序装上去的东西", 六步: ①停服务 ②注销服务(需管理员/root) ③撤开机自启
   ④删快捷方式 ⑤清运行态 ⑥删安装记录 install-info.json。
   ★ 默认**保留** data/ outputs/ logs/ .venv/ 与整棵程序目录 —— 卸载程序 ≠ 销毁数据;
     --purge 才删数据(再问一次, --keep-logs 可留日志), --all 才删程序本体。
   ★ 程序目录不自己删: Windows 上正在跑的 python.exe 就在 .venv 里, 删自己必然只删一半;
     uninstall.bat 先 cd /d "%TEMP%" 再调 Python (Windows 不允许删"活进程的当前目录", 而 --all
     要删的正是这棵目录; 路径全用绝对路径, 不依赖 cwd) ⇒ 实测 --all 能连安装根一起删掉。
   --dry-run 只打印计划; --selftest 两类断言(计划齐全 + dry-run 无副作用)。
   实测: 临时副本上真卸载 rc=0, install-info.json/run//两个 .lnk 全清、data+outputs 件数不变;
        --purge 删 3 项; --all 后"安装根还在? False"。
   顺带修 read_installed → utf-8-sig: 带 BOM 的 install-info.json 原先会让版本检查"明明装了却报没装过"。
   开箱验证新增第 ③b 步(scripts/check_transferable.py::uninstall_probe): 在**刚装好的副本**上真跑
   卸载器并断言"记录/运行态/快捷方式都拆掉 + 数据与产物一件不少 + 端口已释放"; 子进程输出点名
   encoding='utf-8' (原先按 cp936 解 ⇒ 核验打印成乱码, 证据没法看)。

② 版本管理与打包命名 (用户令: 大=方案/架构/核心功能, 中=非核心功能增减改, 小=消缺)
   src/version.py 增 RULE / LEVEL_MEANING / BUMP_RULE / level_of() / package_name() / HISTORY,
   VERSION: 0.4.0 → **2.5.0** (二代架构第 5 次功能性发布, 无消缺项)。
   打包器删掉自带的 VERSION='0.4.0' 常量改读真源(此前它与 README 抬头、说明书标题三处并存),
   默认包名 guanlan-v<版本>_dist_<日期>.zip → **app_guanlang_v<版本号>.zip**
   ⇒ 本次产出 F:\temp\app_guanlang_v2.5.0.zip (879 MB, 3440 件)。
   新增 scripts/version_log.py: 由 HISTORY 生成 docs/版本记录.md(规则+版本表)并支持 --check;
   guanlan.py 增 `version` 子命令; check 增两行(版本记录与代码一致 / 卸载入口在位)。
   uninstall.bat·uninstall.sh 纳入入口引用闭合与编码守则(它引用 scripts/guanlan_uninstall.py,
   少进包 = 装得上卸不掉)。

文档: README 增"八、卸载"、五、打包补版本号规则与包名; docs/系统设计说明.md 增 §14.5(卸载)、
     §15(版本管理与打包命名)、§15.3 版本表, 变更记录增一行; 新增 docs/版本记录.md(自动生成)。
      docs/系统设计说明.md §14.5 里写明两条设计取舍(默认不删数据/不删程序目录、dry-run 当自证口径)。

自检: config_audit rc=0 · log_audit rc=0 · version_log --check OK · 呼应校验 rc=0 ·
     入口引用闭合 45 条 0 缺 · 编码守则 0 问题; pages_audit rc=6 仅剩"用户 14:58 清产物"那两条。
     开箱验证: 解压 3441 件 → ①b 闭合+守则通过 → ②离线安装 rc=0/177s → ②b 无窗口成立+包内无 .vbs
     → ③页面 5/5(副本独立端口 28700, 主实例未动) → ③b 卸载核验通过 → ④清理。
zhouyang.xie há 3 semanas atrás
pai
commit
b91d7f7890

+ 35 - 8
README_先读我.MD

@@ -55,8 +55,15 @@
     pack.bat dry      只预演: 报表里会打什么、多大, 以及逐条排除理由(不写文件)
     pack.bat verify   对已打好的 zip 做开箱验证: 解压 → 离线安装 → 起服务核验页面 → 清理
     <PY> scripts/pack_dist.py --out <目标.zip>     指定输出路径
-  输出: 默认写到**安装目录的上一级**, 文件名 guanlan-v<版本>_dist_<日期>.zip
-        (例: 装到 D:\guanlan\app, 则产物在 D:\guanlan\guanlan-v0.4.0_dist_20260917.zip)
+  输出: 默认写到**安装目录的上一级**, 文件名 app_guanlang_v<版本号>.zip
+        (例: 装到 D:\guanlan\app 且本包版本 2.5.0, 则产物在 D:\guanlan\app_guanlang_v2.5.0.zip)
+  ★ 版本号规则 (用户令 2026-09-17): 编号 v<大版本号>.<中版本号>.<小版本号>, 例 v2.5.0
+      大版本号 —— 系统解决方案、架构或核心功能改变
+      中版本号 —— 非核心功能新增、减少、修改
+      小版本号 —— 消缺完善
+    版本号只写在 src/version.py 一行(VERSION), 打包器/安装脚本/安装记录/自检都读它;
+    改完跑 <PY> scripts/version_log.py --write 让 docs/版本记录.md 跟上(自检会校验两者一致);
+    看当前版本与规则: <PY> guanlan.py version
   ★ 包里**不含**这四类(默认口径; 每条理由都写进包内 dist-manifest.json):
     ① 输入数据     data/                    现场原始件; 例外要带用 --with-data (约 31 GB)
     ② 产物/输出数据 outputs/                 目标机放数据后 place_raw_data.py + rebuild_all.py 重算;
@@ -66,7 +73,7 @@
                    编辑器临时与备份(*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*)、
                    系统索引(.DS_Store、Thumbs.db、desktop.ini), 以及 .git/.venv/*.pyc/*.lnk/顶层 *.zip
   包里**有**: src/ scripts/ guanlan.py · configs/ · release/(门户/仿真/三维/交付件) · resources/ · reference/ ·
-             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack 脚本 · requirements.txt
+             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack/uninstall 脚本 · requirements.txt
   打完自查: 包内 dist-manifest.json 记着 version / no_data / no_products / no_logs / no_temp + 逐条排除理由;
             打包器自己还会核"入口脚本引用的文件都在包里"与"入口脚本编码守则", 不合格的包会被删掉并返回非 0。
   要让页面开箱即有数(例外做法): <PY> scripts/pack_dist.py --with-products
@@ -79,14 +86,11 @@
   · 某页没数据: 先看控制台的退出码与 logs/, 再看 docs/重算缺口与补件清单_v0.1.md。
 
 包内含: 程序与配置 / 门户与仿真 / 三维资产 release/viewer / 治理清单交付件 release/如东(客户交付物勿外传) /
-        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh
+        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh /
+        安装与卸载脚本 install.bat · install.sh · uninstall.bat · uninstall.sh
 包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(运行日志) · 临时文件与缓存 ·
           .venv(安装时重建) · .git(版本库)
 
-详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径) · docs/重算操作手册_v0.1.md
-          docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
-          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md
-
 七、服务化 (装成 Windows 服务 / systemd; 用户令 2026-09-17)
   Windows (管理员 PowerShell):  <PY> scripts/service_ctl.py install         注册为 Windows 服务(开机自起)
                                 <PY> scripts/service_ctl.py install --dry-run  先看它要做什么(免管理员)
@@ -97,3 +101,26 @@
   ★ 安装时会先检查是否已安装及版本: 读 <安装目录>/install-info.json 与本包版本比较,
     同版本/升级/降级/老安装无版本记录 四类都会提示并询问是否重装;
     跳过询问用 install.bat -Force (Linux: sh install.sh --force)。版本单一真源 = src/version.py。
+
+八、卸载 (用户令 2026-09-17)
+  Windows : 双击 uninstall.bat     Linux / macOS : sh uninstall.sh
+  带开关的用法 (两种平台一样):
+    --dry-run      只打印"将要做什么", 一个字节都不动 (先看这个最稳妥)
+    --yes          不再询问, 直接执行 (脚本/无人值守用)
+    --purge        连 数据/产物/日志/虚拟环境 一起删 (会再问一次; 加 --keep-logs 保留日志)
+    --all          连程序本体一起删 (删不掉的逐条列出, 并给一条收尾命令)
+    --selftest     自检: 六个步骤齐全 + dry-run 无副作用
+  默认只拆"本程序装上去的东西", 顺序是:
+    ① 停服务(服务化常驻体 + 手工启动的组件)  ② 注销服务(sc.exe delete / systemctl disable; 需管理员/root)
+    ③ 撤开机自启(登录自启项)  ④ 删快捷方式(启动观澜.lnk / start_guanlan.lnk, 含桌面)
+    ⑤ 清运行态(run/、*.pid)  ⑥ 删安装记录 install-info.json (删掉它 = 这台机器回到"没装过")
+  ★ **默认保留**: data/(输入数据) · outputs/(产物) · logs/ · .venv/ · 整棵程序目录。
+    卸载程序 ≠ 销毁数据; 要连数据一起清, 显式给 --purge。
+  ★ 程序目录不自己删(Windows 上正在跑的 python.exe 就在 .venv 里, 删自己必然只删一半):
+    要清干净用 --all, 或卸载完直接删目录 `rmdir /s /q <安装目录>`。
+  免管理员也能卸: 服务注销那一步缺权限时**照做其余步骤**, 并打印你该手工执行的那两条命令。
+
+详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径 · §14 服务化与卸载 · §15 版本管理与打包命名)
+          docs/版本记录.md(版本号规则与版本表) · docs/重算操作手册_v0.1.md
+          docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
+          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md

+ 36 - 0
docs/版本记录.md

@@ -0,0 +1,36 @@
+# 版本记录(观澜·如东样板 v2)
+
+> 本文件由 `scripts/version_log.py` 生成,**勿手改**:事实(版本号、级别、变更)写在
+> `src/version.py` 的 `HISTORY` 里,改完跑 `python scripts/version_log.py --write`。
+> `guanlan.py check` 与打包器都会校验本文件与代码里的版本号是否一致。
+
+<!-- VERSION-LOG:BEGIN -->
+## 版本号规则(用户令 2026-09-17)
+
+编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.5.0`。
+
+| 级别 | 含义 |
+|---|---|
+| 大版本号 | 系统解决方案、架构或核心功能改变 |
+| 中版本号 | 非核心功能新增、减少、修改 |
+| 小版本号 | 消缺完善 |
+
+**怎么改**:改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级
+
+**打包文件名** = `app_guanlang_v2.5.0.zip`(规则:`app_guanlang_v<版本号>.zip`)。
+
+版本号只写在 `src/version.py` 一行(`VERSION = '2.5.0'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
+
+| 版本 | 日期 | 级别 | 主要变更 | 说明 |
+|---|---|---|---|---|
+| `v2.5.0` | 2026-09-17 | 中 | 卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录 | 二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;交付包 app_guanlang_v2.5.0.zip |
+| `v0.4.0` | 2026-09-17 | 旧编号 | 打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;页面归口审计;输入数据放置检查;安装时版本检查与服务化 | 旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip) |
+| `v0.2.0` | 2026-09-01 | 旧编号 | 含产物的旧交付基线(留存的历史交付包,用作"包内无生成端"那批产物的补救源) | 旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip(已入库备份) |
+
+## 本次交付
+
+* 版本:`v2.5.0`(观澜·如东样板 v2)
+* 交付包:`app_guanlang_v2.5.0.zip`
+* 服务名:`guanlan`(Windows 服务 / systemd 单元)
+* 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`
+<!-- VERSION-LOG:END -->

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
docs/系统设计说明.md


+ 33 - 1
guanlan.py

@@ -183,6 +183,25 @@ def cmd_check(c):
             "; ".join(_enc[:2]) or "编码与字符都对 (PS 5.1 才不会把中文按 GBK 解错; 批处理里不放中文/重定向符)")
     except Exception as _e:
         row("入口脚本引用闭合", False, f"{type(_e).__name__}: {_e}")
+    # 版本管理 (2026-09-17 用户令): ① 版本记录 md 必须与 src/version.py 一致(否则包里会出现
+    # "包说 v2.5.0、记录表最后一行还是 v2.4.0");② 卸载入口必须在位(装得上卸不掉 = 交付缺陷)。
+    try:
+        from src import version as _V
+        import importlib.util as _ilu2
+        _sp = _ilu2.spec_from_file_location('_version_log', ROOT / "scripts" / "version_log.py")
+        _vm = _ilu2.module_from_spec(_sp)
+        _sp.loader.exec_module(_vm)
+        _doc = ROOT / "docs" / "版本记录.md"
+        _ok = _doc.is_file() and _vm.block_of(_doc.read_text(encoding="utf-8")) == _vm.body().strip()
+        row(f"版本管理: {_V.NAME} v{_V.VERSION}(记录表与代码一致, 包名 {_V.package_name()})", _ok,
+            "" if _ok else "docs/版本记录.md 与 src/version.py 不一致 —— 跑 python scripts/version_log.py --write")
+        _un = [f for f in ("uninstall.bat", "uninstall.sh") if (ROOT / f).is_file()]
+        _unpy = ROOT / "scripts" / "guanlan_uninstall.py"
+        _uok = len(_un) == 2 and _unpy.is_file()
+        row("卸载入口在位 (uninstall.bat / uninstall.sh → scripts/guanlan_uninstall.py)", _uok,
+            f"{len(_un)}/2 个入口" + ("" if _uok else " —— 缺卸载入口: 装得上卸不掉"))
+    except Exception as _e:
+        row("版本管理与卸载入口", False, f"{type(_e).__name__}: {_e}")
     # 页面归口 (2026-09-17 用户令 1): 门户里哪些页面是"数据派生快照"(属于产物)、它们陈旧没陈旧。
     # 用户问过"#findings/#sim/#documents 该不该随输入数据变、算不算产物" —— 答案落在
     # configs/portal_pages.yaml 登记表 + scripts/pages_audit.py 的检查里, 这里只报结论。
@@ -316,11 +335,24 @@ def cmd_stop(c):
     print("全部已停" if not left else f"仍在监听 (可能是别的实例): {left}"); return 0
 
 
+def cmd_version(c):
+    """版本号 / 规则 / 版本记录 (用户令 2026-09-17: 打包版本管理)。"""
+    from src import version as V
+    print(f"{V.NAME}  v{V.VERSION}  ({V.EDITION})")
+    for ln in V.rule_lines():          # 规则 + 例 + 包名(措辞与 README/版本记录同一份)
+        print("  " + ln)
+    print("  版本表 (完整见 docs/版本记录.md):")
+    for h in V.HISTORY:
+        print(f"    v{h.get('version', '?'):<8} {h.get('date', '?')}  [{h.get('level', '?')}]  {h.get('title', '')[:60]}")
+    return 0
+
+
 def main():
     a = sys.argv[1] if len(sys.argv) > 1 else "check"; c = cfg()
     if a == "open": url = f"http://{c['host']}:{c['gateway']}/"; print(url); webbrowser.open(url); return 0
     if a == "qa": return subprocess.call([py(c), str(ROOT / "scripts/llm_known_answer.py"), "--port", str(c["detail"])] + sys.argv[2:], cwd=str(ROOT))
-    return {"check": cmd_check, "serve": cmd_serve, "status": cmd_status, "stop": cmd_stop}.get(a, lambda c: (print(__doc__), 2)[1])(c)
+    return {"check": cmd_check, "serve": cmd_serve, "status": cmd_status, "stop": cmd_stop,
+            "version": cmd_version}.get(a, lambda c: (print(__doc__), 2)[1])(c)
 
 
 if __name__ == "__main__":

+ 59 - 0
scripts/check_transferable.py

@@ -234,6 +234,9 @@ def install_in(dst: pathlib.Path) -> pathlib.Path | None:
     return py
 
 
+LAST_GATEWAY_PORT: int | None = None      # run_and_probe 实际用的网关端口(卸载核验要拿它查"释放了没")
+
+
 def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
     """在副本里起服务并核验页面 —— **完全不动正在跑的实例**: 副本整套换端口跑。
 
@@ -244,6 +247,7 @@ def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
     换到空闲端口 (网关 + 5 个组件), 主实例照常跑, 谁也不打扰谁。
     端口占用仍会兜底: 找不到空闲端口就跳过启动核验 (不计失败)。
     """
+    global LAST_GATEWAY_PORT
     comp_keys = ('detail', 'cms', 'sim', 'sim_sys', 'viewer')
     cfg_p = dst / 'configs' / P.SERVE_JSON.name          # config-path-allow: 改的是**副本**里的那份配置
     try:
@@ -256,6 +260,7 @@ def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
     gw = base_port if not _busy(base_port) else _free_port(28700)
     if gw is None:
         print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
+    LAST_GATEWAY_PORT = gw
     changed = {}
     if gw != base_port:
         changed['gateway'] = gw
@@ -310,6 +315,60 @@ def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
     return ok
 
 
+def _files_of(p: pathlib.Path) -> int:
+    """目录/文件的件数(卸载核验里"数据原样在位"的比对面)。"""
+    if not p.exists():
+        return -1
+    return 1 if p.is_file() else sum(1 for _ in p.rglob('*') if _.is_file())
+
+
+def uninstall_probe(dst: pathlib.Path, py: pathlib.Path, gw: int | None = None) -> int:
+    """卸载核验(用户令 2026-09-17:增加卸载脚本)——在**副本**上真跑一遍卸载器。
+
+    为什么放在开箱验证里:卸载脚本这类东西"在开发机上跑一次看着没事"不算数,它要证明的是
+    **在刚装好的那份副本上**:① 计划齐全且 --dry-run 一个字节都不动;② 真卸载会把服务/运行态/
+    安装记录/快捷方式拆掉;③ **数据与产物一件不少**(默认口径不碰用户数据)。
+
+    → 返回失败条数(0 = 全过)
+    """
+    bad = 0
+    say = lambda s: print('   ' + s)                                        # noqa: E731
+    # ★ 子进程输出的编码必须点名 utf-8:卸载器写中文, Windows 上默认按 cp936 解会得到
+    #   "�ƻ���ȫ" 这种乱码(2026-09-17 实测: 核验打印成了天书, 证据就没法看了)。
+    sub_env = dict(os.environ, PYTHONUTF8='1')
+    r = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--selftest'], cwd=str(dst),
+                       capture_output=True, text=True, encoding='utf-8', errors='replace',
+                       env=sub_env, timeout=600)
+    for ln in (r.stdout or '').strip().splitlines():
+        if '[FAIL]' in ln or '结论' in ln or '[OK]' in ln:
+            say(ln.strip())
+    if r.returncode != 0:
+        bad += 1
+        say('[X] 卸载器 --selftest 未通过')
+    keep = [d for d in ('data', 'outputs') if (dst / d).exists()]
+    before = {d: _files_of(dst / d) for d in keep}
+    r2 = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--yes'], cwd=str(dst),
+                        capture_output=True, text=True, encoding='utf-8', errors='replace',
+                        env=sub_env, timeout=900)
+    tail = (r2.stdout or '').strip().splitlines()
+    for ln in tail[-8:]:
+        say(ln.rstrip())
+    if r2.returncode != 0:
+        bad += 1
+        say(f'[X] 卸载器退出码 {r2.returncode}')
+    checks = [('安装记录 install-info.json 已删', not (dst / 'install-info.json').exists()),
+              ('运行态 run/ 已清', not (dst / 'run').exists()),
+              ('快捷方式已撤', not list(dst.glob('*.lnk')))]
+    for d in keep:
+        checks.append((f'数据 {d}/ 原样在位({before[d]} 件)', _files_of(dst / d) == before[d]))
+    if gw:
+        checks.append((f'卸载后端口 {gw} 已释放', not _busy(gw)))
+    for name, good in checks:
+        say(('[OK] ' if good else '[X] ') + name)
+        bad += 0 if good else 1
+    return bad
+
+
 def simulate(into=None, keep=False) -> int:
     """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
     dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'

+ 450 - 0
scripts/guanlan_uninstall.py

@@ -0,0 +1,450 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""观澜卸载器 (2026-09-17 用户令: 增加卸载脚本 `uninstall.bat` / `uninstall.sh`)。
+
+入口就是这两个壳脚本 + 本文件;三处共用**同一份**实现(与 install.bat/install.sh→install.ps1 同构),
+免得"Windows 一套、Linux 一套"各写一遍逻辑、过两个月行为不一致。
+
+## 卸载拆成哪几步(默认口径:只拆"本程序装上去的东西",绝不动用户数据)
+
+    ① 停服务      先停服务化常驻体(Windows 服务 / systemd 单元),再停手工启动的那套
+    ② 注销服务    `sc.exe delete guanlan` / `systemctl disable --now` + 删单元文件(需管理员/root)
+    ③ 撤开机自启  HKCU\...\Run 的 guanlan 项 / systemd 用户单元(不需要管理员)
+    ④ 删快捷方式  `启动观澜.lnk`、`start_guanlan.lnk`(安装根 + 桌面 + applications)
+    ⑤ 清运行态    `run/pids.json`、残留 `*.pid`
+    ⑥ 删安装记录  `install-info.json`("已装的是哪一版"的唯一凭据 —— 删掉它 = 这台机器回到"没装过")
+
+    **保留**:`data/`(输入数据)· `outputs/`(产物)· `logs/`(日志)· `.venv/`(虚拟环境)· 整棵程序目录
+
+## 为什么默认既不删数据、也不删程序目录
+
+· `data/` 与 `outputs/` 是**现场资产**(原始件十几 GB + 重算产物);"卸载程序"不等于"销毁数据"。
+  要连数据一起清,显式给 `--purge`(并且**再问一次**,默认答否;`--keep-logs` 可留下日志)。
+· 程序目录**不自己删**:Windows 上正在跑的 `python.exe` 就在 `.venv` 里,删自己必然部分失败,
+  留下"删了一半的目录"比留着整个目录更难排查。要清干净给 `--all`:本器尽力删,**删不掉的逐条列出**,
+  并打印一条收尾命令(`rmdir /s /q <根>` 或 `rm -rf <根>`)让你关掉本窗口后执行。
+
+## 用法
+
+    uninstall.bat                    交互:先打印计划 → 问一次 → 执行
+    uninstall.bat --dry-run          只打印计划,一个字节都不动(★自证用)
+    uninstall.bat --yes              不问(脚本/无人值守)
+    uninstall.bat --purge --yes      连 data/ outputs/ logs/ .venv/ 一起删
+    uninstall.bat --all --yes        连程序文件一起删(最后给收尾命令)
+    sh uninstall.sh [同样的开关]      Linux / macOS
+
+    退出码: 0 完成 · 2 需要确认(非交互又没给 --yes) · 3 权限不足(②需管理员/root, 其余照做) · 5 有步骤失败
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import version as V                                            # noqa: E402
+
+WIN32 = os.name == 'nt'
+UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
+USER_UNIT = pathlib.Path.home() / '.config' / 'systemd' / 'user' / f'{V.SERVICE_NAME}.service'
+AUTOSTART = pathlib.Path.home() / '.config' / 'autostart' / f'{V.SERVICE_NAME}.desktop'
+SHORTCUTS = ('启动观澜.lnk', 'start_guanlan.lnk')
+
+# 卸载时要清掉的可重建件 / 用户数据(--purge 才动)
+PURGE_DIRS = ('data', 'outputs', 'logs', 'run', 'runtime')
+# --all 才动的"程序本体"
+PROGRAM_DIRS = ('src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs', 'wheels', 'vendor')
+PROGRAM_FILES = ('guanlan.py', 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
+                 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh',
+                 'check.bat', 'start.bat', 'stop.bat', 'pack.bat', 'pack.sh', 'dist-manifest.json')
+
+RESULTS: list[tuple[str, int, str]] = []        # (步骤, rc, 说明) —— 结尾统一汇总
+
+
+def say(msg: str = '') -> None:
+    print(msg)
+
+
+def py_exe() -> str:
+    """优先用本安装的 .venv 解释器;它没了(比如已经被删)就退回当前解释器。"""
+    cand = ROOT / '.venv' / ('Scripts/python.exe' if WIN32 else 'bin/python')
+    return str(cand) if cand.is_file() else sys.executable
+
+
+def is_admin() -> bool:
+    if not WIN32:
+        try:
+            return os.geteuid() == 0
+        except AttributeError:
+            return False
+    try:
+        import ctypes
+        return bool(ctypes.windll.shell32.IsUserAnAdmin())
+    except Exception:
+        return False
+
+
+def run(cmd: list[str], dry: bool, note: str = '') -> int:
+    printable = ' '.join(f'"{x}"' if ' ' in x else x for x in cmd)
+    say(f'   $ {printable}' + (f'   ({note})' if note else ''))
+    if dry:
+        return 0
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
+    except FileNotFoundError:
+        say(f'   [X] 找不到命令 {cmd[0]}')
+        return 5
+    for ln in (r.stdout or '').strip().splitlines():
+        say('     ' + ln)
+    for ln in (r.stderr or '').strip().splitlines():
+        say('     [stderr] ' + ln)
+    return 0 if r.returncode == 0 else 5
+
+
+def rm(path: pathlib.Path, dry: bool) -> int:
+    """真删(目录递归 / 文件单件);不存在算成功(卸载必须幂等,跑第二遍不该报错)。"""
+    if not path.exists():
+        say(f'   - 本来就没有: {rel(path)}')
+        return 0
+    size, n = size_of(path)
+    say(f'   $ 删除 {rel(path)}' + (f'   ({n} 件, {size / 1e6:.1f} MB)' if path.is_dir() else f'   ({size} B)'))
+    if dry:
+        return 0
+    try:
+        if path.is_dir():
+            shutil.rmtree(path)
+        else:
+            path.unlink()
+        return 0
+    except OSError as e:
+        say(f'   [X] 删不掉: {e}')
+        return 5
+
+
+def rel(p: pathlib.Path) -> str:
+    try:
+        return p.relative_to(ROOT).as_posix() or '.'
+    except ValueError:
+        return str(p)
+
+
+def size_of(p: pathlib.Path) -> tuple[int, int]:
+    """→ (字节数, 件数)。"""
+    if p.is_file():
+        try:
+            return p.stat().st_size, 1
+        except OSError:
+            return 0, 0
+    tot = n = 0
+    for dp, _dn, fns in os.walk(p):
+        for fn in fns:
+            try:
+                tot += (pathlib.Path(dp) / fn).stat().st_size
+                n += 1
+            except OSError:
+                pass
+    return tot, n
+
+
+# ── 各步 ────────────────────────────────────────────────────────────────────────────
+def service_registered() -> bool:
+    if WIN32:
+        try:
+            r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
+            return r.returncode == 0
+        except FileNotFoundError:
+            return False
+    return UNIT_PATH.is_file()
+
+
+def step_stop(dry: bool) -> None:
+    say('① 停服务')
+    rc = 0
+    note = []
+    if service_registered():
+        rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'stop'], dry, '停服务化常驻体')
+        note.append('服务化常驻体已停' if rc == 0 else '常驻体那步没成功(多为权限)')
+    else:
+        say(f'   - 没注册过 {V.SERVICE_NAME} 服务(跳过常驻体)')
+        note.append('无服务化常驻体')
+    r2 = run([py_exe(), str(ROOT / 'guanlan.py'), 'stop'], dry, '停启动器起的组件')
+    rc = rc or r2
+    note.append('启动器组件已停' if r2 == 0 else '启动器组件停止失败')
+    RESULTS.append(('停止服务', rc, ';'.join(note)))
+
+
+def step_unregister(dry: bool) -> None:
+    say('② 注销服务(需管理员 / root)')
+    if not service_registered():
+        say(f'   - 本机没注册过 {V.SERVICE_NAME} 服务(跳过)')
+        RESULTS.append(('注销服务', 0, '本来就没注册'))
+        return
+    if not is_admin() and not dry:
+        say('   [!] 权限不足:注销服务需要管理员/root —— 其余步骤照做,这一步留给你手工执行:')
+        if WIN32:
+            say(f'       sc.exe stop {V.SERVICE_NAME} && sc.exe delete {V.SERVICE_NAME}')
+        else:
+            say(f'       sudo systemctl disable --now {V.SERVICE_NAME} && sudo rm -f {UNIT_PATH}')
+        RESULTS.append(('注销服务', 3, '权限不足(已给出手工命令)'))
+        return
+    rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'uninstall'], dry, '停 + 删注册')
+    RESULTS.append(('注销服务', rc, '服务已注销' if rc == 0 else '注销失败'))
+
+
+def step_autostart(dry: bool) -> None:
+    say('③ 撤开机自启(不需要管理员)')
+    rc = 0
+    if WIN32:
+        key = r'Software\Microsoft\Windows\CurrentVersion\Run'
+        say(f'   $ reg delete HKCU\\{key} /v {V.SERVICE_NAME} /f')
+        if not dry:
+            try:
+                import winreg
+                with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as k:
+                    try:
+                        winreg.DeleteValue(k, V.SERVICE_NAME)
+                        say('   已移除登录自启项')
+                    except FileNotFoundError:
+                        say('   - 本来就没有这项')
+            except Exception as e:                                     # 注册表异常不该中断整场卸载
+                say(f'   [X] {type(e).__name__}: {e}')
+                rc = 5
+    elif AUTOSTART.exists() or USER_UNIT.exists():
+        rc = run(['systemctl', '--user', 'disable', '--now', V.SERVICE_NAME], dry, '用户级自启(可能没装)')
+        rc = rc or rm(AUTOSTART, dry)
+        rc = rc or rm(USER_UNIT, dry)
+    else:
+        say('   - 没有用户级自启项(跳过)')
+    RESULTS.append(('撤开机自启', rc, '登录/开机自启项已清' if rc == 0 else '清理失败'))
+
+
+def step_shortcuts(dry: bool) -> None:
+    say('④ 删快捷方式(安装根 + 桌面 + applications)')
+    targets: list[pathlib.Path] = [ROOT / n for n in SHORTCUTS]
+    if WIN32:
+        up = os.environ.get('USERPROFILE')
+        if up:
+            targets += [pathlib.Path(up) / d / n for d in ('Desktop', '桌面') for n in SHORTCUTS]
+    else:
+        targets += [pathlib.Path.home() / d / f'{n[:-4]}.desktop'
+                    for d in ('Desktop', '.local/share/applications') for n in SHORTCUTS]
+    hit = [p for p in targets if p.exists()]
+    rc = 0
+    for p in hit:
+        rc = rc or rm(p, dry)
+    if not hit:
+        say('   - 没有安装时建的快捷方式(跳过)')
+    RESULTS.append(('删快捷方式', rc, f'删了 {len(hit)} 个' if hit else '本来就没有'))
+
+
+def step_runtime(dry: bool) -> None:
+    say('⑤ 清运行态(run/pids.json、残留 *.pid)')
+    rc = rm(ROOT / 'run', dry)
+    for p in sorted(ROOT.glob('*.pid')):
+        rc = rc or rm(p, dry)
+    RESULTS.append(('清运行态', rc, 'PID 记录已清' if rc == 0 else '清理失败'))
+
+
+def step_install_record(dry: bool) -> None:
+    say('⑥ 删安装记录 install-info.json')
+    ins = V.read_installed(ROOT)
+    if ins:
+        say(f"   - 记录的是: {ins.get('name', '?')} v{ins.get('version', '?')}"
+            f"(装于 {ins.get('installed_at', '?')},运行方式 {ins.get('mode', '?')})")
+    else:
+        say('   - 没有安装记录(本来就没装过 / 已被删)')
+    rc = rm(V.info_path(ROOT), dry)
+    RESULTS.append(('删安装记录', rc, '这台机器回到"没装过"状态' if rc == 0 else '删除失败'))
+
+
+def step_purge(dry: bool, yes: bool, skip: tuple[str, ...] = ()) -> None:
+    say('⑦ --purge: 删数据与可重建件(输入数据 / 产物 / 日志 / 虚拟环境)')
+    dirs = tuple(d for d in PURGE_DIRS if d not in skip)
+    targets = [ROOT / d for d in dirs] + [ROOT / '.venv']
+    present = [p for p in targets if p.exists()]
+    if skip:
+        say(f'   (--keep-logs: 保留 {", ".join(skip)})')
+    if not present:
+        say('   - 没什么可清的(都不在)')
+        RESULTS.append(('--purge 清数据', 0, '无对象'))
+        return
+    tot = sum(size_of(p)[0] for p in present)
+    say(f'   将删除: {", ".join(rel(p) for p in present)}  —— 合计 {tot / 1e9:.2f} GB')
+    if not yes:
+        if not sys.stdin or not sys.stdin.isatty():
+            say('   [!] 非交互环境:--purge 需要显式 --yes 确认 —— 本步跳过(数据原样保留)')
+            RESULTS.append(('--purge 清数据', 2, '非交互未确认,已跳过'))
+            return
+        ans = input('   这会永久删除现场数据与产物,确认? 输入 yes 继续,其它键跳过: ').strip().lower()
+        if ans not in ('yes', 'y'):
+            say('   - 已跳过(数据原样保留)')
+            RESULTS.append(('--purge 清数据', 0, '用户选择跳过'))
+            return
+    rc = 0
+    for p in present:
+        rc = rc or rm(p, dry)
+    RESULTS.append(('--purge 清数据', rc, f'删除 {len(present)} 项' if rc == 0 else '有删不掉的'))
+
+
+def step_all(dry: bool) -> None:
+    say('⑧ --all: 删程序本体(尽力而为,删不掉的逐条列出)')
+    # ★ 先把自己的工作目录移出安装根:Windows 不允许删除"当前目录",也不允许删正在跑的 python.exe
+    if not dry:
+        try:
+            os.chdir(os.environ.get('TEMP') or os.environ.get('TMP') or '/tmp')
+        except OSError:
+            pass
+    failed: list[str] = []
+    for name in PROGRAM_DIRS + PROGRAM_FILES:
+        p = ROOT / name
+        if p.exists() and rm(p, dry) != 0:
+            failed.append(rel(p))
+    if not dry and ROOT.exists():
+        for leftover in sorted(ROOT.iterdir()):                        # 兜底:清单之外的零碎也清
+            if rm(leftover, dry) != 0:
+                failed.append(rel(leftover))
+        try:
+            ROOT.rmdir()
+            say(f'   已删除安装根 {ROOT}')
+        except OSError as e:
+            say(f'   [!] 安装根没删掉({e.__class__.__name__})—— 正常:本程序正在跑, 自己的目录被占用')
+            say('       关掉本窗口后手工执行: '
+                + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
+    if failed:
+        say(f'   删不掉的 {len(failed)} 项: {", ".join(failed[:8])}' + (' …' if len(failed) > 8 else ''))
+    RESULTS.append(('--all 删程序', 0 if not failed else 5,
+                    '程序文件已清' if not failed else f'{len(failed)} 项留待手工删'))
+
+
+# ── 计划与自检 ──────────────────────────────────────────────────────────────────────
+def plan_lines() -> list[str]:
+    """把"将要做什么"列出来(--dry-run 打印它;也是自检的断言对象)。"""
+    ins = V.read_installed(ROOT)
+    return [
+        f'观澜卸载计划 @ {ROOT}',
+        f'  本包版本: {V.NAME} v{V.VERSION}'
+        + (f";安装记录写的: v{ins.get('version', '?')}(装于 {ins.get('installed_at', '?')})"
+           if ins else ';没有安装记录(本来就没装过 / 已被删)'),
+        '  ① 停服务: service_ctl stop → guanlan.py stop',
+        '  ② 注销服务: '
+        + (f'sc.exe stop/delete {V.SERVICE_NAME}' if WIN32 else f'systemctl disable --now {V.SERVICE_NAME} + 删 {UNIT_PATH}')
+        + f'  (已注册={service_registered()}, 有权限={is_admin()})',
+        '  ③ 撤开机自启: ' + (r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run' if WIN32 else str(USER_UNIT)),
+        '  ④ 删快捷方式: ' + ' / '.join(SHORTCUTS) + '(安装根 + 桌面 + applications)',
+        '  ⑤ 清运行态: run/ 与 *.pid',
+        f'  ⑥ 删安装记录: {V.INSTALL_INFO}',
+        f'  ⑦/⑧ 只有给了 --purge / --all 才动数据与程序本体',
+        '  保留(默认): ' + ' '.join(f'{d}/' + ('' if (ROOT / d).exists() else '(没有)')
+                                    for d in ('data', 'outputs', 'logs', '.venv')) + ' 与整棵程序目录',
+    ]
+
+
+def selftest() -> int:
+    r"""自检(--selftest):证明"计划齐全"且"dry-run 一个字节都不动"。
+
+    一个是**内容断言**(六个步骤一个不缺),一个是**副作用断言**(跑完 --dry-run 后关键路径的
+    存在性与大小完全不变)—— 后者才是"不会误删"的证据。
+    """
+    ok = True
+    need = ('① 停服务', '② 注销服务', '③ 撤开机自启', '④ 删快捷方式', '⑤ 清运行态', '⑥ 删安装记录', '保留')
+    txt = '\n'.join(plan_lines())
+    miss = [k for k in need if k not in txt]
+    print(f'  [{"OK" if not miss else "FAIL"}] 计划齐全(六个步骤 + 保留清单)' + ('' if not miss else f' — 缺 {miss}'))
+    ok &= not miss
+    watch = ['install-info.json', 'run', 'data', 'outputs', 'logs', '.venv']
+    before = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
+    step_stop(True)
+    step_unregister(True)
+    step_autostart(True)
+    step_shortcuts(True)
+    step_runtime(True)
+    step_install_record(True)
+    after = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
+    same = before == after
+    print(f'  [{"OK" if same else "FAIL"}] dry-run 无副作用({len(watch)} 个关键路径的存在性与大小都没变)')
+    ok &= same
+    print('  [OK] 六步全部构建成功: ' + '; '.join(f'{n}={rc}' for n, rc, _ in RESULTS))
+    RESULTS.clear()
+    print('  结论: ' + ('自检通过' if ok else '自检未通过'))
+    return 0 if ok else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description=f'{V.NAME} v{V.VERSION} 卸载器')
+    ap.add_argument('--dry-run', action='store_true', help='只打印计划, 一个字节都不动')
+    ap.add_argument('--yes', '-y', action='store_true', help='不问, 直接执行(脚本/无人值守用)')
+    ap.add_argument('--purge', action='store_true', help='连 data/ outputs/ logs/ .venv/ 一起删(会再问一次)')
+    ap.add_argument('--all', action='store_true', help='连程序本体一起删(尽力而为, 最后给收尾命令)')
+    ap.add_argument('--keep-logs', action='store_true', help='--purge 时保留 logs/(排查用)')
+    ap.add_argument('--selftest', action='store_true', help='自检: 计划齐全 + dry-run 无副作用')
+    a = ap.parse_args()
+
+    say(f'== {V.NAME} v{V.VERSION} 卸载 · {ROOT} ==')
+    if a.selftest:
+        return selftest()
+    for ln in plan_lines():
+        say(ln)
+    say('')
+
+    if not a.dry_run:
+        if not a.yes:
+            if not sys.stdin or not sys.stdin.isatty():
+                say('   [!] 非交互环境:卸载需要确认。请显式加 --yes(或先在终端里手工跑一次看计划)')
+                say('       本次未做任何改动。')
+                return 2
+            ans = input('   确认卸载? 输入 y 继续, 其它键退出: ').strip().lower()
+            if ans not in ('y', 'yes'):
+                say('   已取消(未改动任何文件)')
+                return 0
+        say('')
+
+    step_stop(a.dry_run)
+    step_unregister(a.dry_run)
+    step_autostart(a.dry_run)
+    step_shortcuts(a.dry_run)
+    step_runtime(a.dry_run)
+    step_install_record(a.dry_run)
+    if a.purge:
+        step_purge(a.dry_run, a.yes, skip=('logs',) if a.keep_logs else ())
+    if a.all:
+        step_all(a.dry_run)
+
+    say('')
+    say('== 结果 ==')
+    for name, rc, note in RESULTS:
+        say(f'   [{"OK" if rc == 0 else ("!" if rc == 3 else "X")}] {name}: {note}')
+    worst = max((rc for _n, rc, _s in RESULTS), default=0)
+    if a.dry_run:
+        say('   (dry-run: 以上一个字节都没动 — 去掉 --dry-run 才真做)')
+        return 0
+    say('   保留: ' + ('(--all 已连程序本体一起处理)' if a.all else 'data/ outputs/ logs/ .venv/ 与整棵程序目录'))
+    if worst == 0:
+        say('   卸载完成。')
+        if not a.all:
+            say('   想连目录一起清掉: ' + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
+    elif worst == 3:
+        say('   卸载基本完成,但**服务注销那一步缺权限** —— 用管理员/root 重跑一次即可(命令见上)。')
+    else:
+        say('   有步骤失败,见上面的 [X]。')
+    # 机器审计流水(卸载也要留痕;logs/ 被 --purge 删掉时这一步自然就没了,属预期)
+    try:
+        from src import logfile as LF
+        LF.append_jsonl('uninstall', dict(root=str(ROOT), version=V.VERSION, purge=bool(a.purge),
+                                          all=bool(a.all), worst_rc=worst,
+                                          steps=[dict(name=n, rc=rc, note=s) for n, rc, s in RESULTS]))
+    except Exception:
+        pass
+    return 0 if worst == 0 else worst
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 14 - 5
scripts/pack_dist.py

@@ -76,6 +76,7 @@ import zipfile
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
+from src import version as V       # noqa: E402  ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
 
 INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
                 'wheels', 'vendor']
@@ -83,7 +84,9 @@ PRODUCTS_DIR = 'outputs'          # 产物仓: **默认不进包** (用户令 20
 # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
 #   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
 #   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
-VERSION = '0.4.0'                 # 包版本 (2026-09-16): 写进 dist-manifest.json 与默认文件名
+# ★ 版本号不再在这里写死 (2026-09-17 用户令"打包版本管理"): 改版本只改 `src/version.py` 一行,
+#   本器读它写进 dist-manifest.json 并用它拼默认包名 `app_guanlang_v<版本号>.zip`。
+VERSION = V.VERSION               # 包版本 = src/version.py 的 VERSION(单一真源)
 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
@@ -91,7 +94,10 @@ INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'chec
                  #   安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
                  'requirements.txt', 'README_先读我.MD', '测试须知.txt',
                  # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用)
-                 'pack.bat', 'pack.sh']
+                 'pack.bat', 'pack.sh',
+                 # 卸载入口 (用户令 2026-09-17: 增加卸载脚本; 它引用的 scripts\guanlan_uninstall.py
+                 # 由 INCLUDE_DIRS 的 scripts/ 覆盖, 入口本身必须在这里点名)
+                 'uninstall.bat', 'uninstall.sh']
 # 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
 EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__',
                 '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
@@ -340,14 +346,17 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
         return 1
     print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
     ok = xt.run_and_probe(tmp, py)
+    print('③b 卸载核验 (用户令 2026-09-17: 装得上, 也要卸得掉)')
+    ubad = xt.uninstall_probe(tmp, py, gw=xt.LAST_GATEWAY_PORT)
     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
+    print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}'
+          + (f';卸载核验 {"通过" if ubad == 0 else f"{ubad} 项未过"}'))
+    return 0 if (ok >= 4 and ubad == 0) else 1
 
 
 def main() -> int:
@@ -366,7 +375,7 @@ def main() -> int:
     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')
+           else ROOT.parent / V.package_name())
     if a.dry_run:
         inc, skipped = plan(a.with_data, a.with_products)
         tot_n = tot_s = 0

+ 101 - 0
scripts/version_log.py

@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""版本记录生成/校验 (2026-09-17 用户令: 源代码开发维护与打包版本管理)。
+
+## 为什么不是"再手写一个 CHANGELOG.md"
+
+手写版本记录的老毛病是**和代码里的版本号对不上**:改了 `src/version.py` 忘了改 md,或者反过来
+(本仓 0.4.0 期间就出现过三处版本号各说各话)。这里把事实只放在 `src/version.py` 的 `HISTORY` 里,
+`docs/版本记录.md` 由本器**生成**:
+
+    python scripts/version_log.py            # 打印(人看)
+    python scripts/version_log.py --write    # 写进 docs/版本记录.md(自动块)
+    python scripts/version_log.py --check    # 校验 md 与 src/version.py 是否一致(rc=6 表示不一致)
+
+`guanlan.py check` 与打包器都会调用 `--check` 这一条:**版本记录与版本号必须一致**,
+否则交付包里会出现"包说 2.5.0、记录表最后一行是 2.4.0"这种事。
+
+退出码: 0 一致/已写 · 6 不一致(需要跑 --write) · 2 文件缺失
+"""
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import version as V                                            # noqa: E402
+
+DOC = ROOT / 'docs' / '版本记录.md'
+BEGIN, END = '<!-- VERSION-LOG:BEGIN -->', '<!-- VERSION-LOG:END -->'
+
+LEVEL_CN = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号', 'same': '无', '?': '?'}
+
+
+def body() -> str:
+    """生成自动块正文(规则 + 当前版本 + 版本表 + 怎么改)。"""
+    L = ['## 版本号规则(用户令 2026-09-17)', '',
+         f'编号 **`{V.RULE}`**,例如 `v{V.VERSION}`。', '',
+         '| 级别 | 含义 |', '|---|---|']
+    for k in ('major', 'minor', 'patch'):
+        # LEVEL_MEANING 里是"大版本号 —— 系统解决方案…"整句(与用户令措辞一致),
+        # 表头已经有"级别"一列,这里只取破折号后的定义,免得读成"大版本号 | 大版本号 —— …"。
+        L.append(f'| {LEVEL_CN[k]}版本号 | {V.LEVEL_MEANING[k].split(" —— ", 1)[-1]} |')
+    L += ['', f'**怎么改**:{V.BUMP_RULE}', '',
+          f'**打包文件名** = `{V.package_name()}`(规则:`{V.PACKAGE_STEM}_v<版本号>.zip`)。', '',
+          f'版本号只写在 `src/version.py` 一行(`VERSION = \'{V.VERSION}\'`);'
+          '打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。', '',
+          '| 版本 | 日期 | 级别 | 主要变更 | 说明 |', '|---|---|---|---|---|']
+    for h in V.HISTORY:
+        L.append(f"| `v{h.get('version', '?')}` | {h.get('date', '?')} | {LEVEL_CN.get(h.get('level', '?'), '?')} | "
+                 f"{h.get('title', '')} | {h.get('note', '')} |")
+    L += ['', '## 本次交付', '',
+          f'* 版本:`v{V.VERSION}`({V.NAME})',
+          f'* 交付包:`{V.package_name()}`',
+          f'* 服务名:`{V.SERVICE_NAME}`(Windows 服务 / systemd 单元)',
+          f'* 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`', '']
+    return '\n'.join(L)
+
+
+def render() -> str:
+    return (f'# 版本记录({V.NAME})\n\n'
+            '> 本文件由 `scripts/version_log.py` 生成,**勿手改**:事实(版本号、级别、变更)写在\n'
+            '> `src/version.py` 的 `HISTORY` 里,改完跑 `python scripts/version_log.py --write`。\n'
+            '> `guanlan.py check` 与打包器都会校验本文件与代码里的版本号是否一致。\n\n'
+            f'{BEGIN}\n{body()}{END}\n')
+
+
+def block_of(text: str) -> str:
+    i, j = text.find(BEGIN), text.find(END)
+    return text[i + len(BEGIN):j].strip() if i >= 0 and j > i else ''
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='版本记录生成/校验')
+    ap.add_argument('--write', action='store_true', help='写进 docs/版本记录.md')
+    ap.add_argument('--check', action='store_true', help='只校验是否一致 (rc=6 不一致)')
+    a = ap.parse_args()
+    want = body().strip()
+    if a.write:
+        DOC.parent.mkdir(parents=True, exist_ok=True)
+        DOC.write_text(render(), encoding='utf-8', newline='\n')
+        print(f'已写入 {DOC.relative_to(ROOT).as_posix()}(版本 v{V.VERSION},{len(V.HISTORY)} 条历史)')
+        return 0
+    if a.check:
+        if not DOC.is_file():
+            print(f'[X] 缺 {DOC.relative_to(ROOT).as_posix()} —— 跑 python scripts/version_log.py --write')
+            return 2
+        got = block_of(DOC.read_text(encoding='utf-8'))
+        if got != want:
+            print(f'[X] 版本记录与 src/version.py 不一致(版本 v{V.VERSION})'
+                  f' —— 跑 python scripts/version_log.py --write')
+            return 6
+        print(f'[OK] 版本记录一致(v{V.VERSION},{len(V.HISTORY)} 条)')
+        return 0
+    print(render())
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 3 - 1
src/entry_refs.py

@@ -33,8 +33,10 @@ import re
 # ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
 #   `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
 # ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。
+# ★ 2026-09-17 用户令"增加卸载脚本": `uninstall.bat` / `uninstall.sh` 同样是用户会直接双击的入口
+#   —— 它引用的 `scripts\guanlan_uninstall.py` 少打进包, 后果就是"装得上、卸不掉"。
 ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh',
-               'install.bat', 'install.ps1', 'install.sh')
+               'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh')
 
 # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
 _REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'

+ 70 - 3
src/version.py

@@ -19,25 +19,92 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '0.4.0'          # ★ 改版本只改这里
+VERSION = '2.5.0'          # ★ 改版本只改这里
 EDITION = 'offline-single-package'
+PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
 INSTALL_INFO = 'install-info.json'      # 相对安装根
 SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
 SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
 
+# ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
+#   编号:  v<大版本号>.<中版本号>.<小版本号>          例: v2.5.0
+#   定义:  大版本号 —— 系统解决方案、架构或核心功能改变
+#          中版本号 —— 非核心功能新增、减少、修改
+#          小版本号 —— 消缺完善
+#   包名:  打包文件名 = app_guanlang_v<版本号>.zip   例: app_guanlang_v2.5.0.zip
+#   用法:  用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
+#          (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
+RULE = 'v<大版本号>.<中版本号>.<小版本号>'
+LEVEL_MEANING = {
+    'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
+    'minor': '中版本号 —— 非核心功能新增、减少、修改',
+    'patch': '小版本号 —— 消缺完善',
+}
+BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
+             '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
+             '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
+
+# 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
+#   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
+#   旧编号体系(0.x,未按本规则),仅作历史对账用。
+HISTORY: tuple[dict, ...] = (
+    dict(version='2.5.0', date='2026-09-17', level='minor',
+         title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
+         note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
+              '交付包 app_guanlang_v2.5.0.zip'),
+    dict(version='0.4.0', date='2026-09-17', level='legacy',
+         title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
+               '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
+         note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
+    dict(version='0.2.0', date='2026-09-01', level='legacy',
+         title='含产物的旧交付基线(留存的历史交付包,用作"包内无生成端"那批产物的补救源)',
+         note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip(已入库备份)'),
+)
+
+
+def package_name(version: str | None = None, ext: str = 'zip') -> str:
+    """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
+    return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
+
+
+def level_of(old: str, new: str = VERSION) -> str:
+    """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
+    a, b = _parts(old), _parts(new)
+    if a == b:
+        return 'same'
+    if len(a) < 3 or len(b) < 3:
+        return '?'
+    if b[0] != a[0]:
+        return 'major'
+    if b[1] != a[1]:
+        return 'minor'
+    return 'patch'
+
+
+def rule_lines() -> list[str]:
+    """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
+    return [f'编号: {RULE}   例: v{VERSION}',
+            *[f'  {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
+            f'包名: 打包文件名 = {package_name()}']
+
 
 def info_path(root: pathlib.Path) -> pathlib.Path:
     return pathlib.Path(root) / INSTALL_INFO
 
 
 def read_installed(root: pathlib.Path) -> dict | None:
-    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。"""
+    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
+
+    ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
+      (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
+      于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
+    """
     p = info_path(root)
     if not p.is_file():
         return None
     try:
-        d = json.loads(p.read_text(encoding='utf-8'))
+        d = json.loads(p.read_text(encoding='utf-8-sig'))
         return d if isinstance(d, dict) else None
     except Exception:
         return None

+ 22 - 0
uninstall.bat

@@ -0,0 +1,22 @@
+@echo off
+rem Guanlan uninstaller (entry). Windows batch must stay pure ASCII: see src/entry_refs.py.
+rem Usage: uninstall.bat [--dry-run] [--yes] [--purge] [--all] [--keep-logs] [--selftest]
+rem NOTE: the console working directory is moved to %TEMP% on purpose. Windows refuses to delete
+rem       a directory that is the current directory of a live process, and --all has to delete
+rem       this very folder. All paths below are absolute, so nothing depends on the cwd.
+chcp 65001 >nul
+set PYTHONUTF8=1
+set "HERE=%~dp0"
+set "SCRIPT=%HERE%scripts\guanlan_uninstall.py"
+cd /d "%TEMP%"
+if exist "%HERE%.venv\Scripts\python.exe" (
+  "%HERE%.venv\Scripts\python.exe" "%SCRIPT%" %*
+  goto done
+)
+where py >nul 2>nul && (
+  py -3 "%SCRIPT%" %*
+  goto done
+)
+python "%SCRIPT%" %*
+:done
+pause

+ 18 - 0
uninstall.sh

@@ -0,0 +1,18 @@
+#!/bin/sh
+# 观澜·如东样板 v2 — 卸载 (Linux / macOS)
+# 用法: sh uninstall.sh                交互: 先打印计划, 问一次再执行
+#       sh uninstall.sh --dry-run      只打印计划, 一个字节都不动
+#       sh uninstall.sh --yes          不问(脚本/无人值守)
+#       sh uninstall.sh --purge --yes  连 data/ outputs/ logs/ .venv/ 一起删
+#       sh uninstall.sh --all --yes    连程序文件一起删(最后给收尾命令)
+# 默认只拆"本程序装上去的东西"(服务/自启/快捷方式/运行态/安装记录); 数据与产物一律保留。
+cd "$(dirname "$0")" || exit 1
+export PYTHONUTF8=1
+if [ -x ".venv/bin/python" ]; then
+  exec ".venv/bin/python" "scripts/guanlan_uninstall.py" "$@"
+fi
+for c in python3.12 python3.13 python3.11 python3; do
+  p=$(command -v $c 2>/dev/null) && exec "$p" "scripts/guanlan_uninstall.py" "$@"
+done
+echo "[X] 找不到 Python 3 (.venv 也已被删) —— 手工删掉本目录即可: rm -rf \"$(pwd)\"" >&2
+exit 2

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff