展开菜单
首页 精品内容 本月促销 装机必备 Windows PDF教程 macOS软件 IOS软件 Android AI 专题
全部分类

当前位置:

首页 > 编程开发 > Python+wxPython从零编写一个AES加 ZIP工具(附源码)

Python+wxPython从零编写一个AES加 ZIP工具(附源码)

一、需求与技术选型 需求看起很简单,但每一条背后都藏着技术决策的取舍: 需求决策点选择多个文件wx.FileDialog(FD_MULTIPLE) + 拖拽 wx.FileDropTarget输入 ZIP 名称 / 密码输入合法性校验(Windows 非法字符、两次密码一致)生成加密 ZIP标准库做

本文目录

    一、需求与技术选型

    需求看起很简单,但每一条背后都藏着技术决策的取舍:

    Python+wxPython从零编写一个AES加 ZIP工具(附源码)

    需求决策点
    选择多个文件wx.FileDialog(FD_MULTIPLE) + 拖拽 wx.FileDropTarget
    输入 ZIP 名称 / 密码输入合法性校验(Windows 非法字符、两次密码一致)
    生成加密 ZIP标准库做不到,必须引第三方库
    操作保存到数据库SQLite 双表 + 外键级联
    配置保存并下次回填JSON + 字段白名单
    打包 exe 后仍能定位同目录的配置和数据库这条最容易写错,见第二节
    操作简单、界面美观自绘按钮、分组框、进度条、可取消

    最终技术栈:

    Python 3.x

    wxPython 4.2.4 (wxWidgets 3.2.8 msw, phoenix) —— GUI

    pyzipper 0.4.0 —— AES 加密 ZIP 读写

    Pillow 11.3.0 —— 可选,图片解码兜底

    sqlite3 / json / ctypes / threading —— 标准库

    安装:

    pip install wxPython pyzipper pillow

    为什么选 wxPython 而不是 PyQt/Tkinter?

    • Tkinter 自带但界面观感太“上世纪”,ListCtrl 这类多列表格要自己拼;
    • PyQt/PySide 好看,但 LGPL/GPL 授权问题在商用场景上要额外考虑,打包体积也更大;
    • wxPython 用的是操作系统原生控件,Windows 上天然就是 Win 的观感,打包后体积可控。

    代价是:原生控件意味着你要迁就系统主题的脾气。这个代价在第七节会具体咬人一口。

    二、第一个坑:打包成 exe 后配置文件去哪了

    这是需求里最硬的一条约束,也是新手最容易翻车的地方。

    先看正确写法:

    def app_dir():
        """程序所在目录:.py 直接运行取脚本目录,PyInstaller 打包后取 exe 目录。"""
        if getattr(sys, "frozen", False):
            # onefile 模式下 sys.executable 是 exe 本身,不是解包的临时目录
            return os.path.dirname(os.path.abspath(sys.executable))
        return os.path.dirname(os.path.abspath(__file__))
    
    
    BASE_DIR = app_dir()
    CONFIG_PATH = os.path.join(BASE_DIR, CONFIG_NAME)
    DB_PATH = os.path.join(BASE_DIR, DB_NAME)
    

    三种错误写法,以及各自的死法

    错法 1:直接用相对路径

    CONFIG_PATH = "config.json"          # ❌
    

    相对路径是相对于当前工作目录(CWD),不是程序目录。用户从桌面快捷方式启动,CWD 可能是 C:WindowsSystem32;从命令行 cd D: && python C:toolzip_maker.py 启动,CWD 是 D:。结果就是配置文件“随机”散落在文件系统里,用户会觉得“我的设置怎么时有时无”。

    错法 2:只用 __file__

    BASE_DIR = os.path.dirname(os.path.abspath(__file__))    # ❌ 打包后就错了
    

    PyInstaller onefile 模式下,exe 运行时会把自己解压到一个临时目录(形如 C:UsersxxxAppDataLocalTemp_MEIxxxxx),__file__ 指向的是那个临时目录里的脚本。于是:

    • 配置写进了临时目录;
    • 程序一退出,临时目录连同你的配置和数据库一起被删除。

    用户的体验是“每次打开都是空的,历史记录一条都没有”——而且几乎无法自查,因为文件确实被写成功过。

    错法 3:用 sys._MEIPASS

    BASE_DIR = sys._MEIPASS                                   # ❌ 语义反了
    

    很多博客会告诉你用这个。但 sys._MEIPASS 是只读资源目录,语义是“我打包进去的图标、字体、模板在哪”,不是“用户数据该写在哪”。往里面写数据,同样活不过进程生命周期。

    为什么sys.executable是对的

    运行方式sys.frozensys.executable__file__
    python zip_maker.py不存在Python 解释器路径脚本路径 ✅
    PyInstaller onefile exeTrueexe 自身路径 ✅临时解包目录 ❌
    PyInstaller onedir exeTrueexe 自身路径 ✅临时/内部目录 ❌

    所以两个分支各取所需,正好互补。用 getattr(sys, "frozen", False) 而不是 sys.frozen 是因为未打包时这个属性根本不存在,直接访问会 AttributeError。

    最后一个细节——把路径显示在状态栏上,让用户(和未来的你)随时能自查:

    self.CreateStatusBar()
    self.SetStatusText(f"配置: {CONFIG_PATH}    数据库: {DB_PATH}")
    

    这一行的排障价值远超它的代码量。任何“我的数据丢了”的报告,第一句话就能问清楚。

    三、加密 ZIP:为什么标准库 zipfile 根本写不了

    先破除一个普遍误解。Python 标准库 zipfile 对加密的支持是极度残缺的:

    import zipfile
    zf = zipfile.ZipFile("a.zip")
    zf.setpassword(b"123456")     # ✅ 能用:解密读取传统 ZipCrypto
    
    zf = zipfile.ZipFile("a.zip", "w")
    zf.setpassword(b"123456")     # ⚠️ 有这个方法,但对写入完全无效
    zf.write("f.txt")             # 结果:一个毫无加密的普通 zip
    

    setpassword() 的文档写得很清楚:只用于解密。标准库从来没有实现过加密写入。所以那些“用 zipfile 加密压缩”的教程,产出的其实是明文 zip——而且不报错,静默失败,这才是最危险的。

    而且即便标准库支持,传统 ZipCrypto 也是一个 1990 年代的算法,已被已知明文攻击彻底攻破,有现成工具能在几分钟内破解。用它等于没加密。

    pyzipper 的正确用法

    pyzipper 是 zipfile 的一个 fork,API 几乎完全兼容,但补上了 WinZip AES 加密:

    with pyzipper.AESZipFile(
            self.zip_path, "w",
            compression=pyzipper.ZIP_DEFLATED,
            encryption=pyzipper.WZ_AES,
    ) as zf:
        zf.setpassword(self.password.encode("utf-8"))
        zf.setencryption(pyzipper.WZ_AES, nbits=self.nbits)
        ...
        zf.write(path, arcname)
    

    三个必须注意的点:

    1. setpassword 和 setencryption 都要调。只设密码不设加密方式,某些路径下不会真正启用 AES;setencryption 才是决定密钥长度的那个开关。
    2. 密码必须是 bytes,且编码要固定。这里统一用 utf-8,含中文的密码才不会在跨机器解压时对不上。
    3. AES 位数暴露给用户选。用一个字典把界面文案和数值绑在一起:
    AES_BITS = {"AES-256 (最强)": 256, "AES-192": 192, "AES-128 (兼容性好)": 128}
    self.cho_enc = wx.Choice(box_opt, choices=list(AES_BITS.keys()))
    

    界面拿 key、逻辑拿 value,无需维护第二份映射表,也不会出现“下拉框加了一项但逻辑忘了改”的错位。Python 3.7+ 的 dict 有序,list(keys()) 的顺序就是你写的顺序,可以放心依赖。

    兼容性提示:WinZip AES 是事实标准,7-Zip、WinRAR、WinZip 都能正常解开。但 Windows 资源管理器内置的“解压缩全部”不认,双击进去会提示密码错误或直接失败。这不是 bug,是系统资源管理器只支持 ZipCrypto。

    归档名去重

    同名文件从不同目录加进来是很常见的(报告/data.csv 和 备份/data.csv)。ZIP 内部只存 arcname,不去重就会产生两个同名条目——解压时后者覆盖前者,静默丢数据。

    used = set()
    ...
    arcname = os.path.basename(path)
    stem, ext = os.path.splitext(arcname)
    n = 1
    while arcname.lower() in used:
        arcname = f"{stem}({n}){ext}"
        n += 1
    used.add(arcname.lower())
    

    用 .lower() 比较是因为 Windows 文件系统大小写不敏感,Data.csv 和 data.csv 解压到同一目录还是会撞。重命名风格 data(1).csv 沿用 Windows 的习惯,用户一看就懂。

    四、不卡界面:工作线程 + 自定义 wx 事件

    压缩 1GB 文件要几十秒。如果在主线程里干,窗口会白屏、标题栏变“(无响应)”,Windows 甚至会建议用户强制结束进程。

    GUI 编程的铁律:主线程只做界面,耗时操作一律扔出去;反过来,非主线程绝对不能碰任何控件。

    wxPython 里跨线程通知 UI 的标准做法是自定义事件:

    ProgressEvent, EVT_PROGRESS = wx.lib.newevent.NewEvent()
    DoneEvent, EVT_DONE = wx.lib.newevent.NewEvent()
    

    NewEvent() 一次返回两个东西:事件类(用来构造)和事件绑定器(用来 Bind)。工作线程只负责 wx.PostEvent:

    wx.PostEvent(self.sink, ProgressEvent(current=i, total=total, name=arcname))
    

    wx.PostEvent 是线程安全的——它把事件塞进主线程的事件队列,由主线程在下一轮事件循环里取出处理。传什么关键字参数,事件对象上就有什么属性,主线程直接 evt.current 取用。

    主线程侧的绑定和处理干净得像同步代码:

    self.Bind(EVT_PROGRESS, self.on_progress)
    self.Bind(EVT_DONE, self.on_done)
    
    def on_progress(self, evt):
        self.gauge.SetValue(evt.current)
        self.set_status(f"正在压缩 ({evt.current}/{evt.total}): {evt.name}")
    

    顺带说一句 wx.CallAfter:它也线程安全,适合“随手调一下某个函数”。而自定义事件的优势是语义清晰、可被多处监听、参数结构化。任务型的通知(进度/完成)用事件,零散调用用 CallAfter。

    可取消,而且取消要“干净”

    class ZipWorker(threading.Thread):
        def __init__(self, sink, files, zip_path, password, nbits):
            super().__init__(daemon=True)
            ...
            self._cancel = threading.Event()
    
        def cancel(self):
            self._cancel.set()
    

    三个设计点:

    1. daemon=True:用户点右上角关闭时,主线程退出后进程不会被这个后台线程吊住。

    2. 用 threading.Event 而不是布尔标志:Event 的读写有内存屏障保证,不依赖 GIL 的实现细节。虽然 CPython 下布尔标志实际也能跑,但 Event 是表达“我知道自己在写并发代码”的正确姿势。

    3. 取消检查点放在循环开头,且要清理残骸:

    for i, path in enumerate(self.files, 1):
        if self._cancel.is_set():
            raise InterruptedError("用户取消操作")
    
    except InterruptedError as e:
        self._cleanup()
        wx.PostEvent(self.sink, DoneEvent(ok=False, message=str(e), ...))
    
    def _cleanup(self):
        try:
            if os.path.exists(self.zip_path):
                os.remove(self.zip_path)
        except OSError:
            pass
    

    用异常而非 return 来实现取消,是为了复用 with 语句的退出逻辑。 with pyzipper.AESZipFile(...) 在异常穿出时会正常关闭文件句柄,然后 _cleanup() 才能删得掉这个文件——Windows 上句柄没释放的文件是删不掉的(WinError 32)。顺序错了就会留下一个半成品 zip。

    半成品 zip 必须删。留在磁盘上的话,用户会以为压缩成功了,去解压才发现文件不全——而那时原文件可能已经被“压缩后删除”功能清掉了。

    异常信息要给人看,不是给栈看

    except Exception:
        self._cleanup()
        wx.PostEvent(self.sink, DoneEvent(
            ok=False,
            message=traceback.format_exc(limit=2).strip().splitlines()[-1],
            elapsed=time.time() - started))
    

    format_exc(limit=2) 限制栈深度,取最后一行——也就是 PermissionError: [Errno 13] Permission denied: 'D:/x.zip' 这种真正有信息量的一行。既不会把三十行栈糊到用户脸上,也不会丢掉根因。

    五、SQLite 双表设计与“加列即迁移”

    一次压缩操作是“一条记录 + N 个文件”,典型的一对多。硬塞进一张表的两种做法都不好:把文件列表 JSON 序列化成一个字段(没法查询、没法统计),或者一个文件一行、记录字段全部冗余(改一处要改 N 行)。

    正经拆两张表:

    CREATE TABLE IF NOT EXISTS zip_records (
        id          INTEGER PRIMARY KEY AUTOINCREMENT,
        zip_name    TEXT    NOT NULL,
        target_dir  TEXT    NOT NULL,
        zip_path    TEXT    NOT NULL,
        file_count  INTEGER NOT NULL DEFAULT 0,
        source_size INTEGER NOT NULL DEFAULT 0,
        zip_size    INTEGER NOT NULL DEFAULT 0,
        encryption  TEXT    NOT NULL,
        status      TEXT    NOT NULL,
        message     TEXT,
        elapsed     REAL,
        password    TEXT,
        created_at  TEXT    NOT NULL
    );
    CREATE TABLE IF NOT EXISTS zip_record_files (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        record_id INTEGER NOT NULL REFERENCES zip_records(id) ON DELETE CASCADE,
        file_path TEXT    NOT NULL,
        file_size INTEGER
    );
    CREATE INDEX IF NOT EXISTS idx_record_files ON zip_record_files(record_id);

    几个容易被忽略的点:

    • file_count 是刻意的冗余。列表页要显示文件数,如果每行都去 COUNT(*) 子表,一百条记录就是一百次查询。写入时算一次存下来,读取零成本。
    • ON DELETE CASCADE 必须配合 PRAGMA。SQLite 的外键约束默认关闭,而且是“每个连接”级别的设置,不是数据库级别的:
    def connect(self):
        conn = sqlite3.connect(self.path)
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA foreign_keys = ON")
        return conn
    

    漏了这一行,你的 CASCADE 就是一句注释。这也是为什么每次连接都要重新执行——放在建表时执行一次是无效的。

    • row_factory = sqlite3.Row 让结果支持 row["zip_name"] 按名取值。用下标 row[2] 的代码,在你往 SELECT 里加一个字段的那天就会集体错位,而且不报错。
    • created_at 存 TEXT 而非 INTEGER 时间戳。SQLite 无原生日期类型,存 "2026-08-03 14:30:00" 格式的字符串,字典序即时间序,ORDER BY 和 LIKE '2026-08%' 都直接可用,导出 CSV 给人看也不用转换。

    加列迁移:给已经在用的数据库打补丁

    “操作记录里加入密码”这个需求来得比数据库晚。用户手上已经有一个装着真实记录的 zip_records.db,CREATE TABLE IF NOT EXISTS 对已存在的表什么都不做,新字段永远加不上去。

    cols = {r["name"] for r in conn.execute("PRAGMA table_info(zip_records)")}
    if "password" not in cols:      # 兼容旧版本已存在的数据库
        conn.execute("ALTER TABLE zip_records ADD COLUMN password TEXT")
    

    PRAGMA table_info(表名) 返回每一列的信息,取 name 组成集合,缺谁补谁。这个模式的价值:

    • 幂等:跑一百次和跑一次效果相同,可以无脑放在启动路径上;
    • 无损:老数据一条不动,新列在旧行上是 NULL;
    • 零配置:不需要 Alembic 那种迁移框架,也不需要维护版本号表。

    对于单文件小工具,这是性价比最高的迁移方案。局限也要知道:SQLite 的 ALTER TABLE 只能加列,不能改列类型、不能删列、不能加约束。真要改结构,得走“建新表 → 拷数据 → 删旧表 → 改名”的四步流程。

    新列一定被追加到列顺序的末尾——这就是为什么第三节强调不能用下标访问结果集。

    一个真实缺陷

    def connect(self):
        conn = sqlite3.connect(self.path)
        ...
        return conn
    
    def list_records(self, keyword=""):
        ...
        with self.connect() as conn:
            return conn.execute(sql, args).fetchall()
    

    with sqlite3.connect(...) 管的是事务,不是连接。 它在退出时 commit()(或异常时 rollback()),但不会 close()。所以这里每调用一次就泄漏一个连接,靠垃圾回收兜底。

    这个坑在开发时真实咬过自己一次:测试脚本跑完想删掉临时 db 文件,PermissionError: [WinError 32] 另一个程序正在使用此文件。Windows 上没关的文件句柄会锁住文件。

    正确写法是嵌套 contextlib.closing,或者干脆持有一个长连接:

    from contextlib import closing
    
    with closing(self.connect()) as conn:
        with conn:                      # 事务
            return conn.execute(sql, args).fetchall()
    

    代码里保留了原样,因为桌面工具的调用频次低、进程生命周期短,实际不会耗尽句柄。但如果你把这段代码搬到服务端或者高频循环里,它一定会炸。写出来,比藏起来有价值。

    六、配置持久化:白名单才是安全阀

    class Config:
        DEFAULTS = {
            "files": [],
            "target_dir": "",
            "zip_name": "",
            "encryption": "AES-256 (最强)",
            "remember_password": False,
            "password": "",
            "window_size": [960, 700],
        }
    
        def load(self):
            try:
                with open(self.path, "r", encoding="utf-8") as f:
                    saved = json.load(f)
                if isinstance(saved, dict):
                    for k, v in saved.items():
                        if k in self.DEFAULTS:      # ← 关键在这一行
                            self.data[k] = v
            except (FileNotFoundError, ValueError, OSError):
                pass
    

    if k in self.DEFAULTS 这个白名单过滤是整个类的核心。 没有它,一个手工编辑过(或版本降级留下)的 config.json 就能往 self.data 里塞任意键;界面代码取到意外的键值,报的错会离现场很远,极难排查。有了它:

    • 老版本产生的、新版本已废弃的字段 → 自动忽略;
    • 新版本增加的字段 → 老配置里没有,自动取 DEFAULTS;
    • 手改坏的、类型不对的键 → 至少限定在已知集合内。

    isinstance(saved, dict) 那一层也不能省。config.json 里如果只有一个 [] 或 "abc",json.load 会成功返回,然后 .items() 抛 AttributeError ——而这个异常不在 except 列表里,程序直接崩在启动阶段,连界面都出不来。

    异常一律吞掉是刻意的:配置读失败的正确降级是“用默认值启动”,而不是弹窗拦住用户。第一次运行没有 config.json 更是完全正常的情况。

    有意“不记住”的那一项

    界面上有 6 个可交互设置项,但 DEFAULTS 里只有 5 个对应键——“压缩成功后删除原文件”故意没有配置键。

    这是一个刻意的产品决策:删原文件是不可逆操作(虽然进回收站,但用户不一定会去捡)。如果它被记住,用户上次为某个特殊场景勾了一次,下次打开程序时它还亮着,而用户完全不记得——批量文件就这么没了。

    破坏性操作的默认状态必须是关闭,而且不能被持久化。 每次都要用户重新做一次决定,这点“不方便”是设计的一部分,不是遗漏。同理它在界面上是红色的:

    self.chk_delete = wx.CheckBox(box_opt, label="压缩成功后删除原文件(移入回收站)")
    self.chk_delete.SetForegroundColour(wx.Colour(0xC0, 0x39, 0x2B))
    

    而且真正执行前还有一次二次确认:

    if self.chk_delete.GetValue() and wx.MessageBox(
            f"压缩成功后将把这 {len(self.files)} 个原文件移入回收站。n"
            f"确定继续吗?", APP_NAME,
            wx.YES_NO | wx.ICON_EXCLAMATION) != wx.YES:
        return
    

    配置里的失效路径

    def apply_config(self):
        self.add_files([p for p in self.cfg["files"] if os.path.isfile(p)], silent=True)
    

    上次记住的文件,这次可能已经被移动、重命名或删除了。启动时用 os.path.isfile 过一遍,避免列表里躺着一堆点了就报错的幽灵条目。silent=True 是不让恢复配置的过程去刷“已添加 N 个文件”的状态栏——那句话应该只在用户真的做了添加动作时出现。

    七、UI 层的两个反直觉修复

    Windows 上原生按钮会无视你的背景色

    想做一个蓝色强调按钮,直觉写法:

    btn = wx.Button(panel, label="开始生成")
    btn.SetBackgroundColour(wx.Colour(0x2D, 0x6C, 0xDF))   # ❌ Windows 上毫无效果
    

    代码不报错,方法调用成功,按钮依然是系统灰。

    原因:Windows Vista 之后,按钮由主题引擎(UxTheme)绘制,整个背景是一张主题位图。SetBackgroundColour 设置的是控件背景色属性,而主题绘制过程根本不读这个属性。这不是 wxPython 的 bug,是原生控件的既定行为——在 GTK 上同一行代码是生效的,所以这个坑只在 Windows 上出现。

    解法是换成自绘按钮。wx.lib.buttons.GenButton 是纯 Python 用 DC 画出来的按钮,它自己负责所有像素,所以颜色说了算:

    class FlatButton(wx.lib.buttons.GenButton):
        """扁平强调色按钮。Windows 主题下原生 wx.Button 会忽略 SetBackgroundColour,故改用自绘按钮。"""
    
        DISABLED = wx.Colour(0xC4, 0xC9, 0xD2)
    
        def __init__(self, parent, label, color, hover, size=wx.DefaultSize):
            super().__init__(parent, label=label, size=size, style=wx.BORDER_NONE)
            self._base, self._hover = color, hover
            self.SetBezelWidth(0)              # 去掉 3D 立体边,才是"扁平"
            self.SetUseFocusIndicator(False)   # 去掉焦点虚线框
            self.SetBackgroundColour(color)
            self.SetForegroundColour(wx.WHITE)
            f = self.GetFont()
            f.SetWeight(wx.FONTWEIGHT_BOLD)
            self.SetFont(f)
            self.SetCursor(wx.Cursor(wx.CURSOR_HAND))
            self.Bind(wx.EVT_ENTER_WINDOW, lambda e: self._tint(self._hover))
            self.Bind(wx.EVT_LEAVE_WINDOW, lambda e: self._tint(self._base))
    
        def _tint(self, colour):
            if self.IsEnabled():
                self.SetBackgroundColour(colour)
                self.Refresh()
    
        def Enable(self, enable=True):
            ret = super().Enable(enable)
            self.SetBackgroundColour(self._base if enable else self.DISABLED)
            self.Refresh()
            return ret
    

    自绘的代价是所有视觉状态都得自己管。GenButton 不会因为你 Enable(False) 就自动变灰,所以要重写 Enable,在里面手动改色并 Refresh()。悬停高亮同理,靠 EVT_ENTER_WINDOW / EVT_LEAVE_WINDOW 自己切。忘了 Refresh() 的话颜色属性改了但屏幕不重绘,要等到窗口被遮挡再露出才更新——一个非常迷惑的“偶发 bug”。

    只有需要强调色的两个按钮(生成、取消)用 FlatButton,其余保持原生 wx.Button。混用是有意的:次要按钮跟随系统主题,视觉层级自然分明,也少写代码。

    勾选“显示密码”时,输入框会跳位

    用户报的现象很具体:最大化窗口后勾选“显示密码”,输入框会移动位置。

    有问题的常规思路是:切换时销毁原控件,用新的样式重建。

    # ❌ 这会导致布局跳动
    self.txt_pwd.Destroy()
    self.txt_pwd = wx.TextCtrl(parent, style=0 if show else wx.TE_PASSWORD)
    

    wx.TE_PASSWORD 这个样式位只能在创建时指定,运行时改不了,所以“销毁重建”看似是唯一出路。但新建的控件是被 append 到 sizer 末尾的,在 FlexGridSizer 里就是掉到了另一个格子;而且新控件的 best size 与原来未必一致,一整行的高度都可能变。窗口越宽(最大化时),这个错位越明显。

    解法:两个控件都建好,叠在一起,只切换谁可见。

    class PasswordCtrl(wx.Panel):
        """密码输入框。掩码/明文两个控件叠放并切换显示,避免切换时重建控件导致布局跳动。"""
    
        def __init__(self, parent):
            super().__init__(parent)
            self.SetBackgroundColour(parent.GetBackgroundColour())
            self.masked = wx.TextCtrl(self, style=wx.TE_PASSWORD)
            self.plain = wx.TextCtrl(self)
            self.plain.Hide()
            s = wx.BoxSizer(wx.VERTICAL)
            s.Add(self.masked, 0, wx.EXPAND)
            s.Add(self.plain, 0, wx.EXPAND)
            self.SetSizer(s)
    
        def GetValue(self):
            return (self.plain if self.plain.IsShown() else self.masked).GetValue()
    
        def SetValue(self, value):
            self.masked.SetValue(value)
            self.plain.SetValue(value)
    
        def show_text(self, show):
            value = self.GetValue()
            self.plain.Show(show)
            self.masked.Show(not show)
            self.SetValue(value)
            self.Layout()
    

    关键在于 Hide() 的控件不占 sizer 空间,所以垂直 BoxSizer 里两个控件的实际效果是“同一位置二选一”。外层 PasswordCtrl 作为一个 wx.Panel,从父布局的角度看尺寸和位置永远不变——父级 sizer 根本不知道里面发生了切换,自然不会重排。

    show_text 里先取值、切换、再写回,是因为两个 TextCtrl 各有独立的内容缓冲。用户在掩码框里输入的字符不会自动出现在明文框里,不同步就会“一勾选密码就没了”。

    顺带一提,这个封装还带来一个额外好处:GetValue / SetValue 的签名和 wx.TextCtrl 一致,调用方(validate()、save_config())完全不需要知道内部有两个控件。

    八、图片预览:闭包懒加载 + 解码器兜底

    需求是:在操作记录里选一条,预览其中的图片。设计上有三个决策。

    决策一:从 ZIP 内解密读,而不是读磁盘原文件

    原文件可能已经被“压缩后删除”清掉了,也可能被用户移走。ZIP 才是那条记录的权威内容。所以主路径是拿记录里存的密码去解密 zip,只在 zip 不可用时回退到磁盘:

    def image_items(self, row):
        """收集可预览的图片,返回 (zip 句柄, [(名称, 读字节函数)], 来源说明)。
    
        优先用记录里的密码从 zip 内解密读取;zip 缺失或密码不可用时回退到磁盘上的原文件。
        """
        pwd = deobfuscate(row["password"])
        zip_path = row["zip_path"] or ""
        if os.path.isfile(zip_path):
            zf = None
            try:
                zf = pyzipper.AESZipFile(zip_path)
                if pwd:
                    zf.setpassword(pwd.encode("utf-8"))
                names = sorted(n for n in zf.namelist() if is_image(n))
                if names:
                    zf.open(names[0]).close()   # 先探一次,密码不对就走回退分支
                    return zf, [(n, lambda n=n: zf.read(n)) for n in names], "ZIP 内解密"
            except Exception:
                pass
            if zf:
                zf.close()
        items = [(os.path.basename(f["file_path"]), f["file_path"])
                 for f in self.db.record_files(row["id"])
                 if is_image(f["file_path"]) and os.path.isfile(f["file_path"])]
        return None, [(n, lambda p=p: read_file(p)) for n, p in items], "磁盘原文件"
    

    zf.open(names[0]).close() 这一行是探测:密码错误时 open 会立刻抛异常,此时还来得及走回退分支。如果不探测,等到用户点开预览窗口才发现每张图都读不出来,体验就差了一截。

    来源 字符串会显示在预览窗标题栏上(“来源: ZIP 内解密” / “来源: 磁盘原文件”)。告诉用户他正在看的是哪份数据,这在两份数据可能不一致时很重要。

    决策二:闭包懒加载,别一次解密整包

    用户的压缩包可能有几十张 1024×1536 的图。一次性全解密、全解码,内存和等待时间都不可接受。

    所以列表里存的不是数据,而是取数据的函数:

    [(n, lambda n=n: zf.read(n)) for n in names]
    

    PreviewDialog 只在切到某一张时才调用它:

    def load(self):
        name, read = self.items[self.index]
        self.image, self.cache, err = None, (None, None), ""
        try:
            self.image, err = decode_image(read())     # ← 此刻才真正读取
        except Exception as e:
            err = str(e) or e.__class__.__name__
    

    这里的 lambda n=n: 不是多余的,是躲一个经典的 Python 陷阱。

    # ❌ 错误示范
    fns = [lambda: zf.read(n) for n in names]
    fns[0]()     # 读的是 names[-1],不是 names[0]
    

    Python 闭包捕获的是变量本身(late binding),不是变量当时的值。循环结束后 n 停在最后一个元素上,所有 lambda 都会读同一个文件。用 n=n 把当前值绑成默认参数,就在函数定义时“快照”下来了。同样的技巧在下一行的 lambda p=p: read_file(p) 里再用了一次。

    这个 bug 极其阴险:图片列表长度、文件名显示全都正常,只是每一张显示的都是最后一张的内容。如果你的测试数据恰好是两张相似的图,很可能看不出来。

    决策三(也是用户实际报的 bug):wxWidgets 不认 WebP

    功能写完自测通过,交付。用户回了三个字:“预览不成功”。

    没有错误信息、没有操作步骤。这时候最快的路径不是追问,而是直接看真实数据:读 zip_records.db 里的实际记录,打开用户真实的那个 zip。

    结果一目了然——压缩包里 8 个文件全是 .webp(AI 生成的图片批次,文件名形如 assets_task_01jxy..._img_1.webp)。而自己写测试时造的 fixture 全是 PNG。

    验证根因:

    >>> img = wx.Image(io.BytesIO(webp_bytes))
    >>> img.IsOk()
    False
    >>> wx.BITMAP_TYPE_WEBP
    AttributeError: module 'wx' has no attribute 'BITMAP_TYPE_WEBP'
    

    wxWidgets 3.2.8 里没有 WebP 解码器。 WebP 支持是 wxWidgets 3.3 才加进去的,当前 wxPython 4.2.4 绑的还是 3.2.x。所以每一张图都显示“无法识别的图片格式”,功能等于不存在。

    解法:让 Pillow 兜底。

    try:                              # wxWidgets 3.2 不带 webp 解码器,用 Pillow 兜底
        from PIL import Image as PILImage, ImageOps
    except ImportError:
        PILImage = None
    
    def decode_image(data):
        """把图片字节解成 wx.Image,失败时返回 (None, 原因)。
    
        wx 自带解码器不认的格式(webp 最常见)交给 Pillow,顺带按 EXIF 摆正方向。
        """
        with wx.LogNull():                    # 解析失败时不弹 wx 自带的错误框
            img = wx.Image(io.BytesIO(data))
        if img.IsOk():
            return img, ""
        if PILImage is None:
            return None, "格式不支持,安装 Pillow 后可预览(pip install pillow)"
        try:
            with PILImage.open(io.BytesIO(data)) as pil:
                rgba = ImageOps.exif_transpose(pil).convert("RGBA")
            img = wx.Image(rgba.width, rgba.height)
            raw = rgba.tobytes()
            img.SetData(rgba.convert("RGB").tobytes())
            img.SetAlpha(raw[3::4])
            return img, ""
        except PILImage.UnidentifiedImageError:
            return None, "无法识别的图片格式"
        except Exception as e:
            return None, f"无法解码: {e or e.__class__.__name__}"
    

    拆开看每个细节:

    • with wx.LogNull():wx.Image 解码失败会自己弹一个错误对话框。LogNull 在作用域内屏蔽 wx 的日志目标,让我们能安静地“试一下”再决定怎么处理。没有它,用户会先吃一个丑陋的系统弹窗。
    • 先试 wx 再试 Pillow:wx 走的是 C++ 原生解码,PNG/JPG 这些常见格式更快,也不引入额外依赖。Pillow 只是兜底。
    • ImageOps.exif_transpose:手机拍的照片方向信息在 EXIF 里,不处理会横躺。wx 原生解码不管这个,用 Pillow 时顺手做掉。
    • Pillow → wx.Image 的通道拆分:这是最容易写错的地方。wx.Image 把 RGB 和 Alpha 分开存(SetData 收 3 字节/像素,SetAlpha 收 1 字节/像素),而 Pillow 的 RGBA tobytes() 是 4 字节交错的。所以 raw[3::4] 用切片步长 4 从偏移 3 开始取,正好抽出所有 alpha 字节。RGB 部分则用 convert("RGB").tobytes() 让 Pillow 自己去掉 alpha 通道——比手写切片拼接更不容易错。
    • UnidentifiedImageError 单独捕获:不加这一层的话,用户会在界面上看到 无法解码: cannot identify image file <_io.BytesIO object at 0x000001F2...>。Python 的异常 repr 泄漏到 UI 上是很不专业的,单独捕获这个最常见的异常,换成一句人话。
    • e or e.__class__.__name__:某些异常的 str() 是空字符串,那样会显示成 无法解码: 。退化到类名至少还有信息。

    Pillow 是可选依赖(try/except ImportError),没装时程序照常运行,只是遇到 webp 会提示“安装 Pillow 后可预览”。但打包 exe 时不能漏——用户 100% 会走到这条路径。

    修复后用用户真实的那个压缩包验证:8/8 全部解码成功,逐像素与 Pillow 参考结果一致,HasAlpha() 为 True,ConvertToBitmap() 正常。

    画布:只缩不放 + 缓存缩放结果

    def on_paint(self, _):
        dc = wx.AutoBufferedPaintDC(self.canvas)
        dc.SetBackground(wx.Brush(self.BG))
        dc.Clear()
        cw, ch = self.canvas.GetClientSize()
        if not self.image or cw < 4 or ch < 4:
            return
        iw, ih = self.image.GetWidth(), self.image.GetHeight()
        scale = min(cw / iw, ch / ih, 1.0)      # 只缩不放,避免拉伸模糊
        w, h = max(1, int(iw * scale)), max(1, int(ih * scale))
        if self.cache[0] != (w, h):
            img = self.image if (w, h) == (iw, ih) else self.image.Scale(
                w, h, wx.IMAGE_QUALITY_HIGH)
            self.cache = ((w, h), img.ConvertToBitmap())
        dc.DrawBitmap(self.cache[1], (cw - w) // 2, (ch - h) // 2, True)
    
    • wx.AutoBufferedPaintDC + SetBackgroundStyle(wx.BG_STYLE_PAINT) 是消除闪烁的标准组合。先在内存位图上画完再一次性贴到屏幕,避免用户看到“清背景→画图”的中间态。BG_STYLE_PAINT 告诉 wx“背景我自己画,你别擦”。
    • min(..., 1.0) 里的 1.0 意思是小图保持原始尺寸,不放大。放大只会得到一张模糊的图,不如让用户看清 真实像素。
    • max(1, ...) 防御极端窄窗口下算出 0 宽度——Scale(0, h) 会抛异常。
    • 缓存键是目标尺寸 (w, h)。窗口拖动时 EVT_SIZE 会高频触发重绘,如果每次都 Scale(...IMAGE_QUALITY_HIGH),1024×1536 的图会明显卡顿。尺寸没变就复用上次的 Bitmap。
    • DrawBitmap(..., True) 最后那个参数是 useMask,让 alpha 通道生效,透明 PNG 才不会显示成黑块。
    • (cw - w) // 2 让图片在画布中居中。

    键盘导航用 EVT_CHAR_HOOK 而不是 EVT_KEY_DOWN:

    self.Bind(wx.EVT_CHAR_HOOK, self.on_key)
    
    def on_key(self, evt):
        key = evt.GetKeyCode()
        if key in (wx.WXK_LEFT, wx.WXK_UP, wx.WXK_PAGEUP):
            self.step(-1)
        elif key in (wx.WXK_RIGHT, wx.WXK_DOWN, wx.WXK_PAGEDOWN, wx.WXK_SPACE):
            self.step(1)
        else:
            evt.Skip()
    

    EVT_CHAR_HOOK 绑在 Dialog 上,在按键被分派给具体子控件之前就能拿到。用 EVT_KEY_DOWN 的话,焦点在“下一张”按钮上时,方向键会被按钮自己吃掉去做焦点导航。else: evt.Skip() 必须留着——不然 Tab、Esc 等键全部失灵。

    step 用取模实现循环翻页,最后一张的下一张回到第一张:

    def step(self, delta):
        if len(self.items) > 1:
            self.index = (self.index + delta) % len(self.items)
            self.load()
    

    九、删原文件:用 ctypes 调 Windows 回收站

    “压缩成功后删除原文件”这个需求,os.remove 是不能用的——它是彻底删除,误操作无法挽回。正确做法是走系统回收站,用户还有一次后悔的机会。

    Python 标准库没有回收站 API(send2trash 是第三方库)。为了不增加依赖,用 ctypes 直接调 Win32 的 SHFileOperationW:

    def move_to_trash(paths):
        """把文件移入回收站,返回 (成功数, [(路径, 失败原因)])。
    
        Windows 走 SHFileOperationW + FOF_ALLOWUNDO,误删可从回收站还原;
        其他平台没有统一的回收站 API,退化为直接删除。
        """
        paths = [os.path.abspath(p) for p in paths if os.path.isfile(p)]
        if not paths:
            return 0, []
        if sys.platform != "win32":
            ok, failed = 0, []
            for p in paths:
                try:
                    os.remove(p)
                    ok += 1
                except OSError as e:
                    failed.append((p, str(e)))
            return ok, failed
    
        from ctypes import wintypes
    
        class SHFILEOPSTRUCTW(ctypes.Structure):
            _fields_ = [
                ("hwnd", wintypes.HWND),
                ("wFunc", wintypes.UINT),
                ("pFrom", wintypes.LPCWSTR),
                ("pTo", wintypes.LPCWSTR),
                ("fFlags", ctypes.c_uint),
                ("fAnyOperationsAborted", wintypes.BOOL),
                ("hNameMappings", ctypes.c_void_p),
                ("lpszProgressTitle", wintypes.LPCWSTR),
            ]
    
        FO_DELETE = 3
        FOF_SILENT, FOF_NOCONFIRMATION, FOF_ALLOWUNDO, FOF_NOERRORUI = 0x04, 0x10, 0x40, 0x400
    
        op = SHFILEOPSTRUCTW()
        op.wFunc = FO_DELETE
        # pFrom 是双  结尾的路径串
        op.pFrom = "".join(paths) + ""
        op.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI
        ret = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
        if ret != 0:
            return 0, [(p, f"SHFileOperation 错误码 {ret}") for p in paths]
        if op.fAnyOperationsAborted:
            return 0, [(p, "操作被中止") for p in paths]
        remaining = [p for p in paths if os.path.isfile(p)]
        return len(paths) - len(remaining), [(p, "仍然存在") for p in remaining]
    

    几个必须踩准的细节:

    • FOF_ALLOWUNDO 是“进回收站”的开关。漏了这个标志,FO_DELETE 就是永久删除。整个函数的价值全押在这一个位上。
    • pFrom 必须是双 结尾的字符串。这是 Win32 表达“字符串数组”的老式约定:路径之间用 分隔,整个列表再以一个额外的 收尾。只写一个 会导致最后一个路径被截断或读到越界内存。这也是为什么能一次删多个文件而只调一次 API。
    • 结构体字段顺序和类型必须与头文件严格一致。ctypes.Structure 是按 _fields_ 顺序做内存布局的,错一个字段类型就是内存错位,轻则参数乱掉,重则崩溃。
    • SHFileOperationW 的返回值不是布尔,0 才是成功,非 0 是错误码。而且成功返回 0 不代表文件真的删了——所以后面还要 fAnyOperationsAborted 和 os.path.isfile 两道复查。API 说成功、文件还在的情况真实存在(比如被其他进程占用)。
    • from ctypes import wintypes 放在函数内部。wintypes 在非 Windows 平台上导入会失败,放在模块顶层会让整个程序在 Linux/macOS 上直接 import 不进来。放在 win32 分支之后,非 Windows 平台永远不会执行到。
    • 返回 (成功数, 失败清单) 而不是布尔。批量操作的结果天然是部分成功的,调用方需要知道具体哪些失败、为什么失败,才能给用户有用的提示。

    调用侧还要同步界面状态——删掉的文件不能继续留在“待压缩文件”列表里:

    def trash_sources(self, info):
        """把已成功压缩的原文件移入回收站,并从待压缩列表中同步移除。"""
        ok, failed = move_to_trash(info["files"])
        gone = {p for p in info["files"] if not os.path.isfile(p)}
        for i in range(len(self.files) - 1, -1, -1):
            if self.files[i] in gone:
                self.list.DeleteItem(i)
                del self.files[i]
        self.refresh_count()
        if failed:
            wx.MessageBox("以下原文件未能删除:n" +
                          "n".join(f"{p}n    {why}" for p, why in failed[:5]),
                          APP_NAME, wx.ICON_WARNING)
        return f" · 已将 {ok} 个原文件移入回收站" if ok else ""
    

    range(len - 1, -1, -1) 倒序遍历删除是必须的。正序删除时,DeleteItem(3) 之后原来的第 4 项变成了第 3 项,索引全部错位,会漏删或删错。倒序遍历时被删项之后的索引变化不影响还没遍历到的前面部分。

    判断“删掉了哪些”用 os.path.isfile 复查,而不是信 move_to_trash 的成功数。成功数只是个计数,不告诉你具体是哪几个成功。以磁盘实际状态为准最可靠。

    failed[:5] 限制弹窗只列 5 条——一次删 200 个文件全失败的话,弹窗会长到超出屏幕,用户连“确定”按钮都点不到。

    十、踩坑合集

    把前面散落的坑和几个额外的集中列一遍,方便速查。

    #坑现象根因 / 解法
    1__file__ 定位配置打包后配置每次都丢onefile 的 __file__ 在临时解包目录,进程退出即删。改用 sys.frozen + sys.executable
    2zipfile.setpassword 写入生成的 zip 毫无加密,且不报错标准库 setpassword 只用于解密。改用 pyzipper.AESZipFile
    3Windows 按钮改色SetBackgroundColour 静默无效UxTheme 主题绘制不读背景色属性。改用自绘 GenButton,并自己管 disabled/hover
    4切换密码可见性最大化后输入框跳位TE_PASSWORD 只能创建时指定,销毁重建打乱 sizer。改为两控件叠放 Show/Hide
    5WebP 预览全部显示“无法识别的图片格式”wxWidgets 3.2.8 无 WebP 解码器,无 wx.BITMAP_TYPE_WEBP。Pillow 兜底
    6循环里建 lambda每张图都显示最后一张的内容Python 闭包 late binding。用 lambda n=n: 绑定默认参数快照
    7with sqlite3.connect()删 db 文件报 WinError 32该上下文管理器只管事务不 close 连接。需嵌套 contextlib.closing

    另外三个在写测试时才暴露的:

    8. Pillow 存 WebP 默认是有损的。 造测试 fixture 时用 Image.new(...).save("x.webp"),然后断言解码出来的像素是 (30, 90, 200)——实际拿到 (31, 90, 202)。差了 1~2 个色阶,看起来像解码器串色 bug,其实是 WebP 有损压缩的正常损失。做像素级断言的 fixture 必须 save(path, lossless=True)。

    9. wx.ScreenDC 截图不可靠。 想用截图验证界面渲染,Blit 出来的图大面积空白带斜向撕裂,大概率跟 DPI 缩放和桌面合成(DWM)有关。GUI 的自动化验证不要依赖截图,改成程序化断言更稳:用 GetRed/GetGreen/GetBlue 抽查像素、用 bitmap.IsOk() 确认位图已生成、用矩形相交判断控件有没有重叠。

    10. py_compile.compile(..., cfile=os.devnull) 在 Windows 上会炸。 报 FileExistsError: nul is a non-regular file。Windows 的 nul 设备不是常规文件,写不进去。老实编译到临时文件再删掉。

    十一、必须说清楚的安全性问题

    这一节比前面所有功能都重要。

    代码里有一对函数:

    def obfuscate(text):
        """base64 混淆存储的密码。仅防止肉眼直读,不是加密。"""
        return base64.b64encode(text.encode("utf-8")).decode("ascii") if text else ""
    
    
    def deobfuscate(text):
        if not text:
            return ""
        try:
            return base64.b64decode(text.encode("ascii")).decode("utf-8")
        except (ValueError, UnicodeDecodeError):
            return ""
    

    base64 不是加密,是编码。 它没有密钥,任何人拿到密文就能还原明文——一行 Python、甚至一个在线工具就够了。

    >>> base64.b64decode("MTIzNDU2").decode()

    '123456'

    它在这里的作用只有一个:防止密码在文本编辑器里被瞄到。用户打开 config.json 或用 DB 工具浏览 zip_records.db 时,看到的是 MTIzNDU2 而不是 123456,不会被路过的同事一眼记住。

    它挡不住的:

    • 任何人拿到 zip_records.db 文件 → 所有历史密码明文可得;
    • 任何人拿到 config.json(且用户勾了“记住密码”)→ 当前密码明文可得;
    • 恶意软件扫描用户目录 → 同上。

    所以界面上的复选框文案是 “记住密码(明文风险,仅本机)” ——直接把风险写在标签上,而不是藏在文档里。而且第六节提到的:不勾“记住密码”时,Config.save() 会主动把这个字段写空,不留残留:

    def save(self):
        out = dict(self.data)
        if out["remember_password"] and out["password"]:
            out["password"] = obfuscate(out["password"])
        else:
            out["password"] = ""          # 不记住就清空,不留残留
    

    为什么不做真加密

    因为在这个场景下做不到。要加密就要有密钥,密钥要么:

    • 也存在本地 → 攻击者拿到程序就拿到密钥,等于没加密(这是所谓 “security through obscurity”);
    • 让用户每次输主密码 → 那用户不如直接记住 zip 密码,功能失去意义。

    这是密码管理器要解决的问题,不是一个压缩工具该解决的问题。

    如果你要把这段代码用在多人共用的机器、或有合规要求的环境,正确做法是:

    1. 根本不存密码。历史记录里只记“用过密码”这个事实。代价是预览功能要让用户手动输密码。
    2. 用 Windows DPAPI(CryptProtectData)。它用当前用户的登录凭据派生密钥,密文只有同一 Windows 账户能解开,拷到别的机器上是废数据。这是 Windows 上唯一“不用管密钥”的正经方案。
    3. 接系统凭据管理器(keyring 库)。密码存进 Windows 凭据保管库 / macOS Keychain,程序只存一个引用。

    顺便说清楚:ZIP 文件本身的 AES-256 加密是真加密,是可靠的。 本节讨论的风险只涉及“程序为了方便帮你记下的那份密码副本”。压缩包本身拿到任何机器上,没有密码都是打不开的。

    十二、已知短板与改进方向

    一份诚实的自我检查,也是这份代码最有参考价值的部分。

    1. 数据库连接不 close(第五节详述)。 桌面工具低频调用不致命,搬到高频场景必炸。修复:contextlib.closing。

    2. 删原文件前没有回读校验。 trash_sources 只信 evt.ok(压缩线程没抛异常)就动手删。更稳的做法是先把 zip 完整读一遍再删:

    with pyzipper.AESZipFile(zip_path) as zf:
        zf.setpassword(pwd.encode("utf-8"))
        bad = zf.testzip()                      # 校验所有条目的 CRC
        assert bad is None
    

    在执行不可逆操作之前,验证可逆的那一半确实成功了——这是删除类功能的基本原则。当前实现只做到了“进回收站”这一层兜底。

    3. zf.read() 把整张图一次性读进内存。 单张 1024×1536 的 webp 不到 1MB 无所谓,但如果压缩包里是 100MB 的 TIFF,切一张就吃 100MB。改进方向是 zf.open() 流式读,配合 Pillow 的 draft() 做降采样解码。

    4. PreviewDialog 的闭包捕获了 zf,而 on_preview 的 finally 会关掉它。

    zf, items, note = self.image_items(r)
    try:
        ...
        with PreviewDialog(self, r["zip_name"], items, note) as dlg:
            dlg.ShowModal()
    finally:
        if zf:
            zf.close()
    

    这段只因为对话框是模态的(ShowModal 阻塞)才安全——finally 执行时对话框已经销毁,没人再用那些闭包了。哪天有人把它改成 Show() 非模态,闭包就会去读一个已关闭的 ZipFile,抛 ValueError: seek of closed file。这是一个隐式的耦合,应该用注释标注,或者干脆让 PreviewDialog 自己持有并负责关闭 zf。

    5. 进度粒度是“文件级”的。 压 1000 个小文件进度条很流畅,压 1 个 5GB 的文件则会从 0% 直接跳到 100%,中间毫无反馈。要做字节级进度,得绕过 zf.write() 自己分块喂 zf.open(name, "w")。

    6. 大量文件时 ListCtrl 会慢。 每 InsertItem 一次就触发一次重绘。加 Freeze() / Thaw() 包住批量插入可以显著改善;上万条则应该换 LC_VIRTUAL 虚拟列表模式。

    7. 没有“只压缩不带路径”之外的选项。 现在一律用 os.path.basename 平铺,无法保留目录结构。加个“保留相对路径”的选项并不难,但要处理跨盘符时公共前缀不存在的情况。

    十三、打包与运行

    # 安装依赖
    pip install wxPython pyzipper pillow
    # 直接运行
    python zip_maker.py
    # 打包(-w 不显示控制台,-F 单文件)
    pyinstaller -w -F zip_maker.py

    打包后 dist/zip_maker.exe 可以随意搬动,config.json 和 zip_records.db 会在 exe 同目录生成——这正是第二节那 6 行代码保证的。

    最后一个 Windows 小细节,在 __main__ 里:

    if __name__ == "__main__":
        if sys.platform == "win32":
            try:
                import ctypes
                ctypes.windll.shcore.SetProcessDpiAwareness(1)
            except Exception:
                pass
        App(False).MainLoop()
    

    不声明 DPI 感知,Windows 会把窗口按系统缩放比例做位图拉伸,在 150%/200% 缩放的高分屏上,整个界面的文字会有一层明显的模糊。SetProcessDpiAwareness(1) 告诉系统“我自己按物理像素画”,字体就锐利了。

    必须包在 try/except 里:shcore.dll 是 Windows 8.1 才有的,更老的系统上会 AttributeError。而且这个调用必须在创建任何窗口之前执行——放到 App.OnInit 里就晚了。

    App(False) 的 False 是 redirect 参数,意思是不要把 stdout/stderr 重定向到一个弹出窗口。默认行为在打包后会让每个 print 都弹一个框,非常烦人。

    结语

    这个工具最终 1106 行,功能清单不长:多选文件、AES 加密压缩、操作落库、配置回填、历史查询、图片预览、原文件回收。但里面有价值的东西不在功能上,而在那些“看起来该这么写、实际不能这么写”的地方:

    • 打包后的路径语义,__file__ / sys.executable / sys._MEIPASS 三者的分工;
    • 标准库 zipfile 加密支持的静默失败——最危险的 bug 是不报错的那种;
    • 原生控件在不同平台的脾气(SetBackgroundColour 在 GTK 生效、在 Windows 不生效);
    • Python 闭包的 late binding,在循环里造函数时永远要想一次;
    • 自造的测试数据和用户真实数据的分布差异——PNG 全绿、WebP 全红,这是整个项目里最贵的一课。

    最后那条值得单独强调。功能上线前跑了 36 项断言全部通过,用户只回了三个字“预览不成功”。测试通过和功能可用之间的距离,就是 fixture 和用户真实文件之间的距离。

    本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
    作者最新文章
    编程开发 Python
    相关文章 更多
    精品专题 更多
    装机必备

    正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

    Windows

    正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

    PDF教程

    正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

    macOS软件

    正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

    IOS软件

    正软商城iOS软件专区,精选适用于iPhone和iPad的办公、学习、影音、设计、效率及AI应用,提供功能介绍、适用设备、系统要求和正版获取方式等信息。

    AI

    正软商城AI软件专区,汇集AI写作、AI绘画、AI视频、AI办公、AI编程、AI翻译、智能客服和数据分析等人工智能工具,提供功能介绍、适用平台、收费方式及正版购买信息。

    Mac软件 更多
    Postman macOS版
    Postman macOS版

    Postman是一套面向API全生命周期的开发与测试平台,可在Mac上发送和调试多种协议请求,管理集合、环境及认证信息,并完成脚本测试、模拟服务、监控、文档发布和团队协作。它适合开发、测试及运维人员使用,提供个人免费方案。

    Docker Desktop macOS版
    Docker Desktop macOS版

    Docker Desktop 是 Docker 官方为 Mac 提供的容器开发环境,集成 Docker Engine、命令行工具、Compose、Build、Kubernetes 与图形化管理界面。

    Android Studio macOS版
    Android Studio macOS版

    Android Studio 是 Google 面向安卓应用开发提供的官方集成开发环境,基于 IntelliJ IDEA 构建。它整合智能代码编辑、Gradle 构建、界面预览、设备模拟、性能分析、调试测试及 APK 分析等工具。

    Xcode macOS版
    Xcode macOS版

    Xcode 是苹果面向 Mac 提供的官方集成开发环境,将代码编辑、项目构建、SwiftUI 实时预览、设备模拟、自动化测试、断点调试、性能分析和应用分发整合在同一套工作流中,适合开发与维护 iPhone、iPad、Mac、Apple Wa

    JetBrains WebStorm macOS版
    JetBrains WebStorm macOS版

    JetBrains WebStorm 是面向 JavaScript、TypeScript 和现代 Web 技术的专业集成开发环境,可智能理解项目结构,提供代码补全、重构、错误检查、运行调试、测试及 Git 工具。

    PhpStorm macOS版
    PhpStorm macOS版

    PhpStorm 是 JetBrains 推出的专业 PHP 集成开发环境,可在 Mac 上完成代码编写、智能检查、重构、调试、测试和数据库管理。它支持主流 PHP 框架、Composer、Git、Docker 与远程解释器。

    JetBrains PyCharm macOS版
    JetBrains PyCharm macOS版

    JetBrains PyCharm 是面向 Python 开发者的集成开发环境,提供智能补全、代码检查、重构、调试、测试、版本控制及虚拟环境管理功能。Mac 版兼容 Intel 与 Apple 芯片,核心功能可免费使用。

    JetBrains IntelliJ IDEA macOS版
    JetBrains IntelliJ IDEA macOS版

    IntelliJ IDEA 是 JetBrains 推出的专业集成开发环境,重点服务于 Java、Kotlin 与 JVM 项目开发。软件集成代码补全、静态检查、重构、调试、测试、版本控制及构建工具,并可借助插件扩展语言和框架支持。

    Visual Studio Code macOS版
    Visual Studio Code macOS版

    Visual Studio Code 是微软提供的免费代码编辑器,支持 macOS,并为 Intel 与 Apple 芯片 Mac 提供适配版本。软件集代码编辑、调试、Git 源代码管理、集成终端和扩展市场于一体。

    REAPER macOS版
    REAPER macOS版

    REAPER是Cockos开发的数字音频工作站,提供多轨音频与MIDI录制、剪辑、处理、混音和母带制作工具。Mac版兼容Intel与Apple芯片,支持AU、VST、VST3、CLAP等插件格式,并提供高度可定制的工作流程。

    Adobe Audition macOS版
    Adobe Audition macOS版

    Adobe Audition 是面向播客、影视后期、音乐制作及内容创作者的专业音频工作站,提供波形精修、多轨录制与混音、频谱编辑、降噪修复、响度匹配和效果处理工具,并可与 Adobe Premiere 协同完成视频声音制作。

    Pro Tools macOS版
    Pro Tools macOS版

    Pro Tools 是 Avid 推出的专业数字音频工作站,覆盖多轨录音、无损剪辑、MIDI 编曲、插件处理、自动化混音及声音与画面同步等工作。它拥有 Intro、Artist、Studio 和 Ultimate 等方案。

    WINDOWS 更多
    PixPix 网页版
    PixPix 网页版

    PixPix 是面向品牌、卖家和内容团队的在线 AI 图片视频创作平台,提供图视频 Agent、无限画布、图片生成、视频生成、自然语言编辑及商品套图、产品精修和带货视频等电商工具。

    Postman Windows版
    Postman Windows版

    Postman 是面向 API 全生命周期的开发与协作平台。Windows 桌面版可用于构建和发送 HTTP、GraphQL、gRPC、WebSocket、MQTT 等请求,并提供环境变量、集合、脚本测试、模拟服务、监控、文档及团队工作区等

    Docker Desktop Windows版
    Docker Desktop Windows版

    Docker Desktop 为 Windows 开发者提供集成式容器环境,内置 Docker Engine、命令行工具、Compose、Build 与 Kubernetes 等组件,可在本机完成镜像构建、容器运行、日志查看和资源管理。

    Android Studio Windows版
    Android Studio Windows版

    Android Studio 是 Google 面向 Android 应用开发提供的官方集成开发环境,以 IntelliJ IDEA 为基础,集成智能代码编辑、Gradle 构建、设备模拟、界面预览、调试测试及性能分析工具,

    JetBrains WebStorm Windows版
    JetBrains WebStorm Windows版

    WebStorm 是 JetBrains 面向 JavaScript、TypeScript 和现代 Web 技术打造的集成开发环境。它提供项目级代码分析、智能补全、安全重构、导航搜索,并集成调试、测试、终端、Git 与常用前端工具,

    JetBrains PhpStorm Windows版
    JetBrains PhpStorm Windows版

    PhpStorm 是 JetBrains 面向 PHP 与现代 Web 开发打造的专业集成开发环境,提供代码补全、静态检查、重构、调试、测试、数据库、版本控制及前端语言支持。Windows 用户可直接下载安装程序,

    JetBrains PyCharm Windows版
    JetBrains PyCharm Windows版

    PyCharm 是 JetBrains 推出的 Python 集成开发环境,提供智能代码补全、错误检查、重构、调试、测试、Git、终端及 Jupyter Notebook 支持。统一版安装后可体验 30 天 Pro 功能,

    JetBrains IntelliJ IDEA Windows版
    JetBrains IntelliJ IDEA Windows版

    JetBrains IntelliJ IDEA 是以 Java、Kotlin 和 JVM 项目为核心的专业集成开发环境,提供智能补全、代码检查、安全重构、调试、测试、版本控制及构建工具集成。统一发行版的核心功能可免费用于商业和非商业项目,

    Visual Studio Code Windows版
    Visual Studio Code Windows版

    Visual Studio Code 是微软推出的免费代码编辑器,Windows 版集成智能补全、代码导航、调试、终端和版本控制,并可通过扩展添加语言、格式化及远程开发能力。它启动较快、配置灵活,

    REAPER Windows版
    REAPER Windows版

    REAPER 是 Cockos 推出的数字音频工作站,适用于录音、MIDI 编曲、音频编辑、混音、母带制作及声音设计。软件安装包小巧,支持多轨录制、灵活路由、自动化、第三方插件和脚本扩展,并提供完整功能的 60 天免费评估期。

    Adobe Audition Windows版
    Adobe Audition Windows版

    Adobe Audition 是面向播客、影视后期、广播与音乐制作的专业音频工作站,提供波形编辑、多轨混音、频谱分析、录音、降噪修复和效果处理等功能。Windows 版通过 Adobe Creative Cloud 安装和管理,

    Pro Tools Windows版
    Pro Tools Windows版

    Pro Tools 是 Avid 推出的专业数字音频工作站,提供多轨录音、音频剪辑、MIDI编曲、插件处理、自动化混音及影视声音制作能力。它适合音乐人、录音师、制作人和音频后期团队使用,并提供免费入门版、订阅方案及永久授权选择。