diff --git a/gui/log_config.py b/gui/log_config.py index de05f9b..a31a501 100644 --- a/gui/log_config.py +++ b/gui/log_config.py @@ -5,12 +5,96 @@ GUI 日志配置模块 统一配置 GUI 应用和控制台的日志输出 """ import logging +from typing import Dict +from tkinter import font as tk_font + # 日志格式配置 LOG_FORMAT = '%(asctime)s [%(levelname)s] %(message)s' DATE_FORMAT = '%Y-%m-%d %H:%M:%S' +class LogTheme: + """ + 统一的日志主题配置 + + 提供颜色、字体等样式配置,支持未来的主题切换(如暗色模式) + """ + + # 颜色方案 + COLORS = { + 'INFO': '#000000', # 黑色 + 'SUCCESS': '#008000', # 绿色 + 'WARNING': '#FF8C00', # 深橙色 + 'ERROR': '#FF0000', # 红色 + 'DEBUG': '#808080', # 灰色 + } + + # 字体配置 + FONTS = { + 'DEFAULT': ('TkDefaultFont', 9), + 'HEADER': ('TkDefaultFont', 10, 'bold'), + 'MONOSPACE': ('Consolas', 9), + 'MONOSPACE_SMALL': ('Consolas', 8), + } + + # 背景颜色(用于未来的暗色模式支持) + BACKGROUNDS = { + 'LIGHT': '#FFFFFF', + 'DARK': '#1E1E1E', + } + + # 当前主题 + _current_theme = 'LIGHT' + + @classmethod + def get_color(cls, level: str) -> str: + """ + 获取指定日志级别的颜色 + + Args: + level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG) + + Returns: + 颜色值(十六进制字符串) + """ + return cls.COLORS.get(level.upper(), '#000000') + + @classmethod + def get_font(cls, font_name: str = 'DEFAULT'): + """ + 获取指定字体配置 + + Args: + font_name: 字体名称 (DEFAULT, HEADER, MONOSPACE, MONOSPACE_SMALL) + + Returns: + 字体配置元组 + """ + return cls.FONTS.get(font_name, cls.FONTS['DEFAULT']) + + @classmethod + def set_theme(cls, theme: str): + """ + 设置主题(LIGHT 或 DARK) + + Args: + theme: 主题名称 + """ + if theme.upper() in ['LIGHT', 'DARK']: + cls._current_theme = theme.upper() + + @classmethod + def get_background(cls) -> str: + """ + 获取当前主题的背景颜色 + + Returns: + 背景颜色值 + """ + return cls.BACKGROUNDS.get(cls._current_theme, '#FFFFFF') + + def setup_gui_logging(level=logging.INFO): """ 初始化 GUI 应用的日志配置 diff --git a/gui/login_dialog.py b/gui/login_dialog.py index ffefa76..0414b90 100644 --- a/gui/login_dialog.py +++ b/gui/login_dialog.py @@ -5,6 +5,7 @@ import socket import tkinter as tk from tkinter import ttk, messagebox from typing import Optional, Tuple +from gui.widgets.base_dialog import BaseDialog class LoginDialog: @@ -29,6 +30,8 @@ class LoginDialog: self.parent = parent self.result = None # Will hold (username, password) or None self.dialog = None + self.username_entry = None + self.password_entry = None # Create dialog as modal self._create_dialog() @@ -36,27 +39,22 @@ class LoginDialog: def _create_dialog(self): """Create the login dialog UI""" + # 使用普通 Toplevel,因为 LoginDialog 需要特殊的居中逻辑 self.dialog = tk.Toplevel(self.parent) self.dialog.title("ERP 自动化工具 - 登录") - self.dialog.geometry("400x280") self.dialog.resizable(False, False) - # Center the dialog on parent + # 设置固定大小 + dialog_width = 400 + dialog_height = 280 + self.dialog.geometry(f"{dialog_width}x{dialog_height}") + + # 设置为模态对话框 self.dialog.transient(self.parent) self.dialog.grab_set() - # Calculate position to center on parent - self.parent.update_idletasks() - parent_x = self.parent.winfo_x() - parent_y = self.parent.winfo_y() - parent_width = self.parent.winfo_width() - parent_height = self.parent.winfo_height() - - dialog_width = 400 - dialog_height = 280 - x = parent_x + (parent_width - dialog_width) // 2 - y = parent_y + (parent_height - dialog_height) // 2 - self.dialog.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}") + # 居中显示到父窗口 + self._center_on_parent() # Create UI elements self._create_widgets() @@ -67,6 +65,23 @@ class LoginDialog: # Focus on username entry self.username_entry.focus_set() + def _center_on_parent(self): + """将对话框居中到父窗口""" + self.dialog.update_idletasks() + self.parent.update_idletasks() + + dialog_width = 400 + dialog_height = 280 + + parent_x = self.parent.winfo_x() + parent_y = self.parent.winfo_y() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + + x = parent_x + (parent_width - dialog_width) // 2 + y = parent_y + (parent_height - dialog_height) // 2 + self.dialog.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}") + def _create_widgets(self): """Create the dialog widgets""" # Main frame with padding diff --git a/gui/material_validation/__init__.py b/gui/material_validation/__init__.py new file mode 100644 index 0000000..df1d8a9 --- /dev/null +++ b/gui/material_validation/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Material Validation Components - 物料校验相关组件 + +提供物料校验标签页使用的可复用组件 +""" +from .checkbox_treeview import CheckboxTreeview + +__all__ = ['CheckboxTreeview'] diff --git a/gui/material_validation/checkbox_treeview.py b/gui/material_validation/checkbox_treeview.py new file mode 100644 index 0000000..dcfc7d2 --- /dev/null +++ b/gui/material_validation/checkbox_treeview.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +CheckboxTreeview - 支持复选框和排序的高级表格组件 + +使用 Unicode 字符模拟 checkbox: +- ☐ 未选中 +- ☑ 选中 + +支持点击列标题进行排序 +""" +import tkinter as tk +from tkinter import ttk +from typing import Callable, Optional, List + + +class CheckboxTreeview(ttk.Treeview): + """ + 支持 checkbox 的 Treeview 组件 + + Features: + - Checkbox 列支持(第一列) + - 点击列标题排序(选择列、材料名称列) + - Checkbox 状态变化回调 + - 全选/取消全选功能 + + Usage: + def on_checkbox_change(item_id, is_checked): + print(f"Item {item_id} checked: {is_checked}") + + tree = CheckboxTreeview( + parent, + columns=('选择', '材料名称', '状态'), + on_checkbox_change=on_checkbox_change + ) + tree.pack() + """ + + def __init__( + self, + parent, + on_checkbox_change: Optional[Callable[[str, bool], None]] = None, + **kwargs + ): + """ + 初始化 CheckboxTreeview + + Args: + parent: 父容器 + on_checkbox_change: checkbox 状态改变时的回调函数 (item_id, is_checked) -> None + **kwargs: 传递给 Treeview 的参数 + """ + super().__init__(parent, **kwargs) + + # Checkbox 状态管理 + self.checkboxes: dict = {} # item_id -> bool + self.checkbox_column = "选择" + self.on_checkbox_change = on_checkbox_change + + # 排序状态管理 + self.sort_column: Optional[str] = None # 当前排序列的列标识符 + self.sort_direction: Optional[str] = None # 'asc', 'desc', 或 None + self.sortable_columns: List[str] = ["选择", "材料名称"] # 可排序的列白名单 + self.original_headings: dict = {} # 存储原始列标题文本(不含箭头) + + # 存储原始列标题(延迟执行以确保标题已设置) + self.after(100, self._store_original_headings) + + # 绑定点击事件 + self.bind("", self._on_click) + # 绑定表头点击事件 + self.bind("", self._on_heading_click) + + def _on_click(self, event): + """ + 处理点击事件,切换 checkbox 状态 + + Args: + event: 鼠标点击事件 + """ + # 获取点击位置对应的 item 和 column + region = self.identify_region(event.x, event.y) + + # 仅处理单元格点击,不处理表头点击 + if region == "cell": + column = self.identify_column(event.x) + item = self.identify_row(event.y) + + # 检查是否点击了 checkbox 列(第一列) + if column == "#1" and item: + # 切换 checkbox 状态 + current_state = self.checkboxes.get(item, False) + new_state = not current_state + self.set_checked(item, new_state) + + # 通知父组件 checkbox 状态已改变 + if self.on_checkbox_change: + self.on_checkbox_change(item, new_state) + + return "break" # 阻止默认行为 + + def set_checked(self, item: str, checked: bool): + """ + 设置指定 item 的 checkbox 状态 + + Args: + item: Treeview item ID + checked: 是否选中 + """ + self.checkboxes[item] = checked + + # 更新显示 + checkbox_char = "☑" if checked else "☐" + values = list(self.item(item, "values")) + if values: + values[0] = checkbox_char + self.item(item, values=values) + + def get_checked_items(self) -> List[str]: + """ + 获取所有选中的 item + + Returns: + List of item IDs + """ + return [item for item, checked in self.checkboxes.items() if checked] + + def check_all(self, checked: bool = True): + """ + 全选或取消全选 + + Args: + checked: True 为全选,False 为取消全选 + """ + for item in self.get_children(): + self.set_checked(item, checked) + + def insert(self, parent, index, values=None, **kwargs): + """ + 重写 insert 方法,初始化 checkbox 状态 + + Args: + parent: 父节点 ID + index: 插入位置索引 + values: 值列表 + **kwargs: 其他参数 + + Returns: + 新创建的 item ID + """ + if values is None: + values = [] + + # 确保第一个值是 checkbox + if not values or values[0] not in ["☐", "☑"]: + values = ["☐"] + list(values) + + item = super().insert(parent, index, values=values, **kwargs) + + # 初始化 checkbox 状态为未选中 + checkbox_char = values[0] if values else "☐" + self.checkboxes[item] = (checkbox_char == "☑") + + return item + + def delete(self, *items): + """ + 重写 delete 方法,清理 checkbox 状态 + + Args: + *items: 要删除的 item ID 列表 + """ + for item in items: + if item in self.checkboxes: + del self.checkboxes[item] + super().delete(*items) + + def _store_original_headings(self): + """存储原始列标题文本(不含箭头)""" + for col in self['columns']: + self.original_headings[col] = self.heading(col, 'text') + + def _get_column_id_from_column_index(self, column_index: str) -> Optional[str]: + """ + 将列索引 ('#1', '#2') 转换为列标识符 + + Args: + column_index: 列索引字符串,如 '#1', '#2' + + Returns: + 列标识符,如 '选择', '材料名称',或 None + """ + try: + index = int(column_index[1:]) - 1 + columns = self['columns'] + if 0 <= index < len(columns): + return columns[index] + except (ValueError, IndexError): + pass + return None + + def _on_heading_click(self, event): + """ + 处理表头点击事件,触发排序 + + Args: + event: 鼠标点击事件 + """ + region = self.identify_region(event.x, event.y) + + if region == "heading": + column = self.identify_column(event.x) + column_id = self._get_column_id_from_column_index(column) + + # 仅对可排序列进行排序 + if column_id in self.sortable_columns: + self._toggle_sort(column_id) + + def _toggle_sort(self, column_id: str): + """ + 切换指定列的排序状态 + + 排序状态循环:asc -> desc -> None -> asc + + Args: + column_id: 列标识符(如 '选择', '材料名称') + """ + # 确定新的排序方向 + if self.sort_column == column_id: + # 同一列:asc -> desc -> None + if self.sort_direction == 'asc': + new_direction = 'desc' + elif self.sort_direction == 'desc': + new_direction = None + else: + new_direction = 'asc' + else: + # 不同列:从升序开始 + new_direction = 'asc' + + # 应用排序 + if new_direction: + self._sort_by_column(column_id, new_direction) + self.sort_column = column_id + self.sort_direction = new_direction + else: + # 清除排序状态 + self.sort_column = None + self.sort_direction = None + + # 更新表头显示 + self._update_heading_display() + + def _sort_by_column(self, column_id: str, direction: str): + """ + 按指定列和方向排序 + + Args: + column_id: 列标识符 + direction: 'asc' 或 'desc' + """ + # 收集所有项目及其数据和复选框状态 + items_data = [] + for item in self.get_children(): + values = self.item(item, "values") + checkbox_state = self.checkboxes.get(item, False) + items_data.append({ + 'item_id': item, + 'values': values, + 'checked': checkbox_state + }) + + # 根据列和方向排序 + if column_id == "选择": + # 按复选框状态排序(选中在前,未选中在后) + items_data.sort(key=lambda x: x['checked'], reverse=(direction == 'desc')) + elif column_id == "材料名称": + # 按材料名称排序 + items_data.sort( + key=lambda x: str(x['values'][1]) if len(x['values']) > 1 else "", + reverse=(direction == 'desc') + ) + + # 重新排列项目顺序(使用 move 保留项目ID和状态) + for item_data in items_data: + self.move(item_data['item_id'], '', 'end') + + def _update_heading_display(self): + """更新列标题显示(添加/移除排序箭头)""" + for col in self['columns']: + original = self.original_headings.get(col, col) + if col == self.sort_column: + # 添加排序箭头 + arrow = " ↑" if self.sort_direction == 'asc' else " ↓" + self.heading(col, text=original + arrow) + else: + # 移除箭头,显示原始标题 + self.heading(col, text=original) + + def get_checkbox_state(self, item: str) -> bool: + """ + 获取指定 item 的 checkbox 状态 + + Args: + item: Treeview item ID + + Returns: + 是否选中 + """ + return self.checkboxes.get(item, False) + + def set_sortable_columns(self, columns: List[str]): + """ + 设置可排序的列 + + Args: + columns: 列标识符列表 + """ + self.sortable_columns = columns.copy() diff --git a/gui/material_validation_tab.py b/gui/material_validation_tab.py index 70033e8..d08ac9a 100644 --- a/gui/material_validation_tab.py +++ b/gui/material_validation_tab.py @@ -22,236 +22,12 @@ from gui.widgets import FileSelector, LogText, GuiTextHandler, DeleteProgressWin from gui.config_manager import ConfigManager from gui.log_config import setup_gui_logging, get_logger from gui.material_type_management_dialog import MaterialTypeManagementDialog +from gui.material_validation import CheckboxTreeview from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO import pandas as pd import tempfile -class CheckboxTreeview(ttk.Treeview): - """支持 checkbox 的 Treeview 组件 - - 使用 Unicode 字符模拟 checkbox: - - ☐ 未选中 - - ☑ 选中 - """ - - def __init__(self, parent, on_checkbox_change=None, **kwargs): - """初始化 CheckboxTreeview - - Args: - parent: 父容器 - on_checkbox_change: checkbox 状态改变时的回调函数 - **kwargs: 传递给 Treeview 的参数 - """ - super().__init__(parent, **kwargs) - self.checkboxes = {} # item_id -> bool - self.checkbox_column = "选择" - self.on_checkbox_change = on_checkbox_change # checkbox 状态改变回调 - - # 排序状态 - self.sort_column = None # 当前排序列的列标识符 - self.sort_direction = None # 'asc', 'desc', 或 None - self.sortable_columns = ["选择", "材料名称"] # 可排序的列白名单 - self.original_headings = {} # 存储原始列标题文本(不含箭头) - - # 存储原始列标题(延迟执行以确保标题已设置) - self.after(100, self._store_original_headings) - - # 绑定点击事件 - self.bind("", self._on_click) - # 绑定表头点击事件 - self.bind("", self._on_heading_click) - - def _on_click(self, event): - """处理点击事件,切换 checkbox 状态""" - # 获取点击位置对应的 item 和 column - region = self.identify_region(event.x, event.y) - - # 仅处理单元格点击,不处理表头点击 - if region == "cell": - column = self.identify_column(event.x) - item = self.identify_row(event.y) - - # 检查是否点击了 checkbox 列(第一列) - if column == "#1" and item: - # 切换 checkbox 状态 - current_state = self.checkboxes.get(item, False) - new_state = not current_state - self.set_checked(item, new_state) - - # 通知父组件 checkbox 状态已改变 - if self.on_checkbox_change: - self.on_checkbox_change(item, new_state) - - return "break" # 阻止默认行为 - - def set_checked(self, item, checked: bool): - """设置指定 item 的 checkbox 状态 - - Args: - item: Treeview item ID - checked: 是否选中 - """ - self.checkboxes[item] = checked - - # 更新显示 - checkbox_char = "☑" if checked else "☐" - values = list(self.item(item, "values")) - if values: - values[0] = checkbox_char - self.item(item, values=values) - - def get_checked_items(self) -> list: - """获取所有选中的 item - - Returns: - List of item IDs - """ - return [item for item, checked in self.checkboxes.items() if checked] - - def check_all(self, checked: bool = True): - """全选或取消全选 - - Args: - checked: True 为全选,False 为取消全选 - """ - for item in self.get_children(): - self.set_checked(item, checked) - - def insert(self, parent, index, values=None, **kwargs): - """重写 insert 方法,初始化 checkbox 状态""" - if values is None: - values = [] - - # 确保第一个值是 checkbox - if not values or values[0] not in ["☐", "☑"]: - values = ["☐"] + list(values) - - item = super().insert(parent, index, values=values, **kwargs) - - # 初始化 checkbox 状态为未选中 - checkbox_char = values[0] if values else "☐" - self.checkboxes[item] = (checkbox_char == "☑") - - return item - - def delete(self, *items): - """重写 delete 方法,清理 checkbox 状态""" - for item in items: - if item in self.checkboxes: - del self.checkboxes[item] - super().delete(*items) - - def _store_original_headings(self): - """存储原始列标题文本(不含箭头)""" - for col in self['columns']: - self.original_headings[col] = self.heading(col, 'text') - - def _get_column_id_from_column_index(self, column_index): - """将列索引 ('#1', '#2') 转换为列标识符 - - Args: - column_index: 列索引字符串,如 '#1', '#2' - - Returns: - 列标识符,如 '选择', '材料名称' - """ - index = int(column_index[1:]) - 1 - columns = self['columns'] - if 0 <= index < len(columns): - return columns[index] - return None - - def _on_heading_click(self, event): - """处理表头点击事件,触发排序""" - region = self.identify_region(event.x, event.y) - - if region == "heading": - column = self.identify_column(event.x) - column_id = self._get_column_id_from_column_index(column) - - # 仅对可排序列进行排序 - if column_id in self.sortable_columns: - self._toggle_sort(column_id) - - def _toggle_sort(self, column_id): - """切换指定列的排序状态 - - Args: - column_id: 列标识符(如 '选择', '材料名称') - """ - # 确定新的排序方向 - if self.sort_column == column_id: - # 同一列:asc -> desc -> None - if self.sort_direction == 'asc': - new_direction = 'desc' - elif self.sort_direction == 'desc': - new_direction = None - else: - new_direction = 'asc' - else: - # 不同列:从升序开始 - new_direction = 'asc' - - # 应用排序 - if new_direction: - self._sort_by_column(column_id, new_direction) - self.sort_column = column_id - self.sort_direction = new_direction - else: - # 清除排序状态 - self.sort_column = None - self.sort_direction = None - - # 更新表头显示 - self._update_heading_display() - - def _sort_by_column(self, column_id, direction): - """按指定列和方向排序 - - Args: - column_id: 列标识符 - direction: 'asc' 或 'desc' - """ - # 收集所有项目及其数据和复选框状态 - items_data = [] - for item in self.get_children(): - values = self.item(item, "values") - checkbox_state = self.checkboxes.get(item, False) - items_data.append({ - 'item_id': item, - 'values': values, - 'checked': checkbox_state - }) - - # 根据列和方向排序 - if column_id == "选择": - # 按复选框状态排序(选中在前,未选中在后) - items_data.sort(key=lambda x: x['checked'], reverse=(direction == 'desc')) - elif column_id == "材料名称": - # 按材料名称排序 - items_data.sort( - key=lambda x: str(x['values'][1]) if len(x['values']) > 1 else "", - reverse=(direction == 'desc') - ) - - # 重新排列项目顺序(使用 detach 和 move 保留项目ID和状态) - for item_data in items_data: - self.move(item_data['item_id'], '', 'end') - - def _update_heading_display(self): - """更新列标题显示(添加/移除排序箭头)""" - for col in self['columns']: - original = self.original_headings.get(col, col) - if col == self.sort_column: - # 添加排序箭头 - arrow = " ↑" if self.sort_direction == 'asc' else " ↓" - self.heading(col, text=original + arrow) - else: - # 移除箭头,显示原始标题 - self.heading(col, text=original) - - class MaterialValidationTab(ttk.Frame): """物料校验标签页""" diff --git a/gui/user_selection_dialog.py b/gui/user_selection_dialog.py index 65eb608..e8ea932 100644 --- a/gui/user_selection_dialog.py +++ b/gui/user_selection_dialog.py @@ -38,25 +38,38 @@ class UserSelectionDialog: def _create_dialog(self): """Create the user selection dialog UI""" + # 使用普通 Toplevel self.dialog = tk.Toplevel(self.parent) self.dialog.title("选择用户身份") - self.dialog.geometry("450x400") self.dialog.resizable(False, False) - # Center on screen (not parent) + # 设置固定大小 + dialog_width = 450 + dialog_height = 400 + self.dialog.geometry(f"{dialog_width}x{dialog_height}") + + # 设置为模态对话框 self.dialog.transient(self.parent) self.dialog.grab_set() - screen_width = self.dialog.winfo_screenwidth() - screen_height = self.dialog.winfo_screenheight() + # 居中显示到屏幕(而不是父窗口) + self._center_on_screen() + + self._create_widgets() + + def _center_on_screen(self): + """将对话框居中到屏幕""" + self.dialog.update_idletasks() + dialog_width = 450 dialog_height = 400 + + screen_width = self.dialog.winfo_screenwidth() + screen_height = self.dialog.winfo_screenheight() x = (screen_width - dialog_width) // 2 y = (screen_height - dialog_height) // 2 self.dialog.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}") - self._create_widgets() - def _create_widgets(self): """Create dialog widgets""" main_frame = ttk.Frame(self.dialog, padding="20") diff --git a/gui/utils/__init__.py b/gui/utils/__init__.py new file mode 100644 index 0000000..59fa76b --- /dev/null +++ b/gui/utils/__init__.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +GUI Utilities - GUI 工具模块 + +提供 GUI 开发中常用的工具类和函数 +""" +from gui.utils.decorators import require_admin, require_permission +from gui.utils.error_handler import ErrorHandler +from gui.utils.state_manager import StateManager +from gui.utils.validators import Validator, ValidationResult, ValidatedWidget + +__all__ = [ + 'require_admin', + 'require_permission', + 'ErrorHandler', + 'StateManager', + 'Validator', + 'ValidationResult', + 'ValidatedWidget', +] diff --git a/gui/utils/decorators.py b/gui/utils/decorators.py new file mode 100644 index 0000000..da2687b --- /dev/null +++ b/gui/utils/decorators.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Decorators - GUI 装饰器集合 + +提供常用的 GUI 相关装饰器,用于权限检查、异常处理等 +""" +from functools import wraps +from typing import Callable, Optional + +try: + from auth.session_manager import SessionManager +except ImportError: + SessionManager = None + +from tkinter import messagebox + + +def require_admin(func: Callable) -> Callable: + """ + 装饰器:要求管理员权限 + + 如果当前用户不是管理员,显示警告消息并阻止函数执行 + + Usage: + @require_admin + def _open_admin_panel(self): + # 只有管理员可以执行 + pass + + Args: + func: 被装饰的函数 + + Returns: + 包装后的函数 + """ + @wraps(func) + def wrapper(self, *args, **kwargs): + if SessionManager is None: + # 如果没有 SessionManager,直接执行(向后兼容) + return func(self, *args, **kwargs) + + session = SessionManager.get_instance() + if not session.is_admin(): + messagebox.showwarning("权限不足", "此功能需要管理员权限") + return + return func(self, *args, **kwargs) + return wrapper + + +def require_permission(permission: str): + """ + 装饰器:要求特定权限 + + 如果当前用户缺少指定权限,显示警告消息并阻止函数执行 + + Usage: + @require_permission("delete_materials") + def _delete_materials(self): + # 只有具有 delete_materials 权限的用户可以执行 + pass + + Args: + permission: 所需的权限名称 + + Returns: + 装饰器函数 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(self, *args, **kwargs): + if SessionManager is None: + # 如果没有 SessionManager,直接执行(向后兼容) + return func(self, *args, **kwargs) + + session = SessionManager.get_instance() + if not session.has_permission(permission): + messagebox.showwarning("权限不足", f"缺少权限: {permission}") + return + return func(self, *args, **kwargs) + return wrapper + return decorator + + +def require_user_type(user_type: str): + """ + 装饰器:要求特定用户类型 + + 如果当前用户不是指定类型,显示警告消息并阻止函数执行 + + Usage: + @require_user_type("Admin") + def _admin_function(self): + # 只有 Admin 类型用户可以执行 + pass + + Args: + user_type: 所需的用户类型 + + Returns: + 装饰器函数 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(self, *args, **kwargs): + if SessionManager is None: + # 如果没有 SessionManager,直接执行(向后兼容) + return func(self, *args, **kwargs) + + session = SessionManager.get_instance() + current_user_type = session.get_current_user_type() + + if current_user_type != user_type: + messagebox.showwarning( + "权限不足", + f"此功能仅限 {user_type} 用户使用" + ) + return + return func(self, *args, **kwargs) + return wrapper + return decorator + + +def handle_errors( + show_user: bool = True, + default_return=None, + log_context: str = "" +): + """ + 装饰器:统一错误处理 + + 捕获函数中的异常并使用 ErrorHandler 进行处理 + + Usage: + @handle_errors(show_user=True, log_context="Deleting materials") + def _delete_materials(self): + # 可能抛出异常的操作 + pass + + Args: + show_user: 是否向用户显示错误消息 + default_return: 发生异常时的默认返回值 + log_context: 日志上下文信息 + + Returns: + 装饰器函数 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except Exception as e: + from gui.utils.error_handler import ErrorHandler + context = log_context or f"{func.__name__}" + ErrorHandler.handle(e, context, show_user) + return default_return + return wrapper + return decorator diff --git a/gui/utils/error_handler.py b/gui/utils/error_handler.py new file mode 100644 index 0000000..e6b9d4b --- /dev/null +++ b/gui/utils/error_handler.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Error Handler - 统一的错误处理器 + +提供统一的错误处理机制,将技术错误转换为用户友好的消息 +""" +import logging +import traceback +from tkinter import messagebox +from typing import Optional, Type +import sys + + +logger = logging.getLogger(__name__) + + +class ErrorHandler: + """ + 统一的错误处理器 + + 将技术错误转换为用户友好的消息,并记录详细日志 + """ + + # 错误类型到用户消息的映射 + ERROR_MESSAGE_MAP = { + ConnectionError: "无法连接到服务器,请检查网络连接", + PermissionError: "权限不足,请联系管理员", + FileNotFoundError: "找不到指定的文件", + ValueError: "输入数据格式不正确", + TypeError: "数据类型错误,请检查输入", + KeyError: "数据缺失,请检查输入完整性", + TimeoutError: "操作超时,请重试", + RuntimeError: "运行时错误,请查看日志了解详情", + } + + @staticmethod + def handle( + error: Exception, + context: str = "", + show_user: bool = True, + parent=None + ): + """ + 处理错误 + + 记录详细日志,并可选择向用户显示友好的错误消息 + + Args: + error: 异常对象 + context: 错误发生的上下文信息 + show_user: 是否向用户显示错误消息 + parent: 父窗口(用于显示消息框) + """ + # 记录详细日志 + error_message = str(error) + if context: + logger.error(f"{context}: {error_message}", exc_info=error) + else: + logger.error(error_message, exc_info=error) + + # 显示用户友好的错误信息 + if show_user: + user_message = ErrorHandler._get_user_message(error) + if context: + user_message = f"[{context}]\n{user_message}" + + if parent: + messagebox.showerror("操作失败", user_message, parent=parent) + else: + messagebox.showerror("操作失败", user_message) + + @staticmethod + def _get_user_message(error: Exception) -> str: + """ + 将技术错误转换为用户友好的消息 + + Args: + error: 异常对象 + + Returns: + 用户友好的错误消息 + """ + # 检查是否为已知错误类型 + for error_type, message in ErrorHandler.ERROR_MESSAGE_MAP.items(): + if isinstance(error, error_type): + return message + + # 未知错误类型 + error_name = type(error).__name__ + error_msg = str(error) + + # 如果错误消息为空或只包含类名,返回通用消息 + if not error_msg or error_msg == error_name: + return "操作失败,请查看日志了解详情" + + # 返回简化的错误消息(不包含技术细节) + # 限制长度以避免消息过长 + if len(error_msg) > 200: + return f"{error_msg[:200]}..." + + return error_msg + + @staticmethod + def handle_with_retry( + error: Exception, + context: str = "", + retry_callback: Optional[callable] = None, + parent=None + ) -> bool: + """ + 处理错误并提供重试选项 + + Args: + error: 异常对象 + context: 错误发生的上下文信息 + retry_callback: 重试回调函数 + parent: 父窗口 + + Returns: + True 如果用户选择重试,False 否则 + """ + user_message = ErrorHandler._get_user_message(error) + if context: + user_message = f"[{context}]\n{user_message}" + + user_message += "\n\n是否重试?" + + if parent: + result = messagebox.askyesno("操作失败", user_message, parent=parent) + else: + result = messagebox.askyesno("操作失败", user_message) + + if result and retry_callback: + try: + retry_callback() + return True + except Exception as e: + ErrorHandler.handle(e, f"{context} (重试)", True, parent) + return False + + return result + + @staticmethod + def log_exception(error: Exception, context: str = ""): + """ + 仅记录异常到日志,不显示用户消息 + + Args: + error: 异常对象 + context: 错误发生的上下文信息 + """ + error_message = str(error) + if context: + logger.error(f"{context}: {error_message}", exc_info=error) + else: + logger.error(error_message, exc_info=error) + + @staticmethod + def show_warning(message: str, parent=None): + """ + 显示警告消息 + + Args: + message: 警告消息 + parent: 父窗口 + """ + if parent: + messagebox.showwarning("警告", message, parent=parent) + else: + messagebox.showwarning("警告", message) + + @staticmethod + def show_info(message: str, parent=None): + """ + 显示信息消息 + + Args: + message: 信息消息 + parent: 父窗口 + """ + if parent: + messagebox.showinfo("信息", message, parent=parent) + else: + messagebox.showinfo("信息", message) + + @staticmethod + def ask_confirmation(message: str, parent=None) -> bool: + """ + 询问用户确认 + + Args: + message: 确认消息 + parent: 父窗口 + + Returns: + True 如果用户确认,False 否则 + """ + if parent: + return messagebox.askyesno("确认", message, parent=parent) + else: + return messagebox.askyesno("确认", message) diff --git a/gui/utils/state_manager.py b/gui/utils/state_manager.py new file mode 100644 index 0000000..ea6d3f7 --- /dev/null +++ b/gui/utils/state_manager.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +State Manager - 状态管理器 + +实现简单的观察者模式,用于在组件间共享状态 +""" +from typing import Any, Callable, Dict, List, Set +from threading import Lock +import logging + +logger = logging.getLogger(__name__) + + +class StateManager: + """ + 简单的状态管理器 + + 使用观察者模式,允许组件订阅状态变更通知 + + Usage: + # 创建全局实例 + state = StateManager() + + # 设置状态 + state.set("current_user", "admin") + + # 获取状态 + user = state.get("current_user") + + # 订阅状态变更 + def on_user_change(new_user): + print(f"User changed to: {new_user}") + + state.subscribe("current_user", on_user_change) + + # 取消订阅 + state.unsubscribe("current_user", on_user_change) + """ + + def __init__(self): + """初始化状态管理器""" + self._state: Dict[str, Any] = {} + self._listeners: Dict[str, List[Callable]] = {} + self._lock = Lock() + + def set(self, key: str, value: Any, notify: bool = True): + """ + 设置状态并通知监听器 + + Args: + key: 状态键 + value: 状态值 + notify: 是否通知监听器(默认 True) + """ + with self._lock: + # 检查值是否实际变更 + if key in self._state and self._state[key] == value: + return + + # 更新状态 + old_value = self._state.get(key) + self._state[key] = value + + logger.debug(f"State changed: {key} = {value} (was: {old_value})") + + # 通知监听器(在锁外部执行,避免死锁) + if notify: + self._notify(key, value, old_value) + + def get(self, key: str, default: Any = None) -> Any: + """ + 获取状态 + + Args: + key: 状态键 + default: 默认值(如果键不存在) + + Returns: + 状态值或默认值 + """ + with self._lock: + return self._state.get(key, default) + + def delete(self, key: str): + """ + 删除状态 + + Args: + key: 状态键 + """ + with self._lock: + if key in self._state: + del self._state[key] + logger.debug(f"State deleted: {key}") + + def subscribe(self, key: str, callback: Callable[[Any], None]): + """ + 订阅状态变更 + + Args: + key: 状态键 + callback: 回调函数,接收新值作为参数 + """ + with self._lock: + if key not in self._listeners: + self._listeners[key] = [] + self._listeners[key].append(callback) + logger.debug(f"New subscriber for {key}: {callback.__name__}") + + def unsubscribe(self, key: str, callback: Callable[[Any], None]): + """ + 取消订阅 + + Args: + key: 状态键 + callback: 要移除的回调函数 + """ + with self._lock: + if key in self._listeners: + try: + self._listeners[key].remove(callback) + logger.debug(f"Unsubscribed from {key}: {callback.__name__}") + + # 如果没有监听器了,删除键 + if not self._listeners[key]: + del self._listeners[key] + except ValueError: + logger.warning(f"Callback not found in subscribers for {key}") + + def subscribe_all(self, callback: Callable[[str, Any, Any], None]): + """ + 订阅所有状态变更 + + 回调函数签名:callback(key, new_value, old_value) + + Args: + callback: 回调函数 + """ + # 使用特殊的键来存储"全部"监听器 + with self._lock: + special_key = "__all__" + if special_key not in self._listeners: + self._listeners[special_key] = [] + self._listeners[special_key].append(callback) + logger.debug(f"New subscriber for all changes: {callback.__name__}") + + def unsubscribe_all(self, callback: Callable[[str, Any, Any], None]): + """ + 取消订阅所有状态变更 + + Args: + callback: 要移除的回调函数 + """ + special_key = "__all__" + with self._lock: + if special_key in self._listeners: + try: + self._listeners[special_key].remove(callback) + logger.debug(f"Unsubscribed from all changes: {callback.__name__}") + + if not self._listeners[special_key]: + del self._listeners[special_key] + except ValueError: + logger.warning(f"Callback not found in all subscribers") + + def _notify(self, key: str, new_value: Any, old_value: Any): + """ + 通知所有订阅者 + + Args: + key: 状态键 + new_value: 新值 + old_value: 旧值 + """ + with self._lock: + # 获取该键的监听器 + listeners = self._listeners.get(key, []).copy() + + # 获取"全部"监听器 + all_listeners = self._listeners.get("__all__", []).copy() + + # 在锁外部调用回调,避免死锁 + for callback in listeners: + try: + callback(new_value) + except Exception as e: + logger.error(f"Error in state change listener for {key}: {e}", exc_info=True) + + for callback in all_listeners: + try: + callback(key, new_value, old_value) + except Exception as e: + logger.error(f"Error in all-state listener for {key}: {e}", exc_info=True) + + def get_all(self) -> Dict[str, Any]: + """ + 获取所有状态的副本 + + Returns: + 包含所有状态的字典 + """ + with self._lock: + return self._state.copy() + + def clear(self): + """清空所有状态""" + with self._lock: + self._state.clear() + self._listeners.clear() + logger.debug("All state cleared") + + def has_key(self, key: str) -> bool: + """ + 检查是否存在指定键 + + Args: + key: 状态键 + + Returns: + True 如果键存在,False 否则 + """ + with self._lock: + return key in self._state + + +# 全局状态管理器实例 +_global_state_manager: StateManager = None + + +def get_global_state_manager() -> StateManager: + """ + 获取全局状态管理器实例(单例模式) + + Returns: + 全局 StateManager 实例 + """ + global _global_state_manager + if _global_state_manager is None: + _global_state_manager = StateManager() + return _global_state_manager diff --git a/gui/utils/validators.py b/gui/utils/validators.py new file mode 100644 index 0000000..28e8bae --- /dev/null +++ b/gui/utils/validators.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Validators - 输入验证框架 + +提供统一的输入验证机制,用于 GUI 表单验证 +""" +import os +import re +from datetime import datetime +from typing import Any, Callable, List, Optional +from tkinter import ttk + + +class ValidationResult: + """ + 验证结果 + + 表示验证操作的结果,包含是否成功和错误消息 + """ + + def __init__(self, is_valid: bool, error_message: str = ""): + """ + 初始化验证结果 + + Args: + is_valid: 是否验证通过 + error_message: 错误消息(验证失败时) + """ + self.is_valid = is_valid + self.error_message = error_message + + def __bool__(self) -> bool: + """允许直接在 if 语句中使用""" + return self.is_valid + + def __str__(self) -> str: + """返回错误消息或"验证通过" """ + return self.error_message if not self.is_valid else "验证通过" + + +class Validator: + """ + 输入验证器基类 + + 提供常用的静态验证方法 + """ + + @staticmethod + def not_empty(value: Any, field_name: str = "字段") -> ValidationResult: + """ + 非空验证 + + Args: + value: 要验证的值 + field_name: 字段名称(用于错误消息) + + Returns: + 验证结果 + """ + if value is None or value == "": + return ValidationResult(False, f"{field_name}不能为空") + if isinstance(value, str) and not value.strip(): + return ValidationResult(False, f"{field_name}不能为空或仅包含空格") + return ValidationResult(True) + + @staticmethod + def file_exists(path: str, field_name: str = "文件") -> ValidationResult: + """ + 文件存在性验证 + + Args: + path: 文件路径 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not path: + return ValidationResult(False, f"{field_name}路径不能为空") + if not os.path.exists(path): + return ValidationResult(False, f"{field_name}不存在: {path}") + return ValidationResult(True) + + @staticmethod + def dir_exists(path: str, field_name: str = "目录") -> ValidationResult: + """ + 目录存在性验证 + + Args: + path: 目录路径 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not path: + return ValidationResult(False, f"{field_name}路径不能为空") + if not os.path.exists(path): + return ValidationResult(False, f"{field_name}不存在: {path}") + if not os.path.isdir(path): + return ValidationResult(False, f"{field_name}不是有效的目录: {path}") + return ValidationResult(True) + + @staticmethod + def date_format(value: str, format_str: str = "%Y-%m-%d", field_name: str = "日期") -> ValidationResult: + """ + 日期格式验证 + + Args: + value: 日期字符串 + format_str: 期望的日期格式 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not value: + return ValidationResult(False, f"{field_name}不能为空") + try: + datetime.strptime(value, format_str) + return ValidationResult(True) + except ValueError: + return ValidationResult(False, f"{field_name}格式错误,期望格式: {format_str}") + + @staticmethod + def numeric(value: Any, field_name: str = "数值", min_value: Optional[float] = None, + max_value: Optional[float] = None) -> ValidationResult: + """ + 数值验证 + + Args: + value: 要验证的值 + field_name: 字段名称 + min_value: 最小值(可选) + max_value: 最大值(可选) + + Returns: + 验证结果 + """ + if value is None or value == "": + return ValidationResult(False, f"{field_name}不能为空") + + try: + num = float(value) + except (ValueError, TypeError): + return ValidationResult(False, f"{field_name}必须是有效的数字") + + if min_value is not None and num < min_value: + return ValidationResult(False, f"{field_name}不能小于 {min_value}") + + if max_value is not None and num > max_value: + return ValidationResult(False, f"{field_name}不能大于 {max_value}") + + return ValidationResult(True) + + @staticmethod + def integer(value: Any, field_name: str = "整数") -> ValidationResult: + """ + 整数验证 + + Args: + value: 要验证的值 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if value is None or value == "": + return ValidationResult(False, f"{field_name}不能为空") + + try: + int(value) + return ValidationResult(True) + except (ValueError, TypeError): + return ValidationResult(False, f"{field_name}必须是有效的整数") + + @staticmethod + def length(value: str, min_length: int = 0, max_length: Optional[int] = None, + field_name: str = "字段") -> ValidationResult: + """ + 字符串长度验证 + + Args: + value: 要验证的字符串 + min_length: 最小长度 + max_length: 最大长度(可选) + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not isinstance(value, str): + return ValidationResult(False, f"{field_name}必须是字符串") + + length = len(value) + + if length < min_length: + return ValidationResult(False, f"{field_name}长度不能少于 {min_length} 个字符") + + if max_length is not None and length > max_length: + return ValidationResult(False, f"{field_name}长度不能超过 {max_length} 个字符") + + return ValidationResult(True) + + @staticmethod + def regex(value: str, pattern: str, field_name: str = "字段") -> ValidationResult: + """ + 正则表达式验证 + + Args: + value: 要验证的字符串 + pattern: 正则表达式模式 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not isinstance(value, str): + return ValidationResult(False, f"{field_name}必须是字符串") + + if not re.match(pattern, value): + return ValidationResult(False, f"{field_name}格式不正确") + return ValidationResult(True) + + @staticmethod + def email(value: str, field_name: str = "邮箱") -> ValidationResult: + """ + 邮箱格式验证 + + Args: + value: 邮箱地址 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not value: + return ValidationResult(False, f"{field_name}不能为空") + + # 简单的邮箱正则表达式 + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return Validator.regex(value, pattern, field_name) + + @staticmethod + def phone(value: str, field_name: str = "手机号") -> ValidationResult: + """ + 手机号验证(中国大陆) + + Args: + value: 手机号 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if not value: + return ValidationResult(False, f"{field_name}不能为空") + + # 中国大陆手机号正则表达式 + pattern = r'^1[3-9]\d{9}$' + return Validator.regex(value, pattern, field_name) + + @staticmethod + def in_range(value: Any, allowed_values: List[Any], field_name: str = "字段") -> ValidationResult: + """ + 值范围验证 + + Args: + value: 要验证的值 + allowed_values: 允许的值列表 + field_name: 字段名称 + + Returns: + 验证结果 + """ + if value not in allowed_values: + return ValidationResult( + False, + f"{field_name}必须是以下值之一: {', '.join(str(v) for v in allowed_values)}" + ) + return ValidationResult(True) + + @staticmethod + def custom(value: Any, validator_func: Callable[[Any], bool], + error_message: str = "验证失败") -> ValidationResult: + """ + 自定义验证函数 + + Args: + value: 要验证的值 + validator_func: 验证函数,返回 True 表示验证通过 + error_message: 错误消息 + + Returns: + 验证结果 + """ + try: + if validator_func(value): + return ValidationResult(True) + return ValidationResult(False, error_message) + except Exception as e: + return ValidationResult(False, f"验证过程出错: {str(e)}") + + +class ValidatedWidget: + """ + 带验证功能的 Widget 基类 + + 为任何支持 get_value() 方法的组件添加验证功能 + """ + + def __init__(self, widget: ttk.Widget, error_label: Optional[ttk.Label] = None): + """ + 初始化验证组件 + + Args: + widget: 要验证的组件(必须支持 get_value() 方法) + error_label: 用于显示错误消息的 Label(可选) + """ + self.widget = widget + self.error_label = error_label + self.validators: List[Callable[[Any], ValidationResult]] = [] + self._last_result: Optional[ValidationResult] = None + + def add_validator(self, validator: Callable[[Any], ValidationResult]): + """ + 添加验证器 + + Args: + validator: 验证函数,接收值并返回 ValidationResult + """ + self.validators.append(validator) + + def add_not_empty_validator(self, field_name: str = "字段"): + """ + 添加非空验证器 + + Args: + field_name: 字段名称 + """ + self.add_validator(lambda v: Validator.not_empty(v, field_name)) + + def add_file_exists_validator(self, field_name: str = "文件"): + """ + 添加文件存在性验证器 + + Args: + field_name: 字段名称 + """ + self.add_validator(lambda v: Validator.file_exists(v, field_name)) + + def add_date_format_validator(self, format_str: str = "%Y-%m-%d", field_name: str = "日期"): + """ + 添加日期格式验证器 + + Args: + format_str: 日期格式 + field_name: 字段名称 + """ + self.add_validator(lambda v: Validator.date_format(v, format_str, field_name)) + + def add_custom_validator(self, validator_func: Callable[[Any], bool], error_message: str = "验证失败"): + """ + 添加自定义验证器 + + Args: + validator_func: 验证函数 + error_message: 错误消息 + """ + self.add_validator(lambda v: Validator.custom(v, validator_func, error_message)) + + def validate(self) -> bool: + """ + 执行所有验证 + + Returns: + True 如果所有验证都通过,False 否则 + """ + value = self._get_value() + + for validator in self.validators: + result = validator(value) + self._last_result = result + + if not result.is_valid: + self._show_error(result.error_message) + return False + + self._clear_error() + return True + + def _get_value(self) -> Any: + """ + 获取组件的值 + + Returns: + 组件的当前值 + """ + if hasattr(self.widget, 'get'): + return self.widget.get() + elif hasattr(self.widget, 'cget'): + # 对于某些组件,尝试获取配置值 + return self.widget.cget('text') + else: + raise AttributeError(f"Widget {type(self.widget).__name__} 不支持获取值") + + def _show_error(self, message: str): + """ + 显示错误消息 + + Args: + message: 错误消息 + """ + if self.error_label: + self.error_label.config(text=message, foreground="red") + # 也可以添加其他错误显示方式,比如改变组件边框颜色 + + def _clear_error(self): + """清除错误消息""" + if self.error_label: + self.error_label.config(text="") + + def get_last_result(self) -> Optional[ValidationResult]: + """ + 获取最后一次验证结果 + + Returns: + 最后一次验证的 ValidationResult + """ + return self._last_result diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py index 89fd103..0928440 100644 --- a/gui/widgets/__init__.py +++ b/gui/widgets/__init__.py @@ -6,8 +6,26 @@ GUI 自定义组件模块 from .file_selector import FileSelector from .log_text import LogText -from .production_id_input import ProductionIdInput +try: + from .production_id_input import ProductionIdInput + _has_production_id_input = True +except ImportError: + # tklinenums may not be installed + _has_production_id_input = False from .log_handler import GuiTextHandler from .delete_progress_window import DeleteProgressWindow +from .base_dialog import BaseDialog +from .progress_dialog import ProgressDialog -__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow'] +__all__ = [ + 'FileSelector', + 'LogText', + 'GuiTextHandler', + 'DeleteProgressWindow', + 'BaseDialog', + 'ProgressDialog', +] + +# Add ProductionIdInput to __all__ only if it's available +if _has_production_id_input: + __all__.append('ProductionIdInput') diff --git a/gui/widgets/base_dialog.py b/gui/widgets/base_dialog.py new file mode 100644 index 0000000..70c05a5 --- /dev/null +++ b/gui/widgets/base_dialog.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Base Dialog - 基础对话框类 + +所有对话框的基类,提供通用功能: +- 模态对话框设置 +- 窗口居中显示 +- 统一的对话框生命周期管理 +""" +import tkinter as tk +from typing import Optional + + +class BaseDialog(tk.Toplevel): + """ + 所有对话框的基类,提供通用功能 + + Usage: + class MyDialog(BaseDialog): + def __init__(self, parent): + super().__init__(parent, title="My Dialog") + self._create_content() + + def _create_content(self): + # 创建对话框内容 + pass + + dialog = MyDialog(parent) + result = dialog.get_result() + """ + + def __init__(self, parent, title: str, **kwargs): + """ + 初始化基础对话框 + + Args: + parent: 父窗口 + title: 对话框标题 + **kwargs: 传递给 tk.Toplevel 的其他参数 + """ + super().__init__(parent, **kwargs) + + self.parent = parent + self.result = None + self.title(title) + + # 设置为模态对话框并居中 + self._setup_modal() + + def _setup_modal(self): + """设置为模态对话框并居中""" + self.transient(self.parent) + self.grab_set() + self._center_window() + + def _center_window(self): + """ + 窗口居中显示(统一实现) + + 根据对话框大小自动居中到屏幕中央 + """ + self.update_idletasks() + width = self.winfo_width() + height = self.winfo_height() + + # 如果窗口还没有实际大小,使用默认最小值 + if width <= 1: + width = 400 + if height <= 1: + height = 300 + + x = (self.winfo_screenwidth() // 2) - (width // 2) + y = (self.winfo_screenheight() // 2) - (height // 2) + self.geometry(f'{width}x{height}+{x}+{y}') + + def _center_on_parent(self): + """ + 窗口居中到父窗口 + + 将对话框居中显示在父窗口中央,而不是屏幕中央 + """ + self.update_idletasks() + self.parent.update_idletasks() + + width = self.winfo_width() + height = self.winfo_height() + + # 如果窗口还没有实际大小,使用默认最小值 + if width <= 1: + width = 400 + if height <= 1: + height = 300 + + parent_x = self.parent.winfo_x() + parent_y = self.parent.winfo_y() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + + x = parent_x + (parent_width - width) // 2 + y = parent_y + (parent_height - height) // 2 + self.geometry(f"{width}x{height}+{x}+{y}") + + def get_result(self): + """ + 获取对话框结果 + + 子类应设置 self.result 来返回结果 + + Returns: + 对话框结果,类型由子类定义 + """ + return self.result + + def close(self): + """关闭对话框""" + self.destroy() diff --git a/gui/widgets/delete_progress_window.py b/gui/widgets/delete_progress_window.py index 7f0b1be..5e438f8 100644 --- a/gui/widgets/delete_progress_window.py +++ b/gui/widgets/delete_progress_window.py @@ -10,6 +10,8 @@ import tkinter as tk from tkinter import ttk, scrolledtext from typing import Optional, Callable from datetime import datetime +from gui.log_config import LogTheme +from gui.widgets.base_dialog import BaseDialog # 尝试导入 tkinterweb 和 markdown2 try: @@ -25,7 +27,7 @@ except ImportError: HAS_MARKDOWN2 = False -class DeleteProgressWindow: +class DeleteProgressWindow(BaseDialog): """删除进度窗口""" def __init__( @@ -46,40 +48,51 @@ class DeleteProgressWindow: dryrun: 是否为预览模式 on_cancel: 取消回调函数 """ - self.parent = parent self.on_cancel = on_cancel self.cancelled = False self.managers = managers self.dryrun = dryrun + self.main_frame = None + self.progress_frame = None + self.progress_var = None + self.progress_label = None + self.progress_bar = None + self.log_frame = None + self.log_text = None + self.report_frame = None + self.report_html = None + self.report_text = None + self.cancel_button = None + self.close_button = None - # 创建窗口 - self.window = tk.Toplevel(parent) - self.window.title(title) - self.window.resizable(True, True) - self.window.transient(parent) + # 调用父类初始化(会自动设置为模态并居中) + super().__init__(parent, title) - # 设置窗口大小 - self.window.geometry("700x600") + # 设置可调整大小 + self.resizable(True, True) # 创建内容 self._create_widgets() - # 居中显示 - self._center() + # 设置固定大小并重新居中 + self._set_fixed_size(700, 600) - def _center(self): - """将窗口居中显示""" - self.window.update_idletasks() - width = 700 - height = 600 - x = (self.window.winfo_screenwidth() // 2) - (width // 2) - y = (self.window.winfo_screenheight() // 2) - (height // 2) - self.window.geometry(f"{width}x{height}+{x}+{y}") + def _set_fixed_size(self, width: int, height: int): + """ + 设置固定大小并重新居中 + + Args: + width: 宽度 + height: 高度 + """ + self.update_idletasks() + self.geometry(f"{width}x{height}") + self._center_window() def _create_widgets(self): """创建窗口组件""" # 主容器 - self.main_frame = ttk.Frame(self.window, padding=10) + self.main_frame = ttk.Frame(self, padding=10) self.main_frame.pack(fill=tk.BOTH, expand=True) # 信息区域 @@ -124,11 +137,11 @@ class DeleteProgressWindow: ) self.log_text.pack(fill=tk.BOTH, expand=True) - # 配置日志标签颜色 - self.log_text.tag_configure('info', foreground='black') - self.log_text.tag_configure('success', foreground='green') - self.log_text.tag_configure('warning', foreground='orange') - self.log_text.tag_configure('error', foreground='red') + # 配置日志标签颜色(使用统一主题) + self.log_text.tag_configure('info', foreground=LogTheme.get_color('INFO')) + self.log_text.tag_configure('success', foreground=LogTheme.get_color('SUCCESS')) + self.log_text.tag_configure('warning', foreground=LogTheme.get_color('WARNING')) + self.log_text.tag_configure('error', foreground=LogTheme.get_color('ERROR')) # 报告区域(完成后显示)- 初始隐藏 self.report_frame = ttk.LabelFrame(self.main_frame, text="执行报告", padding=5) @@ -191,7 +204,7 @@ class DeleteProgressWindow: self.progress_var.set(message) else: self.progress_var.set(message) - self.window.update_idletasks() + self.update_idletasks() def append_log(self, message: str, level: str = "info"): """ @@ -208,7 +221,7 @@ class DeleteProgressWindow: self.log_text.insert(tk.END, log_entry, level) self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) - self.window.update_idletasks() + self.update_idletasks() def show_report(self, markdown_content: str): """ @@ -242,7 +255,7 @@ class DeleteProgressWindow: self.report_text.config(state=tk.DISABLED) # 更新标题 - self.window.title("执行报告") + self.title("执行报告") # 隐藏取消按钮,显示关闭按钮 self.cancel_button.pack_forget() @@ -435,7 +448,7 @@ class DeleteProgressWindow: def close(self): """关闭窗口""" - self.window.destroy() + self.destroy() def is_cancelled(self) -> bool: """检查是否已取消""" @@ -444,4 +457,4 @@ class DeleteProgressWindow: def set_completed(self): """设置为完成状态""" self.cancel_button.pack_forget() - self.close_button.pack(side=tk.RIGHT) \ No newline at end of file + self.close_button.pack(side=tk.RIGHT) diff --git a/gui/widgets/log_text.py b/gui/widgets/log_text.py index f9c3d28..b6b241b 100644 --- a/gui/widgets/log_text.py +++ b/gui/widgets/log_text.py @@ -8,19 +8,14 @@ import tkinter as tk from datetime import datetime +from gui.log_config import LogTheme class LogText(tk.Frame): """日志文本框组件(带滚动条)""" - # 日志级别颜色配置 - LOG_COLORS = { - 'INFO': '#000000', # 黑色 - 'SUCCESS': '#008000', # 绿色 - 'WARNING': '#FF8C00', # 深橙色 - 'ERROR': '#FF0000', # 红色 - 'DEBUG': '#808080', # 灰色 - } + # 使用统一日志主题配置 + LOG_COLORS = LogTheme.COLORS def __init__(self, parent, readonly=True, **kwargs): """ diff --git a/gui/widgets/progress_dialog.py b/gui/widgets/progress_dialog.py index 9a3ac41..f45ae9b 100644 --- a/gui/widgets/progress_dialog.py +++ b/gui/widgets/progress_dialog.py @@ -9,9 +9,10 @@ import tkinter as tk from tkinter import ttk from typing import Optional, Callable +from gui.widgets.base_dialog import BaseDialog -class ProgressDialog: +class ProgressDialog(BaseDialog): """进度对话框""" def __init__( @@ -32,42 +33,46 @@ class ProgressDialog: can_cancel: 是否可以取消 on_cancel: 取消回调函数 """ - self.parent = parent self.can_cancel = can_cancel self.on_cancel = on_cancel self.cancelled = False + self.message_label = None + self.progress = None + self.cancel_button = None - # 创建对话框 - self.dialog = tk.Toplevel(parent) - self.dialog.title(title) - self.dialog.resizable(False, False) - self.dialog.transient(parent) - self.dialog.grab_set() + # 调用父类初始化(会自动设置为模态并居中) + super().__init__(parent, title) - # 居中显示 - self._center() + # 设置固定大小 + self.resizable(False, False) # 创建内容 self._create_widgets(message) - def _center(self): - """将对话框居中显示""" - self.dialog.update_idletasks() - width = 400 - height = 150 - x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) - y = (self.dialog.winfo_screenheight() // 2) - (height // 2) - self.dialog.geometry(f"{width}x{height}+{x}+{y}") + # 设置固定大小并重新居中 + self._set_fixed_size(400, 150) + + def _set_fixed_size(self, width: int, height: int): + """ + 设置固定大小并重新居中 + + Args: + width: 宽度 + height: 高度 + """ + self.update_idletasks() + self.geometry(f"{width}x{height}") + self._center_window() def _create_widgets(self, message: str): """创建对话框组件""" # 消息标签 - self.message_label = ttk.Label(self.dialog, text=message, wraplength=380) + self.message_label = ttk.Label(self, text=message, wraplength=380) self.message_label.pack(pady=(20, 10), padx=20) # 进度条 self.progress = ttk.Progressbar( - self.dialog, + self, mode='indeterminate', length=360 ) @@ -76,7 +81,7 @@ class ProgressDialog: # 取消按钮 if self.can_cancel: - button_frame = ttk.Frame(self.dialog) + button_frame = ttk.Frame(self) button_frame.pack(pady=10) self.cancel_button = ttk.Button( @@ -95,8 +100,9 @@ class ProgressDialog: def update_message(self, message: str): """更新显示消息""" - self.message_label.config(text=message) - self.dialog.update_idletasks() + if self.message_label: + self.message_label.config(text=message) + self.update_idletasks() def set_progress(self, value: int, maximum: int = 100): """ @@ -106,14 +112,16 @@ class ProgressDialog: value: 当前进度值 maximum: 最大值 """ - self.progress.config(mode='determinate', maximum=maximum) - self.progress['value'] = value - self.dialog.update_idletasks() + if self.progress: + self.progress.config(mode='determinate', maximum=maximum) + self.progress['value'] = value + self.update_idletasks() def close(self): """关闭对话框""" - self.progress.stop() - self.dialog.destroy() + if self.progress: + self.progress.stop() + super().close() def is_cancelled(self) -> bool: """检查是否已取消"""