Procházet zdrojové kódy

用户令: 实现打包功能(默认不含 输入数据/产物/运行日志/临时文件; 零开关一条命令) + 打包入口 pack.bat/pack.sh/pack_verify_entry.py + README_先读我.txt 改名 .MD 并新增「打包」一节; 打包器新增 logs/临时文件兜底排除与 no_temp 清单, pack 入口纳入引用闭合检查

zhouyang.xie před 3 týdny
rodič
revize
0c468367ff
8 změnil soubory, kde provedl 313 přidání a 90 odebrání
  1. 28 28
      .gitattributes
  2. 32 7
      README_先读我.MD
  3. 1 0
      docs/系统设计说明.md
  4. 80 0
      pack.bat
  5. 20 0
      pack.sh
  6. 94 50
      scripts/pack_dist.py
  7. 46 0
      scripts/pack_verify_entry.py
  8. 12 5
      src/entry_refs.py

+ 28 - 28
.gitattributes

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

+ 32 - 7
README_先读我.txt → README_先读我.MD

@@ -3,9 +3,10 @@
 <PY> = Windows 的 .venv\Scripts\python.exe, 或 Linux·macOS 的 .venv/bin/python;
        命令里路径写 / 即可两平台通用; Windows 也可以只双击 .bat, 不必敲命令。
 
