📚 目录 附录 C 综合项目完整代码(OOP 通讯录管理器)

附录 C 综合项目完整代码(OOP 通讯录管理器)

本附录把第 20 章的综合项目整理成一份自包含、可直接复制运行的完整代码。把下面整个代码块复制到 address_book.py,在本机 Python 环境直接 python address_book.py 即可运行(需要图形界面环境)。

设计要点回顾:Model(Contact + ContactManager,纯逻辑、不依赖 GUI)、View(ContactForm / ContactTable 两个继承 ttk.Frame 的自定义组件)、Controller(ContactApp,接事件、调 Model、刷新 View)。数据用 JSON 文件持久化(零依赖)。全部采用 class 封装、self 持有、if __name__ == "__main__": 守卫。
# -*- coding: utf-8 -*-
"""OOP 通讯录管理器(MVC 综合项目,JSON 持久化,零依赖)。

把本文件直接保存为 address_book.py,在本机运行 `python address_book.py` 即可。
需要图形界面环境(Windows / macOS / Linux 桌面)。
"""

import json
import os
import tkinter as tk
from dataclasses import dataclass, asdict

from tkinter import ttk
from tkinter import filedialog
from tkinter import messagebox as msgbox


# ===================== Model 层:数据与纯逻辑(不依赖 GUI) =====================
@dataclass
class Contact:
    """一条联系人记录(数据模型)。@dataclass 自动生成构造器。"""
    name: str = ""           # 姓名
    phone: str = ""          # 电话
    category: str = "朋友"     # 分类:朋友/家人/同事/其他
    favorite: bool = False   # 是否收藏

    def to_dict(self):
        """转成普通字典,便于写 JSON。"""
        return asdict(self)

    @classmethod
    def from_dict(cls, d):
        """从字典还原成 Contact(读 JSON 时用)。"""
        return cls(
            name=d.get("name", ""),
            phone=d.get("phone", ""),
            category=d.get("category", "朋友"),
            favorite=bool(d.get("favorite", False)),
        )


class ContactManager:
    """通讯录管理器:增删改查 + 文件存取,完全不依赖 tkinter。"""
    def __init__(self):
        self.contacts = []                       # 联系人列表(元素是 Contact)

    # ---- 增删改查 ----
    def add(self, contact):
        self.contacts.append(contact)

    def remove(self, index):
        if 0 <= index < len(self.contacts):
            self.contacts.pop(index)

    def update(self, index, contact):
        if 0 <= index < len(self.contacts):
            self.contacts[index] = contact

    def get(self, index):
        if 0 <= index < len(self.contacts):
            return self.contacts[index]
        return None

    def count(self):
        return len(self.contacts)

    # ---- 筛选(查的子集),同时返回真实下标以便定位 ----
    def filter_with_indices(self, category="全部", favorites_only=False):
        result = []
        for i, c in enumerate(self.contacts):
            if category != "全部" and c.category != category:
                continue
            if favorites_only and not c.favorite:
                continue
            result.append((c, i))
        return result

    # ---- 文件存取(JSON,零依赖)----
    def save(self, path):
        with open(path, "w", encoding="utf-8") as f:
            json.dump([c.to_dict() for c in self.contacts],
                      f, ensure_ascii=False, indent=2)

    def load(self, path):
        if not os.path.exists(path):
            return
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        self.contacts = [Contact.from_dict(d) for d in data]


