前面第 1~14 節分別介紹了各種視窗元件,但每個範例都只示範「單一元件」的用法。實務上一支應用程式是由許多元件組合而成,元件之間還會互相影響(例如在清單中點選某一列,上方的表單要跟著顯示該筆資料)。

本節以 assets/student_crud.py 這支學生資料維護系統為例,說明如何把前面學過的元件組成一支完整、可以實際使用的程式。這支程式透過 mysql-connector-python 連接 MySQL 資料庫,對 students 資料表進行新增、查詢、修改、刪除四種操作。

資料庫的部分看這裡

本節聚焦在視窗介面。連線設定、SQL 語法、參數化查詢等資料庫觀念,請參閱 1. MYSQL 資料庫連線(特別是 1. MYSQL 資料庫連線 > 十七、整合實戰:圖形介面 CRUD)。

用到的元件一覽

這支程式幾乎用上了前面每一節的內容,可以當作全章的複習:

元件 / 功能在程式中的用途對應章節
tk.Tk()mainloop()建立主視窗、進入事件迴圈1. tkinter 套件
ttk.Label各欄位的提示文字(姓名:、性別:…)2. Label 標籤元件
pack() 版面配置由上而下排列表單、按鈕列、清單3. 視窗版面配置
ttk.Button + command新增/修改/刪除/清除/重新整理4. Button 按鈕元件
ttk.Entry + textvariable輸入姓名、生日、Email、電話、地址5. Entry 文字方塊元件
messagebox成功提示、錯誤警告、刪除確認6. messagebox 對話方塊元件
ttk 主題元件全部改用 ttk 版本,外觀較新11. ttk 主題元件套件
ttk.LabelFramettk.Frame把表單框起來、每一列各用一個容器12. Frame 與 LabelFrame 容器元件
ttk.Combobox性別下拉選單(限定 FM13. Combobox 下拉式選單元件
ttk.Treeview以表格顯示資料表所有資料列14. Treeview 清單(表格)元件

畫面結構

整個視窗由上而下分成三塊,每一塊都是一個容器:

┌── ttk.LabelFrame(學生資料)─────────────────────┐
│  姓名 [Entry]  性別 [Combobox]  生日 [Entry]     │  ← row0(ttk.Frame)
│  Email [Entry]              電話 [Entry]         │  ← row1(ttk.Frame)
│  地址 [Entry]                                    │  ← row2(ttk.Frame)
└─────────────────────────────────────────────────┘
┌── ttk.Frame(按鈕列)────────────────────────────┐
│  [新增] [修改] [刪除] [清除表單] [重新整理]         │
└─────────────────────────────────────────────────┘
┌── ttk.Treeview(清單)───────────────────────────┐
│  編號 │ 姓名 │ 性別 │ 生日 │ Email │ 電話 │ 地址   │
│   1   │ 李大毛│  M  │ ...  │  ...  │ ... │ ...    │
└─────────────────────────────────────────────────┘

ttk.Frame 當「每一列」的容器,是讓多個元件能水平並排的關鍵。若所有元件都直接 pack 到主視窗,它們只會一個一個往下疊。詳見 12. Frame 與 LabelFrame 容器元件

新觀念一:用類別(class)封裝視窗程式

前面各節的範例都是把程式碼直接寫在檔案中(俗稱「平鋪式」寫法)。當元件數量變多、還要互相溝通時,這種寫法會出現兩個問題:

  1. 變數滿天飛:每個元件、每個 StringVar 都是全域變數,很容易命名衝突。
  2. 函式難以取得元件:按鈕的處理函式需要讀取輸入框的內容,得靠全域變數傳遞,程式一大就難以維護。

解法是把整個視窗包成一個類別,元件與資料都存成實例屬性self.xxx):

class StudentApp:
    def __init__(self, root):
        self.root = root
        self.selected_id = None      # 狀態變數
 
        root.title('學生資料維護系統')
        root.geometry('820x520')
 
        self._build_form()           # 依序建立畫面
        self._build_buttons()
        self._build_tree()
 
        self.refresh()               # 一開始就載入資料
 
    def _build_form(self):
        ...
 
    def create(self):
        ...

