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) <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-02-26 21:17:37 +08:00
parent 3a9c6f0978
commit 18f784f067
8 changed files with 648 additions and 294 deletions

View File

@@ -5,3 +5,27 @@ ERP 自动化工具 - GUI 模块
""" """
__version__ = "1.0.0" __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',
]

132
gui/base_tab.py Normal file
View File

@@ -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()

112
gui/constants.py Normal file
View File

@@ -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 = ""

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
数据提取标签页 - 稳定性修复版 数据提取标签页
修复了 LogText.info 不支持 add_timestamp 参数导致的 TypeError 从 ERP 系统提取生产订单数据的标签页
继承自 BaseTab使用统一的日志系统。
""" """
import os import os
@@ -14,6 +15,7 @@ import queue
import tkinter as tk import tkinter as tk
from tkinter import ttk, filedialog, messagebox from tkinter import ttk, filedialog, messagebox
from pathlib import Path from pathlib import Path
from gui.base_tab import BaseTab
from gui.widgets import FileSelector, LogText, ProductionIdInput, GuiTextHandler from gui.widgets import FileSelector, LogText, ProductionIdInput, GuiTextHandler
from gui.config_manager import ConfigManager from gui.config_manager import ConfigManager
from gui.log_config import setup_gui_logging, get_logger 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 from gui.utils import RealtimeOutput
class DataExtractionTab(ttk.Frame): class DataExtractionTab(BaseTab):
"""数据提取标签页""" """数据提取标签页"""
def __init__(self, parent, config: ConfigManager, main_window=None): def __init__(self, parent, config: ConfigManager, main_window=None):
super().__init__(parent) super().__init__(parent, config, main_window)
self.config = config
self.main_window = main_window
self.extracting = False self.extracting = False
self.extractor = None self.extractor = None
self.extraction_thread = None self.extraction_thread = None
@@ -42,10 +42,11 @@ class DataExtractionTab(ttk.Frame):
self.create_widgets() self.create_widgets()
self._apply_ui_config() self._apply_ui_config()
# 初始化日志消息
try: try:
self.log_text.info("数据提取标签页已就绪") self.log_text.info("数据提取标签页已就绪")
except: except Exception as e:
pass self.logger.debug(f"初始化日志消息失败: {e}")
def create_widgets(self): def create_widgets(self):
horizontal_paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) 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) self.production_id_input.apply_font(font_family, font_size)
if hasattr(self.log_text, 'apply_font'): if hasattr(self.log_text, 'apply_font'):
self.log_text.apply_font(font_family, font_size) 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): def _set_pane_width(self, width: int):
try: self.horizontal_paned.sashpos(0, width) try:
except: pass self.horizontal_paned.sashpos(0, width)
except tk.TclError as e:
self.logger.debug(f"设置窗格宽度失败: {e}")
def start_extraction(self): def start_extraction(self):
production_ids = self.production_id_input.get() production_ids = self.production_id_input.get()
@@ -208,8 +212,10 @@ class DataExtractionTab(ttk.Frame):
self._update_log(f"运行时错误: {str(e)}", "ERROR") self._update_log(f"运行时错误: {str(e)}", "ERROR")
finally: finally:
if temp_file and os.path.exists(temp_file): if temp_file and os.path.exists(temp_file):
try: os.unlink(temp_file) try:
except: pass os.unlink(temp_file)
except OSError as e:
self.logger.debug(f"清理临时文件失败: {e}")
self.after(0, self._extraction_complete) self.after(0, self._extraction_complete)
def _extraction_complete(self): def _extraction_complete(self):
@@ -225,32 +231,18 @@ class DataExtractionTab(ttk.Frame):
value, message = self.progress_queue.get_nowait() value, message = self.progress_queue.get_nowait()
self.progress_bar["value"] = value self.progress_bar["value"] = value
self.status_label.config(text=message) self.status_label.config(text=message)
except queue.Empty: break except queue.Empty:
finally: self.after(50, self._poll_progress_queue) 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): def _update_progress(self, value: int, message: str):
try: self.progress_queue.put_nowait((value, message)) try:
except: pass self.progress_queue.put_nowait((value, message))
except queue.Full as e:
def _update_log(self, message: str, level: str = "INFO"): self.logger.debug(f"进度队列已满: {e}")
"""
标准的日志更新方法(兼容接口)
通过统一的 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 _on_production_ids_changed(self, event=None): def _on_production_ids_changed(self, event=None):
if self.main_window: if self.main_window:

View File

@@ -18,7 +18,8 @@ from pathlib import Path
from io import StringIO from io import StringIO
from contextlib import redirect_stdout from contextlib import redirect_stdout
from typing import List, Dict 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.config_manager import ConfigManager
from gui.log_config import setup_gui_logging, get_logger from gui.log_config import setup_gui_logging, get_logger
from gui.material_type_management_dialog import MaterialTypeManagementDialog from gui.material_type_management_dialog import MaterialTypeManagementDialog
@@ -27,232 +28,7 @@ import pandas as pd
import tempfile import tempfile
class CheckboxTreeview(ttk.Treeview): class MaterialValidationTab(BaseTab):
"""支持 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("<Button-1>", self._on_click)
# 绑定表头点击事件
self.bind("<ButtonRelease-1>", 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):
"""物料校验标签页""" """物料校验标签页"""
def __init__(self, parent, config: ConfigManager, session_manager, main_window=None): def __init__(self, parent, config: ConfigManager, session_manager, main_window=None):
@@ -265,10 +41,8 @@ class MaterialValidationTab(ttk.Frame):
session_manager: SessionManager 实例,用于权限控制 session_manager: SessionManager 实例,用于权限控制
main_window: 主窗口引用,用于获取共享的 Production ID main_window: 主窗口引用,用于获取共享的 Production ID
""" """
super().__init__(parent) super().__init__(parent, config, main_window)
self.config = config
self.session_manager = session_manager self.session_manager = session_manager
self.main_window = main_window
self.validating = False self.validating = False
self.validation_results = None self.validation_results = None
self.material_records_cache = None # 缓存完整的物料记录 self.material_records_cache = None # 缓存完整的物料记录
@@ -290,8 +64,8 @@ class MaterialValidationTab(ttk.Frame):
# 稍后显示就绪消息 # 稍后显示就绪消息
try: try:
self.log_text.info("物料校验标签页已就绪") self.log_text.info("物料校验标签页已就绪")
except: except Exception as e:
pass # 如果窗口还未完全就绪,忽略错误 self.logger.debug(f"初始化日志消息失败: {e}")
def create_widgets(self): 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): if temp_production_id_file and os.path.exists(temp_production_id_file):
try: try:
os.unlink(temp_production_id_file) os.unlink(temp_production_id_file)
except: except OSError as e:
pass self.logger.debug(f"清理临时文件失败: {e}")
# 更新 UI 状态 # 更新 UI 状态
self.after(0, self._validation_complete) self.after(0, self._validation_complete)
@@ -1441,26 +1215,6 @@ class MaterialValidationTab(ttk.Frame):
# 刷新筛选器和结果(可能添加了新的负责人) # 刷新筛选器和结果(可能添加了新的负责人)
self._initialize_manager_filter() 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): def export_results(self):
"""导出结果到 Excel""" """导出结果到 Excel"""
# 从配置获取输出文件路径 # 从配置获取输出文件路径
@@ -1676,8 +1430,8 @@ class MaterialValidationTab(ttk.Frame):
try: try:
if 'temp_file' in locals() and os.path.exists(temp_file): if 'temp_file' in locals() and os.path.exists(temp_file):
os.unlink(temp_file) os.unlink(temp_file)
except: except OSError as e:
pass self.logger.debug(f"清理临时文件失败: {e}")
def _delete_complete(self, report: str, stats: dict): def _delete_complete(self, report: str, stats: dict):
""" """

View File

@@ -6,6 +6,8 @@ GUI 工具模块
提供 GUI 相关的工具类和函数。 提供 GUI 相关的工具类和函数。
""" """
import functools
class RealtimeOutput: class RealtimeOutput:
"""实时输出流,每次写入立即回调通知""" """实时输出流,每次写入立即回调通知"""
@@ -37,3 +39,91 @@ class RealtimeOutput:
def isatty(self): def isatty(self):
"""返回 False表示不是终端""" """返回 False表示不是终端"""
return 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

View File

@@ -9,5 +9,13 @@ from .log_text import LogText
from .production_id_input import ProductionIdInput from .production_id_input import ProductionIdInput
from .log_handler import GuiTextHandler from .log_handler import GuiTextHandler
from .delete_progress_window import DeleteProgressWindow from .delete_progress_window import DeleteProgressWindow
from .checkbox_treeview import CheckboxTreeview
__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow'] __all__ = [
'FileSelector',
'LogText',
'ProductionIdInput',
'GuiTextHandler',
'DeleteProgressWindow',
'CheckboxTreeview'
]

View File

@@ -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("<Button-1>", self._on_click)
# 绑定表头点击事件
self.bind("<ButtonRelease-1>", 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)