-★ 本包**不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)**(用户令 2026-09-16):
+★ 本包**不含 输入数据(data/) · 产物/输出数据(outputs/) · 运行日志(logs/、run/、任何 *.log/*.jsonl) ·
+  临时文件(缓存/备份/系统索引)**(用户令 2026-09-17; 打包方法与逐条理由见第五节):
   解压安装后页面会显示"无产物"; 把现场数据包放好后跑一次重算(第四节), 页面立刻有数。
-  随包内容 = 程序/配置/门户与三维资产/交付文档/离线依赖轮子/便携运行时 (没有数据与产物)。
+  随包内容 = 程序/配置/门户与三维资产/交付文档/离线依赖轮子/便携运行时 (没有数据、产物、日志)。
 
 一、安装 (约 10 分钟; 安装目录不要有空格和中文)
   Windows : 解压到 D:\guanlan\app → 双击 install.bat        (自动选 Python → 建 .venv → 包内轮子离线装依赖)
@@ -45,7 +46,30 @@
   算成功判据: /detail/ 左栏「系统维护」两屏, 或 <PY> -m src.ontology.maintenance
   重算后应达锚点: 报警 39211 · 工单 5876 · 油样 404 · temp_monthly 19494 · 本体 9702 对象(审计 0 问题)
 
-五、必读 (如实)
+五、打包 (做交付包; 用户令 2026-09-17)
+  一条命令就够(不需要任何开关); Windows 也可以直接**双击 pack.bat**:
+    Windows : 双击 pack.bat            或  <PY> scripts/pack_dist.py
+    Linux   : sh pack.sh               或  <PY> scripts/pack_dist.py
+    pack.bat dry      只预演: 报表里会打什么、多大, 以及逐条排除理由(不写文件)
+    pack.bat verify   对已打好的 zip 做开箱验证: 解压 → 离线安装 → 起服务核验页面 → 清理
+    <PY> scripts/pack_dist.py --out <目标.zip>     指定输出路径
+  输出: 默认写到**安装目录的上一级**, 文件名 guanlan-v<版本>_dist_<日期>.zip
+        (例: 装到 D:\guanlan\app, 则产物在 D:\guanlan\guanlan-v0.4.0_dist_20260917.zip)
+  ★ 包里**不含**这四类(默认口径; 每条理由都写进包内 dist-manifest.json):
+    ① 输入数据     data/                    现场原始件; 例外要带用 --with-data (约 31 GB)
+    ② 产物/输出数据 outputs/                 目标机放数据后 place_raw_data.py + rebuild_all.py 重算;
+                                            无生成端的那批用 products_restore_missing.py --stash <交付包.zip>
+    ③ 运行日志     logs/ run/ 以及**任何位置**的 *.log、*.jsonl (含 logs/build、logs/legacy)
+    ④ 临时文件     各种缓存(*_cache、__pycache__、.pytest_cache、.npm-cache、.qa-profile)、
+                   编辑器临时与备份(*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*)、
+                   系统索引(.DS_Store、Thumbs.db、desktop.ini), 以及 .git/.venv/*.pyc/*.lnk/顶层 *.zip
+  包里**有**: src/ scripts/ guanlan.py · configs/ · release/(门户/仿真/三维/交付件) · resources/ · reference/ ·
+             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack 脚本 · requirements.txt
+  打完自查: 包内 dist-manifest.json 记着 version / no_data / no_products / no_logs / no_temp + 逐条排除理由;
+            打包器自己还会核"入口脚本引用的文件都在包里"与"入口脚本编码守则", 不合格的包会被删掉并返回非 0。
+  要让页面开箱即有数(例外做法): <PY> scripts/pack_dist.py --with-products
+
+六、必读 (如实)
   · 页面的数来自 outputs/<场站> 下的产物, 产物由 data/raw 下的原始件算出来(下一级目录名=场站名, 扫描辨识)。
     本包两样都没带: 没有 data/raw 时重算会"没有源件可算", 没有 outputs/ 时页面显示"无产物"。
   · 有一批产物**没有生成端**(pitch_daily、pc_monthly_bins、CMS/TCM 链等): 从交付包按需补齐
@@ -53,9 +77,10 @@
   · 某页没数据: 先看控制台的退出码与 logs/, 再看 docs/重算缺口与补件清单_v0.1.md。
 
 包内含: 程序与配置 / 门户与仿真 / 三维资产 release/viewer / 治理清单交付件 release/如东(客户交付物勿外传) /
-        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/
-包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(日志) · .venv(安装时重建) · .git(版本库)
+        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh
+包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(运行日志) · 临时文件与缓存 ·
+          .venv(安装时重建) · .git(版本库)
 
-详细说明: docs/系统设计说明.md · docs/重算操作手册_v0.1.md
+详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径) · docs/重算操作手册_v0.1.md
           docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
-          docs/振动数据接入_v0.1.md(振动侧 CMS/TCM 落位与摄入) · docs/说明书_观澜如东样板v2_v0.2.md
+          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
docs/系统设计说明.md


+ 80 - 0
pack.bat

@@ -0,0 +1,80 @@
+@echo off
+rem ============================================================================
+rem  Guanlan pack.bat - build the delivery package (zip).
+rem
+rem  ASCII ONLY ON PURPOSE (see docs section 11.3): Chinese text in a .bat is
+rem  codepage-dependent and cmd.exe has been seen to execute parts of it.
+rem
+rem  What the package contains: program + configs + pages/assets + offline
+rem  wheels + vendor runtime + delivery docs + the install/start/stop/pack
+rem  scripts. What it does NOT contain (default, no flags needed):
+rem      1) input data      data/            (field raw files)
+rem      2) products        outputs/         (page data)
+rem      3) run logs        logs/ run/ and any *.log / *.jsonl anywhere
+rem      4) temp files      caches (*_cache, __pycache__), *.tmp *.bak* *.old
+rem                         *.orig *.rej *.swp *~ ~$* .DS_Store Thumbs.db
+rem
+rem  Usage:
+rem    pack.bat              build the package (same as: python scripts\pack_dist.py)
+rem    pack.bat dry          show what would be packed, write nothing
+rem    pack.bat verify       open-box verify an already built zip
+rem    pack.bat help         this text
+rem
+rem  Output: a .zip named guanlan-v[version]_dist_[date].zip, written next to the install dir.
+rem  Details: README at install root, section "PACK / packaging" (Chinese)
+rem           and docs\??????.md section 9 (delivery package)
+rem ============================================================================
+chcp 65001 >nul
+set PYTHONUTF8=1
+cd /d "%~dp0"
+
+if not exist ".venv\Scripts\python.exe" (
+  echo [X] Not installed yet: .venv\Scripts\python.exe not found
+  echo     Run install.bat first.
+  pause
+  exit /b 2
+)
+
+if /i "%~1"=="help" goto help
+if /i "%~1"=="dry" goto dry
+if /i "%~1"=="-n" goto dry
+if /i "%~1"=="verify" goto verify
+
+echo [i] Building the delivery package (no data / no products / no logs / no temp) ...
+".venv\Scripts\python.exe" scripts\pack_dist.py %*
+set RC=%ERRORLEVEL%
+echo.
+if not "%RC%"=="0" (
+  echo [X] Packaging failed, exit code %RC%. See the output above.
+) else (
+  echo [i] Done. Copy the .zip printed above to the target machine, then run
+  echo     install.bat there, then check.bat, then start.bat.
+)
+pause
+exit /b %RC%
+
+:dry
+echo [i] Dry run - showing what would be packed, nothing is written.
+".venv\Scripts\python.exe" scripts\pack_dist.py --dry-run
+pause
+exit /b 0
+
+:verify
+if "%~2"=="" (
+  echo [i] Usage: pack.bat verify [path\to\package.zip]
+  echo     With no path, the newest guanlan-v*_dist_*.zip next to the install dir is used.
+)
+".venv\Scripts\python.exe" scripts\pack_verify_entry.py %2
+pause
+exit /b 0
+
+:help
+echo Usage:
+echo   pack.bat              build the delivery package (zip)
+echo   pack.bat dry          show what would be packed, write nothing
+echo   pack.bat verify [zip] open-box verify: extract + offline install + page probe
+echo   pack.bat help         this text
+echo Excluded by default: input data (data\), products (outputs\), logs (logs\, run\,
+echo any *.log/*.jsonl), temp files (caches, *.tmp, *.bak*, *~, ~$*, .DS_Store ...).
+echo See README at the install root, section PACK.
+exit /b 0

+ 20 - 0
pack.sh

@@ -0,0 +1,20 @@
+#!/bin/sh
+# 观澜 · 打交付包 (Linux/macOS) —— 与 Windows 的 pack.bat 等价。
+#
+# 默认不含: ① 输入数据 data/  ② 产物 outputs/  ③ 运行日志 logs/ run/ 与任何 *.log/*.jsonl
+#           ④ 临时文件 (缓存 *.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$* .DS_Store …)
+#
+# 用法:
+#   sh pack.sh              # 打包 (等价于 <PY> scripts/pack_dist.py)
+#   sh pack.sh dry          # 只看会打什么, 不写文件
+#   sh pack.sh verify [zip] # 开箱验证: 解压 + 离线安装 + 起服务核验页面
+set -e
+cd "$(dirname "$0")"
+PY=".venv/bin/python"
+[ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
+
+case "${1:-}" in
+  dry)    "$PY" scripts/pack_dist.py --dry-run ;;
+  verify) shift; "$PY" scripts/pack_verify_entry.py "$@" ;;
+  *)      "$PY" scripts/pack_dist.py "$@" ;;
+esac

+ 94 - 50
scripts/pack_dist.py

@@ -11,37 +11,55 @@ r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
 
 ## 包含 / 排除 (规则即文档)
 
+★ 2026-09-17 用户令: **交付包默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件** ——
+即"开箱是一套干净的程序", 数据与产物到目标机自己放、自己算。默认执行
+`python scripts/pack_dist.py` 就是这四类全不含, 不需要任何开关 (要破例才加 `--with-data` / `--with-products`)。
+
 **包含**: 程序(`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`。
+· 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
+· 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
+`pack.sh`) · `requirements.txt`。
 
 **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
 **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
 `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
 
-**排除**(每条都写了理由):
+**排除**(每条都写了理由; 前四类就是用户令点名的四类):
+    data/                            ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
+    outputs/                         ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
+                                        `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
+                                        交付包补齐无生成端的那批(products_restore_missing.py --stash <zip>)
+    logs/ run/                       ③ **运行日志与运行态**: 本机日志(含 logs/build、logs/legacy)、run/pids.json
+                                        (旧 PID 到新机器上是无效引用); 要连产物一起发用 --with-products
+    *.log *.jsonl(任何位置)           ③ 运行日志/审计流水的兜底规则: 不管躺在哪都不进包
+    *_cache/ .*_cache/ __pycache__/  ④ **临时文件与缓存**: 解释器缓存、第三方工具缓存
+    .pytest_cache .mypy_cache .ruff_cache .ipynb_checkpoints .npm-cache .qa-profile
+    *.tmp *.temp *.bak* *.old *.orig *.rej *.swp *.swo *~ ~$*
+                                    ④ 编辑器/工具留下的临时与备份文件
     .git/ .venv/ .github/            版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
     _products_off*/                  历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
-    logs/ run/                       本机日志与 run/pids.json(旧 PID, 到新机器上是无效引用)
-    data/raw/                        现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
-    __pycache__/ *.pyc               解释器缓存
+    *.pyc *.pyo                      解释器缓存(字节码)
     *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
     *.zip(顶层)                      旧的交付压缩包; 避免包中包
+    .DS_Store Thumbs.db desktop.ini  其它系统在目录里留下的临时索引
 
 ## 用法
 
     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                            # 打包(默认不含 数据/产物/日志/临时文件)
+    python scripts/pack_dist.py --out <目标 zip>            # 指定输出路径
+    python scripts/pack_dist.py --with-products            # 例外: 连产物一起打 (页面开箱即有数)
+    python scripts/pack_dist.py --with-data                # 例外: 连 data/raw 一起打 (30 GB, 慎用)
+    python scripts/pack_dist.py --no-products              # (旧开关, 现在默认就是这个; 保留兼容不报错)
     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。
+包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs / no_temp 与**逐条**排除理由;
+`guanlan.py check` 读它 —— 没带产物的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
+
+一键入口(给不敲命令行的人): Windows 双击 `pack.bat`(或 `pack.bat dry` 只看计划)、Linux/macOS `sh pack.sh`。
 """
 from __future__ import annotations
 
@@ -61,7 +79,7 @@ sys.path.insert(0, str(ROOT))
 
 INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
                 'wheels', 'vendor']
-PRODUCTS_DIR = 'outputs'          # 产物仓: 默认打进包 (页面开箱有数); --no-products 时排除
+PRODUCTS_DIR = 'outputs'          # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products
 # ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
 #   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
 #   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
@@ -71,17 +89,26 @@ INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'chec
                  #   "无法找到脚本文件 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__'}
+                 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
+                 # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用)
+                 'pack.bat', 'pack.sh']
+# 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
+EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__',
+                '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
+                '.cache', '.npm-cache', '.qa-profile', '.Trash', '$RECYCLE.BIN', '.venv.bak'}
 # ★ 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'}
+EXCLUDE_GLOBS = ['_products_off*', 'data', 'outputs']
+# 进包**只**看目录/名的顶部前缀, 这些则按后缀/文件名排除 —— 都是"本机/装机时/编辑时"才产生的东西
+#   (用户令 2026-09-17 的"临时文件"与"运行日志"两条就落在这里):
+#   .pyc/.pyo  解释器缓存;  .lnk 安装时建的快捷方式(绝对路径, 换机失效, 由 install.ps1 重建);
+#   .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**;  .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份
+EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl',
+                  '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'}
+# 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件
+EXCLUDE_NAMES = ('*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.crdownload', '*.partial')
 
 
 def _xfer():
@@ -93,25 +120,25 @@ def _xfer():
     return m
 
 
-def plan(with_data=False, no_products=False):
+def plan(with_data=False, with_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。
+    ★ 2026-09-17 用户令: 交付包**默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件**。
+      · 输入数据 `data/`    —— 默认排除 (--with-data 才带);
+      · 产物     `outputs/` —— 默认排除 (--with-products 才带);
+      · 运行日志 `logs/ run/` 与任何位置的 `*.log`/`*.jsonl` —— 默认排除;
+      · 临时文件 `*_cache/ __pycache__/ *.tmp *.bak* *~ ~$* .DS_Store …` —— 默认排除。
+    于是"开箱是一套干净程序": 页面显示"无产物", 目标机自己放数据、自己重算 (docs §9 / §12)。
+    包内 `dist-manifest.json` 记 `no_data/no_products/no_logs/no_temp`, 目标机 `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_products and (ROOT / PRODUCTS_DIR).is_dir():
+        inc.append(ROOT / PRODUCTS_DIR)
     if with_data:
         inc.append(ROOT / 'data')
     for f in INCLUDE_FILES:
@@ -130,19 +157,26 @@ def plan(with_data=False, no_products=False):
         for p in extra:
             print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
         inc.extend(extra)
-    skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
+    skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')]
+    if (ROOT / PRODUCTS_DIR).is_dir():
+        skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
+                                 'rebuild_all.py 重算; 无生成端的那批用 products_restore_missing.py --stash <交付包.zip>)'
+                                 + ('' if not with_products else ' ※本次给了 --with-products, 例外打进包'))]
+    skipped += [('logs/, run/', '③ 运行日志与运行态 (logs/build、logs/legacy、run/pids.json 旧 PID)')]
+    skipped += [('*.log, *.jsonl (任何位置)', '③ 运行日志/审计流水兜底: 不管躺在哪都不进包')]
     _glob_why = {
         '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
-        'data': '现场原始输入件 (约定"原始件不随包分发"; 要一起交付用 --with-data) —— 用户令: 输入数据不进包',
+        'data': '① 输入数据: 现场原始件, 用户令不进包 (--with-data 才带)',
+        'outputs': '② 产物/输出数据: 用户令不进包 (--with-products 才带)',
     }
     skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
-    skipped += [('logs/, run/', '本机日志与旧 PID —— 用户令: 日志不进包')]
-    skipped += [('__pycache__/ *.pyc', '解释器缓存')]
+    skipped += [('*_cache/ __pycache__/ 等', '④ 临时文件与缓存 (解释器/工具缓存: .pytest_cache .npm-cache .qa-profile …)')]
+    skipped += [('*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*', '④ 编辑器/工具留下的临时与备份文件')]
+    skipped += [('.DS_Store Thumbs.db desktop.ini', '④ 系统临时索引文件')]
+    skipped += [('.git/ .venv/ .github/', '版本库与虚拟环境 (venv 换机必失效, 目标机安装时重建)')]
+    skipped += [('__pycache__/ *.pyc *.pyo', '字节码缓存')]
     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
 
 
@@ -152,10 +186,14 @@ def keep(f: pathlib.Path) -> bool:
     ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
     而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
     现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
+    ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
     """
+    import fnmatch
     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 any(fnmatch.fnmatch(f.name, pat) for pat in EXCLUDE_NAMES):
+        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])
@@ -171,10 +209,11 @@ def size_of(p: pathlib.Path):
     return n, s
 
 