這樣寫的好處:

好處說明
不污染全域所有元件與變數都掛在 self 底下,彼此不衝突。
方法互相看得到任何方法都能用 self.var_name.get() 取得輸入內容,不需要全域變數。
狀態集中管理self.selected_id 這種「目前選到哪一筆」的資訊,存在物件裡最自然。
容易複製想開第二個視窗,再建立一個 StudentApp 實例即可。

方法名稱前面的底線

_build_form()_build_buttons() 開頭的底線 _ 是 Python 的命名慣例,表示「這是內部使用的方法,不打算給外部呼叫」。它只是約定,並不會真的阻止外部呼叫,但看到底線就該知道不要亂動。

新觀念二:if __name__ == '__main__': 進入點

程式最後這三行是視窗程式的固定收尾:

if __name__ == '__main__':
    root = tk.Tk()
    app = StudentApp(root)
    root.mainloop()
敘述作用
if __name__ == '__main__':只有「直接執行這個檔案」時才成立;被別的程式 import 時不會執行。
root = tk.Tk()建立主視窗。
app = StudentApp(root)建立應用程式物件,畫面在 __init__ 中組好。
root.mainloop()進入事件迴圈,開始等待使用者操作。

為什麼要加這個判斷

若沒有這一行,別的程式只要 import student_crud,視窗就會立刻跳出來。加上判斷後,這個檔案既可以直接執行(跳出視窗),也可以被當成模組匯入(只借用裡面的函式,不跳視窗)。

元件與資料的雙向流動

表單欄位都用 StringVar 綁定,因此「讀取」與「填入」都非常單純:

self.var_name = tk.StringVar()
ttk.Entry(row0, textvariable=self.var_name, width=20).pack(side='left', padx=5)
方向寫法使用時機
讀出(畫面 → 程式)self.var_name.get()按下「新增」時取得使用者輸入
寫入(程式 → 畫面)self.var_name.set(值)點選清單某列後,把資料帶回表單

事件驅動:三種觸發方式

視窗程式不是由上往下執行,而是等待事件發生才執行對應的函式。這支程式用到三種:

# 1. 按鈕的 command:按下按鈕時呼叫
ttk.Button(frame, text='新增', command=self.create).pack(side='left', padx=3)
 
# 2. bind 綁定事件:使用者在清單中選取某列時呼叫
self.tree.bind('<<TreeviewSelect>>', self.on_select)
 
# 3. 直接呼叫:程式自己在需要時呼叫
self.refresh()

command=self.create 後面不要加括號

command=self.create 是把「函式本身」交給按鈕,等按下時才執行。
若寫成 command=self.create(),會在建立按鈕的當下就立刻執行一次,然後把回傳值(None)當成 command,按鈕就失效了。這是初學最常見的錯誤之一。

狀態管理:一組表單,兩種模式

這支程式只有一組輸入表單,卻同時支援「新增」與「修改」,關鍵在一個狀態變數:

