From b2b8e53ce987bedc510c3a9db3f7ab81991ff4dd Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Wed, 5 Mar 2025 02:13:34 +0800 Subject: [PATCH] Create role_library.py --- ui/role_library.py | 1519 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1519 insertions(+) create mode 100644 ui/role_library.py diff --git a/ui/role_library.py b/ui/role_library.py new file mode 100644 index 0000000..51e13a4 --- /dev/null +++ b/ui/role_library.py @@ -0,0 +1,1519 @@ +# ui/role_library.py +import os +import tkinter as tk +from tkinter import filedialog +import shutil +import re +import customtkinter as ctk +from tkinter import messagebox, BooleanVar +from customtkinter import CTkScrollableFrame, CTkTextbox, END +from utils import read_file, save_string_to_txt # 导入 utils 中的函数 +from novel_generator.common import invoke_with_cleaning # 新增导入 +from prompt_definitions import Character_Import_Prompt + +class RoleLibrary: + def __init__(self, master, save_path, llm_adapter): # 新增llm_adapter参数 + self.master = master + self.save_path = os.path.join(save_path, "角色库") + self.selected_category = None + self.current_roles = [] + self.selected_del = [] + self.llm_adapter = llm_adapter # 保存LLM适配器实例 + + # 初始化窗口 + self.window = ctk.CTkToplevel(master) + self.window.title("角色库管理") + self.window.geometry("1200x800") + self.window.protocol("WM_DELETE_WINDOW", self.on_close) + + # 创建目录结构 + self.create_library_structure() + # 构建UI + self.create_ui() + # 窗口居中 + self.center_window() + # 窗口模态设置 + self.window.grab_set() + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + + def create_library_structure(self): + """创建必要的目录结构""" + os.makedirs(self.save_path, exist_ok=True) + all_dir = os.path.join(self.save_path, "全部") + os.makedirs(all_dir, exist_ok=True) + + def create_ui(self): + """创建主界面""" + # 分类按钮区 + self.create_category_bar() + + # 主内容区 + main_frame = ctk.CTkFrame(self.window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左侧面板(保持不变) + left_panel = ctk.CTkFrame(main_frame, width=300) + left_panel.pack(side="left", fill="both", padx=5, pady=5) + + # 上部角色列表区(保持不变) + role_list_container = ctk.CTkFrame(left_panel) + role_list_container.pack(fill="both", expand=True, pady=(0, 5)) + + self.role_list_frame = ctk.CTkScrollableFrame(role_list_container) + self.role_list_frame.pack(fill="both", expand=True) + + # 下部内容预览区(保持不变) + preview_container = ctk.CTkFrame(left_panel) + preview_container.pack(fill="both", expand=True, pady=(5, 0)) + + self.preview_text = ctk.CTkTextbox(preview_container, wrap="word", + font=("Microsoft YaHei", 12)) + scrollbar = ctk.CTkScrollbar( + preview_container, command=self.preview_text.yview) + self.preview_text.configure(yscrollcommand=scrollbar.set) + + self.preview_text.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 右侧面板(信息编辑区) + right_panel = ctk.CTkFrame(main_frame) + right_panel.pack(side="right", fill="both", expand=True, padx=5, pady=5) + + # 分类选择行 + category_frame = ctk.CTkFrame(right_panel) + category_frame.pack(fill="x", padx=5, pady=5) + + # 分类选择标签 + ctk.CTkLabel(category_frame, text="分类选择").pack(side="left", padx=(0, 5)) + + # 分类选择框 + self.category_combobox = ctk.CTkComboBox( + category_frame, + values=self._get_all_categories(), + width=200 + ) + self.category_combobox.pack(side="left", padx=0) + + # 分类保存按钮 + self.save_category_btn = ctk.CTkButton( + category_frame, + text="保存分类", + width=80, + command=self._move_to_category + ) + self.save_category_btn.pack(side="left", padx=(0, 5)) + + # 打开文件夹按钮 + ctk.CTkButton( + category_frame, + text="打开文件夹", + width=80, + command=lambda: os.startfile( + os.path.join(self.save_path, self.category_combobox.get())) + ).pack(side="left", padx=0) + + # 角色名编辑行 + name_frame = ctk.CTkFrame(right_panel) + name_frame.pack(fill="x", padx=5, pady=5) + + # 角色名称标签 + ctk.CTkLabel(name_frame, text="角色名称").pack(side="left", padx=(0, 5)) + + self.role_name_var = tk.StringVar() + self.role_name_entry = ctk.CTkEntry( + name_frame, + textvariable=self.role_name_var, + placeholder_text="角色名称", + width=200 + ) + self.role_name_entry.pack(side="left", padx=0) + + ctk.CTkButton( + name_frame, + text="修改", + width=60, + command=self._rename_role_file + ).pack(side="left", padx=(0, 5)) + + ctk.CTkButton( + name_frame, + text="新增", + width=60, + command=lambda: self._create_new_role("全部") + ).pack(side="left", padx=0) + + # 属性编辑区(基础框架) + self.attributes_frame = ctk.CTkScrollableFrame(right_panel) + self.attributes_frame.pack(fill="both", expand=True, padx=5, pady=5) + # 设置统一的列权重 + self.attributes_frame.grid_columnconfigure(1, weight=1) + + button_frame = ctk.CTkFrame(right_panel) + button_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkButton(button_frame, text="导入角色", + command=self.import_roles).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="删除", + command=self.delete_current_role).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="保存", + command=self.save_current_role).pack(side="left", padx=5) + + def _get_all_categories(self): + """获取所有有效分类(包括动态更新)""" + categories = ["全部"] + for d in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部": + categories.append(d) + return categories + + def _move_to_category(self): + """分类转移功能""" + if not hasattr(self, 'current_role') or not self.current_role: + messagebox.showwarning("警告", "请先选择一个角色", parent=self.window) + return + + new_category = self.category_combobox.get() + + # 如果当前在"全部"分类下,需要找到角色实际所在分类 + if self.selected_category == "全部": + # 遍历所有分类查找实际存储位置(包含全部目录) + actual_category = None + for category in os.listdir(self.save_path): + test_path = os.path.join( + self.save_path, category, f"{self.current_role}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + msg = messagebox.showerror("错误", f"找不到角色 {self.current_role} 的实际存储位置", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + old_path = os.path.join( + self.save_path, actual_category, f"{self.current_role}.txt") + else: + old_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + + # 如果目标分类是"全部",则实际移动到"全部"分类 + if new_category == "全部": + new_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + else: + new_path = os.path.join( + self.save_path, new_category, f"{self.current_role}.txt") + + # 检查是否已经在目标分类 + if os.path.exists(new_path): + msg = messagebox.showinfo("提示", "角色已在目标分类中", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + confirm = messagebox.askyesno( + "确认", f"确定要将角色 {self.current_role} 移动到 {new_category} 分类吗?", parent=self.window) + if not confirm: + return + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(new_path), exist_ok=True) + + try: + # 执行移动操作 + shutil.move(old_path, new_path) + + # 更新显示 + self.selected_category = new_category if new_category != "全部" else "全部" + self.show_category(self.selected_category) + self.category_combobox.set(new_category) + + # 成功提示 + messagebox.showinfo("成功", "分类已更新", parent=self.window) + return # 成功时直接返回 + + except Exception as e: + # 失败时恢复原分类显示 + self.category_combobox.set(self.selected_category) + raise e + except Exception as e: + msg = messagebox.showerror("错误", f"分类转移失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + self.category_combobox.set(self.selected_category) + + def import_roles(self): + """导入角色窗口""" + import_window = ctk.CTkToplevel(self.window) + import_window.title("角色导入") + import_window.geometry("800x600") + import_window.transient(self.window) # 设置为子窗口 + import_window.grab_set() # 模态窗口 + import_window.lift() # 置于父窗口前面 + + # 窗口居中计算 + import_window.update_idletasks() + i_width = import_window.winfo_width() + i_height = import_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - i_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - i_height) // 2 + import_window.geometry(f"+{x}+{y}") + + # 主内容区 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左右面板容器 + content_frame = ctk.CTkFrame(main_frame) + content_frame.pack(fill="both", expand=True, pady=(0, 10)) + content_frame.grid_columnconfigure(0, weight=1) # 左侧面板权重 + content_frame.grid_columnconfigure(1, weight=1) # 右侧面板权重 + + # 左侧面板 - 使用权重让控件占满空间 + left_panel = ctk.CTkFrame(content_frame) + left_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=5) + left_panel.grid_rowconfigure(0, weight=1) + left_panel.grid_columnconfigure(0, weight=1) + left_panel.grid_propagate(False) # 防止子控件改变父容器大小 + + # 右侧面板(2份宽度) - 添加初始可编辑文本框 + right_panel = ctk.CTkFrame(content_frame) + right_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0), pady=5) + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + # 创建初始可编辑文本框 + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + text_box.configure(state="normal") # 保持可编辑状态 + + # 初始化角色列表 + self.import_roles_list = [] + + # 底部按钮区 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=(0, 10)) + + # 导入按钮 + ctk.CTkButton( + btn_frame, + text="导入临时角色库", + width=120, + command=lambda: self.confirm_import(import_window) + ).pack(side="left", padx=10) + + # 分析文件按钮 + ctk.CTkButton( + btn_frame, + text="分析文件", + width=100, + command=lambda: self.analyze_character_state(right_panel, left_panel) + ).pack(side="left", padx=10) + + # 加载character_state.txt按钮 + ctk.CTkButton( + btn_frame, + text="加载character_state.txt", + width=160, + command=lambda: self.load_default_character_state(right_panel) + ).pack(side="right", padx=10) + + # 从文件导入按钮 + ctk.CTkButton( + btn_frame, + text="从文件导入", + width=100, + command=lambda: self.import_from_file(right_panel) + ).pack(side="right", padx=10) + + # 设置内容区权重 + content_frame.grid_rowconfigure(0, weight=1) + + def analyze_character_state(self, right_panel, left_panel): + """分析角色状态文件,使用LLM提取角色信息并保存到临时角色库""" + content = "" + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + content = widget.get("1.0", "end").strip() + break + + if not content: + messagebox.showwarning("警告", "未找到可分析的内容", parent=self.window) + return + + try: + # 创建临时角色库目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + # 调用LLM进行分析 + prompt = f"{Character_Import_Prompt}\n<<待分析小说文本开始>>\n{content}\n<<待分析小说文本结束>>" + response = invoke_with_cleaning( + self.llm_adapter, + prompt + ) + + # 解析LLM响应 + roles = self._parse_llm_response(response) + + if not roles: + messagebox.showwarning("警告", "未解析到有效角色信息", parent=self.window) + return + + # 直接显示分析结果而不保存到文件 + self._display_analyzed_roles(left_panel, roles) + + except Exception as e: + messagebox.showerror("分析失败", f"LLM分析出错:{str(e)}", parent=self.window) + + def _display_temp_roles(self, parent, temp_dir): + """显示临时角色库中的角色""" + # 清空左侧面板 + for widget in parent.winfo_children(): + widget.destroy() + + # 创建滚动容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True) + + # 读取所有临时角色文件 + self.character_checkboxes = {} + for file_name in os.listdir(temp_dir): + if file_name.endswith(".txt"): + role_name = os.path.splitext(file_name)[0] + file_path = os.path.join(temp_dir, file_name) + + # 解析角色属性 + attributes = self._parse_temp_role_file(file_path) + + # 创建带勾选框的条目 + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称 + lbl = ctk.CTkLabel(frame, text=role_name, + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in attributes.items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role_name] = { + 'var': var, + 'data': {'name': role_name, 'attributes': attributes} + } + + # 添加操作按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="取消选择", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _parse_temp_role_file(self, file_path): + """解析临时角色文件""" + attributes = {} + current_attr = None + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 统一解析├──和└──两种前缀 + if any(prefix in line for prefix in ['├──', '└──']) and ':' in line: + prefix = '├──' if '├──' in line else '└──' + current_attr = line.split(prefix)[1].split(':')[0].strip() + attributes[current_attr] = [] + elif any(prefix in line for prefix in ['│ ├──', '│ └──']): + prefix = '│ ├──' if '│ ├──' in line else '│ └──' + if current_attr: + item = line.split(prefix)[1].strip() + attributes[current_attr].append(item) + except Exception as e: + messagebox.showerror("解析错误", f"解析临时文件失败:{str(e)}", parent=self.window) + return attributes + + def _parse_llm_response(self, response): + """解析LLM返回的角色数据""" + roles = [] + current_role = None + current_attr = None + current_subattr = None + + attribute_pattern = re.compile(r'^([├└]──)([\w\u4e00-\u9fa5]+)\s*[::]') + item_pattern = re.compile(r'^│\s+([├└]──)\s*(.*)') + + for line in response.split('\n'): + line = line.rstrip() + + # 检测角色名称行(兼容中英文冒号和前后空格) + role_match = re.match(r'^\s*([\u4e00-\u9fa5a-zA-Z0-9]+)\s*[::]\s*$', line) + if role_match: + current_role = role_match.group(1).strip() + roles.append({'name': current_role, 'attributes': {}}) + continue + + if not current_role: + continue + + # 解析属性(支持子属性) + attr_match = attribute_pattern.match(line) + if attr_match: + prefix, attr_name = attr_match.groups() + current_attr = attr_name.strip() + roles[-1]['attributes'][current_attr] = [] + current_subattr = None + continue + + # 解析属性条目(支持多级结构) + item_match = item_pattern.match(line) + if item_match and current_attr: + prefix, content = item_match.groups() + content = content.strip() + + # 解析子属性(例如"身体状态: xxx") + if ':' in content or ':' in content: + subattr_match = re.split(r'[::]', content, 1) + if len(subattr_match) > 1: + current_subattr = subattr_match[0].strip() + value = subattr_match[1].strip() + if value: # 值不为空时才添加 + roles[-1]['attributes'][current_attr].append( + f"{current_subattr}: {value}" + ) + continue + + # 普通条目处理 + if content: + if current_subattr: + # 子属性的延续条目 + roles[-1]['attributes'][current_attr][-1] += f",{content}" + else: + roles[-1]['attributes'][current_attr].append(content) + return roles + + def _display_analyzed_roles(self, parent, roles): + """显示分析后的角色列表""" + self.character_checkboxes = {} + + # 创建带滚动条的容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + scroll_frame.grid_rowconfigure(0, weight=1) + scroll_frame.grid_columnconfigure(0, weight=1) + + # 为每个角色创建带勾选框的条目 + for role in roles: + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称标签 + lbl = ctk.CTkLabel(frame, text=role['name'], + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in role['attributes'].items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role['name']] = { + 'var': var, + 'data': role + } + + # 添加全选/反选按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="反选", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _toggle_all(self, select): + """全选/反选操作""" + for role in self.character_checkboxes.values(): + current_state = role['var'].get() + # 如果是反选操作,则设置相反状态 + if isinstance(select, bool): + role['var'].set(select) + else: + role['var'].set(not current_state) + + + def import_from_file(self, right_panel): + """从文件导入内容到右侧窗口""" + filetypes = ( + ('文本文件', '*.txt'), + ('Word文档', '*.docx'), + ('所有文件', '*.*') + ) + + file_path = filedialog.askopenfilename( + title="选择要导入的文件", + initialdir=os.path.expanduser("~"), + filetypes=filetypes + ) + + if not file_path: + return + + try: + content = "" + if file_path.endswith('.docx'): + # 处理Word文档 + from docx import Document + doc = Document(file_path) + content = "\n".join([para.text for para in doc.paragraphs]) + else: + # 处理普通文本文件 + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 更新右侧文本框中 + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + widget.delete("1.0", "end") + widget.insert("1.0", content) + break + + except Exception as e: + messagebox.showerror("导入失败", f"无法读取文件:{str(e)}", parent=self.window) + + def load_default_character_state(self, right_panel): + """加载character_state.txt文件到右侧窗口""" + # 获取保存路径 + save_path = os.path.dirname(self.save_path) + file_path = os.path.join(save_path, "character_state.txt") + + if not os.path.exists(file_path): + messagebox.showwarning("警告", f"未找到文件: {file_path}", parent=self.window) + return + + try: + # 读取文件内容 + content = read_file(file_path) + + # 清空右侧面板中可能存在的旧控件 + for widget in right_panel.winfo_children(): + widget.destroy() + + # 查找或创建文本框 + text_box = None + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + text_box = widget + break + + if not text_box: + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + text_box.configure(state="normal") + text_box.delete("1.0", "end") + text_box.insert("1.0", content) + + # 设置右边面板的布局权重 + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + except Exception as e: + messagebox.showerror("错误", f"加载文件失败: {str(e)}", parent=self.window) + + def confirm_import(self, import_window): + """从临时角色库导入选中的角色""" + # 创建必要的目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + try: + # 获取选中的角色 + selected_roles = [role_data['data'] for role_data in self.character_checkboxes.values() + if role_data['var'].get()] + + if not selected_roles: + # 创建错误提示窗口 + error_window = ctk.CTkToplevel(import_window) + error_window.title("错误") + error_window.transient(import_window) + error_window.grab_set() + + # 窗口内容 + ctk.CTkLabel(error_window, text="请至少选择一个角色").pack(padx=20, pady=10) + ctk.CTkButton(error_window, text="确定", command=error_window.destroy).pack(pady=10) + + # 窗口居中 + error_window.update_idletasks() + e_width = error_window.winfo_width() + e_height = error_window.winfo_height() + x = import_window.winfo_x() + (import_window.winfo_width() - e_width) // 2 + y = import_window.winfo_y() + (import_window.winfo_height() - e_height) // 2 + error_window.geometry(f"+{x}+{y}") + error_window.attributes('-topmost', 1) + return + + # 从内存数据直接保存角色 + for role in selected_roles: + dest_path = os.path.join(target_dir, f"{role['name']}.txt") + + # 构建角色内容 + content_lines = [f"{role['name']}:"] + for attr, items in role['attributes'].items(): + content_lines.append(f"├──{attr}:") + for i, item in enumerate(items): + prefix = "├──" if i < len(items)-1 else "└──" + content_lines.append(f"│ {prefix}{item}") + + # 直接写入文件,覆盖已存在的文件 + with open(dest_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content_lines)) + + # 刷新分类显示 + self.load_categories() + import_window.destroy() + + except Exception as e: + # 静默处理错误 + import_window.destroy() + + + + def delete_current_role(self): + """删除当前角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + confirm = messagebox.askyesno( + "确认删除", f"确定要删除角色 {self.current_role} 吗?", parent=self.window) + if not confirm: + return + + role_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + try: + os.remove(role_path) + # 从"全部"分类也删除 + all_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + if os.path.exists(all_path): + os.remove(all_path) + self.show_category(self.selected_category) + self.preview_text.delete("1.0", "end") + msg = messagebox.showinfo("成功", "角色已删除", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + except Exception as e: + msg = messagebox.showerror("错误", f"删除失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _build_role_content(self): + """构建角色文件内容""" + content = [f"{self.role_name_var.get()}:"] + attributes_order = ["物品", "能力", "状态", "主要角色间关系网", "触发或加深的事件"] + + for attr_name in attributes_order: + content.append(f"├──{attr_name}:") + # 找到对应的 attribute_block + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + # 遍历该 block 中的所有 CTkEntry + for child in block.winfo_children(): + if isinstance(child, ctk.CTkFrame): # 条目行 + for item in child.winfo_children(): + if isinstance(item, ctk.CTkEntry): + entry_text = item.get().strip() + if entry_text: # 只添加非空条目 + content.append(f"│ ├──{entry_text}") + break # 找到对应属性后跳出循环 + return content + + def _save_role_file(self, content, save_path): + """保存角色文件""" + with open(save_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content)) + + def _check_role_name_conflict(self, new_name): + """检查角色名是否重复,遍历整个角色文件夹""" + conflicts = [] + # 遍历所有分类目录 + for category in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, category)): + # 检查该分类下是否有同名角色 + role_path = os.path.join( + self.save_path, category, f"{new_name}.txt") + if os.path.exists(role_path): + # 如果是"全部"分类,需要进一步检查是否是实际文件 + if category == "全部": + # 检查"全部"目录下的文件是否是实际文件 + all_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + if os.path.isfile(all_path): + # 如果是实际文件,则认为是冲突 + conflicts.append(category) + else: + # 普通分类直接记录冲突 + conflicts.append(category) + return conflicts + + def save_current_role(self): + """保存当前编辑的角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + new_name = self.role_name_var.get().strip() + if not new_name: + msg = messagebox.showwarning("警告", "角色名称不能为空", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 检查角色名是否重复 + if new_name != self.current_role: + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + content = self._build_role_content() + save_path = os.path.join(self.save_path, self.selected_category, + f"{new_name}.txt") + + try: + self._save_role_file(content, save_path) + # 如果修改了角色名,更新文件名 + if new_name != self.current_role: + old_path = os.path.join(self.save_path, self.selected_category, + f"{self.current_role}.txt") + os.rename(old_path, save_path) + + # 更新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.show_role(new_name) # 刷新角色显示 + messagebox.showinfo("成功", "角色已保存", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"保存失败:{str(e)}", parent=self.window) + + def _rename_role_file(self): + """修改角色名称""" + old_name = self.current_role + new_name = self.role_name_var.get().strip() + + if not old_name or not new_name: + return + + # 处理中英文冒号 + for colon in [":", ":"]: + old_name = old_name.split(colon)[0] + new_name = new_name.split(colon)[0] + + # 如果角色名没有改变,直接返回 + if new_name == old_name: + return + + # 检查角色名是否重复 + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + try: + # 如果是"全部"分类,需要找到实际存储的分类 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + if os.path.exists(all_path): + # 如果"全部"目录下有文件,则直接操作 + actual_category = "全部" + else: + # 遍历所有分类查找实际存储位置 + actual_category = None + for category in os.listdir(self.save_path): + if category == "全部": + continue + test_path = os.path.join( + self.save_path, category, f"{old_name}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + raise FileNotFoundError( + f"找不到角色 {old_name} 的实际存储位置") + else: + actual_category = self.selected_category + + # 读取旧文件内容并更新角色名 + old_path = os.path.join( + self.save_path, actual_category, f"{old_name}.txt") + with open(old_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 获取第一行内容 + first_line = content.split('\n')[0].strip() + # 提取内容中的角色名 + content_role_name = first_line.split(':')[0].split(':')[0].strip() + # 如果内容中的角色名与旧文件名不同,更新内容 + if content_role_name != old_name: + content = content.replace( + f"{content_role_name}:", f"{new_name}:", 1) + else: + content = content.replace(f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + new_path = os.path.join( + self.save_path, actual_category, f"{new_name}.txt") + with open(new_path, 'w', encoding='utf-8') as f: + f.write(content) + + # 删除旧文件 + os.remove(old_path) + + # 处理"全部"目录 + all_old_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + all_new_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + + # 如果"全部"目录存在旧文件 + if os.path.exists(all_old_path): + try: + # 更新"全部"目录中的文件内容 + with open(all_old_path, 'r', encoding='utf-8') as f: + all_content = f.read() + updated_all_content = all_content.replace( + f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + with open(all_new_path, 'w', encoding='utf-8') as f: + f.write(updated_all_content) + + # 删除旧文件 + os.remove(all_old_path) + except Exception as e: + messagebox.showerror("错误", f"更新全部目录失败: {str(e)}", parent=self.window) + # 回滚重命名操作 + os.rename(new_path, old_path) + return + + # 刷新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.role_name_var.set(new_name) + self.show_role(new_name) # 刷新角色显示区域 + + except Exception as e: + msg = messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _create_new_role(self, category): + """在指定分类创建新角色""" + role_dir = os.path.join(self.save_path, category) + base_name = "未命名" + counter = 1 + + # 生成唯一文件名 + while os.path.exists(os.path.join(role_dir, f"{base_name}.txt")): + base_name = f"未命名{counter}" + counter += 1 + + # 创建基础文件结构(包含初始条目) + content = f"{base_name}:\n" + "\n".join([ + "├──物品:", + "│ └──待补充", + "├──能力:", + "│ └──待补充", + "├──状态:", + "│ └──待补充", + "├──主要角色间关系网:", + "│ └──待补充", + "├──触发或加深的事件:", + "│ └──待补充" + ]) + + with open(os.path.join(role_dir, f"{base_name}.txt"), "w", encoding="utf-8") as f: + f.write(content) + + # 刷新显示 + self.show_category(category) + self.role_name_var.set(base_name) + self.current_role = base_name + + def create_category_bar(self): + """创建分类按钮区""" + category_frame = ctk.CTkFrame(self.window) + category_frame.pack(fill="x", padx=10, pady=5) + + # 操作提示 + ctk.CTkLabel(category_frame, + text="右键分类名即可重命名", + font=("Microsoft YaHei", 10), + text_color="gray").pack(side="top", anchor="w", padx=5) + + # 固定按钮 + ctk.CTkButton(category_frame, text="全部", width=50, + command=lambda: self.show_category("全部")).pack(side="left", padx=2) + + # 滚动分类区 + self.scroll_frame = ctk.CTkScrollableFrame( + category_frame, orientation="horizontal", height=30) + self.scroll_frame.pack(side="left", fill="x", expand=True, padx=5) + + # 操作按钮 + ctk.CTkButton(category_frame, text="新增", width=50, + command=self.add_category).pack(side="right", padx=2) + ctk.CTkButton(category_frame, text="删除", width=50, + command=self.delete_category).pack(side="right", padx=2) + + self.load_categories() + + def center_window(self): + """窗口居中""" + self.window.update_idletasks() + parent_x = self.master.winfo_x() + parent_y = self.master.winfo_y() + parent_width = self.master.winfo_width() + parent_height = self.master.winfo_height() + win_width = 1200 + win_height = 800 + x = parent_x + (parent_width - win_width) // 2 + y = parent_y + (parent_height - win_height) // 2 + self.window.geometry(f"{win_width}x{win_height}+{x}+{y}") + + def load_categories(self): + """加载分类按钮""" + for widget in self.scroll_frame.winfo_children(): + widget.destroy() + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + + for category in categories: + btn = ctk.CTkButton(self.scroll_frame, text=category, width=80) + btn.bind("", lambda e, c=category: self.show_category(c)) + btn.bind("", lambda e, c=category: self.rename_category(c)) + btn.pack(side="left", padx=2) + + def _create_category_directory(self, category_name): + """创建分类目录""" + new_dir = os.path.join(self.save_path, category_name) + if not os.path.exists(new_dir): + os.makedirs(new_dir) + return new_dir + + def add_category(self): + """新增分类""" + self._create_category_directory("未命名") + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + + def delete_category(self): + """删除分类对话框""" + if not self.window.winfo_exists(): + return + + del_window = ctk.CTkToplevel(self.window) + del_window.title("删除分类") + del_window.transient(self.window) + del_window.grab_set() + del_window.attributes('-topmost', 1) + + # 居中计算 + parent_x = self.window.winfo_x() + parent_y = self.window.winfo_y() + parent_width = self.window.winfo_width() + parent_height = self.window.winfo_height() + del_window.geometry( + f"300x400+{parent_x + (parent_width-300)//2}+{parent_y + (parent_height-400)//2}") + + scroll_frame = ctk.CTkScrollableFrame(del_window) + scroll_frame.pack(fill="both", expand=True) + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + self.selected_del = [] + + for cat in categories: + var = tk.BooleanVar() + chk = ctk.CTkCheckBox(scroll_frame, text=cat, variable=var) + chk.pack(anchor="w") + self.selected_del.append((cat, var)) + + # 操作按钮 + btn_frame = ctk.CTkFrame(del_window) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="删除选中", + command=lambda: self.confirm_delete(del_window)).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="取消", + command=del_window.destroy).pack(side="right", padx=5) + + self.category_combobox.configure(values=self._get_all_categories()) + self.category_combobox.set("全部") + + def confirm_delete(self, original_window): + """确认删除操作""" + selected = [item[0] for item in self.selected_del if item[1].get()] + if not selected: + msg = messagebox.showwarning("警告", "请至少选择一个分类", parent=self.window) + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + return + + # 创建选择窗口时添加前置设置 + choice_window = ctk.CTkToplevel(self.window) + choice_window.transient(self.window) # 设置为子窗口 + choice_window.grab_set() # 模态窗口 + choice_window.lift() # 置顶 + choice_window.attributes('-topmost', 1) # 强制置顶 + + # 添加居中计算 + choice_window.update_idletasks() + c_width = choice_window.winfo_width() + c_height = choice_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - c_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - c_height) // 2 + choice_window.geometry(f"+{x}+{y}") + + ctk.CTkLabel(choice_window, text="请选择删除方式:").pack(pady=10) + btn_frame = ctk.CTkFrame(choice_window) + btn_frame.pack(pady=10) + + def perform_delete(mode): + all_dir = os.path.join(self.save_path, "全部") + for cat in selected: + cat_path = os.path.join(self.save_path, cat) + if mode == "move": + for role_file in os.listdir(cat_path): + if role_file.endswith(".txt"): + src = os.path.join(cat_path, role_file) + dst = os.path.join(all_dir, role_file) + try: + shutil.move(src, dst) + except: + os.remove(dst) + shutil.move(src, dst) + shutil.rmtree(cat_path) + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + original_window.destroy() + choice_window.destroy() + + ctk.CTkButton(btn_frame, text="全部删除", + command=lambda: perform_delete("all")).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="移动角色", + command=lambda: perform_delete("move")).pack(side="left", padx=5) + + def count_roles(self, categories): + """统计角色数量""" + count = 0 + for cat in categories: + cat_path = os.path.join(self.save_path, cat) + count += len([f for f in os.listdir(cat_path) if f.endswith(".txt")]) + return count + + def show_category(self, category): + """显示分类内容""" + self.selected_category = category + self.category_combobox.set(category) + for widget in self.role_list_frame.winfo_children(): + widget.destroy() + + # 如果是"全部"分类,显示所有角色 + if category == "全部": + # 获取所有分类目录 + categories = [d for d in os.listdir(self.save_path) + + if os.path.isdir(os.path.join(self.save_path, d))] + # 用于去重的角色集合 + unique_roles = set() + + for cat in categories: + role_dir = os.path.join(self.save_path, cat) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + # 去重 + if role_name not in unique_roles: + unique_roles.add(role_name) + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + continue + else: + # 普通分类显示 + role_dir = os.path.join(self.save_path, category) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + messagebox.showerror("错误", "分类目录不存在", parent=self.window) + + def show_role(self, role_name): + """显示角色详细信息(支持UTF-8/ANSI编码)""" + try: + # 清空现有属性控件 + self.preview_text.delete('1.0', tk.END) + for widget in self.attributes_frame.winfo_children(): + widget.destroy() + + # 更新角色名称显示 + self.current_role = role_name.split(":")[0].split(":")[0] + self.role_name_var.set(self.current_role) + + # 查找角色实际所在目录 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{role_name}.txt") + if os.path.exists(all_path): + file_path = all_path + actual_category = "全部" + else: + # 如果"全部"目录下没有,则遍历其他分类查找 + file_path = None + for cat in os.listdir(self.save_path): + if cat == "全部": + continue + test_path = os.path.join( + self.save_path, cat, f"{role_name}.txt") + if os.path.exists(test_path): + file_path = test_path + actual_category = cat + # 保存实际分类 + self.actual_category = cat + break + if file_path is None: + raise FileNotFoundError(f"找不到角色文件:{role_name}") + + # 只更新分类选择框的显示值,不改变当前选中的分类 + self.category_combobox.set(actual_category) + else: + # 普通分类直接使用当前路径 + file_path = os.path.join( + self.save_path, self.selected_category, f"{role_name}.txt") + + content, _ = self._read_file_with_fallback_encoding(file_path) + + # 解析属性结构 + attributes = { + "物品": [], + "能力": [], + "状态": [], + "主要角色间关系网": [], + "触发或加深的事件": [] + } + current_attribute = None + for line in content[1:]: + # 改进属性名称识别 + if line.startswith(("├──", "├──")): + # 提取属性名称(兼容冒号和空格) + attr_part = line.split("──")[1].strip() + attr_name = re.split(r'[::]', attr_part, 1)[0].strip() + + # 匹配预设属性 + for preset_attr in attributes: + if attr_name == preset_attr: + current_attribute = preset_attr + indent_level = line.find( + "├") if "├" in line else line.find("├") + break + else: + current_attribute = None + + # 改进条目内容提取 + elif current_attribute and line.startswith(("│ ", " ")): + # 提取整个条目内容 + item_content = line.strip() + # 去掉前面的符号和空格 + item_content = re.sub(r'^[│├└─\s]*', '', item_content) + attributes[current_attribute].append(item_content) + + # 显示原始文件内容 + self.preview_text.insert(tk.END, '\n'.join(content)) + + # 重构属性编辑区 + for attr_name, items in attributes.items(): + self._create_attribute_section(attr_name, items) + + except FileNotFoundError as e: + messagebox.showerror("错误", f"文件不存在:{str(e)}", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"读取文件失败:{str(e)}", parent=self.window) + + def _create_attribute_section(self, attr_name, items): + """创建单个属性的编辑区域""" + + # 属性块 (attribute_block) + attribute_block = ctk.CTkFrame(self.attributes_frame) + attribute_block.pack(fill="x", pady=5) + attribute_block.attribute_name = attr_name # 存储属性名称 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + + # 属性名称标签 + label = ctk.CTkLabel(attribute_block, text=attr_name) + label.grid(row=0, column=0, sticky="w", padx=(5, 10), pady=2) + + # 第一个条目和“增加”按钮的容器 + first_item_frame = ctk.CTkFrame(attribute_block) + first_item_frame.grid(row=0, column=1, sticky="ew", padx=5, pady=2) + first_item_frame.grid_columnconfigure(0, weight=1) + + # 第一个条目输入框 + first_entry = ctk.CTkEntry(first_item_frame) + first_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + if items: + first_entry.insert(0, items[0]) # 填充第一个条目的内容 + + # “增加”按钮容器 + add_button_frame = ctk.CTkFrame(first_item_frame, fg_color="transparent") + add_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + + # “增加”按钮 + add_button = ctk.CTkButton( + add_button_frame, + text="+", + width=30, + command=lambda: self._add_item(attr_name) + ) + add_button.grid(row=0, column=0) + + # 创建剩余的条目(如果有) + for i, item_text in enumerate(items[1:]): + self._add_item(attr_name, item_text) # 传入初始文本 + + def _add_item(self, attr_name, initial_text=""): + """为指定属性添加一个新条目""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 计算新条目的行号 + row_number = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + row_number += 1 + + # 条目容器 + item_frame = ctk.CTkFrame(attribute_block) + item_frame.grid(row=row_number, column=1, sticky="ew", padx=5, pady=2) + item_frame.grid_columnconfigure(0, weight=1) + + # 条目输入框 + new_entry = ctk.CTkEntry(item_frame) + new_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + new_entry.insert(0, initial_text) # 设置初始文本 + + # 删除按钮容器 + del_button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") + del_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + # “删除”按钮 + del_button = ctk.CTkButton( + del_button_frame, + text="-", + width=30, + command=lambda f=item_frame: self._remove_item(f, attr_name) + ) + del_button.grid(row=0, column=0) + + + def _remove_item(self, item_frame, attr_name): + """移除指定的条目,并重新调整布局""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 确认不是删除带"+"号的原始条目 + for child in item_frame.winfo_children(): + if isinstance(child, ctk.CTkFrame): + for btn in child.winfo_children(): + if isinstance(btn, ctk.CTkButton) and btn.cget("text") == "+": + msg = messagebox.showinfo("提示", "不能删除带'+'号的原始条目", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 移除条目 + item_frame.destroy() + + # 重新调整剩余条目的行号 + current_row = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + if current_row == 0: # 找到属性标签 + current_row += 1 + continue + ctk.CTkFrame.grid_configure(child, row=current_row) + current_row += 1 + + def _read_file_with_fallback_encoding(self, file_path): + """带编码回退的文件读取,支持UTF-8、GBK(ANSI)和BOM""" + encodings = ['utf-8-sig', 'utf-8', 'gbk', 'latin1'] # 增加更多编码支持 + + for encoding in encodings: + try: + with open(file_path, "r", encoding=encoding) as f: + content = f.read() + # 检查内容是否包含乱码 + if any(ord(char) > 127 and not char.isprintable() for char in content): + continue # 如果包含乱码,尝试下一个编码 + return content.splitlines(), encoding + except UnicodeDecodeError: + continue + except Exception as e: + raise + + # 如果所有编码尝试都失败,尝试二进制读取 + try: + with open(file_path, "rb") as f: + raw_data = f.read() + # 尝试UTF-8解码 + try: + return raw_data.decode('utf-8').splitlines(), 'utf-8' + except UnicodeDecodeError: + # 尝试GBK解码 + try: + return raw_data.decode('gbk').splitlines(), 'gbk' + except UnicodeDecodeError: + # 最后尝试latin1解码 + return raw_data.decode('latin1').splitlines(), 'latin1' + except Exception as e: + raise ValueError(f"无法识别的文件编码:{file_path}") + + def rename_category(self, old_name): + """分类重命名(带居中功能)""" + new_name = None # 初始化变量 + + # 创建对话框窗口 + dialog = ctk.CTkToplevel(self.window) + dialog.title("重命名分类") + dialog.transient(self.window) + dialog.grab_set() + + # 窗口内容 + content_frame = ctk.CTkFrame(dialog) + content_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 顶部提示 + ctk.CTkLabel(content_frame, text=f"当前分类:{old_name}").pack(pady=(10, 5)) + + # 输入框 + input_frame = ctk.CTkFrame(content_frame) + input_frame.pack(fill="x", pady=5) + ctk.CTkLabel(input_frame, text="新名称:").pack(side="left", padx=5) + name_var = tk.StringVar() + name_entry = ctk.CTkEntry(input_frame, textvariable=name_var, width=150) + name_entry.pack(side="left", padx=5) + + # 按钮区 + button_frame = ctk.CTkFrame(content_frame) + button_frame.pack(fill="x", pady=(10, 5)) + + def confirm_rename(): + nonlocal new_name # 引用外部变量 + new_name = name_var.get().strip() + if not new_name: + messagebox.showwarning("警告", "分类名称不能为空", parent=self.window) + return + if new_name == old_name: + dialog.destroy() + return + if os.path.exists(os.path.join(self.save_path, new_name)): + messagebox.showerror("错误", "分类名称已存在", parent=self.window) + return + + try: + os.rename(os.path.join(self.save_path, old_name), + os.path.join(self.save_path, new_name)) + self.load_categories() + # 更新分类选择框 + self.category_combobox.configure( + values=self._get_all_categories()) + self.category_combobox.set(new_name) + dialog.destroy() + except Exception as e: + messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + + ctk.CTkButton(button_frame, text="确认", + command=confirm_rename).pack(side="left", padx=10) + ctk.CTkButton(button_frame, text="取消", + command=dialog.destroy).pack(side="right", padx=10) + + # 窗口居中 + dialog.update_idletasks() + d_width = dialog.winfo_width() + d_height = dialog.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - d_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - d_height) // 2 + dialog.geometry(f"+{x}+{y}") + dialog.attributes('-topmost', 1) + + def on_close(self): + """关闭窗口""" + self.window.destroy() + +