iT邦幫忙

0

[Day27]抽籤/點名器 GUI 進階版

  • 分享至 

  • xImage
  •  

延續 Day 26 的 MVP,今天把抽籤/點名器升級為進階版:
一鍵抽五個、復原上一抽、左側清單雙擊刪除,以及匯出中獎名單 CSV。
依然零相依,只用 Python 標準庫(Tkinter、csv、random、pathlib)。

今天多了什麼?

  • 復原上一抽:抽錯人或誤觸可撤回,將該名從「已抽」移回「候選」。
  • 批次抽取:按一下就抽五名(可自行改成 3、10…)。
  • 雙擊刪除候選:在候選清單上雙擊即可刪除該人。
  • 匯出中獎清單:存成 CSV(含抽出順序),方便公告或存檔。
  • 基本流程同 Day 26:匯入 TXT/CSV、手動新增、抽一個、不重複、統計剩餘/已抽。

互動設計重點

  • 撤銷(Undo)堆疊:每次抽出即把該名推入 undo_stack,復原時彈出並回填候選。
  • 清單顯示策略:中獎清單最新在最上方(insert(0, name));撤回時同步刪掉最上層。
  • 強隨機源:依舊使用 random.SystemRandom()。
  • 去重與相容性:匯入時會略過已存在於候選或已中獎清單的人名;支援 .txt 與 .csv(取第一欄)。

程式碼(存成 raffle_gui_pro.py)

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import csv, random
from pathlib import Path

