# -*- 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() # 进入主循环