# ===================== View 层:两个自定义组件(继承 ttk.Frame) =====================
class ContactForm(ttk.Frame):
    """录入/编辑一条联系人的表单组件。"""
    CATEGORIES = ["朋友", "家人", "同事", "其他"]

    def __init__(self, master, on_submit=None, **kw):
        super().__init__(master, **kw)
        self.on_submit = on_submit              # 提交回调(由 Controller 注入)
        self._build_widgets()

    def _build_widgets(self):
        # 姓名
        ttk.Label(self, text="姓名:").grid(row=0, column=0, sticky="e", padx=4, pady=4)
        self.name_var = tk.StringVar()
        ttk.Entry(self, textvariable=self.name_var, width=18).grid(
            row=0, column=1, sticky="we", padx=4, pady=4)
        # 电话
        ttk.Label(self, text="电话:").grid(row=1, column=0, sticky="e", padx=4, pady=4)
        self.phone_var = tk.StringVar()
        ttk.Entry(self, textvariable=self.phone_var, width=18).grid(
            row=1, column=1, sticky="we", padx=4, pady=4)
        # 分类(下拉框)
        ttk.Label(self, text="分类:").grid(row=2, column=0, sticky="e", padx=4, pady=4)
        self.cat_var = tk.StringVar(value="朋友")
        ttk.Combobox(self, textvariable=self.cat_var, values=self.CATEGORIES,
                     state="readonly", width=16).grid(
            row=2, column=1, sticky="we", padx=4, pady=4)
        # 收藏(勾选框)
        self.fav_var = tk.BooleanVar()
        ttk.Checkbutton(self, text="设为收藏", variable=self.fav_var).grid(
            row=3, column=0, columnspan=2, sticky="w", padx=4, pady=4)
        # 提交按钮
        self.submit_btn = ttk.Button(self, text="添加联系人", command=self._submit)
        self.submit_btn.grid(row=4, column=0, columnspan=2, sticky="we", padx=4, pady=8)

    def _submit(self):
        # 收集表单 → 构造 Contact → 交给 Controller 的回调
        contact = Contact(
            name=self.name_var.get().strip(),
            phone=self.phone_var.get().strip(),
            category=self.cat_var.get(),
            favorite=self.fav_var.get(),
        )
        if not contact.name:
            msgbox.showwarning("提示", "姓名不能为空")
            return
        if self.on_submit:
            self.on_submit(contact)

    def load_contact(self, contact):
        """把一条联系人填进表单(用于编辑)。"""
        self.name_var.set(contact.name)
        self.phone_var.set(contact.phone)
        self.cat_var.set(contact.category)
        self.fav_var.set(contact.favorite)
        self.submit_btn.config(text="保存修改")

    def clear(self):
        self.name_var.set("")
        self.phone_var.set("")
        self.cat_var.set("朋友")
        self.fav_var.set(False)
        self.submit_btn.config(text="添加联系人")


class ContactTable(ttk.Frame):
    """用 Treeview 展示联系人列表的表格组件。"""
    COLUMNS = ("name", "phone", "category", "favorite")

    def __init__(self, master, on_select=None, **kw):
        super().__init__(master, **kw)
        self.on_select = on_select            # 选中回调(由 Controller 注入)
        self._indices = []                    # 每行对应的 manager 真实下标
        self._build()

    def _build(self):
        self.tree = ttk.Treeview(self, columns=self.COLUMNS,
                                 show="headings", height=12)
        self.tree.heading("name", text="姓名")
        self.tree.heading("phone", text="电话")
        self.tree.heading("category", text="分类")
        self.tree.heading("favorite", text="收藏")
        self.tree.column("name", width=90)
        self.tree.column("phone", width=120)
        self.tree.column("category", width=80)
        self.tree.column("favorite", width=50)
        self.tree.grid(row=0, column=0, sticky="nsew")
        # 纵向滚动条
        vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
        vsb.grid(row=0, column=1, sticky="ns")
        self.tree.configure(yscrollcommand=vsb.set)
        # 让表格随父容器拉伸
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        # 选中事件 → 回调 Controller
        self.tree.bind("<<TreeviewSelect>>", self._on_select)

    def refresh(self, contacts, indices=None):
        self.tree.delete(*self.tree.get_children())          # 先清空
        self._indices = indices if indices is not None else list(range(len(contacts)))
        for c in contacts:
            self.tree.insert("", "end", values=(
                c.name, c.phone, c.category, "★" if c.favorite else ""))

    def _on_select(self, event):
        if self.on_select:
            self.on_select(self.get_selected_index())

    def get_selected_index(self):
        sel = self.tree.selection()
        if not sel:
            return None
        pos = self.tree.index(sel[0])        # 当前可见行号
        return self._indices[pos]            # 映射回 manager 真实下标