class RafflePro:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("抽籤/點名器 - 進階版")

        # 狀態
        self.pool = []          # 尚未抽到的名單
        self.winners = []       # 已抽中的名單(依抽出順序)
        self.undo_stack = []    # 可復原上一抽
        self.rng = random.SystemRandom()

        self._build_ui()
        self._refresh_counts()

    # ---------------- UI ----------------
    def _build_ui(self):
        main = ttk.Frame(self.root, padding=16); main.grid(sticky="nsew")
        self.root.rowconfigure(0, weight=1); self.root.columnconfigure(0, weight=1)
        main.columnconfigure(0, weight=1); main.columnconfigure(1, weight=1)
        main.rowconfigure(2, weight=1)

        # 上:控制列
        top = ttk.Frame(main); top.grid(row=0, column=0, columnspan=2, sticky="we", pady=(0,8))
        ttk.Button(top, text="匯入名單", command=self.import_list).grid(row=0, column=0, padx=4)
        ttk.Button(top, text="匯出中獎", command=self.export_winners).grid(row=0, column=1, padx=4)
        ttk.Button(top, text="重置", command=self.reset_all).grid(row=0, column=2, padx=4)
        ttk.Button(top, text="復原上一抽", command=self.undo_last).grid(row=0, column=3, padx=4)

        # 中:抽中顯示
        display = ttk.Frame(main); display.grid(row=1, column=0, columnspan=2, sticky="we", pady=8)
        display.columnconfigure(0, weight=1)
        ttk.Label(display, text="抽中:", font=("Segoe UI", 11)).grid(row=0, column=0, sticky="w")
        self.var_current = tk.StringVar(value="—")
        ttk.Label(display, textvariable=self.var_current, font=("Segoe UI", 20)).grid(row=1, column=0, sticky="w")

        # 左:候選(可雙擊刪除)
        left = ttk.Frame(main); left.grid(row=2, column=0, sticky="nsew", padx=(0,8))
        left.columnconfigure(0, weight=1); left.rowconfigure(1, weight=1)
        ttk.Label(left, text="候選名單(雙擊刪除)").grid(row=0, column=0, sticky="w")
        self.list_pool = tk.Listbox(left, height=12)
        self.list_pool.grid(row=1, column=0, sticky="nsew")
        self.list_pool.bind("<Double-1>", self._delete_selected_in_pool)

        # 右:已抽中(最新在最上)
        right = ttk.Frame(main); right.grid(row=2, column=1, sticky="nsew")
        right.columnconfigure(0, weight=1); right.rowconfigure(1, weight=1)
        ttk.Label(right, text="中獎名單(最新在最上方)").grid(row=0, column=0, sticky="w")
        self.list_win = tk.Listbox(right, height=12)
        self.list_win.grid(row=1, column=0, sticky="nsew")

        # 下:新增/抽取/統計
        bottom_left = ttk.Frame(main); bottom_left.grid(row=3, column=0, sticky="we", pady=(8,0))
        ttk.Label(bottom_left, text="新增姓名").grid(row=0, column=0)
        self.entry_new = ttk.Entry(bottom_left, width=18); self.entry_new.grid(row=0, column=1, padx=4)
        ttk.Button(bottom_left, text="加入", command=self.add_name).grid(row=0, column=2, padx=4)

        bottom_right = ttk.Frame(main); bottom_right.grid(row=3, column=1, sticky="we", pady=(8,0))
        ttk.Button(bottom_right, text="抽一個 (Space/Enter)", command=self.draw_one).grid(row=0, column=0, padx=4)
        ttk.Button(bottom_right, text="抽五個", command=lambda: self.draw_multi(5)).grid(row=0, column=1, padx=4)
        self.lbl_counts = ttk.Label(bottom_right, text="剩餘:0|已抽:0")
        self.lbl_counts.grid(row=0, column=2, sticky="e")

        # 快捷鍵
        self.root.bind("<space>", lambda e: self.draw_one())
        self.root.bind("<Return>", lambda e: self.draw_one())

    # ---------------- 核心功能 ----------------
    def _refresh_counts(self):
        self.lbl_counts.config(text=f"剩餘:{len(self.pool)}|已抽:{len(self.winners)}")

    def _reload_pool_listbox(self):
        self.list_pool.delete(0, tk.END)
        for n in self.pool:
            self.list_pool.insert(tk.END, n)

    def import_list(self):
        path = filedialog.askopenfilename(
            title="匯入名單(TXT 或 CSV)",
            filetypes=[("文字檔或 CSV", "*.txt *.csv"), ("所有檔案", "*.*")]
        )
        if not path: return
        p = Path(path)
        names = []
        try:
            if p.suffix.lower() == ".csv":
                with p.open("r", encoding="utf-8-sig", newline="") as f:
                    for row in csv.reader(f):
                        if row and row[0].strip():
                            names.append(row[0].strip())
            else:
                with p.open("r", encoding="utf-8") as f:
                    for line in f:
                        name = line.strip()
                        if name:
                            names.append(name)
        except Exception as e:
            messagebox.showerror("匯入失敗", str(e)); return

        # 去重:既有 pool/winners 都不可重複
        existing = set(self.pool) | set(self.winners)
        added = [n for n in names if n not in existing]
        if not added:
            messagebox.showinfo("提示", "沒有可新增的項目(可能全都重複)"); return
        self.pool.extend(added)
        self._reload_pool_listbox()
        self._refresh_counts()
        messagebox.showinfo("完成", f"已匯入 {len(added)} 筆(略過重複)")

    def export_winners(self):
        if not self.winners:
            messagebox.showinfo("提示", "尚無中獎名單"); return
        path = filedialog.asksaveasfilename(
            title="匯出中獎名單",
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv")]
        )
        if not path: return
        try:
            with open(path, "w", encoding="utf-8", newline="") as f:
                w = csv.writer(f)
                w.writerow(["順序", "姓名"])
                for i, name in enumerate(self.winners, 1):
                    w.writerow([i, name])
            messagebox.showinfo("完成", f"已匯出 {len(self.winners)} 筆至:\n{path}")
        except Exception as e:
            messagebox.showerror("匯出失敗", str(e))

    def add_name(self):
        name = self.entry_new.get().strip()
        if not name: return
        if name in self.pool or name in self.winners:
            messagebox.showwarning("重複", f"「{name}」已存在"); return
        self.pool.append(name)
        self.entry_new.delete(0, tk.END)
        self._reload_pool_listbox()
        self._refresh_counts()

    def _delete_selected_in_pool(self, _event=None):
        sel = self.list_pool.curselection()
        if not sel: return
        idx = sel[0]
        name = self.list_pool.get(idx)
        if messagebox.askyesno("刪除確認", f"從候選名單刪除「{name}」?"):
            try:
                self.pool.remove(name)
            except ValueError:
                pass
            self._reload_pool_listbox()
            self._refresh_counts()

    def reset_all(self):
        if messagebox.askyesno("重置", "清空中獎名單並回復所有候選?"):
            self.pool += self.winners
            self.winners.clear()
            self.undo_stack.clear()
            self.var_current.set("—")
            self._reload_pool_listbox()
            self.list_win.delete(0, tk.END)
            self._refresh_counts()

    def draw_one(self):
        if not self.pool:
            messagebox.showinfo("提示", "候選名單已抽完,請重置或新增成員"); return
        # 從 pool 抽一個,移到 winners
        idx = self.rng.randrange(len(self.pool))
        name = self.pool.pop(idx)
        self.winners.append(name)
        self.undo_stack.append(name)
        self.var_current.set(name)
        # 更新 UI
        self._reload_pool_listbox()
        self.list_win.insert(0, name)  # 最新在最上
        self._refresh_counts()
        self.root.bell()

    def draw_multi(self, k: int):
        for _ in range(k):
            if not self.pool: break
            self.draw_one()

    def undo_last(self):
        if not self.undo_stack:
            messagebox.showinfo("提示", "沒有可復原的抽取"); return
        name = self.undo_stack.pop()
        # 從 winners 還原到 pool
        try:
            self.winners.remove(name)
        except ValueError:
            pass
        self.pool.append(name)
        self.var_current.set("—")
        # 更新 UI:候選清單、已中清單刪除最上面一筆
        self._reload_pool_listbox()
        if self.list_win.size() > 0:
            self.list_win.delete(0)
        self._refresh_counts()

def main():
    root = tk.Tk()
    app = RafflePro(root)
    root.mainloop()

if __name__ == "__main__":
    main()

使用方式
python raffle_gui_pro.py

操作建議
先按「匯入名單」選 TXT/CSV;或用「新增姓名」補幾筆測試。
按「抽一個」或鍵盤 Space/Enter。
若要快速抽多人,按「抽五個」。
抽錯人就按「復原上一抽」。
完成後按「匯出中獎」,得到含順序的 CSV。
若要重來:按「重置」,所有已抽者回到候選清單。

實作:
https://ithelp.ithome.com.tw/upload/images/20251017/20169368LK1JTe6oUZ.png
https://ithelp.ithome.com.tw/upload/images/20251017/20169368aYr2INpgfG.png
FAQ
1.匯入檔編碼?
建議 UTF-8 或 UTF-8 with BOM;程式對 CSV 使用 utf-8-sig 讀取以避免中文亂碼。
2.重複人名會怎樣?
匯入時會略過已存在的名字;手動新增也會檢查重複。
3.批次抽取會重複嗎?
不會。每次抽出即從候選池移除,直到抽完為止。
4.撤回後順序是否改變?
從中獎清單移除最後一次抽出的人,並回到候選池(在候選末尾,不影響抽籤公平性)。
5.可否改成一次抽 N 個?
把 draw_multi(5) 裡的 5 改成你想要的數字,或新增一個輸入框控制數量。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言