From 18f784f0670735f674c883c4f21ac664723012b8 Mon Sep 17 00:00:00 2001 From: Misaka Date: Thu, 26 Feb 2026 21:17:37 +0800 Subject: [PATCH] refactor: extract common tab functionality to BaseTab and add decorators - Add BaseTab base class for common tab initialization and logging - Add constants module for shared GUI constants - Move CheckboxTreeview to separate widget file - Add admin_only and require_session decorators to utils.py - Refactor DataExtractionTab and MaterialValidationTab to inherit BaseTab - Remove duplicate _update_log method from tab classes Co-Authored-By: Claude (glm-5) --- gui/__init__.py | 24 +++ gui/base_tab.py | 132 +++++++++++++++ gui/constants.py | 112 +++++++++++++ gui/data_extraction_tab.py | 66 ++++---- gui/material_validation_tab.py | 266 ++----------------------------- gui/utils.py | 90 +++++++++++ gui/widgets/__init__.py | 10 +- gui/widgets/checkbox_treeview.py | 242 ++++++++++++++++++++++++++++ 8 files changed, 648 insertions(+), 294 deletions(-) create mode 100644 gui/base_tab.py create mode 100644 gui/constants.py create mode 100644 gui/widgets/checkbox_treeview.py diff --git a/gui/__init__.py b/gui/__init__.py index 86a82f7..f6c5b8d 100644 --- a/gui/__init__.py +++ b/gui/__init__.py @@ -5,3 +5,27 @@ ERP 自动化工具 - GUI 模块 """ __version__ = "1.0.0" + +# 导出常用模块 +from .base_tab import BaseTab +from .constants import ( + WINDOW_SIZE, + MIN_WINDOW_SIZE, + POLL_INTERVAL_MS, + LOG_COLORS, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, +) +from .utils import admin_only, require_session + +__all__ = [ + 'BaseTab', + 'WINDOW_SIZE', + 'MIN_WINDOW_SIZE', + 'POLL_INTERVAL_MS', + 'LOG_COLORS', + 'DEFAULT_FONT_FAMILY', + 'DEFAULT_FONT_SIZE', + 'admin_only', + 'require_session', +] diff --git a/gui/base_tab.py b/gui/base_tab.py new file mode 100644 index 0000000..7197967 --- /dev/null +++ b/gui/base_tab.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +BaseTab - 标签页基类 + +提供所有标签页共享的通用功能,包括: +- 统一的日志更新方法 +- 线程安全的 GUI 操作 +- 通用工具方法 +""" + +import logging +import tkinter as tk +from tkinter import ttk + + +class BaseTab(ttk.Frame): + """标签页基类 + + 提供所有标签页共享的通用功能。 + 子类应继承此类并实现 create_widgets 方法。 + """ + + def __init__(self, parent, config=None, main_window=None): + """初始化基类 + + Args: + parent: 父容器 + config: 配置管理器(可选) + main_window: 主窗口引用(可选) + """ + super().__init__(parent) + self.config = config + self.main_window = main_window + + def _update_log(self, message: str, level: str = "INFO"): + """ + 线程安全的日志更新方法 + + 通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。 + + Args: + message: 日志消息 + level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG) + + Note: + 子类需要设置 self.logger 和 self._gui_handler 才能使用此方法。 + 如果 self.logger 未设置,将使用 logging.getLogger(__name__) 作为后备。 + """ + # 获取 logger(优先使用实例的 logger,否则使用模块 logger) + logger = getattr(self, 'logger', None) or logging.getLogger(__name__) + + # 将自定义级别映射到 logging 级别 + level_upper = level.upper() + if level_upper == "SUCCESS": + # SUCCESS 映射到 INFO,但在 UI 中仍显示为 SUCCESS + logger.info(message) + else: + # 其他级别直接映射 + log_level = getattr(logging, level_upper, logging.INFO) + logger.log(log_level, message) + + def _run_on_main_thread(self, callback, *args, **kwargs): + """在主线程中执行回调函数(线程安全) + + Args: + callback: 要执行的回调函数 + *args: 位置参数 + **kwargs: 关键字参数 + + Note: + 使用 after(0, ...) 确保在主线程中执行。 + """ + self.after(0, lambda: callback(*args, **kwargs)) + + def _is_admin(self) -> bool: + """检查当前用户是否为管理员 + + Returns: + bool: 如果是管理员返回 True,否则返回 False + + Note: + 需要 main_window 或 session_manager 支持。 + """ + # 尝试从 session_manager 获取 + if hasattr(self, 'session_manager') and self.session_manager: + return self.session_manager.is_admin() + + # 尝试从 main_window 获取 + if self.main_window and hasattr(self.main_window, 'session_manager'): + return self.main_window.session_manager.is_admin() + + return False + + def _get_username(self) -> str: + """获取当前用户名 + + Returns: + str: 当前用户名,如果无法获取则返回空字符串 + + Note: + 需要 session_manager 支持。 + """ + if hasattr(self, 'session_manager') and self.session_manager: + return self.session_manager.get_username() or "" + + if self.main_window and hasattr(self.main_window, 'session_manager'): + return self.main_window.session_manager.get_username() or "" + + return "" + + def set_busy(self, busy: bool): + """设置窗口忙碌状态(显示等待光标) + + Args: + busy: True 显示等待光标,False 恢复正常光标 + """ + try: + cursor = "watch" if busy else "arrow" + # 获取顶层窗口 + toplevel = self.winfo_toplevel() + if toplevel: + toplevel.config(cursor=cursor) + toplevel.update() + except tk.TclError: + # 窗口可能已被销毁 + pass + + def reload_config(self): + """重新加载配置(子类可覆盖此方法)""" + if self.config and hasattr(self.config, 'reload'): + self.config.reload() \ No newline at end of file diff --git a/gui/constants.py b/gui/constants.py new file mode 100644 index 0000000..c0245d5 --- /dev/null +++ b/gui/constants.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +GUI 常量模块 + +集中管理 GUI 相关的常量配置,包括: +- 窗口尺寸 +- 进度条轮询间隔 +- 日志颜色 +- 默认字体配置 +""" + +# ============================================================================ +# 窗口尺寸 +# ============================================================================ + +# 主窗口默认尺寸 +WINDOW_SIZE = (1000, 700) + +# 主窗口最小尺寸 +MIN_WINDOW_SIZE = (800, 600) + +# 日志面板默认高度(行数) +LOG_PANEL_HEIGHT = 15 + +# 结果表格默认高度(行数) +RESULT_TABLE_HEIGHT = 10 + +# ============================================================================ +# 时间间隔(毫秒) +# ============================================================================ + +# 进度队列轮询间隔 +POLL_INTERVAL_MS = 50 + +# UI 更新延迟 +UI_UPDATE_DELAY_MS = 100 + +# 标题存储延迟 +HEADING_STORE_DELAY_MS = 100 + +# ============================================================================ +# 日志颜色 +# ============================================================================ + +# 日志级别对应的颜色 +LOG_COLORS = { + "INFO": "#000000", # 黑色 + "SUCCESS": "#008000", # 绿色 + "WARNING": "#FF8C00", # 橙色 + "ERROR": "#FF0000", # 红色 + "DEBUG": "#808080", # 灰色 +} + +# ============================================================================ +# 字体配置 +# ============================================================================ + +# 默认字体 +DEFAULT_FONT_FAMILY = "Microsoft YaHei UI" + +# 默认字号 +DEFAULT_FONT_SIZE = 10 + +# 可用字体列表 +AVAILABLE_FONTS = [ + "Microsoft YaHei UI", + "SimSun", + "KaiTi", + "FangSong", + "Arial", + "Segoe UI", +] + +# ============================================================================ +# 文件类型过滤器 +# ============================================================================ + +# Excel 文件过滤器 +EXCEL_FILE_TYPES = [("Excel 文件", "*.xlsx"), ("所有文件", "*.*")] + +# 文本文件过滤器 +TEXT_FILE_TYPES = [("文本文件", "*.txt"), ("所有文件", "*.*")] + +# ============================================================================ +# 默认值 +# ============================================================================ + +# 默认数据目录 +DEFAULT_DATA_DIR = "data/" + +# 默认输出文件名 +DEFAULT_OUTPUT_FILE = "离散备料计划维护_合并.xlsx" + +# 默认校验输出文件名 +DEFAULT_VALIDATION_OUTPUT = "物料状态校验结果.xlsx" + +# 默认批次大小 +DEFAULT_BATCH_SIZE = 100 + +# 默认数据库批次大小 +DEFAULT_DB_BATCH_SIZE = 2000 + +# ============================================================================ +# 复选框字符 +# ============================================================================ + +# 选中状态 +CHECKBOX_CHECKED = "☑" + +# 未选中状态 +CHECKBOX_UNCHECKED = "☐" \ No newline at end of file diff --git a/gui/data_extraction_tab.py b/gui/data_extraction_tab.py index 31c485b..02f53f4 100644 --- a/gui/data_extraction_tab.py +++ b/gui/data_extraction_tab.py @@ -1,9 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -数据提取标签页 - 稳定性修复版 +数据提取标签页 -修复了 LogText.info 不支持 add_timestamp 参数导致的 TypeError。 +从 ERP 系统提取生产订单数据的标签页。 +继承自 BaseTab,使用统一的日志系统。 """ import os @@ -14,6 +15,7 @@ import queue import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path +from gui.base_tab import BaseTab from gui.widgets import FileSelector, LogText, ProductionIdInput, GuiTextHandler from gui.config_manager import ConfigManager from gui.log_config import setup_gui_logging, get_logger @@ -21,13 +23,11 @@ from gui.progress import ProgressInfo, ProgressCalculator from gui.utils import RealtimeOutput -class DataExtractionTab(ttk.Frame): +class DataExtractionTab(BaseTab): """数据提取标签页""" def __init__(self, parent, config: ConfigManager, main_window=None): - super().__init__(parent) - self.config = config - self.main_window = main_window + super().__init__(parent, config, main_window) self.extracting = False self.extractor = None self.extraction_thread = None @@ -42,10 +42,11 @@ class DataExtractionTab(ttk.Frame): self.create_widgets() self._apply_ui_config() + # 初始化日志消息 try: self.log_text.info("数据提取标签页已就绪") - except: - pass + except Exception as e: + self.logger.debug(f"初始化日志消息失败: {e}") def create_widgets(self): horizontal_paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) @@ -137,11 +138,14 @@ class DataExtractionTab(ttk.Frame): self.production_id_input.apply_font(font_family, font_size) if hasattr(self.log_text, 'apply_font'): self.log_text.apply_font(font_family, font_size) - except: pass + except Exception as e: + self.logger.debug(f"应用 UI 配置失败: {e}") def _set_pane_width(self, width: int): - try: self.horizontal_paned.sashpos(0, width) - except: pass + try: + self.horizontal_paned.sashpos(0, width) + except tk.TclError as e: + self.logger.debug(f"设置窗格宽度失败: {e}") def start_extraction(self): production_ids = self.production_id_input.get() @@ -208,8 +212,10 @@ class DataExtractionTab(ttk.Frame): self._update_log(f"运行时错误: {str(e)}", "ERROR") finally: if temp_file and os.path.exists(temp_file): - try: os.unlink(temp_file) - except: pass + try: + os.unlink(temp_file) + except OSError as e: + self.logger.debug(f"清理临时文件失败: {e}") self.after(0, self._extraction_complete) def _extraction_complete(self): @@ -225,32 +231,18 @@ class DataExtractionTab(ttk.Frame): value, message = self.progress_queue.get_nowait() self.progress_bar["value"] = value self.status_label.config(text=message) - except queue.Empty: break - finally: self.after(50, self._poll_progress_queue) + except queue.Empty: + break + except Exception as e: + self.logger.debug(f"轮询进度队列失败: {e}") + finally: + self.after(50, self._poll_progress_queue) def _update_progress(self, value: int, message: str): - try: self.progress_queue.put_nowait((value, message)) - except: pass - - def _update_log(self, message: str, level: str = "INFO"): - """ - 标准的日志更新方法(兼容接口) - - 通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。 - - Args: - message: 日志消息 - level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG) - """ - # 将自定义级别映射到 logging 级别 - level_upper = level.upper() - if level_upper == "SUCCESS": - # SUCCESS 映射到 INFO,但在 UI 中仍显示为 SUCCESS - self.logger.info(message) - else: - # 其他级别直接映射 - log_level = getattr(logging, level_upper, logging.INFO) - self.logger.log(log_level, message) + try: + self.progress_queue.put_nowait((value, message)) + except queue.Full as e: + self.logger.debug(f"进度队列已满: {e}") def _on_production_ids_changed(self, event=None): if self.main_window: diff --git a/gui/material_validation_tab.py b/gui/material_validation_tab.py index 70033e8..adcf098 100644 --- a/gui/material_validation_tab.py +++ b/gui/material_validation_tab.py @@ -18,7 +18,8 @@ from pathlib import Path from io import StringIO from contextlib import redirect_stdout from typing import List, Dict -from gui.widgets import FileSelector, LogText, GuiTextHandler, DeleteProgressWindow +from gui.base_tab import BaseTab +from gui.widgets import FileSelector, LogText, GuiTextHandler, DeleteProgressWindow, CheckboxTreeview from gui.config_manager import ConfigManager from gui.log_config import setup_gui_logging, get_logger from gui.material_type_management_dialog import MaterialTypeManagementDialog @@ -27,232 +28,7 @@ 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): +class MaterialValidationTab(BaseTab): """物料校验标签页""" def __init__(self, parent, config: ConfigManager, session_manager, main_window=None): @@ -265,10 +41,8 @@ class MaterialValidationTab(ttk.Frame): session_manager: SessionManager 实例,用于权限控制 main_window: 主窗口引用,用于获取共享的 Production ID """ - super().__init__(parent) - self.config = config + super().__init__(parent, config, main_window) self.session_manager = session_manager - self.main_window = main_window self.validating = False self.validation_results = None self.material_records_cache = None # 缓存完整的物料记录 @@ -290,8 +64,8 @@ class MaterialValidationTab(ttk.Frame): # 稍后显示就绪消息 try: self.log_text.info("物料校验标签页已就绪") - except: - pass # 如果窗口还未完全就绪,忽略错误 + except Exception as e: + self.logger.debug(f"初始化日志消息失败: {e}") def create_widgets(self): """创建界面组件""" @@ -1179,8 +953,8 @@ class MaterialValidationTab(ttk.Frame): if temp_production_id_file and os.path.exists(temp_production_id_file): try: os.unlink(temp_production_id_file) - except: - pass + except OSError as e: + self.logger.debug(f"清理临时文件失败: {e}") # 更新 UI 状态 self.after(0, self._validation_complete) @@ -1441,26 +1215,6 @@ class MaterialValidationTab(ttk.Frame): # 刷新筛选器和结果(可能添加了新的负责人) self._initialize_manager_filter() - def _update_log(self, message: str, level: str = "INFO"): - """ - 线程安全的日志更新(兼容接口) - - 通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。 - - Args: - message: 日志消息 - level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG) - """ - # 将自定义级别映射到 logging 级别 - level_upper = level.upper() - if level_upper == "SUCCESS": - # SUCCESS 映射到 INFO,但在 UI 中仍显示为 SUCCESS - self.logger.info(message) - else: - # 其他级别直接映射 - log_level = getattr(logging, level_upper, logging.INFO) - self.logger.log(log_level, message) - def export_results(self): """导出结果到 Excel""" # 从配置获取输出文件路径 @@ -1676,8 +1430,8 @@ class MaterialValidationTab(ttk.Frame): try: if 'temp_file' in locals() and os.path.exists(temp_file): os.unlink(temp_file) - except: - pass + except OSError as e: + self.logger.debug(f"清理临时文件失败: {e}") def _delete_complete(self, report: str, stats: dict): """ diff --git a/gui/utils.py b/gui/utils.py index 27044fe..50169ed 100644 --- a/gui/utils.py +++ b/gui/utils.py @@ -6,6 +6,8 @@ GUI 工具模块 提供 GUI 相关的工具类和函数。 """ +import functools + class RealtimeOutput: """实时输出流,每次写入立即回调通知""" @@ -37,3 +39,91 @@ class RealtimeOutput: def isatty(self): """返回 False,表示不是终端""" return False + + +def admin_only(func): + """ + 管理员权限装饰器 + + 用于标记需要管理员权限的方法。如果当前用户不是管理员, + 方法将不执行任何操作并返回 None。 + + Usage: + @admin_only + def some_admin_function(self): + # 只有管理员才能执行的代码 + pass + + Note: + - 被装饰的方法必须属于一个有 session_manager 属性的对象 + - session_manager 必须有 is_admin() 方法 + + Returns: + 如果是管理员,返回原函数的结果;否则返回 None + """ + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # 尝试从 self 获取 session_manager + session_manager = getattr(self, 'session_manager', None) + + # 如果没有 session_manager,尝试从 main_window 获取 + if session_manager is None: + main_window = getattr(self, 'main_window', None) + if main_window: + session_manager = getattr(main_window, 'session_manager', None) + + # 检查是否为管理员 + if session_manager and hasattr(session_manager, 'is_admin'): + if session_manager.is_admin(): + return func(self, *args, **kwargs) + + # 非管理员,记录日志并返回 None + logger = getattr(self, 'logger', None) + if logger: + logger.debug(f"权限拒绝: {func.__name__} 需要管理员权限") + + return None + + return wrapper + + +def require_session(func): + """ + 会话验证装饰器 + + 确保方法执行时有有效的会话。如果会话无效, + 方法将不执行任何操作并返回 None。 + + Usage: + @require_session + def some_function(self): + # 需要有效会话才能执行的代码 + pass + + Returns: + 如果有有效会话,返回原函数的结果;否则返回 None + """ + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # 尝试从 self 获取 session_manager + session_manager = getattr(self, 'session_manager', None) + + # 如果没有 session_manager,尝试从 main_window 获取 + if session_manager is None: + main_window = getattr(self, 'main_window', None) + if main_window: + session_manager = getattr(main_window, 'session_manager', None) + + # 检查会话是否有效 + if session_manager and hasattr(session_manager, 'is_authenticated'): + if session_manager.is_authenticated(): + return func(self, *args, **kwargs) + + # 会话无效,记录日志并返回 None + logger = getattr(self, 'logger', None) + if logger: + logger.warning(f"会话无效: {func.__name__} 需要有效会话") + + return None + + return wrapper diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py index 89fd103..5eb79f6 100644 --- a/gui/widgets/__init__.py +++ b/gui/widgets/__init__.py @@ -9,5 +9,13 @@ from .log_text import LogText from .production_id_input import ProductionIdInput from .log_handler import GuiTextHandler from .delete_progress_window import DeleteProgressWindow +from .checkbox_treeview import CheckboxTreeview -__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow'] +__all__ = [ + 'FileSelector', + 'LogText', + 'ProductionIdInput', + 'GuiTextHandler', + 'DeleteProgressWindow', + 'CheckboxTreeview' +] diff --git a/gui/widgets/checkbox_treeview.py b/gui/widgets/checkbox_treeview.py new file mode 100644 index 0000000..b028f79 --- /dev/null +++ b/gui/widgets/checkbox_treeview.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +CheckboxTreeview 组件 + +支持 checkbox 的 Treeview 组件,使用 Unicode 字符模拟 checkbox: +- ☐ 未选中 +- ☑ 选中 +""" + +import tkinter as tk +from tkinter import ttk + + +class CheckboxTreeview(ttk.Treeview): + """支持 checkbox 的 Treeview 组件 + + 使用 Unicode 字符模拟 checkbox: + - ☐ 未选中 + - ☑ 选中 + + Features: + - 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) \ No newline at end of file