#!/usr/bin/env python # -*- coding: utf-8 -*- """ Task Panel Module ---------------- A panel for creating and managing task folder structures with a node-based editor. """ import os import traceback import customtkinter as ctk from tkinter import filedialog import uuid from typing import Dict, List, Optional, Any, Tuple from .subfolder_editor import NodeEditor from ..utilities.icon_utils import get_icon_path from config.constants import ( DEFAULT_WORKSPACE_PATH, DEFAULT_MAYA_PLUGINS_PATH, DEFAULT_SP_SHELF_PATH, TASK_PANEL_BG_LIGHT, TASK_PANEL_BG_DARK, COLOR_SUCCESS, COLOR_SUCCESS_HOVER, COLOR_ERROR, COLOR_ERROR_HOVER, COLOR_WARNING, DIALOG_BG_COLOR, DIALOG_TEXT_COLOR, BUTTON_GRAY, BUTTON_GRAY_HOVER, RESET_BUTTON_BORDER, SAVE_BUTTON_BORDER, DIALOG_MESSAGE_SIZE, DIALOG_YES_NO_SIZE, SUBFOLDER_EDITOR_WINDOW_SIZE, SUBFOLDER_EDITOR_MIN_SIZE ) def validate_folder_name(name: str) -> Tuple[bool, str]: """验证文件夹名称是否安全 Args: name: 文件夹名称 Returns: (是否有效, 错误消息) """ # 检查空名称 if not name or not name.strip(): return False, "文件夹名称不能为空" # 检查非法字符 invalid_chars = '<>:"|?*\\' for char in invalid_chars: if char in name: return False, f"文件夹名称包含非法字符: {char}" # 检查路径遍历 if '..' in name or name.startswith('/') or name.startswith('\\'): return False, "文件夹名称不能包含路径遍历符号" # 检查保留名称(Windows) reserved_names = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'] if name.upper() in reserved_names: return False, f"'{name}' 是系统保留名称" # 检查长度 if len(name) > 255: return False, "文件夹名称过长(最多255字符)" return True, "" def validate_path_safety(base_path: str, target_path: str) -> Tuple[bool, str]: """验证目标路径是否在基础路径内 Args: base_path: 基础路径 target_path: 目标路径 Returns: (是否安全, 错误消息) """ try: # 获取绝对路径 abs_base = os.path.abspath(base_path) abs_target = os.path.abspath(target_path) # 解析符号链接 real_base = os.path.realpath(abs_base) real_target = os.path.realpath(abs_target) # 检查是否在基础路径内 if not real_target.startswith(real_base): return False, "目标路径超出允许范围" return True, "" except Exception as e: return False, f"路径验证失败: {str(e)}" class TaskPanel(ctk.CTkFrame): """A panel for creating and managing task folder structures. This panel provides a user interface for creating tasks with customizable folder structures using a node-based editor. """ def __init__(self, parent, config_manager, **kwargs): """Initialize the TaskPanel. Args: parent: The parent widget config_manager: The configuration manager instance **kwargs: Additional keyword arguments for CTkFrame """ super().__init__(parent, **kwargs) self.config_manager = config_manager self.current_project = self.config_manager.get_current_project() # Folder templates and current structure are kept in-memory; persistence # will be moved into the main config.json via ConfigManager. self.folder_templates: Dict[str, Any] = {} self.folder_structure: List[Dict[str, Any]] = [] # 调试模式控制 self.debug_mode = False # 是否使用类型层级(默认不使用) self.use_type_hierarchy = ctk.BooleanVar(value=False) # 存储主内容框架引用,用于更新颜色 self.main_content_frame = None # Configure grid self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) # Subfolder editor window reference self.subfolder_editor_window = None # Create widgets self._create_widgets() # Initialize structure with defaults before refresh so UI buttons work self.setup_default_structure(self.task_type.get()) # Refresh to load any saved templates / project values. The actual # node graph will be initialized when the SubFolder Editor window is # opened. self.refresh() def _log(self, message: str, level: str = "INFO"): """统一的日志方法 Args: message: 日志消息 level: 日志级别 (DEBUG, INFO, WARNING, ERROR) """ # DEBUG级别的日志只在调试模式下输出 if level == "DEBUG" and not self.debug_mode: return prefix = f"[{level}]" full_message = f"{prefix} {message}" print(full_message) def _normalize_path(self, path: str) -> str: """将路径标准化为反斜杠格式 Args: path: 原始路径 Returns: 标准化后的路径(使用反斜杠) """ if not path: return path # 将所有正斜杠统一为反斜杠,然后返回 return path.replace("/", "\\") def _create_widgets(self): """Create and layout the UI widgets.""" # 创建主内容框架,带圆角和背景色(与 Project 面板一致) self.main_content_frame = ctk.CTkFrame( self, corner_radius=10, # 与 Project 面板的 apps_outer_frame 保持一致 fg_color=(TASK_PANEL_BG_LIGHT, TASK_PANEL_BG_DARK) # 与 Project 面板一致的颜色 ) self.main_content_frame.grid(row=0, column=0, padx=0, pady=0, sticky="nsew") self.main_content_frame.grid_rowconfigure(1, weight=1) # middle_frame 可扩展 self.main_content_frame.grid_rowconfigure(2, weight=0) # bottom_frame 固定高度 self.main_content_frame.grid_columnconfigure(0, weight=1) # Top frame for controls top_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") top_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew") # Make middle columns expand with window width top_frame.grid_columnconfigure(0, weight=0) # labels top_frame.grid_columnconfigure(1, weight=1) # main entries top_frame.grid_columnconfigure(2, weight=0) # right-side button # Workspace selection ctk.CTkLabel(top_frame, text="Workspace:").grid(row=0, column=0, padx=5, pady=5, sticky="w") self.workspace_var = ctk.StringVar(value=os.path.join(DEFAULT_WORKSPACE_PATH, self.current_project)) workspace_entry = ctk.CTkEntry(top_frame, textvariable=self.workspace_var) workspace_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") workspace_entry.bind("", lambda e: self.validate_and_save_path(self.workspace_var, "Workspace")) browse_btn = ctk.CTkButton( top_frame, text="Browse...", command=self.browse_workspace, width=100, ) browse_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") # Maya Plugin Path ctk.CTkLabel(top_frame, text="Maya Plugin Path:").grid(row=1, column=0, padx=5, pady=5, sticky="w") self.maya_plugin_path_var = ctk.StringVar(value=DEFAULT_MAYA_PLUGINS_PATH) maya_plugin_entry = ctk.CTkEntry(top_frame, textvariable=self.maya_plugin_path_var) maya_plugin_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") maya_plugin_entry.bind("", lambda e: self.validate_and_save_path(self.maya_plugin_path_var, "Maya Plugin Path")) maya_plugin_browse_btn = ctk.CTkButton( top_frame, text="Browse...", command=self.browse_maya_plugin_path, width=100, ) maya_plugin_browse_btn.grid(row=1, column=2, padx=5, pady=5, sticky="e") # SP Shelf Path ctk.CTkLabel(top_frame, text="SP Shelf Path:").grid(row=2, column=0, padx=5, pady=5, sticky="w") self.sp_shelf_path_var = ctk.StringVar(value=DEFAULT_SP_SHELF_PATH) sp_shelf_entry = ctk.CTkEntry(top_frame, textvariable=self.sp_shelf_path_var) sp_shelf_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew") sp_shelf_entry.bind("", lambda e: self.validate_and_save_path(self.sp_shelf_path_var, "SP Shelf Path")) sp_shelf_browse_btn = ctk.CTkButton( top_frame, text="Browse...", command=self.browse_sp_shelf_path, width=100, ) sp_shelf_browse_btn.grid(row=2, column=2, padx=5, pady=5, sticky="e") # Task type selection (从 config 动态读取) ctk.CTkLabel(top_frame, text="Task Type:").grid(row=3, column=0, padx=5, pady=5, sticky="w") # 从 config_manager 获取任务类型列表 task_types = self.config_manager.get_task_types() self.task_type = ctk.CTkComboBox( top_frame, values=task_types, command=self.on_task_type_change, width=150, ) self.task_type.grid(row=3, column=1, padx=5, pady=5, sticky="w") # 设置默认值(优先使用第一个,如果有 Character 则使用 Character) if task_types: default_type = "Character" if "Character" in task_types else task_types[0] self.task_type.set(default_type) # 添加"使用类型层级"复选框(放在 Task Type 右侧) self.use_hierarchy_checkbox = ctk.CTkCheckBox( top_frame, text="使用类型层级", variable=self.use_type_hierarchy, onvalue=True, offvalue=False, command=self.on_use_hierarchy_change ) self.use_hierarchy_checkbox.grid(row=3, column=2, padx=5, pady=5, sticky="w") # Task name (own row, full-width entry) ctk.CTkLabel(top_frame, text="Task Name:").grid(row=4, column=0, padx=5, pady=(0, 5), sticky="w") self.task_name = ctk.CTkEntry(top_frame) self.task_name.grid(row=4, column=1, columnspan=2, padx=5, pady=(0, 5), sticky="ew") # SubFolder Editor button (new row, full-width) self.subfolder_editor_button = ctk.CTkButton( top_frame, text="SubFolder Editor", command=self.open_subfolder_editor, ) self.subfolder_editor_button.grid(row=5, column=0, columnspan=3, padx=5, pady=(0, 5), sticky="ew") # 中间空白区域(用于显示预览或其他内容) middle_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") middle_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew") # Bottom frame for main-panel actions (Create Folder / Open Folder) bottom_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") bottom_frame.grid(row=2, column=0, padx=10, pady=(5, 10), sticky="ew") bottom_frame.grid_columnconfigure(0, weight=1) bottom_frame.grid_columnconfigure(1, weight=1) # Create Folder and Open Folder buttons share width equally and stretch with window self.create_folder_button = ctk.CTkButton( bottom_frame, text="Create Folder", command=self.create_task, ) self.create_folder_button.grid(row=0, column=0, padx=5, pady=5, sticky="ew") self.open_folder_button = ctk.CTkButton( bottom_frame, text="Open Folder", command=self.open_task_folder, ) self.open_folder_button.grid(row=0, column=1, padx=5, pady=5, sticky="ew") def browse_workspace(self): """Open a directory dialog to select the workspace.""" # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 current_path = self.workspace_var.get() if current_path and os.path.exists(current_path): initial_dir = current_path elif current_path and os.path.exists(os.path.dirname(current_path)): initial_dir = os.path.dirname(current_path) else: initial_dir = os.path.expanduser("~") workspace = filedialog.askdirectory( initialdir=initial_dir, title="Select Workspace Directory", ) if workspace: self.workspace_var.set(self._normalize_path(workspace)) self.save_task_settings() def browse_maya_plugin_path(self): """Open a directory dialog to select the Maya Plugin Path.""" # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 current_path = self.maya_plugin_path_var.get() if current_path and os.path.exists(current_path): initial_dir = current_path elif current_path and os.path.exists(os.path.dirname(current_path)): initial_dir = os.path.dirname(current_path) else: initial_dir = os.path.expanduser("~") maya_plugin_path = filedialog.askdirectory( initialdir=initial_dir, title="Select Maya Plugin Path", ) if maya_plugin_path: self.maya_plugin_path_var.set(self._normalize_path(maya_plugin_path)) self.save_task_settings() def browse_sp_shelf_path(self): """Open a directory dialog to select the SP Shelf Path.""" # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 current_path = self.sp_shelf_path_var.get() if current_path and os.path.exists(current_path): initial_dir = current_path elif current_path and os.path.exists(os.path.dirname(current_path)): initial_dir = os.path.dirname(current_path) else: initial_dir = os.path.expanduser("~") sp_shelf_path = filedialog.askdirectory( initialdir=initial_dir, title="Select SP Shelf Path", ) if sp_shelf_path: self.sp_shelf_path_var.set(self._normalize_path(sp_shelf_path)) self.save_task_settings() def open_subfolder_editor(self): """Open the SubFolder Editor window that hosts the NodeEditor.""" if self.subfolder_editor_window is None or not self.subfolder_editor_window.winfo_exists(): self.subfolder_editor_window = SubFolderEditorWindow(self) else: self.subfolder_editor_window.focus() def open_task_folder(self): """Open the currently configured task folder in the system file browser.""" task_name = self.task_name.get().strip() if not task_name: self.show_error("Error", "Please enter a task name") return workspace = self.workspace_var.get() task_type = self.task_type.get() # 根据开关决定路径 if self.use_type_hierarchy.get(): task_root = os.path.join(workspace, task_type, task_name) else: task_root = os.path.join(workspace, task_name) if not os.path.isdir(task_root): self.show_error("Error", f"Task folder does not exist: {task_root}") return try: os.startfile(task_root) except Exception as e: self.show_error("Error", f"Failed to open folder: {str(e)}") def on_task_type_change(self, choice): """Handle task type change. Args: choice: The selected task type """ # Update task name suggestion based on new task type set current_name = self.task_name.get() should_update = not current_name if current_name: # 重新加载配置以获取最新的任务类型列表 self.config_manager.reload_config() all_types = self.config_manager.get_task_types() # 检查当前任务名是否以选择的类型开头 is_current_type = current_name.startswith(f"{choice}_") # 如果不是当前选择的类型,则检查是否是任务类型前缀 if not is_current_type: # 检查是否以任何任务类型开头 is_task_type_prefix = any(current_name.startswith(f"{t}_") for t in all_types) # 如果是任务类型前缀(旧类型或其他类型),则更新 # 如果不是任务类型前缀(可能是旧的已删除的类型),也更新 should_update = True # 调试输出 self._log("Task Type Changed", "DEBUG") self._log(f"Selected type: {choice}", "DEBUG") self._log(f"Current task name: {current_name}", "DEBUG") self._log(f"All types: {all_types}", "DEBUG") self._log(f"Is current type: {is_current_type}", "DEBUG") self._log(f"Should update: {should_update}", "DEBUG") if should_update: new_name = f"{choice}_001" self.task_name.delete(0, "end") self.task_name.insert(0, new_name) self._log(f"Task name updated: {new_name}", "INFO") # 从新类型的模板加载结构,并更新 SubFolders self.update_subfolders_from_task_type(choice) if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): # 取消之前的居中任务,避免冲突 self.subfolder_editor_window.subfolder_editor._cancel_pending_center() self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) # 更新 SubFolder Editor 窗口中的信息显示(如果方法存在) if hasattr(self.subfolder_editor_window, 'update_info_labels'): self.subfolder_editor_window.update_info_labels() # 保存设置到 config self.save_task_settings() def on_use_hierarchy_change(self): """处理"使用类型层级"开关变化""" # 保存设置到 config self.save_task_settings() self._log(f"Use type hierarchy changed to: {self.use_type_hierarchy.get()}", "INFO") def setup_default_structure(self, task_type: Optional[str] = None): """Set up the default folder structure. The defaults are aligned with the legacy Maya taskbuild script. """ self.folder_structure = [] # 优先从 config 的 task_settings.SubFolders 读取 current_project = self.current_project project_data = self.config_manager.config_data.get("projects", {}).get(current_project, {}) task_settings = project_data.get("task_settings", {}) paths = task_settings.get("SubFolders") # 如果没有 SubFolders,则按 TaskType 查询 if not paths: current_task_type = task_type or self.task_type.get() paths = self.config_manager.get_task_folder_template(current_task_type) self._log(f"Using TaskType '{current_task_type}' default template", "DEBUG") else: self._log(f"Using project '{current_project}' SubFolders config", "DEBUG") # 使用 _convert_paths_to_structure 来正确计算节点位置 if paths: self.folder_structure = self._convert_paths_to_structure(paths) else: # 如果没有路径,创建空的根节点 self.folder_structure = [{ "id": str(uuid.uuid4()), "name": "TaskFolder", "parent_id": None, "x": 500, "y": 400, "width": 120, "height": 60, "children": [], }] if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) def update_subfolders_from_task_type(self, task_type: str): """根据任务类型更新 SubFolders 配置 Args: task_type: 任务类型名称 """ # 从任务类型模板获取路径列表 paths = self.config_manager.get_task_folder_template(task_type) if paths: # 更新内存中的结构 self.folder_structure = self._convert_paths_to_structure(paths) # 更新 config 中的 task_settings.SubFolders current_project = self.current_project if not self.config_manager.config_data.get("projects"): self.config_manager.config_data["projects"] = {} if current_project not in self.config_manager.config_data["projects"]: self.config_manager.config_data["projects"][current_project] = {} # 确保 task_settings 存在 if "task_settings" not in self.config_manager.config_data["projects"][current_project]: self.config_manager.config_data["projects"][current_project]["task_settings"] = {} # 保存到 task_settings.SubFolders 字段 self.config_manager.config_data["projects"][current_project]["task_settings"]["SubFolders"] = paths # 保存配置文件 if self.config_manager.save_config(): self._log(f"Updated SubFolders to '{task_type}' template", "INFO") self._log(f" Path count: {len(paths)}", "DEBUG") else: self._log("Failed to save SubFolders", "ERROR") else: self._log(f"No template for task type '{task_type}'", "WARNING") def create_task(self): """Create the task folder structure on disk.""" task_name = self.task_name.get().strip() # 验证任务名称 is_valid, error_msg = validate_folder_name(task_name) if not is_valid: self.show_error("错误", error_msg) return # 保存当前设置到 config self.save_task_settings() workspace = self.workspace_var.get() if not workspace: self.show_error("错误", "请选择工作空间路径") return # 检查工作空间路径是否有效 try: # 尝试获取绝对路径 workspace = os.path.abspath(workspace) # 检查路径是否在合理范围内(例如不在系统目录) system_dirs = [ os.environ.get('SYSTEMROOT', 'C:\\Windows'), os.environ.get('PROGRAMFILES', 'C:\\Program Files'), os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)') ] for sys_dir in system_dirs: if workspace.startswith(sys_dir): self.show_error( "错误", f"不允许在系统目录创建任务文件夹:\n{workspace}\n\n请选择其他位置。" ) return self._log(f"Workspace path: {workspace}", "INFO") except Exception as e: self.show_error("错误", f"工作空间路径无效: {str(e)}") return if not os.path.isdir(workspace): try: self._log(f"Creating workspace directory: {workspace}", "INFO") os.makedirs(workspace, exist_ok=True) self._log("Workspace created successfully", "INFO") except PermissionError: self.show_error("权限错误", f"没有权限在此位置创建文件夹:\n{workspace}\n\n请选择其他位置或以管理员身份运行程序") return except OSError as e: self.show_error("路径错误", f"无法创建工作空间:\n{workspace}\n\n错误: {str(e)}") return except Exception as e: self.show_error("错误", f"创建工作空间失败: {str(e)}") return # Get the folder structure from the node editor structure = self.folder_structure or [] # Task root: 根据开关决定是否包含类型层级 task_type = self.task_type.get() if self.use_type_hierarchy.get(): # 使用类型层级: Workspace / TaskType / TaskName task_root = os.path.join(workspace, task_type, task_name) else: # 不使用类型层级: Workspace / TaskName task_root = os.path.join(workspace, task_name) # 验证最终路径安全性 is_safe, error_msg = validate_path_safety(workspace, task_root) if not is_safe: self.show_error("安全错误", f"路径验证失败:\n{error_msg}") return # 检查任务文件夹是否已存在 folder_existed = os.path.exists(task_root) # 检查文件夹结构是否为空 if not structure: self.show_error("错误", "没有可创建的文件夹结构\n请先在SubFolder Editor中设计文件夹结构") return # Create the folder structure under the task root try: self._log(f"Creating task root: {task_root}", "INFO") # 创建根目录(如果不存在) try: os.makedirs(task_root, exist_ok=True) self._log(f"Task root created/verified: {task_root}", "INFO") except PermissionError: self.show_error("权限错误", f"没有权限创建任务文件夹:\n{task_root}\n\n请选择其他位置或以管理员身份运行程序") return except OSError as e: self.show_error("路径错误", f"无法创建任务文件夹:\n{task_root}\n\n错误: {str(e)}") return # 创建子文件夹结构(跳过已存在的,只创建缺失的) self._log(f"Creating subfolders, structure count: {len(structure)}", "INFO") created_count, skipped_count = self._create_folders(task_root, structure) self._log(f"Folder creation completed: {created_count} created, {skipped_count} skipped", "INFO") # 根据情况显示不同的消息 if folder_existed: if created_count > 0: self.show_success("提示", f"该文件夹已存在\n已补全 {created_count} 个缺失的文件夹\n跳过 {skipped_count} 个已存在的文件夹") else: self.show_success("提示", f"该文件夹已存在\n所有文件夹都已存在,无需创建") else: self.show_success("成功", f"任务文件夹创建成功!\n创建了 {created_count} 个文件夹") # 自动打开任务文件夹 try: self._log(f"Opening folder: {task_root}", "INFO") os.startfile(task_root) except Exception as e: self._log(f"Failed to open folder: {e}", "WARNING") except PermissionError as e: self.show_error("权限错误", f"没有权限创建文件夹\n请以管理员身份运行程序或选择其他位置\n\n详细错误: {str(e)}") except OSError as e: self.show_error("系统错误", f"文件系统错误\n可能是磁盘空间不足或路径问题\n\n详细错误: {str(e)}") except Exception as e: self.show_error("创建失败", f"创建任务文件夹时发生未知错误\n\n详细错误: {str(e)}\n\n请检查:\n1. 路径是否有效\n2. 是否有足够权限\n3. 磁盘空间是否充足") def _create_folders(self, base_path: str, nodes: List[Dict[str, Any]], parent_path: str = "") -> tuple: """Recursively create folders based on the node structure. Args: base_path: The base path where folders should be created nodes: List of node dictionaries parent_path: Current parent path for recursive calls Returns: Tuple of (created_count, skipped_count) """ created_count = 0 skipped_count = 0 for node in nodes: # Skip the root TaskFolder node when creating directories if node['name'] == 'TaskFolder' and not parent_path: child_created, child_skipped = self._create_folders(base_path, node.get('children', []), parent_path) created_count += child_created skipped_count += child_skipped continue # Create the directory path dir_name = node['name'] dir_path = os.path.join(base_path, parent_path, dir_name) try: # 检查文件夹是否已存在 if os.path.exists(dir_path): self._log(f"Already exists: {dir_path}", "DEBUG") skipped_count += 1 else: os.makedirs(dir_path, exist_ok=True) self._log(f"Directory: {dir_path}", "INFO") created_count += 1 except PermissionError as e: self._log(f"Permission denied: {dir_path}", "ERROR") raise PermissionError(f"没有权限创建文件夹: {dir_path}") except OSError as e: self._log(f"OS error creating directory {dir_path}: {e}", "ERROR") raise OSError(f"系统错误,无法创建文件夹 {dir_path}: {str(e)}") except Exception as e: self._log(f"Unexpected error creating directory {dir_path}: {e}", "ERROR") raise Exception(f"创建文件夹时发生未知错误 {dir_path}: {str(e)}") # Recursively create child directories if 'children' in node and node['children']: child_path = os.path.join(parent_path, dir_name) if parent_path else dir_name child_created, child_skipped = self._create_folders(base_path, node['children'], child_path) created_count += child_created skipped_count += child_skipped return created_count, skipped_count def save_template(self): """Save the current folder structure as a template to config.json.""" task_type = self.task_type.get() if not task_type: self.show_error("Error", "Please select a task type") return # Get the current structure from subfolder_editor_window if open, otherwise from folder_structure if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): structure = self.subfolder_editor_window.subfolder_editor.get_structure() else: structure = self.folder_structure if not structure: self.show_error("Error", "No folder structure to save") return # Convert node structure to folder path list folder_paths = self._convert_structure_to_paths(structure) # Save to config_manager if self.config_manager.set_task_folder_template(task_type, folder_paths): self.show_success("Success", f"Template saved for '{task_type}'") # 保存成功后,重新从 config 加载该类型的模板,确保数据同步 self.setup_default_structure(task_type) self._log(f"Refreshed {task_type} folder structure", "INFO") else: self.show_error("Error", "Failed to save template") def load_template(self): """Load a saved template from config.json.""" task_type = self.task_type.get() if not task_type: self.show_error("Error", "Please select a task type") return # 使用 setup_default_structure 从 config 加载最新模板 self.setup_default_structure(task_type) # Update subfolder_editor if it exists if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) self.show_success("Success", f"Template loaded for '{task_type}' from config") self._log(f"Loaded {task_type} template from config", "INFO") def _convert_structure_to_paths(self, structure: List[Dict[str, Any]]) -> List[str]: """Convert node structure to flat folder path list. Args: structure: Node structure from subfolder_editor Returns: List of folder paths (e.g., ['Brief', 'Baking/HP', 'Texture/SP']) """ paths = [] def traverse(nodes, parent_path=""): for node in nodes: # Skip root TaskFolder node if node['name'] == 'TaskFolder' and not parent_path: traverse(node.get('children', []), parent_path) continue # Build current path using / as separator current_path = f"{parent_path}/{node['name']}" if parent_path else node['name'] paths.append(current_path) # Traverse children if 'children' in node and node['children']: traverse(node['children'], current_path) traverse(structure) return paths def _convert_paths_to_structure(self, paths: List[str]) -> List[Dict[str, Any]]: """Convert flat folder path list to node structure. Args: paths: List of folder paths (e.g., ['Brief', 'MP/Screenshot']) Returns: Node structure for subfolder_editor """ def new_node(name: str, x: int, y: int, parent_id: Optional[str]) -> Dict[str, Any]: return { "id": str(uuid.uuid4()), "name": name, "parent_id": parent_id, "x": x, "y": y, "width": 120, "height": 60, "children": [], } root_node = new_node("TaskFolder", 500, 50, None) node_map = {"TaskFolder": root_node} # 第一步:构建树结构 for path in paths: # 支持 \\ 和 / 作为分隔符,统一转换为 / parts = path.replace("\\", "/").split("/") parent_key = "TaskFolder" for i, part in enumerate(parts): current_key = "/".join(parts[:i+1]) if current_key not in node_map: # 创建节点(位置稍后计算) parent_node = node_map[parent_key] new_child = new_node(part, 0, 0, parent_node["id"]) parent_node["children"].append(new_child) node_map[current_key] = new_child parent_key = current_key # 第二步:按层级收集所有节点 levels = {} # {depth: [nodes]} def collect_by_level(node, depth=0): """按层级收集节点""" if depth not in levels: levels[depth] = [] levels[depth].append(node) for child in node["children"]: collect_by_level(child, depth + 1) collect_by_level(root_node) # 第三步:为每层的节点分配位置(横向布局) self._log("Node Layout", "DEBUG") # 计算最佳布局参数 max_nodes_in_layer = max(len(nodes) for nodes in levels.values()) # 动态调整间距,确保内容适应合理的宽度 # 目标宽度:尽量不超过1000像素(更保守的宽度) target_max_width = 1000 min_spacing = 130 # 减少最小间距,节点宽度是120,留10像素间隙 max_spacing = 200 # 减少最大间距 if max_nodes_in_layer > 1: calculated_spacing = target_max_width / (max_nodes_in_layer - 1) horizontal_spacing = max(min_spacing, min(calculated_spacing, max_spacing)) else: horizontal_spacing = min_spacing # 使用相对中心位置,让居中逻辑来处理实际的画布居中 # 使用一个相对居中的坐标,避免硬编码偏移 canvas_center_x = 600 # 标准中心X坐标 (将由居中算法调整) canvas_center_y = 250 # 标准Y坐标,给顶部留些空间 self._log(f"Max nodes in layer: {max_nodes_in_layer}, spacing: {horizontal_spacing:.0f}", "DEBUG") for depth, nodes in levels.items(): self._log(f"Level {depth}: {len(nodes)} nodes", "DEBUG") if depth == 0: # 根节点放在画布中心 nodes[0]["x"] = canvas_center_x nodes[0]["y"] = canvas_center_y self._log(f" {nodes[0]['name']}: ({nodes[0]['x']}, {nodes[0]['y']})", "DEBUG") else: # Y: 根据层级(垂直向下) y = canvas_center_y + depth * 150 # 减少垂直间距 # X: 在该层内水平均匀分布 node_count = len(nodes) # 计算该层的起始X坐标(居中对齐根节点) total_width = (node_count - 1) * horizontal_spacing start_x = canvas_center_x - total_width / 2 for idx, node in enumerate(nodes): node["x"] = start_x + idx * horizontal_spacing node["y"] = y self._log(f" {node['name']}: ({node['x']}, {node['y']})", "DEBUG") return [root_node] def load_folder_templates(self) -> Dict[str, Any]: """Load folder templates from config_manager.""" return self.config_manager.get_all_task_folder_templates() def save_folder_templates(self): """Save folder templates to config_manager.""" pass # No need to implement this method as config_manager handles it def _log(self, message: str, level: str = "INFO"): """统一的日志方法 Args: message: 日志消息 level: 日志级别 (DEBUG, INFO, WARNING, ERROR) """ # DEBUG级别的日志只在调试模式下输出 if level == "DEBUG" and not self.debug_mode: return prefix = f"[{level}]" full_message = f"{prefix} {message}" print(full_message) def _set_dialog_icon(self, dialog): """为对话框设置 NexusLauncher 图标(统一方法) Args: dialog: CTkToplevel 对话框实例 """ icon_path = get_icon_path() if os.path.exists(icon_path): try: # 使用多种方法设置图标 dialog.iconbitmap(icon_path) dialog.iconbitmap(default=icon_path) # 尝试使用 wm_iconbitmap try: dialog.wm_iconbitmap(icon_path) except: pass # 多次延迟设置,防止被覆盖 def set_icon(): try: dialog.iconbitmap(icon_path) dialog.iconbitmap(default=icon_path) dialog.wm_iconbitmap(icon_path) except: pass dialog.after(10, set_icon) dialog.after(50, set_icon) dialog.after(100, set_icon) dialog.after(200, set_icon) dialog.after(500, set_icon) except Exception as e: self._log(f"Failed to set dialog icon: {e}", "WARNING") def show_error(self, title: str, message: str): """Show an error message with dark theme. Args: title: The title of the error message message: The error message """ self._show_custom_dialog(title, message, "error") def show_success(self, title: str, message: str): """Show a success message with dark theme. Args: title: The title of the success message message: The success message """ self._show_custom_dialog(title, message, "success") def _show_custom_dialog(self, title: str, message: str, dialog_type: str = "info"): """显示自定义深色对话框 Args: title: 对话框标题 message: 对话框消息 dialog_type: 对话框类型 ("success", "error", "info") """ # 创建顶层窗口 dialog = ctk.CTkToplevel(self) dialog.title(title) dialog.geometry(DIALOG_MESSAGE_SIZE) # 增加高度确保按钮可见 dialog.resizable(False, False) # 设置窗口图标(使用统一方法) self._set_dialog_icon(dialog) # 设置为模态 dialog.transient(self) dialog.grab_set() # 居中显示 dialog.update_idletasks() x = (dialog.winfo_screenwidth() - 400) // 2 y = (dialog.winfo_screenheight() - 250) // 2 dialog.geometry(f"{DIALOG_MESSAGE_SIZE}+{x}+{y}") # 根据类型选择图标和颜色 if dialog_type == "success": icon = "[OK]" icon_color = COLOR_SUCCESS elif dialog_type == "error": icon = "[ERROR]" icon_color = COLOR_ERROR else: icon = "ℹ" icon_color = COLOR_WARNING # 主框架 main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) main_frame.pack(fill="both", expand=True, padx=0, pady=0) # 图标和消息框架 content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") content_frame.pack(fill="both", expand=True, padx=20, pady=20) # 图标 icon_label = ctk.CTkLabel( content_frame, text=icon, font=("Segoe UI", 40, "bold"), text_color=icon_color ) icon_label.pack(pady=(10, 10)) # 消息 message_label = ctk.CTkLabel( content_frame, text=message, font=("Segoe UI", 12), text_color=DIALOG_TEXT_COLOR, wraplength=350 ) message_label.pack(pady=(0, 20)) # 确定按钮 ok_button = ctk.CTkButton( main_frame, text="确定", command=dialog.destroy, fg_color=icon_color, hover_color=self._darken_color(icon_color), font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) ok_button.pack(pady=(0, 20)) # 绑定 Enter 和 Escape 键 dialog.bind("", lambda e: dialog.destroy()) dialog.bind("", lambda e: dialog.destroy()) # 等待窗口关闭 dialog.wait_window() def _show_yes_no_dialog(self, title: str, message: str) -> bool: """显示 Yes/No 确认对话框 Args: title: 对话框标题 message: 对话框消息 Returns: True 表示用户点击 Yes,False 表示点击 No """ result = [False] # 使用列表来存储结果,以便在嵌套函数中修改 # 创建顶层窗口 dialog = ctk.CTkToplevel(self) dialog.title(title) dialog.geometry(DIALOG_YES_NO_SIZE) dialog.resizable(False, False) # 设置窗口图标(使用统一方法) self._set_dialog_icon(dialog) # 设置为模态 dialog.transient(self) dialog.grab_set() # 居中显示 dialog.update_idletasks() x = (dialog.winfo_screenwidth() - 450) // 2 y = (dialog.winfo_screenheight() - 250) // 2 dialog.geometry(f"{DIALOG_YES_NO_SIZE}+{x}+{y}") # 主框架 main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) main_frame.pack(fill="both", expand=True, padx=0, pady=0) # 图标和消息框架 content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") content_frame.pack(fill="both", expand=True, padx=20, pady=20) # 警告图标 icon_label = ctk.CTkLabel( content_frame, text="[WARNING]", font=("Segoe UI", 40, "bold"), text_color=COLOR_WARNING ) icon_label.pack(pady=(10, 10)) # 消息 message_label = ctk.CTkLabel( content_frame, text=message, font=("Segoe UI", 11), text_color=DIALOG_TEXT_COLOR, wraplength=400, justify="left" ) message_label.pack(pady=(0, 20)) # 按钮框架 button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") button_frame.pack(pady=(0, 20)) def on_yes(): result[0] = True dialog.destroy() def on_no(): result[0] = False dialog.destroy() # Yes 按钮 yes_button = ctk.CTkButton( button_frame, text="是 (Y)", command=on_yes, fg_color=COLOR_SUCCESS, hover_color=COLOR_SUCCESS_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) yes_button.pack(side="left", padx=10) # No 按钮 no_button = ctk.CTkButton( button_frame, text="否 (N)", command=on_no, fg_color=COLOR_ERROR, hover_color=COLOR_ERROR_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) no_button.pack(side="left", padx=10) # 绑定快捷键 dialog.bind("", lambda e: on_yes()) dialog.bind("y", lambda e: on_yes()) dialog.bind("Y", lambda e: on_yes()) dialog.bind("", lambda e: on_no()) dialog.bind("n", lambda e: on_no()) dialog.bind("N", lambda e: on_no()) # 等待窗口关闭 dialog.wait_window() return result[0] def _darken_color(self, hex_color: str, factor: float = 0.8) -> str: """将颜色变暗 Args: hex_color: 十六进制颜色代码 factor: 变暗因子 (0-1) Returns: 变暗后的颜色 """ # 移除 # 号 hex_color = hex_color.lstrip('#') # 转换为 RGB r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) # 变暗 r = int(r * factor) g = int(g * factor) b = int(b * factor) # 转换回十六进制 return f"#{r:02x}{g:02x}{b:02x}" def refresh(self): """Refresh the panel with the current project settings.""" self.current_project = self.config_manager.get_current_project() # 刷新任务类型列表(从 config 读取最新的类型) self.refresh_task_types() # 从 config 加载项目的 Task 设置 task_settings = self.config_manager.get_task_settings(self.current_project) # 更新 Workspace workspace = task_settings.get("workspace", DEFAULT_WORKSPACE_PATH) self.workspace_var.set(self._normalize_path(workspace)) # 更新 Task Type task_type = task_settings.get("task_type", "Character") self.task_type.set(task_type) # 更新"使用类型层级"开关 use_hierarchy = task_settings.get("use_type_hierarchy", False) self.use_type_hierarchy.set(use_hierarchy) # 更新 Maya Plugin Path maya_plugin_path = task_settings.get("maya_plugin_path", DEFAULT_MAYA_PLUGINS_PATH) self.maya_plugin_path_var.set(self._normalize_path(maya_plugin_path)) # 更新 SP Shelf Path sp_shelf_path = task_settings.get("sp_shelf_path", DEFAULT_SP_SHELF_PATH) self.sp_shelf_path_var.set(self._normalize_path(sp_shelf_path)) # Reload templates self.folder_templates = self.load_folder_templates() # 加载当前任务类型的默认结构 self.setup_default_structure(task_type) # 如果项目没有 SubFolders,从任务类型模板加载 if "SubFolders" not in task_settings or not task_settings["SubFolders"]: # 从任务类型模板获取路径列表并保存到 SubFolders template_paths = self.config_manager.get_task_folder_template(task_type) if template_paths: # 直接更新到 config 中 if not self.config_manager.config_data.get("projects"): self.config_manager.config_data["projects"] = {} if self.current_project not in self.config_manager.config_data["projects"]: self.config_manager.config_data["projects"][self.current_project] = {} if "task_settings" not in self.config_manager.config_data["projects"][self.current_project]: self.config_manager.config_data["projects"][self.current_project]["task_settings"] = {} self.config_manager.config_data["projects"][self.current_project]["task_settings"]["SubFolders"] = template_paths # 自动保存当前设置到 config(类似任务类型切换时的行为) self.save_task_settings() # Update task name suggestion based on current task type # 检查当前任务名是否以任何任务类型开头 current_name = self.task_name.get() should_update = not current_name if current_name: # 获取所有任务类型 all_types = self.config_manager.get_task_types() # 检查是否以任何类型开头 should_update = any(current_name.startswith(f"{t}_") for t in all_types) if should_update: self.task_name.delete(0, "end") self.task_name.insert(0, f"{task_type}_001") # 不在这里触碰 subfolder_editor,实际结构加载在 SubFolderEditorWindow 打开时处理 def refresh_task_types(self): """刷新任务类型列表(从 config 读取)""" try: # 重新加载 config 数据 self.config_manager.reload_config() # 获取最新的任务类型列表 task_types = self.config_manager.get_task_types() # 更新 ComboBox 的选项 current_value = self.task_type.get() self.task_type.configure(values=task_types) # 如果当前值仍然在列表中,保持不变;否则设置为第一个 if current_value in task_types: self.task_type.set(current_value) elif task_types: self.task_type.set(task_types[0]) except Exception as e: self._log(f"Failed to refresh task type list: {e}", "ERROR") def validate_and_save_path(self, path_var, path_name): """验证路径是否存在,如果不存在询问是否创建 Args: path_var: StringVar 对象 path_name: 路径名称(用于显示) """ path = path_var.get().strip() if not path: self.save_task_settings() return # 检查路径是否存在 if not os.path.exists(path): # 弹窗询问是否创建 from ..utilities import custom_dialogs result = custom_dialogs.ask_yes_no( self, "路径不存在", f"{path_name} 路径不存在:\n{path}\n\n是否创建该路径?" ) if result: # 用户选择"是" try: os.makedirs(path, exist_ok=True) self._log(f"Created directory: {path}", "INFO") custom_dialogs.show_info(self, "成功", f"已创建路径:\n{path}") except Exception as e: self._log(f"Failed to create directory {path}: {e}", "ERROR") custom_dialogs.show_error(self, "错误", f"创建路径失败:\n{str(e)}") return # 保存设置 self.save_task_settings() def save_task_settings(self): """保存当前 Task 设置到 config""" self.config_manager.set_task_settings( self.current_project, workspace=self.workspace_var.get(), task_type=self.task_type.get(), use_type_hierarchy=self.use_type_hierarchy.get(), maya_plugin_path=self.maya_plugin_path_var.get(), sp_shelf_path=self.sp_shelf_path_var.get() ) def update_colors(self, bg_color: str = None): """更新面板的背景颜色 Args: bg_color: 背景颜色,如果为 None 则使用默认颜色 """ if self.main_content_frame: if bg_color: self.main_content_frame.configure(fg_color=bg_color) else: # 使用默认颜色(与 Project 面板一致) self.main_content_frame.configure(fg_color=(TASK_PANEL_BG_LIGHT, TASK_PANEL_BG_DARK)) class SubFolderEditorWindow(ctk.CTkToplevel): """Separate window for editing subfolder structures with the NodeEditor. Layout参考设置窗口:上面是信息区,下面是节点编辑器和操作按钮。 """ def __init__(self, parent: "TaskPanel"): super().__init__(parent) self.parent_panel = parent self.debug_mode = False # 调试模式控制 # Local NodeEditor instance used only inside this window. We sync its # structure with the TaskPanel so data is shared, not the widget. self.subfolder_editor = NodeEditor(self) # 窗口大小变化防抖 self._resize_after_id = None self.title("SubFolder Editor") self.geometry(SUBFOLDER_EDITOR_WINDOW_SIZE) # 增加高度确保按钮可见 self.minsize(*SUBFOLDER_EDITOR_MIN_SIZE) # 绑定快捷键 self.bind("", lambda e: self.save_structure()) self.bind("", lambda e: self.subfolder_editor.force_recenter()) # 绑定窗口大小变化事件 self.bind("", self._on_window_resize) self._log("Bound shortcuts: Ctrl+S (Save), Ctrl+0 (Center)", "INFO") self._log("Bound window resize listener", "INFO") # 先隐藏窗口,避免加载时闪烁 self.withdraw() # 设置窗口图标(在transient之前设置) icon_path = get_icon_path() if os.path.exists(icon_path): try: # 尝试多种方法设置图标 self.iconbitmap(icon_path) self.wm_iconbitmap(icon_path) except Exception as e: self._log(f"Failed to set icon: {e}", "DEBUG") # 先设置为瞬态窗口 self.transient(parent) # 等待窗口创建完成 self.update_idletasks() # 再次尝试设置图标(确保生效) if os.path.exists(icon_path): def set_icon_delayed(): try: self.iconbitmap(icon_path) self.wm_iconbitmap(icon_path) except Exception: pass self.after(50, set_icon_delayed) self.after(200, set_icon_delayed) # 总体布局 self.grid_rowconfigure(0, weight=0) # 顶部信息区固定 self.grid_rowconfigure(1, weight=1) # 编辑器区域可扩展 self.grid_columnconfigure(0, weight=1) # 顶部信息区 info_frame = ctk.CTkFrame(self) info_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew") info_frame.grid_columnconfigure(0, weight=0) # 标签列固定 info_frame.grid_columnconfigure(1, weight=1) # 内容列可扩展 info_frame.grid_columnconfigure(2, weight=0) # 按钮列固定 ctk.CTkLabel(info_frame, text="Workspace:").grid(row=0, column=0, padx=5, pady=5, sticky="e") self.workspace_label = ctk.CTkLabel(info_frame, text=self.parent_panel.workspace_var.get(), anchor="w") self.workspace_label.grid(row=0, column=1, padx=5, pady=5, sticky="ew") ctk.CTkLabel(info_frame, text="Task Type:").grid(row=1, column=0, padx=5, pady=5, sticky="e") self.task_type_label = ctk.CTkLabel(info_frame, text=self.parent_panel.task_type.get(), anchor="w") self.task_type_label.grid(row=1, column=1, padx=5, pady=5, sticky="ew") ctk.CTkLabel(info_frame, text="Task Name:").grid(row=2, column=0, padx=5, pady=5, sticky="e") self.task_name_label = ctk.CTkLabel(info_frame, text=self.parent_panel.task_name.get(), anchor="w") self.task_name_label.grid(row=2, column=1, padx=5, pady=5, sticky="ew") # 中部:NodeEditor + 滚动条 editor_outer = ctk.CTkFrame(self) editor_outer.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew") editor_outer.grid_rowconfigure(0, weight=1) editor_outer.grid_columnconfigure(0, weight=1) # 创建滚动条 scrollbar_y = ctk.CTkScrollbar(editor_outer, orientation="vertical") scrollbar_x = ctk.CTkScrollbar(editor_outer, orientation="horizontal") # 配置 NodeEditor 的滚动 self.subfolder_editor.configure( yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set ) scrollbar_y.configure(command=self.subfolder_editor.yview) scrollbar_x.configure(command=self.subfolder_editor.xview) # 初始滚动区域将由居中逻辑动态设置 # self.subfolder_editor.configure(scrollregion=(0, 0, 2000, 2000)) # 布局 self.subfolder_editor.grid(row=0, column=0, sticky="nsew") scrollbar_y.grid(row=0, column=1, sticky="ns") scrollbar_x.grid(row=1, column=0, sticky="ew") # 创建悬浮重置按钮(左下角) self.floating_reset_btn = ctk.CTkButton( self, text="🔄 Reset", command=self.reset_to_template, fg_color=BUTTON_GRAY, hover_color=BUTTON_GRAY_HOVER, font=("Segoe UI", 14, "bold"), width=120, height=50, corner_radius=25, # 大圆角 border_width=2, border_color=RESET_BUTTON_BORDER, ) # 使用 place 定位在左下角 self.floating_reset_btn.place(relx=0.05, rely=0.95, anchor="sw") # 创建悬浮保存按钮(右下角) self.floating_save_btn = ctk.CTkButton( self, text="💾 Save", command=self.save_structure, fg_color=COLOR_SUCCESS, hover_color=COLOR_SUCCESS_HOVER, font=("Segoe UI", 14, "bold"), width=120, height=50, corner_radius=25, # 大圆角 border_width=2, border_color=SAVE_BUTTON_BORDER, ) # 使用 place 定位在右下角 self.floating_save_btn.place(relx=0.95, rely=0.95, anchor="se") self._log("Created floating reset button (bottom left)", "INFO") self._log("Created floating save button (bottom right)", "INFO") # 在打开时根据当前 Task Type 加载默认结构 try: current_task_type = self.parent_panel.task_type.get() current_project = self.parent_panel.current_project # 检查 config 中是否有 SubFolders project_data = self.parent_panel.config_manager.config_data.get("projects", {}).get(current_project, {}) task_settings = project_data.get("task_settings", {}) subfolders = task_settings.get("SubFolders") if not subfolders or len(subfolders) == 0: # SubFolders 为空或不存在,从 task_folder_templates 加载 self._log(f"SubFolders is empty or not found, loading from task_folder_templates", "INFO") self._log(f"Current task type: {current_task_type}", "INFO") template_paths = self.parent_panel.config_manager.get_task_folder_template(current_task_type) if template_paths: self._log(f"Found template for '{current_task_type}' with {len(template_paths)} paths", "INFO") # 转换为节点结构并加载 self.parent_panel.folder_structure = self.parent_panel._convert_paths_to_structure(template_paths) self.subfolder_editor.load_structure(self.parent_panel.folder_structure) self._log(f"Loaded template structure for {current_task_type}", "INFO") else: self._log(f"No template found for task type '{current_task_type}'", "WARNING") # 使用默认结构 self.parent_panel.setup_default_structure(current_task_type) if self.parent_panel.folder_structure: self.subfolder_editor.load_structure(self.parent_panel.folder_structure) else: # SubFolders 存在,使用现有配置 self._log(f"Loading from existing SubFolders ({len(subfolders)} paths)", "INFO") self.parent_panel.setup_default_structure(current_task_type) if self.parent_panel.folder_structure: self.subfolder_editor.load_structure(self.parent_panel.folder_structure) self._log(f"Loaded {current_task_type} folder structure", "INFO") else: self._log(f"Warning: No default structure for {current_task_type}", "WARNING") except Exception as e: self._log(f"Failed to load structure: {e}", "ERROR") traceback.print_exc() # 所有内容加载完成后,强制更新布局 self.update_idletasks() # 居中显示窗口并抓取焦点 self._center_window() # 再次更新确保按钮可见 self.update_idletasks() self.deiconify() # 移除模态设置,允许用户与主窗口交互 # self.grab_set() # 注释掉模态设置 self.focus_set() self._log(f"SubFolder Editor window shown, size: {self.winfo_width()}x{self.winfo_height()}", "INFO") # 设置深色标题栏 self.after(10, lambda: self._set_dark_title_bar(self)) # 设置窗口关闭时的处理 self.protocol("WM_DELETE_WINDOW", self._on_closing) # 启动定时同步 self.after(500, self._sync_labels) # 延迟检查按钮可见性 self.after(200, self._check_save_button) def _log(self, message: str, level: str = "INFO"): """统一的日志方法 Args: message: 日志消息 level: 日志级别 (DEBUG, INFO, WARNING, ERROR) """ # DEBUG级别的日志只在调试模式下输出 if level == "DEBUG" and not self.debug_mode: return prefix = f"[{level}]" full_message = f"{prefix} {message}" print(full_message) def _set_dialog_icon(self, dialog): """为对话框设置 NexusLauncher 图标(统一方法) Args: dialog: CTkToplevel 对话框实例 """ icon_path = get_icon_path() if os.path.exists(icon_path): try: # 使用多种方法设置图标 dialog.iconbitmap(icon_path) dialog.iconbitmap(default=icon_path) # 尝试使用 wm_iconbitmap try: dialog.wm_iconbitmap(icon_path) except: pass # 多次延迟设置,防止被覆盖 def set_icon(): try: dialog.iconbitmap(icon_path) dialog.iconbitmap(default=icon_path) dialog.wm_iconbitmap(icon_path) except: pass dialog.after(10, set_icon) dialog.after(50, set_icon) dialog.after(100, set_icon) dialog.after(200, set_icon) dialog.after(500, set_icon) except Exception as e: self._log(f"Failed to set dialog icon: {e}", "WARNING") def reset_to_template(self): """从配置文件重新加载当前任务类型的模板""" try: current_task_type = self.parent_panel.task_type.get() if not current_task_type: self.parent_panel.show_error("重置失败", "请先选择任务类型") return # 显示确认对话框 confirm_msg = ( f"确定要重置为 {current_task_type} 类型的默认模板吗?\n\n" f"当前的所有修改将会丢失!" ) result = self._show_yes_no_dialog("确认重置", confirm_msg) if not result: self._log("User cancelled reset", "ERROR") return self._log("Reset to Template", "DEBUG") self._log(f"Task type: {current_task_type}", "DEBUG") # 根据当前 TaskType 从 task_folder_templates 加载对应模板 folder_paths = self.parent_panel.config_manager.get_task_folder_template(current_task_type) if folder_paths: self._log(f"Loaded from task_folder_templates.{current_task_type}", "INFO") self._log(f" Template paths: {folder_paths}", "DEBUG") # 转换为节点结构 structure = self.parent_panel._convert_paths_to_structure(folder_paths) self.parent_panel.folder_structure = structure else: self._log("No template found for {current_task_type}, using default", "WARNING") # 使用默认模板 self.parent_panel.setup_default_structure(current_task_type) if self.parent_panel.folder_structure: # 清空当前编辑器 self.subfolder_editor.nodes.clear() self.subfolder_editor.connections.clear() self.subfolder_editor.selected_nodes.clear() self.subfolder_editor.selected_connections.clear() # 加载模板结构 self.subfolder_editor.load_structure(self.parent_panel.folder_structure) self._log("Reset to {current_task_type} template", "INFO") self.parent_panel.show_success( "重置成功", f"已重置为 {current_task_type} 类型的默认模板" ) else: self._log("No template found", "ERROR") self.parent_panel.show_error("重置失败", f"未找到 {current_task_type} 的模板配置") except Exception as e: self._log(f"Reset failed: {e}", "ERROR") traceback.print_exc() self.parent_panel.show_error("重置失败", f"发生错误: {str(e)}") def save_structure(self): """保存当前节点结构到 config 文件的 SubFolders 字段""" try: # 获取当前结构 structure = self.subfolder_editor.get_structure() if not structure: self._log("No structure to save", "ERROR") self.parent_panel.show_error("保存失败", "没有可保存的文件夹结构") return # 检测未连接的节点 unconnected_nodes = self._find_unconnected_nodes() if unconnected_nodes: node_names = ", ".join([n.name for n in unconnected_nodes]) warning_msg = ( f"检测到 {len(unconnected_nodes)} 个未连接的节点:\n\n" f"{node_names}\n\n" f"这些节点不会被保存到配置文件。\n" f"是否继续保存?" ) result = self._show_yes_no_dialog("未连接节点警告", warning_msg) if not result: self._log("User cancelled save", "ERROR") return self._log("Ignoring {len(unconnected_nodes)} unconnected nodes", "WARNING") # 转换为路径列表 folder_paths = self.parent_panel._convert_structure_to_paths(structure) if not folder_paths: self._log("Converted path list is empty", "ERROR") self.parent_panel.show_error("保存失败", "文件夹结构为空") return # 获取当前项目和任务类型 current_project = self.parent_panel.current_project task_type = self.parent_panel.task_type.get() self._log("Save SubFolders to Config", "DEBUG") self._log(f"Project: {current_project}", "DEBUG") self._log(f"Task type: {task_type}", "DEBUG") self._log(f"Folder count: {len(folder_paths)}", "DEBUG") self._log("Folder list:", "DEBUG") for i, path in enumerate(folder_paths, 1): self._log(f" {i}. {path}", "DEBUG") # 保存到 config 的 task_settings.SubFolders 字段 if not self.parent_panel.config_manager.config_data.get("projects"): self.parent_panel.config_manager.config_data["projects"] = {} if current_project not in self.parent_panel.config_manager.config_data["projects"]: self.parent_panel.config_manager.config_data["projects"][current_project] = {} # 确保 task_settings 存在 if "task_settings" not in self.parent_panel.config_manager.config_data["projects"][current_project]: self.parent_panel.config_manager.config_data["projects"][current_project]["task_settings"] = {} # 保存到 task_settings.SubFolders 字段 self.parent_panel.config_manager.config_data["projects"][current_project]["task_settings"]["SubFolders"] = folder_paths # 保存配置文件 if self.parent_panel.config_manager.save_config(): self._log("Successfully saved to config.json", "INFO") self._log(" Field: projects.{current_project}.task_settings.SubFolders", "DEBUG") self._log(f" Path count: {len(folder_paths)}", "DEBUG") # 同步到 TaskPanel self.parent_panel.folder_structure = structure # 显示成功提示 self.parent_panel.show_success( "保存成功", f"已保存 {len(folder_paths)} 个文件夹到配置文件\n\n" f"项目: {current_project}\n" f"字段: task_settings.SubFolders" ) else: self._log("Failed to save config file", "ERROR") self.parent_panel.show_error("保存失败", "无法写入配置文件") except Exception as e: self._log(f"Failed to save structure: {e}", "ERROR") traceback.print_exc() self.parent_panel.show_error("保存失败", f"发生错误: {str(e)}") def _show_yes_no_dialog(self, title: str, message: str) -> bool: """显示 Yes/No 确认对话框 Args: title: 对话框标题 message: 对话框消息 Returns: True 表示用户点击 Yes,False 表示点击 No """ result = [False] # 使用列表来存储结果,以便在嵌套函数中修改 # 创建顶层窗口 dialog = ctk.CTkToplevel(self) dialog.title(title) dialog.geometry(DIALOG_YES_NO_SIZE) dialog.resizable(False, False) # 设置窗口图标(使用统一方法) self._set_dialog_icon(dialog) # 设置为模态 dialog.transient(self) dialog.grab_set() # 居中显示 dialog.update_idletasks() x = (dialog.winfo_screenwidth() - 450) // 2 y = (dialog.winfo_screenheight() - 250) // 2 dialog.geometry(f"{DIALOG_YES_NO_SIZE}+{x}+{y}") # 主框架 main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) main_frame.pack(fill="both", expand=True, padx=0, pady=0) # 图标和消息框架 content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") content_frame.pack(fill="both", expand=True, padx=20, pady=20) # 警告图标 icon_label = ctk.CTkLabel( content_frame, text="[WARNING]", font=("Segoe UI", 40, "bold"), text_color=COLOR_WARNING ) icon_label.pack(pady=(10, 10)) # 消息 message_label = ctk.CTkLabel( content_frame, text=message, font=("Segoe UI", 11), text_color=DIALOG_TEXT_COLOR, wraplength=400, justify="left" ) message_label.pack(pady=(0, 20)) # 按钮框架 button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") button_frame.pack(pady=(0, 20)) def on_yes(): result[0] = True dialog.destroy() def on_no(): result[0] = False dialog.destroy() # Yes 按钮 yes_button = ctk.CTkButton( button_frame, text="是 (Y)", command=on_yes, fg_color=COLOR_SUCCESS, hover_color=COLOR_SUCCESS_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) yes_button.pack(side="left", padx=10) # No 按钮 no_button = ctk.CTkButton( button_frame, text="否 (N)", command=on_no, fg_color=COLOR_ERROR, hover_color=COLOR_ERROR_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) no_button.pack(side="left", padx=10) # 绑定快捷键 dialog.bind("", lambda e: on_yes()) dialog.bind("y", lambda e: on_yes()) dialog.bind("Y", lambda e: on_yes()) dialog.bind("", lambda e: on_no()) dialog.bind("n", lambda e: on_no()) dialog.bind("N", lambda e: on_no()) # 等待窗口关闭 dialog.wait_window() return result[0] def _find_unconnected_nodes(self): """查找所有未连接到 TaskFolder 的节点""" unconnected = [] # 找到根节点 root = next((n for n in self.subfolder_editor.nodes if n.name == "TaskFolder"), None) if not root: return unconnected # 使用 BFS 找到所有连接的节点 connected = set() queue = [root] while queue: node = queue.pop(0) connected.add(node.id) for child in node.children: if child.id not in connected: queue.append(child) # 找出未连接的节点 for node in self.subfolder_editor.nodes: if node.id not in connected and node.name != "TaskFolder": unconnected.append(node) return unconnected def destroy(self): """销毁窗口前解除事件绑定,避免潜在泄漏""" try: # 解除快捷键绑定 self.unbind("") self.unbind("") self.unbind("") except Exception as e: self._log(f"Failed to unbind events on destroy: {e}", "DEBUG") # 调用父类销毁 return super().destroy() def _on_closing(self): """窗口关闭时的处理 - 自动保存到 config""" try: self._log("Closing SubFolder Editor", "DEBUG") # 获取当前结构 structure = self.subfolder_editor.get_structure() if structure: # 同步到 TaskPanel self.parent_panel.folder_structure = structure self._log("Synced structure to TaskPanel", "INFO") # 自动保存到 config self._log("[LOADING] Auto-saving to config...", "DEBUG") self.save_structure() else: self._log("No structure to save", "WARNING") except Exception as e: self._log(f"Failed to save on close: {e}", "ERROR") traceback.print_exc() # 销毁窗口 self.destroy() def _check_save_button(self): """检查悬浮保存按钮是否可见""" try: if hasattr(self, 'floating_save_btn'): exists = self.floating_save_btn.winfo_exists() visible = self.floating_save_btn.winfo_viewable() mapped = self.floating_save_btn.winfo_ismapped() width = self.floating_save_btn.winfo_width() height = self.floating_save_btn.winfo_height() x = self.floating_save_btn.winfo_x() y = self.floating_save_btn.winfo_y() self._log("Floating Save Button Check", "DEBUG") self._log(f"Exists: {exists}", "DEBUG") self._log(f"Visible: {visible}", "DEBUG") self._log(f"Mapped: {mapped}", "DEBUG") self._log(f"Size: {width}x{height}", "DEBUG") self._log("Position: ({x}, {y})", "DEBUG") self._log(f"Window size: {self.winfo_width()}x{self.winfo_height()}", "DEBUG") if not visible or not mapped: self._log("Warning: Floating button not visible! Trying to fix...", "WARNING") self.floating_save_btn.lift() self.floating_save_btn.update() self._log("Attempted to lift button layer", "INFO") else: self._log("Floating save button displaying normally", "INFO") else: self._log("Error: floating_save_btn attribute does not exist", "ERROR") except Exception as e: self._log(f"Failed to check button: {e}", "ERROR") traceback.print_exc() def _sync_labels(self): """Sync labels with parent TaskPanel values while window exists.""" if not self.winfo_exists(): return try: self.workspace_label.configure(text=self.parent_panel.workspace_var.get()) self.task_type_label.configure(text=self.parent_panel.task_type.get()) self.task_name_label.configure(text=self.parent_panel.task_name.get()) except Exception: pass # 同步结构回 TaskPanel 的 folder_structure try: structure = self.subfolder_editor.get_structure() if structure: self.parent_panel.folder_structure = structure except Exception: pass self.after(500, self._sync_labels) def _center_window(self): """将窗口居中显示在屏幕中央""" self.update_idletasks() # 获取窗口大小 window_width = self.winfo_width() window_height = self.winfo_height() # 获取屏幕大小 screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() # 计算居中位置 x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 # 设置窗口位置 self.geometry(f"{window_width}x{window_height}+{x}+{y}") def _set_dark_title_bar(self, window): """设置窗口深色标题栏(Windows 10/11)""" try: import ctypes window.update() hwnd = ctypes.windll.user32.GetParent(window.winfo_id()) # DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Windows 11) # DWMWA_USE_IMMERSIVE_DARK_MODE = 19 (Windows 10 1903+) DWMWA_USE_IMMERSIVE_DARK_MODE = 20 value = ctypes.c_int(1) # 1 = 深色模式 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value) ) # 如果 Windows 11 方式失败,尝试 Windows 10 方式 if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) != 0: DWMWA_USE_IMMERSIVE_DARK_MODE = 19 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value) ) except Exception as e: self._log(f"Failed to set dark title bar: {e}", "DEBUG") def _on_window_resize(self, event): """处理窗口大小变化事件""" # 只处理窗口本身的大小变化,忽略子组件的变化 if event.widget != self: return # 获取新的窗口尺寸 new_width = event.width new_height = event.height # 避免频繁触发,使用防抖机制 if hasattr(self, '_resize_after_id') and self._resize_after_id: self.after_cancel(self._resize_after_id) # 延迟执行重新居中,避免在拖拽过程中频繁计算 self._resize_after_id = self.after(500, lambda: self._handle_window_resize(new_width, new_height)) def _handle_window_resize(self, width, height): """处理窗口大小变化后的重新布局""" self._log(f"[RESIZE] Window size changed: {width}x{height}", "DEBUG") # 清理防抖ID self._resize_after_id = None # 如果有节点,重新计算居中 if hasattr(self.subfolder_editor, 'nodes') and self.subfolder_editor.nodes: self._log(" Recalculating center layout...", "DEBUG") # 使用超简单居中方法,避免复杂计算 self.subfolder_editor._ultra_simple_center()