-def build(out: pathlib.Path, with_data=False, progress=True, no_products=False) -> int:
-    inc, skipped = plan(with_data, no_products)
+def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
+    inc, skipped = plan(with_data, with_products)
     tot_n = tot_s = 0
     print('== 要打进包的内容 ==')
+    print('   (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
     for p in inc:
         n, s = size_of(p)
         tot_n += n; tot_s += s
@@ -189,12 +228,14 @@ def build(out: pathlib.Path, with_data=False, progress=True, no_products=False)
                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 不在包内(换机必失效, 安装时重建)。')
+               no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True,
+               note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/) · '
+                    '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**。'
+                    '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
+                    '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
+                    '+ `scripts/rebuild_all.py` (无生成端的那批用 '
+                    '`scripts/products_restore_missing.py --stash <交付包.zip>` 补齐)。'
+                    '.venv 不在包内(换机必失效, 安装时重建); 打包方法见包内 README_先读我.MD 的「打包」一节。')
     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))
@@ -312,9 +353,12 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
 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('--with-data', action='store_true',
+                    help='例外: 连输入数据 data/raw 一起打 (约 31 GB, 慎用; 默认不含)')
+    ap.add_argument('--with-products', action='store_true',
+                    help='例外: 连产物 outputs/ 一起打 (页面开箱即有数; 默认不含 —— 用户令 2026-09-17)')
     ap.add_argument('--no-products', action='store_true',
-                    help='**不含产物** (outputs/): 用户令 2026-09-16 "打包不含 输入数据/产物/日志"')
+                    help='(旧开关) 不含产物 —— 现在**默认就是这个**, 保留只为兼容旧命令')
     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 后保留解压目录')