# ===================== Controller 层:ContactApp =====================
class ContactApp:
    """控制器:建界面、接事件、调 Model、刷新 View。"""
    DATA_FILE = "contacts.json"              # 默认自动保存的文件名
    CATEGORIES = ["全部", "朋友", "家人", "同事", "其他"]

    def __init__(self, root):
        self.root = root
        self.root.title("通讯录管理器(MVC)")
        self.root.geometry("640x420")
        self.manager = ContactManager()      # Model 实例
        self.selected_index = None           # 当前选中的联系人下标

        self._build_menu()                   # 菜单栏
        self._build_toolbar()                # 筛选工具栏
        self._build_body()                   # 表单 + 表格
        self._build_status()                 # 状态栏

        # 退出前自动保存(呼应第19章 protocol)
        self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
        self._refresh()                      # 首次刷新

    # ---- 构建界面 ----
    def _build_menu(self):
        menubar = tk.Menu(self.root)
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="打开…", command=self.open_file)
        file_menu.add_command(label="保存", command=self.save_file)
        file_menu.add_separator()
        file_menu.add_command(label="退出", command=self.on_closing)
        menubar.add_cascade(label="文件", menu=file_menu)

        edit_menu = tk.Menu(menubar, tearoff=0)
        edit_menu.add_command(label="新增", command=self.on_new)
        edit_menu.add_command(label="删除", command=self.on_delete)
        edit_menu.add_command(label="收藏/取消", command=self.on_toggle_fav)
        menubar.add_cascade(label="编辑", menu=edit_menu)

        self.root.config(menu=menubar)

    def _build_toolbar(self):
        bar = ttk.Frame(self.root)
        bar.pack(fill="x")
        ttk.Label(bar, text="筛选分类:").pack(side="left", padx=4)
        self.filter_var = tk.StringVar(value="全部")
        ttk.Combobox(bar, textvariable=self.filter_var, values=self.CATEGORIES,
                     state="readonly", width=10).pack(side="left", padx=2)
        # 下拉框变化立即刷新
        self.filter_var.trace_add("write", lambda *a: self._refresh())
        self.fav_only_var = tk.BooleanVar()
        ttk.Checkbutton(bar, text="只看收藏", variable=self.fav_only_var,
                        command=self._refresh).pack(side="left", padx=8)

    def _build_body(self):
        body = ttk.Frame(self.root)
        body.pack(fill="both", expand=True, padx=6, pady=6)
        # 左:表单(注入 on_submit 回调)
        self.form = ContactForm(body, on_submit=self.on_submit)
        self.form.pack(side="left", fill="y", padx=4)
        # 右:表格(注入 on_select 回调)
        self.table = ContactTable(body, on_select=self.on_select)
        self.table.pack(side="left", fill="both", expand=True, padx=4)

    def _build_status(self):
        self.status_var = tk.StringVar(value="就绪")
        ttk.Label(self.root, textvariable=self.status_var, relief="sunken",
                  anchor="w").pack(side="bottom", fill="x")

    # ---- 表单/表格回调(Controller 逻辑)----
    def on_submit(self, contact):
        if self.selected_index is not None:
            self.manager.update(self.selected_index, contact)   # 改
            self.status_var.set(f"已修改:{contact.name}")
        else:
            self.manager.add(contact)                           # 增
            self.status_var.set(f"已添加:{contact.name}")
        self.selected_index = None
        self.form.clear()
        self._refresh()

    def on_new(self):
        self.selected_index = None
        self.form.clear()
        self.status_var.set("请输入新联系人")

    def on_select(self, index):
        self.selected_index = index
        if index is not None:
            c = self.manager.get(index)
            if c:
                self.form.load_contact(c)      # 载入表单供编辑
                self.status_var.set(f"已选中:{c.name}")
        else:
            self.form.clear()

    def on_delete(self):
        if self.selected_index is None:
            msgbox.showwarning("提示", "请先选中一条联系人")
            return
        c = self.manager.get(self.selected_index)
        if c and msgbox.askyesno("确认", f"确定删除 {c.name} 吗?"):
            self.manager.remove(self.selected_index)
            self.selected_index = None
            self.form.clear()
            self._refresh()
            self.status_var.set(f"已删除:{c.name}")

    def on_toggle_fav(self):
        if self.selected_index is None:
            msgbox.showwarning("提示", "请先选中一条联系人")
            return
        c = self.manager.get(self.selected_index)
        if c:
            c.favorite = not c.favorite        # 直接改 Model 数据
            self._refresh()
            self.status_var.set(f"{c.name} 收藏状态:{c.favorite}")

    # ---- 文件操作(菜单触发)----
    def open_file(self):
        path = filedialog.askopenfilename(filetypes=[("JSON 文件", "*.json")])
        if path:
            self.manager.load(path)
            self._refresh()
            self.status_var.set(f"已打开:{path}")

    def save_file(self):
        path = filedialog.asksaveasfilename(defaultextension=".json",
                                            filetypes=[("JSON 文件", "*.json")])
        if path:
            self.manager.save(path)
            self.status_var.set(f"已保存:{path}")

    # ---- 退出前保存(呼应第19章 on_closing 方法)----
    def on_closing(self):
        self.manager.save(self.DATA_FILE)     # 先保存
        self.root.destroy()                   # 再关窗

    # ---- 刷新界面(统一出口)----
    def _refresh(self):
        cat = self.filter_var.get()
        fav = self.fav_only_var.get()
        pairs = self.manager.filter_with_indices(category=cat, favorites_only=fav)
        contacts = [c for c, _ in pairs]
        indices = [i for _, i in pairs]
        self.table.refresh(contacts, indices)
        self.status_var.set(f"共 {self.manager.count()} 条,显示 {len(contacts)} 条")


# ===================== 启动 =====================
if __name__ == "__main__":
    root = tk.Tk()                  # 建主窗口
    app = ContactApp(root)          # 把窗口交给 Controller 初始化
    root.mainloop()                 # 进入主循环