2095 lines
84 KiB
Python
2095 lines
84 KiB
Python
#!/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 表示用户点击 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("<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 表示用户点击 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("<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()
|