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"
# 导出常用模块
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
# -*- 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:

View File

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

View File

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

View File

@@ -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'
]

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)