''' 一直梦想制作一个不受制于人,方便保存的家谱软件,永久免费、方便保存。以后,即使这个软件丢失了,也有xls存档可用。 因为没有对应版本,cairo在linux环境下的库改为cairosvg。 Linux系统的字体很难与windows统一,设置了多种字体。 软件开始运行时会自动弹出窗口,用于打开家谱xls文件,如果没有历史软件,关闭窗口即可。 支持成员按代分级别,支持按家族父子、同辈兄弟关系排序,并能导出形成一张A3的彩色png树状图,只要打印机够大,成员关系都能看的清。 支持xls保存,只要保存这个xls文件,软件丢了也能看成员信息。 能自动根据公历生日转换农历生日,根据生日推测属相。能根据农历生日排序,方便看看家族谁快过生日了。 支持备注,记录家族成员个人资料、历史。 能备注配偶子女关系。也可以作为关系图。 ''' import os import tkinter as tk from tkinter import ttk, messagebox, filedialog from datetime import datetime from tkcalendar import DateEntry from openpyxl import Workbook, load_workbook from lunarcalendar import Converter, Solar, Lunar import cairosvg import math from PIL import Image, ImageDraw import matplotlib.pyplot as plt from matplotlib.font_manager import FontProperties import networkx as nx from svgwrite import Drawing from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from scipy.cluster import hierarchy # from graphviz import Digraph # from openpyxl.LineProperties import Line class LunarDate: """农历日期工具类,用于将公历日期转换为农历日期""" # 生肖对应表 ZODIAC = ["鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪"] @staticmethod def to_zodiac(date): """根据公历日期获取生肖""" if not date: return "" solar = Solar(date.year, date.month, date.day) lunar = Converter.Solar2Lunar(solar) zodiac_index = (lunar.year - 1876) % 12 # 1900年为鼠年 return LunarDate.ZODIAC[zodiac_index] @staticmethod def to_lunar(date): """将公历日期转换为农历日期""" if not date: return "" solar = Solar(date.year, date.month, date.day) lunar = Converter.Solar2Lunar(solar) return f"{lunar.year}年{lunar.month}月{lunar.day}日" class Member: """成员类,用于存储家谱成员的详细信息""" def __init__(self, name, gender, generation): self.name = name self.gender = gender self.generation = generation self.birth_date = None self.death_date = None self.default_font = ("fangsong ti", 12) self.father = None # 父亲 self.mother = None # 母亲 self.spouse = "" # 配偶初始化为空字符串 self.notes = "" self.focus = False # 关注状态 # 设置默认打开路径为 /home/huanghe/Desktop/ self.default_path = "/home/huanghe/Desktop/" # 添加属相属性 def get_zodiac(self): """获取生肖""" return LunarDate.to_zodiac(self.birth_date) # 添加年龄属性 def get_age(self, current_date_str=None): """获取年龄""" current_date = datetime.now().date() if current_date_str is None else datetime.strptime(current_date_str, "%Y-%m-%d").date() return self.death_date.year - self.birth_date.year if self.death_date != None else current_date.year - self.birth_date.year @property def birth_date(self): return self._birth_date @birth_date.setter def birth_date(self, value): if value: if isinstance(value, str): self._birth_date = datetime.strptime(value, "%Y-%m-%d").date() elif isinstance(value, datetime): self._birth_date = value.date() else: raise ValueError("birth_date 必须是字符串或 datetime 对象") else: self._birth_date = None @property def death_date(self): return self._death_date @death_date.setter def death_date(self, value): if value: if isinstance(value, str): self._death_date = datetime.strptime(value, "%Y-%m-%d").date() elif isinstance(value, datetime): self._death_date = value.date() else: raise ValueError("death_date 必须是字符串或 datetime 对象") else: self._death_date = None def get_lunar_birth_date(self): """获取农历生日""" if not self.birth_date: return "" try: solar = Solar(self.birth_date.year, self.birth_date.month, self.birth_date.day) lunar = Converter.Solar2Lunar(solar) # solar = Solar(date.year, date.month, date.day) # lunar = Converter.Solar2Lunar(solar) return f"{lunar.year}年{lunar.month}月{lunar.day}日" except Exception as e: return "无效日期" def get_lunar_death_date(self): """获取农历忌日""" return LunarDate.to_lunar(self.death_date) def get_lunar_month_day(self): """获取农历月日 (month, day) 元组""" if not self.birth_date: return (13, 32) # 无效日期排最后 solar = Solar(self.birth_date.year, self.birth_date.month, self.birth_date.day) lunar = Converter.Solar2Lunar(solar) return (lunar.month, lunar.day) class FamilyTreeManager: """家谱数据管理类""" def __init__(self): self.members = {} # 存储所有成员,键为姓名,值为 Member 对象 self.relationships = set() # 存储父子关系,例如 ("父亲", "儿子") self.spouses = {} # 存储配偶关系,例如 {("丈夫", "妻子"): 结婚日期} self.sorted_members = [] # 新增排序状态存储 def add_member(self, member): """添加成员""" if member.name in self.members: raise ValueError(f"成员 {member.name} 已存在") self.members[member.name] = member def delete_member(self, name): """删除成员""" if name not in self.members: raise ValueError(f"成员 {name} 不存在") del self.members[name] def add_relationship(self, parent, child): """添加父子关系""" if parent not in self.members or child not in self.members: raise ValueError("父或子成员不存在") if (parent, child) in self.relationships: raise ValueError("关系已存在") self.relationships.add((parent, child)) def delete_relationship(self, parent, child): """删除父子关系""" if (parent, child) not in self.relationships: raise ValueError("关系不存在") self.relationships.remove((parent, child)) def add_spouse(self, spouse1, spouse2): """添加配偶关系""" if spouse1 not in self.members or spouse2 not in self.members: raise ValueError("配偶成员不存在") if (spouse1, spouse2) in self.spouses or (spouse2, spouse1) in self.spouses: raise ValueError("配偶关系已存在") self.spouses[(spouse1, spouse2)] = True self.members[spouse1].spouse = spouse2 self.members[spouse2].spouse = spouse1 def delete_spouse(self, spouse1, spouse2): """删除配偶关系""" if (spouse1, spouse2) not in self.spouses and (spouse2, spouse1) not in self.spouses: raise ValueError("配偶关系不存在") if (spouse1, spouse2) in self.spouses: del self.spouses[(spouse1, spouse2)] else: del self.spouses[(spouse2, spouse1)] self.members[spouse1].spouse = None self.members[spouse2].spouse = None def get_structured_tree(self): """获取结构化家谱树(包含配偶和子女)""" roots = [m for m in self.members.values() if not m.father and not m.mother] trees = [] def build_family(member, tree): # 添加配偶 if member.spouse: spouse = self.members.get(member.spouse) if spouse: tree["spouse"] = { "name": spouse.name, "birth": spouse.birth_date, "zodiac": spouse.get_zodiac(), "death": spouse.death_date } # 添加子女(按出生日期排序) children = [] for parent, child in self.relationships: if parent == member.name: child_member = self.members[child] children.append((child_member.birth_date or datetime.max, child)) # 按出生日期排序 for _, child in sorted(children, key=lambda x: x[0]): child_tree = {} build_family(self.members[child], child_tree) tree.setdefault("children", []).append(child_tree) for root in roots: tree = {"name": root.name, "birth": root.birth_date} build_family(root, tree) trees.append(tree) return trees def _build_tree(self, name, tree): """递归构建家谱树""" member = self.members[name] tree[name] = { "generation": member.generation, "children": [], "spouse": member.spouse } # 添加子女 for parent, child in self.relationships: if parent == name: tree[name]["children"].append(child) self._build_tree(child, tree) class FamilyTreeApp(tk.Tk): """家谱管理应用程序主界面""" # 在FamilyTreeApp类中添加字体设置方法 def setup_font(self): """设置可用字体""" import matplotlib.pyplot as plt from matplotlib.font_manager import FontManager # 直接设置fangsong ti字体,因为它在系统中是可用的 self.preferred_font = 'fangsong ti' # 设置matplotlib字体 - 使用更安全的方式 plt.rcParams['font.sans-serif'] = ['fangsong ti', 'FangSong', 'SimSun', 'DejaVu Sans', 'sans-serif'] plt.rcParams['axes.unicode_minus'] = False # 获取系统可用字体 try: fm = FontManager() available_fonts = {f.name for f in fm.ttflist} # 找到第一个可用的中文字体 self.preferred_font = 'sans-serif' # 默认字体 for font in chinese_fonts: if font in available_fonts: self.preferred_font = font break except: # 如果无法获取字体列表,使用默认设置 self.preferred_font = 'sans-serif' # 设置matplotlib字体 plt.rcParams['font.sans-serif'] = [self.preferred_font] plt.rcParams['axes.unicode_minus'] = False def __init__(self): super().__init__() self.title("家谱管理系统") self.geometry("1200x800") self.manager = FamilyTreeManager() self.bind("<Control-f>", lambda e: self.search_member()) # 添加快捷键绑定 # 设置默认导出路径 self.default_path = "/home/huanghe/Desktop/" # 检测可用字体 self.setup_font() self.bind("<Control-f>", lambda e: self.search_member()) # 添加快捷键绑定 style = ttk.Style(self) style.configure("Treeview", background="#FFFFF0", fieldbackground="#FFFFF0", foreground="Blue", wraplength=500) # 设置了换行宽度 style.map("Treeview", background=[("selected", "#347083")]) # 创建界面布局 self.create_widgets() self.current_members = [] # 新增变量 self.after(100, self.auto_open_file) # 延迟100ms执行打开 def auto_open_file(self): """启动自动打开文件""" # if messagebox.askyesno("启动选项", "是否要打开现有家谱文件?"): self.open_file() def create_widgets(self): """创建界面控件""" # 操作按钮 button_frame = ttk.Frame(self) button_frame.pack(fill=tk.X, padx=5, pady=5) # 合并添加、编辑、删除功能的按钮 ttk.Button(button_frame, text="导出", command=self.export_image).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="保存", command=self.save_file).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="打开", command=self.open_file).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="添加", command=self.add_member).pack(side=tk.LEFT, padx=5) # 添加刷新按钮 ttk.Button(button_frame, text="刷新", command=self.update_member_list).pack(side=tk.LEFT, padx=5) # 设置Treeview的字体大小 style = ttk.Style() style.configure("Treeview", font=("fangsong ti", 12)) style.configure("Treeview.Heading", font=("fangsong ti", 12)) # 成员列表 self.tree = ttk.Treeview( self, columns=( "gender", "generation", "birth", "zodiac", "age", "lunar_birth", "father", "mother", "spouse", "notes", "focus", "death", "lunar_death"), show="tree headings" ) self.tree.heading("gender", text="性别") self.tree.heading("generation", text="代数") self.tree.heading("birth", text="生日") self.tree.heading("zodiac", text="属") # 新增属相列 self.tree.heading("age", text="年龄") # 新增年龄列 self.tree.heading("lunar_birth", text="农历生日") self.tree.heading("father", text="父") self.tree.heading("mother", text="母") self.tree.heading("spouse", text="配偶") self.tree.heading("notes", text="备注", anchor=tk.CENTER) # 标题左对齐, sticky='w' self.tree.heading("focus", text="关注") self.tree.heading("death", text="寿数") self.tree.heading("lunar_death", text="天年") self.tree.bind('<ButtonRelease-1>', self.on_tree_click) # 增加点击按钮 self.tree.bind('<Double-1>', self.on_double_click) # 增加点击按钮 # 设置列宽 self.tree.column("#0", width=80, anchor=tk.CENTER) # 名字列80刚好够用 for col in self.tree["columns"]: # self.tree.column(col, width=500, anchor=tk.CENTER) self.tree.column("notes", width=500, anchor=tk.NW, stretch=True) # 左对齐 self.tree.pack(fill=tk.BOTH, expand=True) # 右键菜单 self.context_menu = tk.Menu(self, tearoff=0) self.context_menu.add_command(label="添加成员", command=self.add_member) self.context_menu.add_command(label="编辑成员", command=self.edit_member) self.context_menu.add_command(label="删除成员", command=self.delete_member) self.context_menu.add_command(label="查找成员", command=self.search_member) # 新增查找功能 self.context_menu.add_command(label="排序", command=self.sort_members) self.context_menu.add_command(label="显示所有", command=self.show_all_members) self.context_menu.add_command(label="仅显示关注", command=self.show_focused_members) self.context_menu.add_command(label="家庭树排序", command=self.sort_by_family_tree) # 绑定右键事件 self.tree.bind("<Button-3>", self.show_context_menu) # 在FamilyTreeApp类中新增导出选项对话框方法 def get_export_choice(self): """显示导出选项对话框""" dialog = tk.Toplevel(self) dialog.title("选择导出方式") dialog.geometry("300x200") choice = tk.StringVar(value="Matplotlib") ttk.Label(dialog, text="请选择导出引擎:").pack(pady=10) engines = [ ("Matplotlib (推荐)", "Matplotlib"), ("Pillow (快速)", "Pillow"), ("PyQt (高质量)", "PyQt"), ("SVG (矢量)", "SVG"), ("Cairo (专业)", "Cairo") ] for text, mode in engines: ttk.Radiobutton(dialog, text=text, variable=choice, value=mode).pack(anchor=tk.W) def confirm(): # dialog.result = choice.get("Matplotlib") dialog.result = choice.get() dialog.destroy() # 确保使用fangsong ti字体 import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['fangsong ti', 'FangSong', 'SimSun', 'DejaVu Sans', 'sans-serif'] plt.rcParams['axes.unicode_minus'] = False ttk.Button(dialog, text="确定", command=confirm).pack(pady=10) dialog.wait_window() return getattr(dialog, 'result', 'Matplotlib') # 修改export_image方法 def export_image(self): file_path = filedialog.asksaveasfilename( initialdir=self.default_path, # 设置默认路径 defaultextension=".png", filetypes=[("PNG 文件", "*.png")] ) if not file_path: return import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['fangsong ti', 'FangSong', 'SimSun', 'DejaVu Sans', 'sans-serif'] plt.rcParams['axes.unicode_minus'] = False exporter = PNGExporter(self.manager) try: choice = self.get_export_choice() if choice == "Pillow": exporter.export_pillow(file_path) elif choice == "Matplotlib": exporter.export_matplotlib(file_path) elif choice == "PyQt": exporter.export_pyqt(file_path) elif choice == "SVG": exporter.export_svg(file_path) elif choice == "Cairo": exporter.export_cairo(file_path) except Exception as e: messagebox.showerror("错误", f"导出失败: {str(e)}") def on_double_click(self, event): """双击编辑事件处理""" item = self.tree.identify_row(event.y) if item: self.tree.selection_set(item) self.edit_member() # 关注按钮点了之后更新,下边还要改变显示方式 def on_tree_click(self, event): region = self.tree.identify_region(event.x, event.y) if region != "cell": return column = self.tree.identify_column(event.x) item = self.tree.identify_row(event.y) if column == "#11": # 关注列是第11列 member_name = self.tree.item(item, "text") member = self.manager.members.get(member_name) if member: member.focus = not member.focus self.update_member_list() def show_context_menu(self, event): """显示右键菜单""" item = self.tree.identify_row(event.y) if item: self.tree.selection_set(item) self.context_menu.post(event.x_root, event.y_root) def update_member_list(self, members=None): """更新成员列表""" self.tree.delete(*self.tree.get_children()) # 清空前先保存当前展开状态 expanded_items = [self.tree.item(item, "text") for item in self.tree.get_children() if self.tree.item(item, "open")] # self.update_idletasks() members = members or list(self.manager.members.values()) self.current_members = members.copy() # 更新当前显示成员 # 配置自动换行,最后配置成功的 self.tree.column("notes", width=500, anchor=tk.NW, stretch=True) self.tree.heading("notes", text="备注", anchor=tk.CENTER) # 必须在插入数据前配置标签(核心修复) self.tree.tag_configure("focused", background="#C1FFC1") # pink淡绿色 # self.tree.tag_configure("deceased", background="brown") self.tree.tag_configure("deceased", background="#CDC9C9") # 使用更柔和的棕色,浅灰色 self.tree.tag_configure("both", background="#FFEFDB") # 新增叠加样式,肉红色 # 标记关注人员底色为粉色 # 添加强制样式刷新(关键补充)注意:这个决定了显示框中的显示内容,不能缺少或者错乱! for member in members: # 格式化日期数据(关键修复点) # birth_date = member.birth_date.strftime("%Y-%m-%d") if member.birth_date else "" # death_date = member.death_date.strftime("%Y-%m-%d") if member.death_date else "" birth_str = member.birth_date.strftime("%Y-%m-%d") if member.birth_date else "" death_str = member.death_date.strftime("%Y-%m-%d") if member.death_date else "" lunar_birth = member.get_lunar_birth_date() or "" lunar_death = member.get_lunar_death_date() or "" values = ( member.gender, member.generation, birth_str, # 使用格式化字符串 member.get_zodiac(), # 添加属相数据 # member.death_date if member.death_date else None, member.get_age(), lunar_birth, # member.get_lunar_birth_date(), # member.get_lunar_death_date(), member.father if member.father else "", member.mother if member.mother else "", member.spouse if member.spouse else "", member.notes, "☑" if member.focus else "☐", # 更改为复选框符号 death_str, lunar_death, # "是" if member.focus else "否" ) # item = self.tree.insert("", tk.END, text=member.name, values=values) # 插入数据时应用标签 tags = [] if member.focus and member.death_date: tags.append("both") elif member.focus: tags.append("focused") elif member.death_date: tags.append("deceased") item = self.tree.insert("", tk.END, text=member.name, values=values, tags=tags) self.manager.sorted_members = members.copy() # 更新排序状态 # 自动调整列宽 self.after(500, self.auto_resize_columns) def auto_resize_columns(self): for col in self.tree["columns"]: # min_width = min( # [self.tree.column(col, "width")] + # [tk.font.Font().measure(str(self.tree.set(item, col))) for item in self.tree.get_children()] # ) # self.tree.column(col, width=min_width + 60) # 增加10像素边距 max_width = max( [tk.font.Font().measure(str(self.tree.heading(col, "text")))] + # 包含表头宽度 [tk.font.Font().measure(str(self.tree.set(item, col))) for item in self.tree.get_children()] ) self.tree.column(col, width=min(max_width + 10, 500)) # 设置最大宽度限制 def sort_members(self): """按生日或忌日排序""" def on_sort(): key = sort_key.get() reverse = sort_order.get() == "降序" only_focused = only_focused_var.get() # 获取成员列表 members = list(self.manager.members.values()) if only_focused: members = [m for m in members if m.focus] if key == "农历生日": # 按农历生日排序(忽略年份,仅按月和日排序)lunar_birth members = sorted( members, key=lambda m: (m.get_lunar_month_day()) if m.birth_date else (13, 32), # if m.lunar_birth else "" # 将空值排到最后 reverse=reverse ) elif key == "birth_date": # 按生日排序 members = sorted( members, key=lambda m: m.birth_date if m.birth_date else datetime.max, reverse=reverse ) elif key == "death_date": # 按忌日排序 members = sorted( [m for m in members if m.death_date], # 仅过滤有忌日的成员 key=lambda m: m.death_date, reverse=reverse ) self.update_member_list(members) dialog.destroy() # 关闭排序对话框 # 创建排序对话框 dialog = tk.Toplevel(self) dialog.title("排序") dialog.grab_set() sort_key = tk.StringVar(value="农历生日") sort_order = tk.StringVar(value="升序") only_focused_var = tk.BooleanVar(value=False) ttk.Label(dialog, text="排序依据:").grid(row=0, column=0, padx=5, pady=5) ttk.Combobox(dialog, textvariable=sort_key, values=["农历生日", "birth_date", "death_date"], state="readonly").grid(row=0, column=1, padx=5, pady=5) ttk.Label(dialog, text="排序顺序:").grid(row=1, column=0, padx=5, pady=5) ttk.Combobox(dialog, textvariable=sort_order, values=["升序", "降序"], state="readonly").grid(row=1, column=1, padx=5, pady=5) ttk.Checkbutton(dialog, text="仅关注", variable=only_focused_var).grid(row=2, columnspan=2, pady=5) ttk.Button(dialog, text="确定", command=on_sort).grid(row=3, columnspan=2, pady=10) def search_member(self): """查找成员""" def on_search(): search_text = search_entry.get().strip().lower() if not search_text: messagebox.showwarning("提示", "请输入查找内容") return # 清空之前的查找结果 for item in self.tree.get_children(): self.tree.item(item, tags=()) # 查找包含搜索文本的成员和配偶 found_items = [] for item in self.tree.get_children(): member_name = self.tree.item(item, "text") member = self.manager.members.get(member_name) if not member: continue # 检查姓名或配偶是否包含搜索文本 # if (search_text in member.name) or (member.spouse and search_text in member.spouse): # found_items.append(item) # 检查所有相关字段是否包含搜索文本(不区分大小写) fields_to_search = [ member.name.lower(), member.father.lower() if member.father else "", member.mother.lower() if member.mother else "", member.spouse.lower() if member.spouse else "", member.notes.lower() if member.notes else "" ] if any(search_text in field for field in fields_to_search): found_items.append(item) # 这句话很重要,有了他能查找备注 # 临时改变找到的成员的显示状态 for item in found_items: self.tree.item(item, tags=("found",)) self.tree.tag_configure("found", background="yellow") # 设置找到的成员背景色 if not found_items: messagebox.showinfo("提示", "未找到匹配的成员") dialog.destroy() # 创建查找对话框 dialog = tk.Toplevel(self) dialog.title("查找成员") dialog.grab_set() ttk.Label(dialog, text="姓名、父母、配偶、备注:").grid(row=0, column=0, padx=5, pady=5) search_entry = ttk.Entry(dialog) search_entry.grid(row=0, column=1, padx=5, pady=5) ttk.Button(dialog, text="查找", command=on_search).grid(row=1, columnspan=2, pady=10) search_entry.focus_set() # 确保所有数据修改操作后调用update_member_list,例如: def add_spouse_relationship(self, spouse1, spouse2): try: self.manager.add_spouse(spouse1, spouse2) self.update_member_list() # 添加后刷新列表 except ValueError as e: messagebox.showerror("错误", str(e)) def show_focused_members(self): """显示关注人员""" focused_members = [m for m in self.manager.members.values() if m.focus] # 获取排序状态 current_sort_column = self.tree.heading("lunar_birth")["text"] # 假设按照生日 reverse = False # 默认升序 # 根据当前排序状态进行排序 if current_sort_column == "农历生日": focused_members = sorted( focused_members, key=lambda m: (m.lunar_birth.month, m.lunar_birth.day) if m.death_date else datetime.min, reverse=False ) elif current_sort_column == "生日": focused_members = sorted( focused_members, key=lambda m: (m.birth_date.month, m.birth_date.day) if m.birth_date else datetime.min, reverse=reverse ) # 其他排序条件可以根据需要添加 self.update_member_list(focused_members) foc = sorted( # self.manager.focused_members.values(), key=lambda m: (m.birth_date.month, m.birth_date.day) if m.birth_date else datetime.min ) self.update_member_list(focused_members) def show_all_members(self): """显示所有人员信息,并按生日从早到晚排序""" members = sorted( self.manager.members.values(), key=lambda m: m.birth_date if m.birth_date else datetime.min ) self.update_member_list(members) def sort_by_family_tree(self): """按家庭树结构排序(递归排序)""" members = list(self.manager.members.values()) processed = set() family_trees = [] def build_family_tree(member): if member.name in processed: return None processed.add(member.name) tree = { "member": member, "spouse": None, "children": [] } # 添加配偶 if member.spouse: spouse = self.manager.members.get(member.spouse) if spouse and spouse.name not in processed: processed.add(spouse.name) tree["spouse"] = spouse # 找到所有子女 for m in members: if (m.father == member.name or m.mother == member.name) and m.name not in processed: child_tree = build_family_tree(m) if child_tree: tree["children"].append(child_tree) print(child_tree) # 按子女的出生日排序 tree["children"].sort(key=lambda x: x["member"].birth_date if x["member"].birth_date else "") return tree # 构建根家庭树 for member in members: if member.name not in processed: tree = build_family_tree(member) if tree: family_trees.append(tree) # 遍历树结构,生成有序成员列表 sorted_members = [] def traverse_tree(tree_node): if not tree_node: return sorted_members.append(tree_node["member"]) # 添加配偶 if tree_node["spouse"]: sorted_members.append(tree_node["spouse"]) # 递归处理子女 for child in tree_node["children"]: traverse_tree(child) for family_tree in family_trees: traverse_tree(family_tree) # print('========================') self.manager.sorted_members = sorted_members self.update_member_list(sorted_members) def add_member(self): """添加成员""" dialog = tk.Toplevel(self) dialog.title("添加成员") dialog.grab_set() # 控件变量 entries = { 'name': ttk.Entry(dialog), # 'gender': tk.StringVar(value="男"), # 使用 StringVar 存储性别 'gender': ttk.Combobox(dialog, values=["男", "女"], state="readonly"), 'generation': ttk.Spinbox(dialog, from_=1, to=20), 'birth': DateEntry(dialog, date_pattern='yyyy-mm-dd'), 'death': DateEntry(dialog, date_pattern='yyyy-mm-dd'), # 'father': ttk.Combobox(dialog, values=list(self.manager.members.keys()), state="readonly"), # 'mother': ttk.Combobox(dialog, values=list(self.manager.members.keys()), state="readonly"), 'father': ttk.Combobox(dialog, state="readonly"), 'mother': ttk.Combobox(dialog, state="readonly"), 'spouse': ttk.Entry(dialog), 'notes': ttk.Entry(dialog), 'focus': tk.BooleanVar() } # entries['gender'].current(0) entries['generation'].set(13) # 设置默认代数为1 entries['birth'].set_date(datetime.now().date()) # 设置忌日默认值为3000年5月5日 default_death_date_str = '3000-05-05' entries['death'].set_date(default_death_date_str) # entries['gender'].grid(row=1, column=1) # 以下改变男女输入方式 gender_frame = ttk.Frame(dialog) gender_var = tk.StringVar(value="男") ttk.Radiobutton(gender_frame, text="男", variable=gender_var, value="男").pack(side=tk.LEFT) ttk.Radiobutton(gender_frame, text="女", variable=gender_var, value="女").pack(side=tk.LEFT) # 布局 ttk.Label(dialog, text="姓 名:*").grid(row=0, column=0, padx=5, pady=2) entries['name'].grid(row=0, column=1, columnspan=2, padx=5, pady=2, sticky='w') ttk.Label(dialog, text="性 别:").grid(row=1, column=0) entries['gender'] = gender_var gender_frame.grid(row=1, column=1, columnspan=2, sticky='w') # 空白是居中,w是左对齐, ttk.Label(dialog, text="代 数:").grid(row=2, column=0) entries['generation'].grid(row=2, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="生日:").grid(row=3, column=0) entries['birth'].grid(row=3, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="忌日:").grid(row=4, column=0) entries['death'].grid(row=4, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="父 亲:").grid(row=5, column=0) entries['father'].grid(row=5, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="母 亲:").grid(row=6, column=0) entries['mother'].grid(row=6, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="配偶:").grid(row=7, column=0) entries['spouse'].grid(row=7, column=1, columnspan=2, padx=10, pady=10, sticky='w') ttk.Label(dialog, text="备 注:").grid(row=8, column=0) entries['notes'].grid(row=8, column=1, columnspan=2, rowspan=2, padx=10, pady=10, sticky='w') ttk.Checkbutton(dialog, text="重点关注", variable=entries['focus']).grid(row=10, columnspan=2, sticky='') # 更新父母候选列表 def update_parent_candidates(): current_gen = int(entries['generation'].get()) father_candidates = [name for name, m in self.manager.members.items() if m.generation == current_gen - 1 and m.gender == "男"] mother_candidates = [name for name, m in self.manager.members.items() if m.generation == current_gen - 1 and m.gender == "女"] fatherspouse_candidates = [s.spouse for s, s in self.manager.members.items() if s.generation == current_gen - 1 and s.gender == "女"] motherspouse_candidates = [s.spouse for s, s in self.manager.members.items() if s.generation == current_gen - 1 and s.gender == "男"] entries['father']['values'] = fatherspouse_candidates + father_candidates entries['mother']['values'] = motherspouse_candidates + mother_candidates entries['gender'].trace_add('write', lambda *args: update_parent_candidates()) # update_parent_candidates() # 初始更新 update_parent_candidates() # 初始更新 # 绑定代数值变化时的回调函数 entries['generation'].configure(command=update_parent_candidates) def save(): try: # 验证必填字段 name = entries['name'].get().strip() if not name: raise ValueError("姓名不能为空") # 验证代数 try: generation = int(entries['generation'].get()) except ValueError: raise ValueError("代数必须为有效整数") # 处理忌日 death_date_str = entries['death'].get() death_date = None if death_date_str == default_death_date_str else death_date_str # 创建成员对象 member = Member( name=name, gender=entries['gender'].get(), generation=generation ) member.birth_date = entries['birth'].get() member.death_date = entries['death'].get() if entries['death'].get() != '3000-05-05' else None member.father = entries['father'].get() or None member.mother = entries['mother'].get() or None member.spouse = entries['spouse'].get() member.notes = entries['notes'].get() member.focus = entries['focus'].get() # 验证父母是否存在 # if member.father not in self.manager.members and member.father not in self.manager.members.items().spouse: # raise ValueError(f"父亲 '{member.father}' 不存在") # if member.mother not in self.manager.members and member.mother not in self.manager.members.items().spouse: # raise ValueError(f"母亲 '{member.mother}' 不存在") self.manager.add_member(member) self.update_member_list() dialog.destroy() # messagebox.showinfo("成功", "成员添加成功!") except Exception as e: messagebox.showerror("错误", f"添加失败:{str(e)}") ttk.Button(dialog, text="保 存", command=save).grid(row=11, column=1, columnspan=1, padx=10, pady=10, sticky='w') # 空白是居中,w是左对齐, # dialog.bind("<Return>", lambda e: save()) # 添加对话框内容区域的弹性布局(关键优化) dialog.grid_columnconfigure(1, weight=1) for i in range(12): # 为所有行配置弹性 dialog.grid_rowconfigure(i, weight=1) def edit_member(self): """编辑成员""" selected = self.tree.selection() if not selected: messagebox.showwarning("提示", "请先选择要编辑的成员") return name = self.tree.item(selected[0], 'text') member = self.manager.members.get(name) if not member: return dialog = tk.Toplevel(self) dialog.title(f"编辑成员 - {name}") dialog.grab_set() # 控件变量 entries = { 'name': ttk.Entry(dialog), # 'gender': ttk.Combobox(dialog, values=["男", "女"], state="readonly"),#这是下拉菜单方式,以下改为点选 # 'gender': tk.StringVar(value=member.gender), # 使用 StringVar 存储性别 # 'gender': ttk.Combobox(dialog, values=member.gender),#这是下拉菜单方式,以下改为点选 # 填充数据时无需额外设置性别(已通过 StringVar 绑定) 'generation': ttk.Spinbox(dialog, from_=1, to=10), 'birth': DateEntry(dialog, date_pattern='yyyy-mm-dd'), 'death': DateEntry(dialog, date_pattern='yyyy-mm-dd'), 'father': ttk.Combobox(dialog, values=list(self.manager.members.keys()), state="readonly"), 'mother': ttk.Combobox(dialog, values=list(self.manager.members.keys()), state="readonly"), 'spouse': ttk.Entry(dialog), 'notes': ttk.Entry(dialog), 'focus': tk.BooleanVar() } # 性别选择 # 以下改变男女输入方式 gender_frame = ttk.Frame(dialog) gender_var = tk.StringVar(value=member.gender) ttk.Radiobutton(gender_frame, text="男", variable=gender_var, value="男").pack(side=tk.LEFT) ttk.Radiobutton(gender_frame, text="女", variable=gender_var, value="女").pack(side=tk.LEFT) # entries['gender'] = member.gender # entries['gender'].current(0) # 填充现有数据 entries['name'].insert(0, member.name) # entries['gender'].set(member.gender)#改为点选后顺便省去了填充 entries['gender'] = gender_var # entries['generation'].set(member.generation) entries['birth'].set_date(member.birth_date) entries['death'].set_date( f'3000-05-05' if member.death_date == None else member.death_date) # 填充忌日时如果为空,则3000,不为空则输入实际日期 # entries['death'].set_date(member.death_date if member.death_date else '3000-05-05'另一种存储忌日方式,有空试试 entries['father'].set(member.father if member.father else "") entries['mother'].set(member.mother if member.mother else "") entries['spouse'].insert(0, member.spouse if member.spouse else "") entries['notes'].insert(0, member.notes if member.notes else "") entries['focus'].set(member.focus) # 'gender': ttk.Combobox(dialog, values=["男", "女"], state="readonly"), # 布局 ttk.Label(dialog, text="姓 名:*").grid(row=0, column=0, padx=5, pady=2) entries['name'].grid(row=0, column=1, padx=5, pady=2) ttk.Label(dialog, text="性 别:").grid(row=1, column=0) # entries['gender'].grid(row=1, column=1) gender_frame.grid(row=1, column=1) ttk.Label(dialog, text="代 数:").grid(row=2, column=0) entries['generation'].grid(row=2, column=1) ttk.Label(dialog, text="生日:").grid(row=3, column=0) entries['birth'].grid(row=3, column=1) ttk.Label(dialog, text="忌日:").grid(row=4, column=0) entries['death'].grid(row=4, column=1) ttk.Label(dialog, text="父 亲:").grid(row=5, column=0) entries['father'].grid(row=5, column=1) ttk.Label(dialog, text="母 亲:").grid(row=6, column=0) entries['mother'].grid(row=6, column=1) ttk.Label(dialog, text="配偶:").grid(row=7, column=0) entries['spouse'].grid(row=7, column=1) ttk.Label(dialog, text="备 注:").grid(row=8, column=0) entries['notes'].grid(row=8, column=1) ttk.Checkbutton(dialog, text="重点关注", variable=entries['focus']).grid(row=9, columnspan=2) # 更新父母候选列表 def update_parent_candidates(): current_gen = int(entries['generation'].get()) father_candidates = [name for name, m in self.manager.members.items() if m.generation == current_gen - 1 and m.gender == "男"] mother_candidates = [name for name, m in self.manager.members.items() if m.generation == current_gen - 1 and m.gender == "女"] fatherspouse_candidates = [s.spouse for s, s in self.manager.members.items() if s.generation == current_gen - 1 and s.gender == "女"] motherspouse_candidates = [s.spouse for s, s in self.manager.members.items() if s.generation == current_gen - 1 and s.gender == "男"] entries['father']['values'] = father_candidates + fatherspouse_candidates + [""] entries['mother']['values'] = motherspouse_candidates + mother_candidates + [""] entries['gender'].trace_add('write', lambda *args: update_parent_candidates()) # update_parent_candidates() # 初始更新 update_parent_candidates() # 初始更新 # 绑定代数值变化时的回调函数 entries['generation'].configure(command=update_parent_candidates) def save(): try: member.name = entries['name'].get().strip() member.gender = gender_var.get() member.generation = int(entries['generation'].get()) member.birth_date = entries['birth'].get() or None member.death_date = entries['death'].get() if entries['death'].get() != '3000-05-05' else None # entries['death'].set_date(default_death_date_str) # member.death_date = entries['death'].get() if entries['death'].get() != '3000-05-05' else None另一种 member.father = entries['father'].get() or None # if entries['father'].get() member.mother = entries['mother'].get() or None member.spouse = entries['spouse'].get() or None member.notes = entries['notes'].get() or None member.focus = entries['focus'].get() self.update_member_list() dialog.destroy() # messagebox.showinfo("成功", "修改已保存!") except Exception as e: messagebox.showerror("错误", f"保存失败:{str(e)}") ttk.Button(dialog, text="保 存", command=save).grid(row=11, columnspan=2, pady=10) def delete_member(self): """删除成员""" selected = self.tree.selection() if not selected: messagebox.showwarning("提示", "请先选择要删除的成员") return name = self.tree.item(selected[0], 'text') if not messagebox.askyesno("确认删除", f"确定要永久删除 {name} 吗?\n此操作不可恢复!"): return try: self.manager.delete_member(name) self.update_member_list() messagebox.showinfo("成功", f"{name} 已删除") except Exception as e: messagebox.showerror("错误", f"删除失败:{str(e)}") def export_structure(self): """导出家庭树结构为excel图形""" file_path = filedialog.asksaveasfilename( defaultextension=".xlsx", filetypes=[("Excel 文件", "*.xlsx"), ("CSV 文件", "*.csv")] ) if not file_path: return try: exporter = FamilyTreeExporter(self.manager) exporter.export_to_excel(file_path) messagebox.showinfo("成功", "家谱结构图导出完成!") except Exception as e: messagebox.showerror("错误", f"导出失败:{str(e)}") def save_file(self): """保存文件""" file_path = filedialog.asksaveasfilename( initialdir=self.default_path, # 设置默认路径 defaultextension=".xlsx", filetypes=[("Excel 文件", "*.xlsx")] ) if not file_path: return try: wb = Workbook() ws = wb.active ws.append(["姓名", "性别", "代数", "生日", "忌日", "父亲", "母亲", "配偶", "备注", "重点关注"]) # for member in self.manager.members.values(): # ws.append([ # member.name, # member.gender, # member.generation, # member.birth_date, # member.death_date if member.death_date!='3000-05-05' else None, # member.father if member.father else "", # member.mother if member.mother else "", # member.spouse if member.spouse else "", # member.notes if member.notes else "", # "☑" if member.focus else "☐" # ]) for member in self.manager.sorted_members: # current_members: ws.append([ member.name, member.gender, member.generation, member.birth_date.strftime("%Y-%m-%d") if member.birth_date else "", member.death_date.strftime("%Y-%m-%d") if member.death_date else "", member.father if member.father else "", member.mother if member.mother else "", member.spouse if member.spouse else "", member.notes if member.notes else "", "☑" if member.focus else "☐" ]) wb.save(file_path) # messagebox.showinfo("成功", "文件保存成功!") except Exception as e: messagebox.showerror("错误", f"保存失败:{str(e)}") def open_file(self): """打开文件""" # 使用默认路径而不是当前文件目录 file_path = filedialog.askopenfilename( initialdir=self.default_path, # 使用桌面路径 filetypes=[("Excel 文件", "*.xlsx")] ) if not file_path: return try: wb = load_workbook(file_path) ws = wb.active self.manager.members.clear() for row in ws.iter_rows(min_row=2, values_only=True): # 检查行数据是否完整 if len(row) < 10: # 确保每一行至少有 10 列数据 continue # 跳过不完整的行 member = Member(row[0], row[1], row[2]) member.birth_date = row[3] if row[3] else None member.death_date = row[4] if row[4] else None member.father = row[5] if row[5] else None member.mother = row[6] if row[6] else None member.spouse = row[7] if row[7] else None member.notes = row[8] if row[8] else "" member.focus = row[9] == "☑" if row[9] else False self.manager.members[member.name] = member self.update_member_list() # messagebox.showinfo("成功", "文件加载成功!") self.tree.update_idletasks() # 强制GUI刷新 except Exception as e: messagebox.showerror("错误", f"加载失败:{str(e)}") # 新增导出器类 # 新增 Graphviz 导出类,通过按钮选择如下三种导出方式的类 # class GraphvizExporter: # class MatplotlibExporter: # class PillowExporter: class PNGExporter: def __init__(self, manager): self.manager = manager self.A3_size = (4960, 3508) # @300dpi self.colors = { 'male': '#6FA8DC', 'female': '#FF9999', 'spouse': '#FF6666', 'parent': '#8FCE00', 'text': '#2C3E50', 'bg': '#FFFFFF' } # 不再依赖app.preferred_font,使用默认字体设置 self.default_font = ("sans-serif", 12) # 通用格式化方法 def _format_member(self, member): # 处理日期格式 def fmt(d): if not d: return "" return f"{(d.year % 100):02}{d.month}{d.day}" text = [ f"{member.name}", # f"{member.birth_date.strftime('%Y%m%d')}",# fmt(member.birth_date), f"{member.get_zodiac()}" # 属 ] if member.death_date: text.append(fmt(member.death_date)) if member.spouse: text.append(f" {member.spouse}") return "\n".join(text) # 方案2:Matplotlib实现 def export_matplotlib(self, filename): """改进的Matplotlib导出方法""" import matplotlib.pyplot as plt from matplotlib.font_manager import FontProperties # 直接使用fangsong ti字体 plt.rcParams['font.sans-serif'] = ['fangsong ti', 'FangSong', 'SimSun', 'DejaVu Sans', 'sans-serif'] plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 plt.figure(figsize=(16.53, 11.69), dpi=600) # A3横向尺寸 G, pos = self._common_layout() # 以下取自网络 # Z = hierarchy.linkage(pos.items(),'ward') plt.title('关系树') plt.ylabel('代') # hierrarchy.dendrogram(Z,labels=df.index,lef_rotation=90) # 颜色配置 # COLOR_SCHEME = { # 'male': ['#6FA8DC', '#4169E1', '#00BFFF'], # 'female': ['#FFB6C1', '#FF69B4', '#DB7093'], # 'dead': '#808080', # 'lines': { # 'spouse': '#FF4500', # 'parent': '#228B22' # } # } # # 方形节点参数 # node_size = 8000 # 调整节点大小 # font_size = 1 # 在export_matplotlib的绘图部分添加: # for u, v, data in G.edges(data=True): # x1, y1 = pos[u] # x2, y2 = pos[v] # if data['relation'] == 'parent': # plt.plot([x1, x2], [y1, y2], color='green', linestyle='--', linewidth=1,zorder=1) # elif data['relation'] == 'spouse': # plt.plot([x1, x2], [y1, y2], color='red', linestyle='--', linewidth=1) # 设置矩形尺寸 rect_height = 80 # 高度 rect_width = rect_height * 5 # 宽度2:1 # 绘制方形节点 for node, (x, y) in pos.items(): member = self.manager.members[node] color = '#6FA8DC' if member.gender == '男' else '#FFB6C1' if member.death_date: color = '#808080' # 绘制矩形 rect = plt.Rectangle((x - rect_width / 2, y - rect_height / 2), rect_width, rect_height, facecolor=color, edgecolor='black', lw=0.8, zorder=2) plt.gca().add_patch(rect) # # 绘制方形 # plt.scatter(x, y, # marker='s', # 关键修正点:使用方形标记 # s=node_size, # c=color, # edgecolors='black', # zorder=2) # # 添加文字 # text = f"{member.name}\n{member.birth_date.year}" # if member.death_date: # text += f"-{member.death_date.year}" # plt.text(x, y, text, # ha='center', va='center', # fontsize=font_size, # fontproperties=FontProperties(fname='fangsong ti.ttf')) # 绘制双节点配偶框 if member.spouse and member.spouse in pos: spouse_x, spouse_y = pos[member.spouse] if spouse_x: # 仅处理一次spouse_x > x plt.plot([x, spouse_x], [y, spouse_y], # 添加配偶连接线 color='#FF6666', # 红色连线 linewidth=20, linestyle='-', # 实线 zorder=0) # zorder是显示层数 if member.father and member.father in pos: father_x, father_y = pos[member.father] if father_x: # 仅处理一次 plt.plot([x, father_x], [y + 40, father_y - 40], # 添加连接线 color='#000000', # 1E90FF linewidth=1, zorder=0) # zorder是显示层数 if member.mother and member.mother in pos: mother_x, mother_y = pos[member.mother] if mother_x: # 仅处理一次 plt.plot([x, mother_x], [y + 40, mother_y - 40], # 添加连接线 color='#FF6666', linewidth=1, zorder=0) # zorder是显示层数 # 以下这段打开就是正方形,关闭就是矩形 # plt.scatter(x, y, # marker='s', # s=800,#5000减少,越少框越小 # edgecolor='black', # facecolor=color, # zorder=2)#zorder是显示层数 # 中文文本渲染 text = self._format_member(member) plt.text(x, y, self._format_member(member), ha='center', va='center', fontsize=12, fontfamily=self.manager.app.preferred_font if hasattr(self.manager, 'app') else 'sans-serif') #fontproperties=FontProperties(fname='C:\Windows\Fonts\fangsong ti.ttf')) # 先绘制所有连线,这个就是配偶之间的黑线来源。打开就有黑线 # for u, v, data in G.edges(data=True): # x1, y1 = pos[u] # x2, y2 = pos[v] # plt.plot([x1, x2], [y1, y2], 'k-', lw=1) # plt.xlim(0, self.A3_size[0]) # plt.ylim(0, self.A3_size[1]) plt.axis('off') plt.savefig(filename, bbox_inches='tight') plt.close() def _common_layout(self): """改进的通用布局算法""" try: G = nx.DiGraph() # positions = {} pos = {} roots = [name for name, m in self.manager.members.items() if not m.father and not m.mother] layer_height = self.A3_size[1] // 12 # 分10层 # 构建网络图结构(增加错误处理) for name, member in self.manager.members.items(): G.add_node(name, generation=member.generation) # if member.spouse and member.spouse in self.manager.members: G.add_edge(name, member.spouse, relation='spouse') # 父子关系 for parent, child in self.manager.relationships: G.add_edge(name, child, relation='parent') # 层次布局核心算法改进 # 按代数分层布局 generations = {} for node in G.nodes(): gen = self.manager.members[node].generation generations.setdefault(gen, []).append(node) if not generations: return G, pos max_gen = max(generations.keys()) layer_height = self.A3_size[1] / (max_gen + 2) # 原为//12,现改为动态分母 x_spacing = self.A3_size[0] / 10 # 节点间横向间距 # 每代水平间距基数 base_x_spacing = self.A3_size[0] / 10 # 基准间距 # 初始化各代节点位置 for gen in generations: y = self.A3_size[1] - gen * layer_height nodes = generations[gen] num_nodes = len(nodes) start_x = (self.A3_size[0] - (num_nodes - 1) * x_spacing) / 2 for i, node in enumerate(nodes): pos[node] = (start_x + i * x_spacing, y) # 调整子节点到父节点下方居中 for _ in range(3): # 多次迭代优化 for parent in G.nodes(): children = [child for (p, child) in self.manager.relationships if p == parent] if not children: continue # 计算父节点位置 if parent not in pos: continue px, py = pos[parent] # 计算子节点理想位置(父下方居中) child_y = py - layer_height child_x_start = px - (len(children) - 1) * x_spacing / 2 # 更新子节点位置 for i, child in enumerate(children): ideal_x = child_x_start + i * x_spacing if child not in pos: pos[child] = (ideal_x, child_y) else: # 平滑移动至目标位置 curr_x, curr_y = pos[child] new_x = curr_x + (ideal_x - curr_x) * 0.3 pos[child] = (new_x, child_y) # return G, pos # 从最顶层开始布局 for gen in sorted(generations.keys()): y = self.A3_size[1] - gen * layer_height nodes = generations[gen] # 按父节点分组子节点 parent_children = {} for node in nodes: children = [child for _, child in self.manager.relationships if _ == node] if children: parent_children[node] = children # 分配父节点位置(居中) x_step = self.A3_size[0] / (len(nodes) + 1) x_positions = {} for i, node in enumerate(nodes): x = (i + 1) * x_step x_positions[node] = x pos[node] = (x, y) # 计算父节点位置并分配子节点到其下方居中 for parent, children in parent_children.items(): if parent not in pos: continue px, py = pos[parent] child_y = py - layer_height # 子节点层y坐标 child_x_start = px - (len(children) - 1) * x_spacing / 2 # 子节点起始x坐标 for i, child in enumerate(children): pos[child] = (child_x_start + i * x_spacing, child_y) return G, pos except Exception as e: print(f"布局错误: {str(e)}") return G, {} if __name__ == "__main__": app = FamilyTreeApp() app.mainloop()
07-27
1万+

08-22
702