self.selected_id = None
selected_id代表模式按下「修改」/「刪除」時
None新增模式提示「請先在清單中選擇一筆資料」
有值(某個 cID編輯模式對該筆資料執行修改或刪除

流程是:

  1. 使用者點選 Treeview 某一列 → 觸發 on_select()
  2. on_select() 把該列資料 set() 回表單,並記錄 self.selected_id
  3. 按「清除表單」→ clear_form()selected_id 設回 None,回到新增模式
def on_select(self, event):
    selected = self.tree.selection()
    if not selected:
        return
    cid, name, sex, birthday, email, phone, addr = self.tree.item(selected[0], 'values')
    self.selected_id = cid
    self.var_name.set(name)
    self.var_sex.set(sex)
    ...

清單的重繪

每次資料異動後都要讓畫面同步,作法是先清空再重新插入

def refresh(self):
    for row in self.tree.get_children():     # 取得所有列
        self.tree.delete(row)                # 逐一刪除
    # ...接著從資料庫重新查詢並 insert

若忘記先清空,每按一次「重新整理」資料就會重複疊加一份。

另外,資料庫中允許為 NULL 的欄位讀出來會是 None,直接顯示會出現「None」字樣,所以用 or '' 轉成空字串:

self.tree.insert('', 'end',
                 values=(cid, name, sex, birthday, email or '', phone or '', addr or ''))

輸入驗證

在寫入資料庫之前先檢查欄位,可以避免資料庫丟出例外,使用者體驗也比較好:

def _validate(self):
    if not self.var_name.get().strip():
        messagebox.showwarning('輸入錯誤', '姓名為必填欄位')
        return False
    birthday = self.var_birthday.get().strip()
    try:
        date.fromisoformat(birthday)          # 檢查是否為 YYYY-MM-DD
    except ValueError:
        messagebox.showwarning('輸入錯誤', '生日格式須為 YYYY-MM-DD')
        return False
    return True

驗證的欄位對應資料表中設為 NOT NULL 的欄位(cNamecBirthday)。strip() 用來排除「只輸入空白」的情況。

危險操作要先確認

刪除是不可復原的,一定要給使用者反悔的機會:

if not messagebox.askyesno('確認刪除', f'確定要刪除編號 {self.selected_id} 的資料嗎?'):
    return

askyesno() 回傳 TrueFalse,使用者按「否」就直接 return,完全不執行資料庫操作。詳見 6. messagebox 對話方塊元件

每個操作的完整流程

四個按鈕的處理方式都遵循同一個樣板:

驗證輸入 → 連線資料庫 → 執行 SQL → commit → 關閉連線
          → 顯示成功訊息 → 清除表單 → 重新整理清單

以「新增」為例:

def create(self):
    if not self._validate():          # ① 驗證
        return
    try:
        conn = get_connection()       # ② 連線
        cur = conn.cursor()
        cur.execute(                  # ③ 執行 SQL(參數化查詢)
            'INSERT INTO students (cName, cSex, cBirthday, cEmail, cPhone, cAddr) '
            'VALUES (%s, %s, %s, %s, %s, %s)',
            self._form_values(),
        )
        conn.commit()                 # ④ 提交
        cur.close()
        conn.close()
        messagebox.showinfo('成功', '新增成功')
        self.clear_form()             # ⑤ 清除表單
        self.refresh()                # ⑥ 重新整理清單
    except mysql.connector.Error as e:
        messagebox.showerror('資料庫錯誤', str(e))

資料庫操作一定要包 try / except

網路斷線、密碼錯誤、資料格式不符都會讓程式丟出例外。視窗程式若沒有攔截,畫面會直接卡住或閃退,使用者完全不知道發生什麼事。
messagebox.showerror() 把錯誤訊息顯示出來,才是友善的作法。

常見錯誤

症狀原因
按鈕一建立就自動執行command=self.create() 多加了括號
按「重新整理」資料越來越多忘記先 delete() 清空 Treeview
清單顯示一堆 None沒有用 or ''NULL 轉成空字串
按「修改」沒反應沒有先在清單中選取,selected_id 仍是 None
資料改了但清單沒變忘記在操作後呼叫 refresh()
視窗一閃就關閉忘記 root.mainloop()
元件全部擠成一直排沒有用 ttk.Frame 分組,或忘了 side='left'

練習

  1. 幫程式加上「依姓名搜尋」功能:新增一個 Entry 與「搜尋」按鈕,只顯示符合的資料列。
    ⚠️ SQL 必須使用參數化查詢,不可以用字串拼接。
  2. Treeview 下方加一個 ttk.Label,即時顯示「目前共 N 筆資料」。
  3. Treeview 支援點擊欄位標題排序(提示:heading() 可以設定 command)。
  4. 把身高 cHeight、體重 cWeight 兩個欄位也加進表單與清單。
  5. 目前每個操作都會重新連線一次,試著改成程式啟動時連線一次、關閉視窗時才斷線,並想想這樣做的優缺點。

相關文件

文件內容
1. MYSQL 資料庫連線資料庫連線、SQL 語法、參數化查詢、完整教案
generate_students學生假資料產生器,可先造測試資料再用本程式維護
assets/student_crud.py本節完整程式碼

🗺️ 本章學習地圖與快速查找 → 0. 章節索引