Files
NexusLauncher/ui/task/task_panel.py
2025-11-23 20:41:50 +08:00

2095 lines
84 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<Return>", 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("<Return>", 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("<Return>", 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("<Return>", lambda e: dialog.destroy())
dialog.bind("<Escape>", 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 表示用户点击 YesFalse 表示点击 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("<Return>", lambda e: on_yes())
dialog.bind("y", lambda e: on_yes())
dialog.bind("Y", lambda e: on_yes())
dialog.bind("<Escape>", 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("<Control-s>", lambda e: self.save_structure())
self.bind("<Control-0>", lambda e: self.subfolder_editor.force_recenter())
# 绑定窗口大小变化事件
self.bind("<Configure>", 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 表示用户点击 YesFalse 表示点击 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("<Return>", lambda e: on_yes())
dialog.bind("y", lambda e: on_yes())
dialog.bind("Y", lambda e: on_yes())
dialog.bind("<Escape>", 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("<Control-s>")
self.unbind("<Control-0>")
self.unbind("<Configure>")
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()