Tkinter 桌面应用开发与验收

SKILL开发
开发 未标注权限 许可证不明确
安装这个技能

在本机(Windows)开发、调试、打包和验收 tkinter 桌面程序的成套方法:解释器探测、无界面自检、PrintWindow 截图验收、PyInstaller 单文件打包与打包后 exe 验收、数据驱动的说明/规则库防误判、pack 顺序导致的「底部内容被挤扁」排查、窗口按桌面工作区自适应(含整窗外框高度公式少乘 2 的坑)、页面装不下时用滚动视口兜底、验收门防随机变红(判问题前先复检)、页面再套一层滚动容器时几何验收为什么会漏(必须滚到底截图,且 moveto(1.0) 一次到不了底)、`_build` 构建残渣的白名单+回收站清理、逐页版面审计、改名类需求怎么做到元信息单一来源、数据目录换位置/搬迁怎么做(单点权威 + 懒迁移,含 `copytree` 兜底把搬家变复制、以及"目录改不动但文件能改"两个坑)、打包产物能不能发给别人的分发可行性审计、tk 高频踩坑清单。当需要新建/改造一个带图形界面的本地工具(清理工具、转换器、看图器、批处理面板等)、需要打包成 exe 交付、给条目批量配人话说明、界面内容显示不全/被裁掉、或 tkinter 程序启动报错/显示异常时使用。另含:函数内延迟导入的模块被 PyInstaller 漏掉导致功能静默消失怎么三重兜底、发布夹同步每版都会留残影怎么根治(`copytree` 是合并不是镜像)、滚动截图前为什么要等 Tk 重绘。

一句话选型结论

按资料字段整理

Tkinter 桌面应用开发与验收是开发类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类开发 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_65fecc37/tkinter-desktop-app-dev
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

开发
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_65fecc37仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验2026-11-06上游最后核验时间 · 数据更新 2026-11-06
正文体量49.9 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
写代码与改 bug

按需求补代码、定位并修掉报错。

读仓库与生成改动

让模型读懂项目结构,再给出可落地的改动。

跑测试与排查故障

按现象排查问题,补测试用例并复跑。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在开发场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
Tkinter 桌面应用开发与验收当前 未标注权限 未标注 未标注 未标注 有
技能审查员 浏览器控制 ClawHub 市场 877.9 万 OpenClaw 有
Vercel React 最佳实践 浏览器控制 GitHub 仓库 849.1 万 Codex、Claude、Cursor 有
GitHub 代码协作 联网访问 ClawHub 市场 763.3 万 OpenClaw 有
网页设计指南 联网访问 GitHub 仓库 668.6 万 Codex、Claude、Cursor 有
本体论 只读 ClawHub 市场 663.7 万 OpenClaw 有

SKILL.md 全文

49.9 KB
模型实际读到的说明书原文,来自技能仓库

Tkinter 桌面应用开发与验收

面向"写一个本地 GUI 小工具"这类任务。核心价值:不用人肉盯屏幕也能验收界面,
以及一份能省掉反复试错的 tk 坑位清单。

1. 先解决解释器:优先用系统级 Python 3.14

# 逐个探测(这是唯一可靠的判据,不要看 PATH)
for P in "C:/Program Files/Python313/python.exe" \
         "C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe" \
         "C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe"; do
  echo "=== $P"; "$P" -c "import sys,tkinter;print(sys.version.split()[0],'tk',tkinter.TkVersion)" 2>&1 | tail -1
done

实测结论(2026-10-20 复核):