@@ -324,7 +368,7 @@ def main() -> int:
     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)
+        inc, skipped = plan(a.with_data, a.with_products)
         tot_n = tot_s = 0
         print(f'== 会打进包 (version {VERSION}) ==')
         for p in inc:
@@ -336,7 +380,7 @@ def main() -> int:
             print(f'   {name:26s} {why}')
         print('\n(dry-run, 未写文件)')
         return 0
-    return build(out, a.with_data, no_products=a.no_products)
+    return build(out, a.with_data, with_products=a.with_products)
 
 
 if __name__ == '__main__':

+ 46 - 0
scripts/pack_verify_entry.py

@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""`pack.bat verify` 的入口:找最新的交付包(或用户给的路径)→ 交给 pack_dist.py --verify。
+
+为什么要单独一个: `pack.bat verify` 不带参数时应该"自动找最新那个 zip",而批处理里做这个
+(dir /b /o-d 之类) 既啰嗦又在中文路径上不稳 —— 放到 Python 里三行搞定,批处理只负责传参。
+
+用法:
+    python scripts/pack_verify_entry.py                 # 找 <安装根父目录>/guanlan-v*_dist_*.zip 里最新的
+    python scripts/pack_verify_entry.py <zip 路径>
+"""
+from __future__ import annotations
+
+import pathlib
+import subprocess
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def newest_zip() -> pathlib.Path | None:
+    """找最新的交付包: 先看安装根的父目录(打包器默认输出处), 再看安装根自己。"""
+    cands: list[pathlib.Path] = []
+    for d in (ROOT.parent, ROOT):
+        if d.is_dir():
+            cands += [p for p in d.glob('guanlan-*.zip') if p.is_file()]
+            cands += [p for p in d.glob('*guanlan*v*.zip') if p.is_file()]
+    cands = sorted(set(cands), key=lambda p: p.stat().st_mtime, reverse=True)
+    return cands[0] if cands else None
+
+
+def main() -> int:
+    arg = sys.argv[1] if len(sys.argv) > 1 else ''
+    zp = pathlib.Path(arg) if arg else newest_zip()
+    if zp is None or not zp.is_file():
+        print('[X] 找不到交付包 zip。给一个路径: python scripts/pack_verify_entry.py <zip>')
+        return 2
+    print(f'[i] 开箱验证: {zp}  ({zp.stat().st_size/1e6:.0f} MB)')
+    if arg == '' and not str(zp).startswith(str(ROOT.parent)):
+        pass
+    return subprocess.call([sys.executable, str(ROOT / 'scripts' / 'pack_dist.py'), '--verify', str(zp)],
+                           cwd=str(ROOT))
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 12 - 5
src/entry_refs.py

@@ -32,7 +32,8 @@ 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',
+# ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。
+ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh',
                'install.bat', 'install.ps1', 'install.sh')
 
 # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
@@ -148,10 +149,16 @@ def encoding_problems(root: pathlib.Path) -> list[str]:
         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
+        # `>` 只在**非注释行**上查重定向语义: 本机实测 `rem` 行里的 `>` 不会被当重定向 (但 `echo <x>` 会),
+        # 所以注释行放行 —— 免得逼着人把说明写成天书, 同时保住真正会炸的那种写法。
+        for i, ln in enumerate(txt.splitlines(), 1):
+            st = ln.strip()
+            if not st or st.lower().startswith(('rem', '::')):
+                continue
+            if re.search(r'>{1,2}(?!\s|"|nul\b|&)', ln):
+                bad.append(f'{rel} 第 {i} 行的 `>` 后面不是重定向目标 '
+                           f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)')
+                break
     for p in _iter_scripts(root, ('.sh',)):
         b = p.read_bytes()
         if b'\r\n' in b:

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů