Python抽奖软件实战:Tkinter+随机算法,告别Excel手搓

发布时间:2026/10/19 6:40:44
Python抽奖软件实战:Tkinter+随机算法,告别Excel手搓
简介这是一份用Python实现的抽奖软件项目源码包面向具备Python基础、希望练习随机数生成与小型桌面工具开发的编程学习者。项目以luckDraw.py为主程序围绕random模块实现随机抽取逻辑涵盖参与者数据准备、抽奖算法、结果输出与用户交互等环节并配有readme.txt说明运行方式与参数调整方法适合课堂活动、年会抽奖等场景二次修改。压缩包共1014个文件约14.21MB除py主程序外还包含pyd扩展模块、tcl/tclindex脚本、png与gif界面素材、dll依赖库、txt说明文档及msg、enc等资源文件另附大量时区数据文件整体结构完整、可直接运行。目前已有1086人学习下载。通过阅读和修改源码读者可掌握文件I/O、循环与条件判断、列表字典等数据结构运用并理解模块化与面向对象设计思路是提升Python实战能力的合适练手项目。1. 年会抽奖别再手搓 Excel 了一个 Python 抽奖软件能省下多少折腾年会前三天行政同事抱着一台笔记本过来说抽奖环节想用 Excel 随机函数凑合一下结果一按 F9 名单就变一等奖差点重复发给同一个人。这种场景我见过太多次了。抽奖看着简单真做起来要处理名单去重、奖项分层、中奖后移出池子、结果留档手搓表格迟早翻车。这份 Python 抽奖软件就是冲着这些实际问题去的一份名单文件丢进去按奖项配置抽几轮中奖结果实时落盘界面能投屏逻辑能审计。适合谁需要给年会、团建、课堂互动做抽奖工具的后端或全栈开发者以及想拿一个完整小项目练手 Python GUI 和随机算法的人。它不依赖数据库单机就能跑改起来也快。2. 技术选型与目录结构为什么用 Tkinter 而不是 Web 那一套2.1 选型逻辑单机、离线、投屏三个硬约束抽奖软件的使用现场通常是会议室或宴会厅网络不一定稳投屏线倒是现成的。这就决定了技术选型的第一原则是单机可运行。Web 方案Flask 浏览器看着现代但现场多一层浏览器全屏、多显示器切换的麻烦投影分辨率一变布局就乱。Tkinter 是 Python 标准库自带的 GUI 框架零额外依赖打包成 exe 也就几十兆双击即开这是它在这个场景下最大的优势。随机数部分用random.SystemRandom而不是普通的random.random。原因很直接普通random模块用的是梅森旋转算法种子可预测虽然年会抽奖没人会去逆推种子但用系统熵源更省心也避免随机函数有规律这种质疑。名单读取用csv和json两个标准库前者读人员名单后者存奖项配置和中奖记录。整个项目不引入 pandas、numpy 这类重依赖因为名单规模通常就几百到几千人标准库完全够用。界面刷新用after()做定时器而不是time.sleep阻塞主线程。这是 Tkinter 新手最容易踩的坑在主线程里 sleep窗口直接卡死变白屏。用after(50, callback)让滚动动画一帧一帧走主循环始终响应。2.2 目录结构一个能直接跑起来的最小工程lottery/ ├── main.py # 入口启动 GUI ├── config.py # 奖项配置、路径常量 ├── core/ │ ├── loader.py # 名单加载与清洗 │ ├── engine.py # 抽奖核心逻辑 │ └── recorder.py # 中奖记录持久化 ├── ui/ │ ├── main_window.py # 主窗口布局 │ └── widgets.py # 滚动号码牌等自定义组件 ├── data/ │ ├── members.csv # 参与名单 │ └── prizes.json # 奖项配置 └── output/ └── winners_20250101.json # 按日期归档的中奖结果这个结构把数据加载—抽奖计算—界面展示—结果落盘四件事拆开每块单独一个文件。好处是改抽奖规则只动engine.py换界面皮肤只动ui/互不干扰。data/和output/分离避免程序把配置文件和结果混在一起也方便把output/整个目录拷走做审计。2.3 依赖与运行环境项目只依赖 Python 3.8 以上版本标准库全覆盖pip install那一步都省了。如果要做成 exe 分发用 PyInstaller 打包# 打包成单文件 exe--noconsole 去掉黑色命令行窗口 pyinstaller -F -w main.py --add-data data;data-F表示单文件-w表示不显示控制台--add-data把data目录一起打进去。注意 Windows 下分隔符是分号Linux/macOS 下是冒号这是打包时最常见的翻车点。打包后 exe 首次启动会解压到临时目录比直接跑脚本慢两三秒属正常现象。3. 核心模块拆解从名单加载到中奖落盘的完整链路3.1 名单加载与清洗去重、去空、编码兜底名单是抽奖的输入源头脏数据会导致后面全乱。loader.py要做三件事读文件、去重、过滤空行。常见做法是先用csv.DictReader按列名读这样名单列顺序变了也不影响。import csv from pathlib import Path def load_members(path: str) - list[dict]: 加载参与名单返回去重后的字典列表。 每行至少包含 name 字段可选 dept、工号等。 members [] seen set() # utf-8-sig 兼容 Excel 导出的带 BOM 的 CSV with open(path, r, encodingutf-8-sig, newline) as f: reader csv.DictReader(f) for row in reader: name (row.get(name) or ).strip() if not name: continue # 跳过空行 key name | (row.get(dept) or ) if key in seen: continue # 同名同部门视为重复 seen.add(key) members.append({name: name, dept: row.get(dept, )}) return members逻辑说明utf-8-sig是关键Excel 另存为 CSV 时默认带 BOM 头用普通utf-8读第一列会多出\ufeff字符导致第一个人的名字匹配不上。去重键用姓名部门组合因为不同部门可能有同名的人单纯按姓名去重会误删。参数path传名单文件路径返回的列表顺序保持文件原始顺序方便现场核对。3.2 抽奖引擎分层抽奖与中奖移出池engine.py是核心。抽奖规则通常有两种一种是每个奖项独立抽中过奖的人还能再中另一种是中奖后移出池子保证一人只中一次。后者更常见也更公平。引擎用一个pool列表维护当前可抽人员每抽中一个就从池子里移除。import random class LotteryEngine: def __init__(self, members: list[dict]): # SystemRandom 用系统熵源比默认 random 更难预测 self._rng random.SystemRandom() self.pool list(members) # 当前可抽池 self.winners {} # {奖项名: [中奖人]} def draw(self, prize_name: str, count: int) - list[dict]: 从池中抽 count 人中奖者移出池子。 池子不够时抽满为止不报错。 count min(count, len(self.pool)) picked self._rng.sample(self.pool, count) for p in picked: self.pool.remove(p) # 移出池保证不重复中奖 self.winners.setdefault(prize_name, []).extend(picked) return picked逻辑说明sample是无放回抽样一次抽多人不会重复。抽完再手动remove是为了让池子状态和winners记录保持一致。count min(count, len(self.pool))这行是防御性写法防止奖项人数配多了导致sample抛ValueError。参数prize_name是奖项名如一等奖count是该奖项人数返回中奖者列表。3.3 中奖记录持久化每抽一轮就落盘现场最怕的是程序崩了中奖结果全丢。recorder.py的策略是每抽完一个奖项就写一次文件而不是等全部抽完再写。用 JSON 格式按日期归档。import json from datetime import datetime from pathlib import Path def save_winners(winners: dict, out_dir: str output) - str: 把中奖结果写入 JSON返回文件路径。 Path(out_dir).mkdir(exist_okTrue) stamp datetime.now().strftime(%Y%m%d_%H%M%S) path Path(out_dir) / fwinners_{stamp}.json payload { generated_at: datetime.now().isoformat(), winners: winners, } # ensure_asciiFalse 保证中文姓名正常显示 with open(path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent2) return str(path)逻辑说明ensure_asciiFalse必须加否则中文姓名会变成\uXXXX转义人工核对时没法看。文件名带时间戳多次抽奖不会互相覆盖。indent2让 JSON 可读方便直接打开检查。参数winners是引擎里的中奖字典out_dir默认output可以改成 U 盘路径做实时备份。3.4 界面滚动动画after 定时器与状态机投屏效果靠ui/widgets.py里的滚动号码牌。原理很简单用一个 Label 不断刷新显示池子里的随机名字抽奖时快速滚动中奖时定格。关键是别用sleep。class RollingLabel: def __init__(self, label, engine, interval50): self.label label self.engine engine self.interval interval # 毫秒越小滚得越快 self._running False def start(self): self._running True self._tick() def _tick(self): if not self._running: return if self.engine.pool: name random.choice(self.engine.pool)[name] self.label.config(textname) # 用 after 递归调度不阻塞主线程 self.label.after(self.interval, self._tick) def stop(self, final_name: str): self._running False self.label.config(textfinal_name)逻辑说明after把_tick排进 Tkinter 事件循环每 50 毫秒执行一次窗口始终能响应按钮点击。_running标志位控制启停stop时把最终中奖名字定在 Label 上。参数interval调小滚动更快但低于 20 毫秒人眼就看不清了反而失去悬念感一般 40 到 80 之间比较合适。4. 避坑与常见问题现场抽奖最容易翻车的五个点4.1 现象投影上中文显示成方块原因Tkinter 默认字体在某些系统上不含中文字形或者打包时字体没跟着走。解决显式指定中文字体在main_window.py里统一设置。import tkinter as tk root tk.Tk() # Windows 用微软雅黑macOS 用 PingFangLinux 用 Noto Sans CJK root.option_add(*Font, (Microsoft YaHei, 24))如果打包后仍然方块把字体文件一起打进--add-data运行时用tkinter.font.Font动态加载。4.2 现象抽奖按钮点一次触发两次原因Tkinter 的Button如果同时绑定了command和Button-1事件一次点击会走两条路径。解决只用command不要额外绑鼠标事件。如果确实需要事件对象用bind时加add并手动去重。4.3 现象名单里有人被抽中两次原因池子没同步。常见于手动改了winners但忘了从pool里删或者多个奖项并发抽奖时共享了池子却没加锁。解决所有抽奖操作走engine.draw单一入口禁止在 UI 层直接操作pool。单机单线程场景下不需要锁但要保证抽奖按钮在动画期间禁用。4.4 现象打包后的 exe 找不到 data 目录原因PyInstaller 打包后资源文件被解压到临时目录sys._MEIPASS代码里写的相对路径data/members.csv指向的是 exe 所在目录不是解压目录。解决写一个路径解析函数。import sys from pathlib import Path def resource_path(relative: str) - Path: 兼容源码运行和 PyInstaller 打包两种模式。 base getattr(sys, _MEIPASS, Path(__file__).parent) return Path(base) / relative所有读data/的地方都走这个函数源码运行和打包运行都能找到文件。4.5 现象抽奖结果 JSON 里中文乱码原因写文件时没指定encodingutf-8Windows 默认用 GBK。解决所有open都显式写encodingutf-8json.dump加ensure_asciiFalse。这两处少一个都会乱码属于血泪经验。5. 二次开发与改造把抽奖软件改成你要的样子拿到这份代码后最常改的三个方向是换数据源、加抽奖规则、接大屏。下面按改动成本从低到高说。换数据源现在名单读 CSV如果你们用的是在线表格或内部系统改loader.py的load_members就行只要返回[{name: ..., dept: ...}]这个结构上层完全不用动。我一般会保留 CSV 作为兜底接口挂了还能手动导一份。加抽奖规则比如部门不能连续中奖或领导不参与一等奖在engine.draw里加过滤条件。改造时注意别破坏中奖移出池的语义过滤应该在sample之前对pool做筛选而不是抽完再判断。def draw_with_filter(self, prize_name, count, exclude_deptNone): 带部门排除的抽奖用于控制部门中奖分布。 candidates [p for p in self.pool if p.get(dept) ! exclude_dept] if len(candidates) count: candidates self.pool # 候选不够时回退到全池 picked self._rng.sample(candidates, min(count, len(candidates))) for p in picked: self.pool.remove(p) self.winners.setdefault(prize_name, []).extend(picked) return picked接大屏Tkinter 窗口全屏用root.attributes(-fullscreen, True)多显示器场景用root.geometry(1920x108019200)把窗口挪到第二块屏。如果要做更炫的动画Tkinter 的 Canvas 能画粒子效果但性能有限超过 500 个粒子就开始掉帧这时候才需要考虑换 PyQt 或 Web 方案。验证改造是否成功改完别直接上现场写个单元测试跑一遍。核心验证点是抽 N 次后池子减少 N 人和winners 里无重复。def test_no_duplicate_winner(): members [{name: fuser{i}, dept: A} for i in range(100)] engine LotteryEngine(members) engine.draw(一等奖, 10) engine.draw(二等奖, 20) all_winners [w[name] for ws in engine.winners.values() for w in ws] assert len(all_winners) len(set(all_winners)), 出现重复中奖 assert len(engine.pool) 70, 池子数量不对这个测试跑通基本逻辑就稳了。从那以后我每次改完抽奖规则都强制先跑一遍这个断言再打包宁可多花两分钟也不想在台上当着几百人面按 F5 重来。希望这份拆解帮到你代码拿去改跑通了再上现场。本文还有配套的精品资源点击获取