解释器 tkinter
系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe ✅ 3.14.2 / tk 8.6(首选)
托管 Python binaries/python/versions/*、venv envs/default ❌ 无
打包用的 _build/venv/Scripts/python.exe ✅(由 uv 的 3.12.14 创建,项目自带)

⚠ 容易搞反的点:托管 Python 与 envs/default 都没有 tkinter——
所以跑界面/截图/版面验收优先用系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe(有 tk 8.6,
且已装齐 numpy/pandas/pymupdf/docx/lxml/pywin32/PIL 等全套生态)。
envs/default 那个 venv 装了 pywin32 却没有 tkinter,别拿它跑界面测试

> 2026-10-20 更新:envs/default 已精简为 13 MB 的极简 venv(仅 pip),
> 不再带 Pillow。需要 Pillow 做 PNG 转换时,改用系统级 3.14.2
> (Pillow 13.0.0 + tkinter 8.6,两者兼有,比旧 venv 更全)。
(症状是 ModuleNotFoundError: No module named 'tkinter',一头雾水)。

→ 交付给用户时,.bat 启动器必须按「工具目录自带 python → 系统级 Python313 → PATH → py -3 → 常见安装位置」
的顺序探测,并且用 "%PY%" -c "import tkinter" 做一次实际验证再启动,否则会静默失败。
平时写代码用普通解释器即可(业务模块无需 tkinter),只有跑界面/自检才用上面那两个。

2. 无界面自检:一次跑完所有页面

把「逐页构建」做成 selftest.py,构建期间不进入事件循环,只 root.update() 一次:

import tkinter as tk, main as app_main
root = tk.Tk(); root.withdraw()
app_main.PAGES.update(app_main._build_pages())
app = app_main.CleanerApp(root)
for key in [n.key for n in app_main.NAV_ITEMS]:
    try:
        app.goto(key); root.update()      # 不调 mainloop,才不会卡住
    except Exception as e:
        print("FAIL", key, e)
root.destroy()

要点:
- 不要 mainloop(),否则脚本不退出;update() 够触发布局计算。
- 页面里若用 after(ms, ...) 延迟加载,这种自检不会执行到,属于正常。
- 后台线程回调要能容忍"控件已销毁":widget.after(...) 外层 catch tk.TclError,
回调内部先判 widget.winfo_exists()。

3. 视觉验收:PrintWindow 截图 + 读图

用 scripts/shot_win.py(本 skill 自带):

# 启动(背景任务,不要用 nohup;用 & + $! 拿到 PID 便于精确回收)
pythonw main.py --page=clean & PID=$!
sleep 7
py -3.14 scripts/shot_win.py "窗口标题关键字" out.png     # 打印 HWND/size
kill $PID 2>/dev/null; sleep 2
py -3.14 scripts/shot_win.py "窗口标题关键字" x.png        # 期望输出 NOT_FOUND,确认没有残留

技术要点:
- 必须用 PrintWindow(hwnd, mdc, 2)(2=PW_RENDERFULLCONTENT)。截屏法/SetForegroundWindow
会截到壁纸或被其它窗口遮挡。
- 取像素用 GetDIBits + BITMAPINFOHEADER(biHeight=-h)(负数=自上而下),
再 PIL.Image.frombuffer("RGBA", ..., "raw", "BGRA", 0, 1)。
- 残留实例是最常见的干扰源:标题相同时 EnumWindows 返回的是 Z 序最上的那个,
很容易截到上一次没杀干净的窗口,得出"功能没生效"的错误结论。
每次截图后必须再跑一次确认 NOT_FOUND。
- 刚启动 1~2 秒内窗口可能还没应用 geometry,截出来只有一条标题栏 → 等 6~10 秒再截。
- 目标解释器常常没有 PIL(豆包运行时 3.14.7 就没有)→ 不要靠 PIL 存图,
直接手写 BMP:b"BM" + struct.pack("<IHHI", off+len(px), 0, 0, 54+40)
+ struct.pack("<IiiHHIIiiII", 40, w, -h, 1, 32, 0, len(px), 2835, 2835, 0, 0)
+ GetDIBits 出来的 BGRA 原始字节(biHeight=-h 即自顶向下)。
需要转 PNG 时再用另一个带 PIL 的解释器转
(系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe
有 Pillow 13.0.0 + tkinter 8.6;envs/default 已精简为仅 pip,不再有 Pillow)。
- 当前模型读不了图时,用"调色板占比"代替肉眼:把截图按程序 UI 的配色表逐点比对,
能客观判断"是否渲染出真界面"。例:左侧 200px 区域深蓝 #1E3A5F 占 80.8%(导航栏)、
右侧内容区浅底 #F4F6FA 占 94.0%(内容区)、缩略图唯一色数 1930(不是纯色空白)→ 渲染正常。
- 冒烟测试用心跳文件判"是否真的起来了"最稳:启动器先写心跳再拉界面,
测试侧轮询该文件,比 sleep 固定秒数可靠。
- 截对话框(Toplevel)比截主窗口要等更久。实测:创建后 0.78s 抓,
拿到的整片是 DWM 窗口底色 #F2F3F4(颜色种类只有几百、关键配色像素全为 0),
一度误判成"内容没渲染出来";等到 2.4s 后恢复正常(颜色种类 869、绿色色条 #12A150 正确)。
判据:采样颜色种类 < 300 基本就是空白图,别急着下结论。
- Tk 的 Toplevel 在 Windows 上有两个 HWND:EnumWindows 按标题找到的(含标题栏,
宽出 16px)与 dlg.winfo_id()(纯客户区)。两个都能抓到内容,
稳妥做法是两个都抓、取"颜色种类最多"的那个;要连标题栏一起看就用前者。

4. tk 坑位清单(都是本次实测踩到的)

  1. pack_propagate(False) 必须配 height:只给 width 会让 Frame 塌陷成 0 高度,
    卡片里的内容全部不可见。
  2. tk 缩放会放大字体:tk scaling 在高 DPI 下约 1.33,15pt 实际约 20px,
    所以固定像素高度的容器要按 1.3~1.4 倍留余量(例:三行文字的卡片给 104px 而不是 78px)。
  3. 滚轮不要用全局 bind_all 常驻:多个滚动容器会互相串。做法是每个容器在
    &lt;Enter&gt; 时 bind_all(&quot;&lt;MouseWheel&gt;&quot;)、&lt;Leave&gt; 时 unbind_all。
    ⚠ 但光这样会有一个隐蔽 bug:指针从容器本身移到它自己的子控件上时,
    Tk 也会给容器发 &lt;Leave&gt; → 把绑定拆掉 → 鼠标一放到文字/表格上就滚不动了。
    所以解绑前必须先确认指针真的离开了这棵子树:

```python
def _ptr_inside(self):
w = self.winfo_containing(self.winfo_pointerx(), self.winfo_pointery())
while w is not None:
if w is self:
return True
w = getattr(w, "master", None)
return False

def _unbind_wheel(self, _e=None):
if self._ptr_inside(): # 只是移到子控件上,别解绑
return
self.unbind_all("<MouseWheel>")
`` 另外滚轮回调里要**从event.widget往上找祖先**,遇到自带滚动条的控件 (Treeview/Text/Canvas/Listbox)就return让给内层滚,否则会出现 &quot;表格滚不动、外面反而乱滚&quot;。 4. **Microsoft YaHei UI 缺 ⑪ 及以后的圈码**,会渲染成豆腐块 → 导航编号用1.2.这类纯数字。 5. **中文文案里不要嵌 ASCII 双引号**:"找出真正的"黑洞""会直接语法错误, 用「」或引号转义。写完务必python -m py_compile .py全量编译。 6. **ttk 主题外观不一致**:要完全可控的按钮就用tk.Label自绘(三态配色 + cursor), 比折腾 ttk style 省事。ttk.Style用clam打底再改色。 7. **页面重建是常态**:切换页面时销毁重建最省心,但要保证重建后_current状态一致, 且**依赖异步数据的页面在数据就绪后要主动重建**(否则用户在数据到达前点进去只会看到空列表)。 8. 表格用ttk.Treeview(show="headings"),iid用行索引、values_fn(row)统一映射, 排序时按列类型(num/str)取键,比到处写 lambda 干净。 9. **tk 9.0 下widget["opt"]/cget()返回Tcl_Obj,不是str**: 它**不可哈希**(放进set报TypeError: cannot use '_tkinter.Tcl_Obj' as a set element), 与str比较也不可靠。断言/统计前**必须先str(...)归一化**。 实测踩坑:验证脚本里写if bar["mode"] == "determinate"恒为假, 导致&quot;0 采样&quot;,一度误判成&quot;跨线程不生效&quot;,白查一轮。 10. **不要从后台线程读 Tk 控件状态做断言**:读widget[...]、cget()本身就不是线程安全操作, 采样会漏甚至报错。正确做法是**在主线程用after()定时器采样**, 后台线程只负责&quot;驱动&quot;(调show()/busy())。 11. **判&quot;跨线程 UI 是否真生效&quot;必须在真实mainloop()里测**,不能用update()循环代替:update()循环不置位 Tk 的dispatching标志,跨线程调用会抛RuntimeError: main thread is not in main loop——**这是测试假象,不是代码缺陷** (本次因此先误判了一轮)。 12. **pack顺序=垂直空间的分配优先级(会造成&quot;内容静默消失&quot;)**: pack 按**加入顺序**分配,先加入的先拿满自己请求的高度。若先 pack 了带expand=True的大表格,它会把高度全吃掉,**排在它后面的固定块只能分到残渣**。 实测两次:① 一张&quot;怎么用这份说明&quot;卡(需 130px)被压成 **1px,从上线起就没露过面**; ② 窗口缩到最小尺寸时,详情卡被切 62px,「为什么这么建议」整行消失。 这类 bug **不报错**,且只在特定窗口尺寸才露出来,靠肉眼极易漏。 **正确结构:固定块先占位(顶部side="top"、底部side="bottom"), 唯一的可伸缩控件(表格/列表)最后 pack。** 注意side="bottom"时 **先 pack 的贴着最下边**,所以要&quot;表格→(上面)详情卡→(最下)按钮行&quot;这个视觉顺序, 得先 pack 按钮行、再 pack 详情卡。 ⚠ **但顺序不是万能的**:Tk 的 parcel 分配是「先按顺序把每个块放到自己那一侧, 剩余空间再给expand」——当**某一侧的总需求本身就已经超过容器**时, 该侧最后那个 parcel 还是会被压扁。实测在 1366x768 笔记本上, 「日志与恢复」把台账表格和底部按钮行**整块压没**,怎么调登记顺序都救不回来。 → 兜底手段见 §4.3「滚动视口」。 13. **在mainloop()之前调app.goto()会丢异步回调**:ui.run_bg的widget.after(0, done)若赶在mainloop启动前发出就会丢失,**页面永远 0 行**。 同样的代码&quot;有时能跑&quot;纯属时序侥幸(本机连挂两次才发现)。 测试脚本一律写成root.after(150, step_goto); root.mainloop()。 14. **<Enter>/<Leave>的回调签名要写成lambda _e=None:**:Tk 9 在**销毁**控件时 会不带事件对象触发一次<Leave>,写lambda e: ...就会TypeError: <lambda>() missing 1 required positional argument: 'e'。 自绘按钮的悬停态最常这么写,而一旦被全局的report_callback_exception兜住,表现就是&quot;**每次切页面都弹一个错误框**&quot;——很难联想到是销毁引起的。 15. **side="bottom"+expand=True` 混用时要留意 parcel 的两趟分配:
实测顺序是「祖先 → 父 → top 侧(按加入序)→ bottom 侧 → left/right 侧」,
两侧之间还会交错。结论:
能确定顺序的场景就靠顺序,不能确定的场景必须上 §4.3
的滚动视口兜底
*,别指望调顺序能覆盖所有尺寸。

4.1 版面验收:用"被挤扁"检测代替肉眼看

判断"底部信息会不会显示不全"不要靠截图(截图只能证明某一个尺寸好看),
直接比 winfo_height()(实际分到)与 winfo_reqheight()(内容需要):
前者小于后者就是内容被裁,与窗口尺寸、DPI 都无关。

for c in page.winfo_children():
    if c.winfo_height() &lt; c.winfo_reqheight() - 1:
        print(&quot;被挤扁:&quot;, c, c.winfo_reqheight(), c.winfo_height())

例外:自带滚动条的控件(Treeview / Text / Canvas / Listbox)允许被压小
(压小只是要滚动,不算丢信息)——用"自身或子孙里有滚动型控件"来放行,
否则表格会被误报。把这个检查逐页跑一遍(变更窗口尺寸多跑几轮),
就能一次扫出所有"静默消失"的块,本机用它扫出 1 处并确认其余 13 页干净。

⚠ 这个判据必须只有一份实现(放 _dev/页面可见性.py 之类,验收脚本共用),
否则两个脚本会各自演化、给出互相矛盾的结论(踩过:探针说没问题、截图有问题)。

另外必须等真的映射完再量:Tk 对未映射控件一律返回 1x1,会假报
"每个块都被压成 1px"。判据用 winfo_ismapped() and winfo_height() &gt; 50 轮询:

def wait_until(pred, cb, tries=60, every=250):
    def tick(n=0):
        try: ok = pred()
        except tk.TclError: ok = True
        return cb() if (ok or n &gt;= tries) else root.after(every, lambda: tick(n + 1))
    tick()

启动后要 1~2 秒才把子窗口全部映射完(有过"改字号 + 顶部渐变条反复重绘"的话更久),
这个假失败本机踩过两次。

还有一条判据要区分"滚得到的裁切":页面放进滚动视口(§4.3)之后,
内容越过视口下沿是设计如此,不能算丢。做法是自底向上遍历祖先,
遇到"stretch 版滚动容器"就跳过它和它外壳的裁切,再往上才取 min。

4.2 窗口尺寸按「桌面工作区」算,不是按屏幕尺寸算

症状:笔记本上(任务栏不隐藏)窗口底边被切,状态栏 / 页面底部按钮行 /
导航最下面的署名全部看不见,不报任何错。

根因:root.geometry(&quot;1280x880&quot;) 给的是客户区,整窗还要加标题栏与边框
(本机 880+39 = 919),而 1366×768 笔记本扣掉任务栏的工作区只有 728px。
写死尺寸 = 必然超屏。

_SPI_GETWORKAREA = 0x0030          # 取&quot;桌面工作区&quot;,任务栏在任何一边都会被扣掉

def work_area():
    u = ctypes.windll.user32
    r = _WRect()
    if u.SystemParametersInfoW(_SPI_GETWORKAREA, 0, ctypes.byref(r), 0):
        return r.left, r.top, r.right - r.left, r.bottom - r.top
    return None

⚠ 最大的坑:整窗比客户区高多少,公式里有个 ×2。
Windows 官方口径(带标题栏 + 可缩放边框):

整窗高 = 客户区高 + SM_CYCAPTION + 2 * (SM_CYSIZEFRAME + SM_CXPADDEDBORDER)
整窗宽 = 客户区宽 +            2 * (SM_CXSIZEFRAME + SM_CXPADDEDBORDER)

本机 caption=23, frame=4, pad=4 → 高 = 23 + 16 = 39px。
我第一版写成 caption + frame + pad 得 31px —— 差的 8px 恰好等于定位留的 margin,
于是窗口紧贴工作区下沿、零余量,现在没露出去纯属侥幸。
必须写一条回归断言:建一个真实 Toplevel、用 GetWindowRect 量外框,与公式比对:

r_fh = (rc.bottom - rc.top) - probe.winfo_height()
assert abs(r_fh - formula_fh) &lt;= 2

再上一层保险 clamp_position():窗口映射后用 GetWindowRect 读真实外框,
真超界就 SetWindowPos 往上/往左挪(有标题栏时窗口外框在 GetAncestor(hwnd, 2) 上)。
公式只能"应该对",实测才叫"确实对"。用 SetWindowPos(HWND_TOPMOST) 而不是
SetForegroundWindow(后者常被系统拦掉)。

其它要点:
- DPI 不用自己换算:程序若没声明 DPI 感知,Windows 会把高 DPI 坐标虚拟化回
96 DPI,SPI_GETWORKAREA 与 winfo_screenwidth 落在同一套坐标里
(本机物理 1920×1080@125%,两处都报 1536×864)。
- minsize 也要夹,否则矮屏上"最小高度"本身就超过屏幕,怎么拖都超屏。
- 对话框(进度框/完成框)同样要夹:它们的底部就是「确定/取消」按钮,
越过屏幕下沿就点不到了。上限用 maxsize。
- fit_window() 应该把 (w,h,x,y,squeezed) 返回给调用方,并让"被压缩"驱动
「紧凑排版」(收紧行距、缩小 logo)。别让调用方去读 win.geometry() ——
Tk 对尚未映射的窗口一律报 1x1,读不出真值(模拟测试里因此假失败过一轮)。
- 自己程序选的几何(win_geom)要存下来,验收/截图脚本"还原真实尺寸"时用这一份,
而不是脚本自己再摆一次位(摆错会把锅算到程序头上)。

4.3 页面内容装不下:滚动视口(ScrollFrame(stretch=True))

窗口能夹进屏幕了,但单个页面可能还是比视口高(实测「日志与恢复」整页 859px,
矮屏视口只有 555px)。§4「pack 顺序」只能缓解,救不了"某一侧总需求超容器"。

做法:把页面宿主换成一个伸缩滚动容器,内容有多高就让 inner 有多高,
装不下就出滚动条;放得下时行为与以前完全一致(带 expand 的表格照旧填满)。

class ScrollFrame(tk.Frame):
    def __init__(self, parent, bg=None, stretch=False, **kw):
        ...
        self.inner.pack_propagate(False)      # stretch 模式:高度自己算
        self.canvas.create_window((0, 0), window=self.inner, anchor=&quot;nw&quot;)

主界面里:

self.body = ui.ScrollFrame(right, stretch=True)
self.body.pack(fill=&quot;both&quot;, expand=True)
self._page_host = self.body.inner          # 以后页面都挂这里
# goto(): 先 self.body.begin_page() → builder(...) → self.body.sync_height()

三条纪律(都不是猜的,是被验收脚本逼出来的):

  1. 变高立即生效,变矮要"连续两次读到同一个值"才生效。
    扫描结束后页面会重建(goto() 重跑 builder),中途测得的 content_need
    会短暂偏小(实测「应用专清」593→518)。跟着缩就会把已经建好的块裁掉 75px。
  2. 滚动条只看"有效内容高度",与 inner 高度同源。
    否则会出现"内容明明超出视口、滚动条却是空的"——验收脚本真抓到过一次,
    就是因为滚动条用了瞬时读数、inner 用了延迟收缩后的值。
    两个都用同一个 _eff_need,就不会自相矛盾。
  3. begin_page() 换页时把 _eff_need 归零,否则从高页面切到矮页面会先
    维持上一页的高度、闪一下滚动条。

另外:
- content_need() 只是"请求高度之和",不等于实际高度;带 expand 的表格会被
撑大,非伸缩的 Canvas(默认 10c x 7c)会虚报 ~265px。所以"放得下"的页面
也用不到精确值,别拿它当布局真值。
- 内容变化时如果控件自身尺寸没变,Tk 一个事件都不发 → 只能靠轮询兜底,
间隔取 150ms(别用 400ms,理由见下),content_need() 求和(主块通常
不到 10 个),开销可忽略。
这是唯一能兜住"异步重渲染"的手段:页面数据常常是后台线程扫出来的,扫完
重建整页,而 inner 尺寸可能一直没变 → 一个 &lt;Configure&gt; 都不会发。
实测 400ms 会留下最长 0.4 秒的窗口:内容已比视口高、滚动条还没出来、页面被压
38px(实测「应用专清」555 vs 593)。150ms 后肉眼无感。这段窗口只能缩短、不能
消除
,所以验收脚本必须容忍它(见 §4.5)。
- 轮询变密后注意别白刷:canvas.configure(scrollregion=...) 只在内容真变时
下发(bbox() 是一次 Tcl 往返,150ms 一次就值得省)。
- 同步构建的页面(builder 跑完内容就定了)再补一次 request_sync()
(内部 after_idle(sync_height)),把"内容就绪 → inner 跟上"压到一个事件循环内。
- 滚动条显隐加 20px 迟滞:竖滚动条只改变宽度,而宽度变化可能让文字多折一行、
内容反而更高,没有迟滞临界点会来回闪。
- 换页后 scroll_to_top(),否则新页面会停在上一页的滚动位置。

4.4 截图不依赖 Pillow:纯标准库写 PNG

本机带 tkinter 的解释器没有 Pillow,带 Pillow 的又没有 tkinter。
以前搞成"截图存 BMP → 换个解释器转 PNG",两个进程、两处容易忘。
zlib + struct 就能直接写 PNG(scripts/png_writer.py):

from png_writer import write_png
write_png(&quot;shot.png&quot;, w, h, px)     # px 是 GetDIBits 的原样输出(BGRA、自顶向下)

输入正是 GetDIBits(..., biHeight=-h, biBitCount=32) 的输出:每行 w*4 字节、
无行填充、通道序 BGRA。注意两点:
- BGRA → RGBA 要交换首尾通道(只换这两个);
- alpha 一律置 255 —— PrintWindow 常把 alpha 写成 0,直接透传会得到一张全透明的图。

顺带好处:颜色统计也不需要解码图片了,直接在原始像素上按 BGRA 比对即可
(比对 RGB 参考色时记得 B/R 对调),于是"截图 + 断言"可以单进程完成。

⚠ PrintWindow 抓的是窗口自己,无论它是否跑到屏幕外都能拿到完整内容 ——
所以它永远证明不了"被任务栏切掉"。要证明这件事只能抓屏幕上的工作区矩形
(BitBlt 屏幕 DC),并且把窗口 SetWindowPos(HWND_TOPMOST) 提到最前
(SetForegroundWindow 会被拦)。抓"窗口 + 下方任务栏"这一条更直观:
一张图里同时看到窗口下边缘和任务栏,切没切一眼可辨。

4.5 验收门不能随机变红:判"有问题"之前先复检

一个会随机变红的验收门比没有门更糟 —— 它训练你忽略红灯,真问题就漏了。
本机实测:逐页布局检查在 1366×681 下连跑 4 轮有 3 轮随机报「应用专清」,
而前一天那次是"14/14 全绿"通过。全绿是运气好,不是修好了。

根因(用探针抓现场定性,别靠猜):该页数据是异步扫出来的,扫完重渲染,
内容 209→593px。中间有一拍 inner 还停在视口高 555、滚动条未显示、内容高出
38px,200ms 内自愈(见 §4.3 的轮询说明)。这不是"底部滚不到",
只是可伸缩表格矮了一点、底部固定内容一行不少。

# 判定为问题之前先给它自愈的机会;只有&quot;持续&quot;超限才算真问题
def measure(key, i, n=0, t0=None):
    t0 = t0 or time.time()
    kind, page, squeezed, clipped, vh, need, bar = read(key)
    if kind != &quot;ok&quot; and n &lt; SETTLE_TRIES:          # 200ms × 7 ≈ 1.4s
        return root.after(SETTLE_MS, lambda: measure(key, i, n + 1, t0))
    ...                                            # 到这里仍不对 → 才记问题

要点:
- 先把"瞬时"和"持续"分开再动代码。写个探针反复加载该页,对同一现场隔
200ms / 1.2s 各读一次,打印 inner 高 / 内容需要 / 有效高度 / 滚动条是否 mapped。
数据摆出来,"过渡态 vs 卡死"一眼分得清(实测 6 轮全为过渡态、0 次卡死)。
- 自愈的不要静默放过,在输出里标注「判定前复检 N 次」—— 既稳,又留下
"这里曾经抖过"的线索;哪天真的退化成卡死,那行日志就是关键证据。
- 等待只在非 ok 时才发生,正常路径零开销(否则 1.4s × 14 页 = 20s)。
- 同类坑:断言"体积/大小/哈希"这类量时,别写死期望值。写死了每次重新构建
都会假报异常(本机踩过:清理脚本里写死旧 exe 的哈希,重打包后必报"交付物异常")。
改成"多份副本互相一致 + 打印实际值"。

4.6 页面自带滚动容器时,几何验收给不出结论 —— 必须滚到底截图

§4.1 的"被挤扁"判据会放过一类页面:页面自己又套了一层滚动容器
(如"设置"页 —— 标题固定在页面里,正文整体放进 ScrollFrame)。

本机实测("设置"页,1280×880,往"关于"区新增了一整块反馈卡片之后):

逐页布局检查:页面高 719 | 内容需要 **405** | 一屏放得下 ✔
真实滚动容器:内容需要 **1233** | 视口 578 | 滚动条可见

外层页面只有一个 scroll 容器,它的 reqheight 只反映视口/早期值,
容器内部那 1233px 完全不在统计口径里
—— 于是这个页面无论往里加多少内容,
几何门都会说"没事"。自动验收全绿 ≠ 新内容真的显示出来了。

规则:凡是"页面里再套一层滚动容器"的页面,几何数字只能证明"容器没被裁",
"容器内部的新增内容可不可见"必须滚到底截图,用眼睛看。
(_dev/截图页面.py 的第 4 个参数 bottom 就是为它加的:)

py -3.14 _dev/截图页面.py settings _dev/shot_settings_bottom.png 1280x880 bottom

⚠ yview_moveto(1.0) 一次到不了底:实测末次 yview() 停在 (0.516, 0.935),
还差 65px。原因是 moveto 按调用那一刻的 scrollregion 定位,之后异步内容
或折行把 scrollregion 撑高,视图的相对位置就"缩水"了。必须迭代到 yview()[1] ≈ 1:

def scroll_bottom(sf, n=0):
    sf.canvas.configure(scrollregion=sf.canvas.bbox(&quot;all&quot;))
    sf.canvas.yview_moveto(1.0)
    root.update_idletasks()
    if sf.canvas.yview()[1] &lt; 0.999 and n &lt; 4:      # 到位判据看 yview 末端
        return root.after(120, lambda: scroll_bottom(sf, n + 1))
    root.after(250, dump_and_grab)                  # 画面稳定后再抓

配套两个小坑:
- 长文案(如"…请截图发送到邮箱:xxx@xxx")用 wraplength 跟随视口自动折行时,
别让下限小于文本自然宽。验收方式:tkfont.Font(...).measure(text) 得自然宽,
与 wraplength 比 —— 自然宽 ≤ wraplength 就一定不折行(邮箱这类无空格长串
一旦被折行就可能拆断)。本机实测自然宽 438px、最小窗口下可用 602px,安全。
- 验收"写入剪贴板"这类交互,直接调自绘按钮的 _on_click() 即可(等价于用户点击),
比造真实鼠标事件稳;同一进程内 clipboard_get() 立刻读得回来。

5. 沙箱限制(写"会删东西"的程序时注意)

本机沙箱有批量删除防护,会拦截 os.remove / shutil.rmtree / rm,报
[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] state lock timeout。
因此销毁性代码路径无法在沙箱内跑通:只能静态审查 + 让用户实机验证;
但移动/重命名(os.rename/shutil.move)不受影响,可以正常做往返测试。

6. 启动链路:bat 编码坑 + pythonw 静默失败(用户报"双击没反应"必查)

GUI 工具最常见的用户反馈是「启动不了 / 双击没反应」。先查这两条,命中率极高。

6.1 bat 必须纯 ASCII,且绝不能调 chcp

症状(用户端只看到黑窗一闪):

系统找不到指定的路径。
&#x27;heck&#x27; 不是内部或外部命令          ← 本该是 &quot;goto check&quot;
&#x27;fined&#x27; 不是内部或外部命令         ← 本该是 &quot;if not defined&quot;
&#x27;鐢ㄨВ閲婂櫒锛?PYW&#x27; ...            ← 中文全乱码

根因:文件按 UTF-8 存(中文 3 字节)+ 脚本里调了 chcp 65001。cmd 是逐字节读文件的,
中途切代码页会让后续行按错位偏移解析,于是行首几个字符被吃掉、%~dp0 的中文路径失效。
三者叠加必然崩。

规矩:
- .bat/.cmd 一个非 ASCII 字节都不能有(注释也用英文)。中文提示交给 Python 侧输出。
- 不要写 chcp。中文 Windows 默认就是 936,英文提示显示正常。
- 校验(应输出 0):
python b = open(&#x27;启动工具.bat&#x27;,&#x27;rb&#x27;).read(); print(sum(1 for x in b if x &gt; 127))

6.2 pythonw 无控制台 → 启动期异常全部静默

pythonw.exe 没有 stdout/stderr,缺 tkinter、模块导入失败、编码异常都会被吞掉,
表现为"双击没反应"。三层兜底:

  1. 预检启动器(launcher.py):依次验证 解释器 → import tkinter → 关键模块逐个
    __import__ → 真实 Tk(); withdraw(); update(); destroy() 建窗 → 数据目录可写。
    全过才启动界面;任一失败就写日志 + ctypes.windll.user32.MessageBoxW(None, 中文报告, 标题, 0x10|0x40000)
    (原生弹窗,不依赖 tkinter,所以 tkinter 挂了也弹得出来)。
  2. 心跳文件:预检通过即写 data/launch.ok;bat 用 start 拉起后轮询该文件,
    ping -n 2 127.0.0.1 &gt;nul 循环 3 次仍没有 → 判定失败,改用 python.exe launcher.py --console
    重跑一遍把中文诊断打在控制台,再 pause 停住。
  3. main.py 兜底:sys.excepthook + root.report_callback_exception 都指向同一个
    handler(写 启动错误.log + 弹原生框),否则 Tk 回调里的异常在 pythonw 下等于消失。

6.3 sys.stdout is None 会让一句日志把程序弄崩

pythonw 下 sys.stdout/sys.stderr 是 None,任何 print() 抛 AttributeError。
入口第一步必须兜底重定向:

if sys.stdout is None or sys.stderr is None:
    f = open(os.path.join(DATA_DIR, &quot;界面日志.log&quot;), &quot;a&quot;, encoding=&quot;utf-8&quot;, buffering=1)
    if sys.stdout is None: sys.stdout = f
    if sys.stderr is None: sys.stderr = f

主线程以外不要弹窗(Tk 非线程安全):if threading.current_thread() is not threading.main_thread(): return。

6.4 bat 探测解释器:逐个验证 tkinter,别用第一个命中的

  • 找到 pythonw.exe 后,必须用同目录 python.exe 跑 -c &quot;import tkinter&quot; 验证,通过才采用;
    不通过就继续试下一个候选(本机 PATH 上很可能刚好是那个没 tkinter 的)。
  • 跳过 Microsoft Store 别名:where python.exe 可能返回
    ...\WindowsApps\python.exe,执行它会弹出应用商店而不是跑 Python。用延迟展开过滤:
    bat setlocal enabledelayedexpansion for /f &quot;delims=&quot; %%P in (&#x27;where pythonw.exe 2^&gt;nul&#x27;) do ( set &quot;CAND=%%P&quot; if &quot;!CAND:WindowsApps=!&quot;==&quot;!CAND!&quot; if not defined PYW call :try &quot;%%P&quot; )
  • 候选顺序:工具目录\python\ → 豆包内置运行时 → PATH → py -3 → 常见安装位置。
    注意 py 在只有 Store 别名的机器上同样会弹商店,也要过滤。

6.5 调试 bat 时:重定向到文件,不要管道给 head

沙箱里 cmd //c &quot;x.bat&quot; | head 会被 SIGTERM(无输出、退出码 1,容易误判成脚本有问题)。
改成落盘再看:

cmd //c &quot;启动工具.bat &gt; _dev\bat_out.txt 2&gt;&amp;1&quot;; cat _dev/bat_out.txt

进程存活判定:tasklist //FI &quot;IMAGENAME eq pythonw.exe&quot;。
注意沙箱随命令结束回收子进程,所以 start 起的 GUI 在沙箱里查不到——属正常,
用户双击时窗口会保留。要截图验收就改用 pythonw launcher.py &amp; PID=$! 同命令内完成。

7. 进度反馈:最常见的"进度条不走"

症状:用户点「一键瘦身/清理」,进度条纹丝不动,最后直接跳到完成。

根因几乎总在同一处:只有部分分支调用了 progress_cb。
本次实测:clean() 的 recycle(默认!)与 quarantine 分支只做
done += len(paths) 却从不调回调,只有 permanent 分支调了 → 默认路径永远 0%。

自查清单:
1. 逐个模式都走一遍,确认每个分支都上报(含"空目录清理"这类收尾步骤)。
2. 底层批量函数(如 recycle_paths)要接受 progress_cb,并按批(每 2000 项)上报;
否则一个批次几万文件,进度条会长时间呆滞然后瞬间跳满。
3. 进度值必须单调不减:用 done = max(done, min(total, int(v))),
且 total = max(计划数,1) + max(len(selected),1),避免除零和回退。
4. 递归遍历(磁盘分析/查重)事先不知道总数 → 别硬套 (done,total),
改用 ttk.Progressbar(mode=&quot;indeterminate&quot;) + start(14) 做循环动画,
配一行"当前正在处理 X"的文字。
5. 收尾要显式 finish() 并在关窗前 after(180, close),
否则用户看不到 100%(窗口先消失)。
6. 回调签名易错:quarantine_move 的回调是 (i, n, src) 三参,
写成两参会 TypeError;且务必把回调包在 try/except 里——
进度上报失败绝不能连累真正的文件操作。
7. 后台线程不要直接改控件(Tk 非线程安全)。两种可靠写法:
- widget.after(0, lambda: apply()) 投递回主线程(本次采用,实测可用);
- 或后台只写共享状态 + 加锁,主线程 after(80, poll) 轮询绘制。

8. 任务结束后的"结果提示框"(比 messagebox 好用的固定套路)

场景:清理 / 转换 / 批处理跑完后,既要告诉用户结果,又要给出下一步入口。
messagebox.showinfo 两个硬伤:只能放纯文本、放不了按钮;而且是阻塞式的。

本工具已落地的组件(ui_common.CleanDoneDialog),骨架可直接照搬:

class DoneDialog(tk.Toplevel):
    &quot;&quot;&quot;结果摘要 + 操作按钮。非阻塞:grab_set 但不 wait_window。&quot;&quot;&quot;
    def __init__(self, parent, res, actions=None, tips=None,
                 title=&quot;完成&quot;, on_close=None):
        ...
        self._closed = False
        self.protocol(&quot;WM_DELETE_WINDOW&quot;, self._close)
        try:
            self.grab_set()                      # 模态输入,但不阻塞调用方
        except tk.TclError:
            pass
        self.bind(&quot;&lt;Escape&gt;&quot;, lambda _e: self._close())

    def _invoke(self, cb):
        self._close()                            # ① 先关窗并收尾
        try:
            if cb:
                cb()                             # ② 再执行操作回调(主线程)
        except Exception as e:
            messagebox.showerror(&quot;操作失败&quot;, str(e))

    def _close(self):
        if self._closed:                         # ← 防重入:destroy 可能被多条路径触发
            return
        self._closed = True
        try:
            self.grab_release()
        except tk.TclError:
            pass
        try:
            self.destroy()
        except tk.TclError:
            pass
        if self._on_close:
            try:
                self._on_close()                 # 收尾:清空选中、重新扫描…
            except Exception:
                pass

要点:
1. 不要 wait_window。用它等于把调用方阻塞在局部事件循环里,流程卡住、异常难回传。
改用 on_close 回调表达"关窗之后要做什么"。
2. close → on_close → action 的顺序(本工具的选择):先收尾再执行操作,
这样操作若是"切页",切过去的页面已是刷新后的状态。代价是 action 里再弹窗会叠在新窗口上,
注意别弹冲突。顺序必须写测试断言(实测断言 calls == [&quot;close&quot;, &quot;act:history&quot;])。
3. _closed 标志防重入:关窗按钮、WM_DELETE_WINDOW、程序化 destroy() 都可能触发,
收尾动作(如重新扫描)重复执行会很难查。
4. 按钮由调用方注入:actions=[(文字, 回调, kind)],界面组件不反向 import 业务模块,
组件才能跨页面复用。同理,要展示的数字全部来自业务层返回值,组件内不二次计算。
5. 尺寸:用 wraplength 控制宽度、高度由内容决定 + resizable(False, False);
居中前先 update_idletasks(),并且父窗口还没布局时 winfo_width() 返回 1,
要退化为屏幕居中,否则窗口会跑到 (0,0):
python pw, ph = parent.winfo_width(), parent.winfo_height() if pw &lt;= 1 or ph &lt;= 1: sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() self.geometry(f&quot;+{max((sw-w)//2,0)}+{max((sh-h)//3,0)}&quot;); return
6. 内容分块(自上而下):成功徽标 + 标题 + 副标题 → 大数字(实际释放等)+ 右侧计数 →
明细(限 8 条,超出提示"详见日志页")→ 分色提示 → 等宽按钮条。
提示文字按语义配色(danger 红字加粗 / warn 橙 / ok 绿 / info 灰),与页面上的风险口径一致。
7. 完成后通常还要重新扫描一次刷新界面——把它放进 on_close,用户不用手动点"刷新"。

验收这类组件,四个断言维度就够(见本工具 _dev/验证清理完成提示.py):
① 内容渲染(大数字/计数/明细条数)② 提示分色(danger 必须 fg == red_text 且字体含 bold)
③ 按钮清单(随场景变化)④ 交互时序(先 close 再 action;点"关闭"只触发 on_close)。
另加一条端到端:用 root.report_callback_exception 捕获 Tk 回调异常
(pythonw 下这类异常会被静默吞掉),跑完整"弹出→点击→收尾"链路并断言无异常。

9. 打包成单文件 exe,以及打包后的验收

用户最终要的多半是「双击即用、目标电脑不装 Python」。本机可行且已跑通的方案是 PyInstaller 单文件。

> 完整细则(构建脚本、界面取证、清理脚本、分发审计、数据目录迁移)→ references/packaging-exe.md
> 本节只留打包必守的硬约束、必踩的三个坑、验收硬门。

9.1 硬约束(不守会踩坑)

  • 能打包的解释器只有一个:豆包客户端内置的 Python(3.14.7 / Tk 9.0.4)。用它 -m venv _build\venv 再装 pyinstaller。
    Tcl/Tk 库编进了 dll 的 zipfs,磁盘上没有 tcl/ 目录,带上 tcl90.dll / tcl9tk90.dll 即可。
  • 解释器分工:带 tkinter 的没有 Pillow,带 Pillow 的没有 tkinter → 截图 grab() 只出裸像素,PNG 转换交另一个解释器。
  • 产物名先纯 ASCII,产完再改中文(bootloader 靠自身路径定位,改名不影响运行)。
  • 全程不删除任何文件:--clean、复用 work 目录、覆盖已有产物都会触发"先删后写",在沙箱/安全策略下返回码 1 且报错与真因无关(实测被拦 4 次)。
    → 每次挑全新 workN/outN;落地用 shutil.copy2(截断写入)而非 os.replace;目录合并用 copytree(dirs_exist_ok=True)。
  • 版本资源走官方 VSVersionInfo 序列化,不要手写模板字符串(它 eval(text) 读文件,缩进差一格即 IndentationError);repr() 要 .replace(&quot;versioninfo.&quot;, &quot;&quot;);
    生成后立刻用 load_version_info_from_text_file() 自解析一遍,把错误提前到打包前。
  • 体积:--exclude-module 掉 sqlite3 asyncio concurrent multiprocessing unittest test distutils lib2to3 idlelib 等。

9.2 必踩的三个坑

坑 现象 判据 / 做法
源码改了,exe 还是旧的 平铺模块 + 函数内 import 会被分析器漏掉,静默少功能、不报错 ① exe 的 mtime 必须晚于所有 .py(本次靠这条发现"exe 建于 10:56、源码改于 11:16");② pyi-archive_viewer.exe -l -r &quot;dist\工具.exe&quot; \| grep 模块名 递归确认模块在 PYZ 里(顶层 toc 只有 dll/pyz);③ 比对模块体积——改过的变、没动的不变才可信
延迟导入的模块漏打 功能静默消失,无任何报错 手工维护 HIDDEN_IMPORTS = [...],每新增一个模块都必须补进去
copytree 是合并不是镜像 发布夹每版都留残影 同步后逐份比 sha256,不要假设脚本复制的一定是最新版

补充两条同源教训:

  • 打包进行中不要改源码或文档——实测落进去的是改到一半的快照(比项目根少 2 字节)。
  • 元信息只留一处来源:构建脚本用 ast 读 settings.py 的常量(不要 import settings,构建机可能缺运行期依赖)。
    否则改界面署名后,exe 右键属性里还是旧名字。

9.3 打包后的验收(不要只信"构建成功")

_dev/验证打包exe.py 的硬门(本机 11/11 通过):PE 子系统 = GUI(2)、manifest requireAdministrator、
版本资源可读且正确、图标资源存在、--diag 返回 0、数据目录建在 exe 旁边、日志含"环境正常"、
无 %TEMP%\_MEI* 泄漏、真实窗口出现、冷启动 < 15 s、窗口可正常关闭。

三个容易写错的判据:

  • 版本资源用 version.dll 的 VerQueryValueW,须显式声明 argtypes/restype;手写 pefile 解 RT_VERSION 反复失败过。
  • "窗口出现"要按进程树匹配 PID:onefile 的 bootloader 会拉起子进程,界面窗口属于子进程,只匹配 Popen.pid 永远找不到;
    也别按标题子串匹配(会命中标题含同一文件夹名的资源管理器)。
  • 数据目录隔离性:把 exe 复制到不含 data 的空目录再跑,验证它把数据建在 exe 旁边,而不是临时解压目录或用户目录。

10. 数据驱动的"说明/规则库"(给条目配人话解释)

给启动项、清理项这类既有对象逐条配中文说明时,用"分层优先级 + 数据表"最省事:
① 风险特征识别(最高,可覆盖其余)→ ② 内置关键词规则表 → ③ 版本资源/元信息 → ④ 路径特征兜底。
规则按子串匹配 (名称|命令|路径) 的小写串,列表顺序即优先级(具体规则必须排在通用规则之前,
例如 Wallpaper Engine 装在 Steam\steamapps\... 下,排在 Steam 规则之后就会被抢走结论)。

踩过的大坑:单元素元组。 规则表里写 ((&quot;steam&quot;), ...) 得到的是字符串而不是元组,
for k in keys 会逐字符迭代,于是单字母 e 匹配一切——本次导致全部启动项都被说成同一个软件。

必须加导入期自检,让它再也犯不了:

def _validate_rules():
    for keys, title, _desc, advice in _RULES:
        if isinstance(keys, str) or not all(isinstance(k, str) for k in keys):
            raise ValueError(&quot;规则「%s」关键字组必须是元组(单元素也要写逗号:(\&quot;x\&quot;,))&quot; % title)
        for k in keys:
            if len(k) &gt;= 3:
                continue
            if len(k) == 2 and any(&quot;\u4e00&quot; &lt;= ch &lt;= &quot;\u9fff&quot; for ch in k):
                continue                       # 2 字中文(火绒、钉钉)放行
            raise ValueError(&quot;规则「%s」关键字「%s」太短,会误匹配&quot; % (title, k))
_validate_rules()

自检还要用一个"陷阱清单"反向验证:拿 10 条无关命令去跑,确认几乎不命中。
本次就是靠它逼出 tim/amd/pps/obs/115/ccb 这些过短英文关键字,逐个改长。

10.1 更大的坑:关键词是「别人的子串」(同音不同字)

真实事故(用户当场验收时指出):关键词 pingbao 命中了 duohuipingbao,
于是把 多绘屏保讲成了「购物推广组件 / 返利插件」——
屏保(píngbǎo) 与 拼宝(pīnbǎo) 同音不同字,子串匹配必然认错。

更根本的问题是优先级排错了:关键词是猜的,exe 版本资源是程序自己写的,
而当时"猜的"居然覆盖了"自报的"——describe() 明明读到了 FileDescription=多绘屏保 却没用上。

两道修补,第二道才是关键:

① 歧义关键词黑名单(导入期拦截,永久生效)

# 本身没错,但是别的产品名的子串,一加进来就张冠李戴
_AMBIGUOUS_KEYWORDS = {
    &quot;pingbao&quot;: &quot;是 duohuipingbao(多绘屏保)的子串 —— 屏保≠拼宝,同音不同字&quot;,
    &quot;ping&quot;: &quot;太泛&quot;, &quot;hui&quot;: &quot;太泛&quot;,
}
# _validate_rules() 里: low = k.lower();  if low in _AMBIGUOUS_KEYWORDS: raise ValueError(...)

(附带好处:它当场把我新写的合法关键词 dhpingbao 也拦了——dh 本就是"多绘"的缩写。
报错把问题顶到脸上,正好反证自检生效,随后把它移出黑名单即可。)

② 拿程序自报名称去复核说明库的结论——通用,且能覆盖所有没想到的同音词

def version_name_conflict(rule_title, rule_desc, fd, product):
    &quot;&quot;&quot;说明库那串汉字与程序自报名称里的汉字一个都不重合 → 判为矛盾。
       名称里没有汉字(WeChat / pdfFactory)时不判断,避免误报。&quot;&quot;&quot;
    rule_cjk = _cjk(rule_title) | _cjk(rule_desc)          # 只收 [\u4e00-\u9fff]
    name_cjk = _cjk(fd) | _cjk(product)
    if not rule_cjk or not name_cjk or (rule_cjk &amp; name_cjk):
        return &quot;&quot;
    return (fd or product).strip()

判矛盾后要一整套动作,不能只改一个字:
撤回推断的说明 → 标题改用程序自报名称 → 建议自动降一档(可禁用→看需求,别再误劝用户禁用)
→ 备注首条写明"说明库推断 X ≠ 程序自报 Y,以程序为准" → 列表短说明以 ⚠ 开头
→ 详情卡加一个橙色「名称不符」徽标 → 导出加一列「识别」。

判定要保守:只比汉字、有一字重合就放过。否则会把好结论也推翻——
必须加一条回归断言:真机上不允许出现任何"名称不符"误报(本项目真机 7 项零误报)。

回归用例要主动构造冲突才测得到:临时往 _RULES 头部 insert() 一条必定误匹配的假规则,
拿真实的同名 exe 去撞,断言冲突路径全部生效,finally 里 remove() 掉。
只测纯函数是不够的——describe() 里那段接线才是最容易漏的。

10.2 其它

性能可忽略(本机实测:枚举 0.001 s,首次生成说明 0.017 s,命中缓存 0.002 s),
但要给结果加缓存,且判定依赖"文件是否存在"的规则在测试里要能关掉
(check_file=False),否则合成路径永远被判成"文件已不存在"。

修改说明库这类会给用户下结论的表,交付前一定要用真实数据跑一遍并把结果贴给用户看——
本次就是用户从结果表格里一眼看出"duohuipingbao 不可能是返利插件"的。

常见问题

5 问
答案由本站依据条目字段整理
Tkinter 桌面应用开发与验收怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_65fecc37/tkinter-desktop-app-dev。安装方式为未标注。装完刷新技能列表或重开客户端。

Tkinter 桌面应用开发与验收支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

Tkinter 桌面应用开发与验收权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

Tkinter 桌面应用开发与验收收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

Tkinter 桌面应用开发与验收用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 2026-11-06。