diff --git a/docs/LOGGING_MECHANISM.md b/docs/LOGGING_MECHANISM.md new file mode 100644 index 0000000..21c96b8 --- /dev/null +++ b/docs/LOGGING_MECHANISM.md @@ -0,0 +1,434 @@ +# GUI 日志工作机制说明 + +## 概述 + +本文档说明了 ERP 自动化工具中 GUI 日志系统的工作机制,包括日志从产生到显示的完整流程。 + +## 架构概览 + +```mermaid +graph TB + subgraph Utils["Utils 脚本层"] + A1[离散备料计划维护数据提取.py] + A2[离散备料计划维护数据清理.py] + A3[material_status_validator.py] + A4[_log 方法] + end + + subgraph GUI["GUI 层"] + B1[DataExtractionTab] + B2[MaterialValidationTab] + B3[_update_log 方法] + B4[LogText 组件] + end + + subgraph Logging["Logging 框架"] + C1[Python logging 模块] + C2[GuiTextHandler] + end + + A1 -->|logger.info| C1 + A2 -->|logger.info| C1 + A3 -->|logger.info| C1 + A1 -->|progress_callback| B1 + A2 -->|progress_callback| B2 + A3 -->|progress_callback| B2 + + B1 -->|_update_log| C1 + B2 -->|_update_log| C1 + B1 -->|直接调用| B4 + B2 -->|直接调用| B4 + + C1 -->|日志记录| C2 + C2 -->|清理消息| B4 + B4 -->|添加格式| Display[用户界面] + + style A1 fill:#e1f5ff + style A2 fill:#e1f5ff + style A3 fill:#e1f5ff + style B1 fill:#fff4e1 + style B2 fill:#fff4e1 + style B4 fill:#e8f5e9 + style C1 fill:#f3e5f5 + style C2 fill:#f3e5f5 +``` + +## 组件职责 + +### 1. Utils 脚本层 + +**职责**: 业务逻辑执行和日志产生 + +**主要文件**: +- `utils/离散备料计划维护数据提取.py` +- `utils/离散备料计划维护数据清理.py` +- `utils/material_status_validator.py` + +**日志输出方式**: +```python +def _log(self, message, level="info"): + """统一日志出口:同步分发到控制台和 UI 回调""" + level = level.lower() + # 方式1: 输出到控制台(添加级别标记) + log_map = { + "info": logger.info, + "warn": logger.warning, + "error": logger.error + } + log_func = log_map.get(level, logger.info) + log_func(message) # 输出: "2026-02-13 21:42:03 [INFO] message" + + # 方式2: 同步到 UI(通过回调) + if self.progress_callback: + self._report_progress("log", 0, 0, message, log_level=level.upper()) +``` + +**问题**: 消息中可能包含 `[INFO]`、`[ERROR]` 等级别前缀 + +### 2. GUI 层 + +#### 2.1 Tab 组件 (DataExtractionTab, MaterialValidationTab) + +**职责**: 用户交互和业务逻辑调用 + +**日志处理**: +```python +def _update_log(self, message: str, level: str = "INFO"): + """线程安全的日志更新""" + # 将自定义级别映射到 logging 级别 + level_upper = level.upper() + if level_upper == "SUCCESS": + self.logger.info(message) + else: + log_level = getattr(logging, level_upper, logging.INFO) + self.logger.log(log_level, message) +``` + +**初始化**: +```python +def __init__(self, parent, config: ConfigManager, main_window=None): + # ... + self.logger = get_logger(__name__) + self._gui_handler = None # 将在 _create_log_panel 中设置 + +def _create_log_panel(self, parent): + self.log_text = LogText(parent, height=15, readonly=True) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # 设置 GUI 日志处理器 + self._gui_handler = GuiTextHandler(self.log_text) + self._gui_handler.setFormatter(logging.Formatter( + '%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + )) + self.logger.addHandler(self._gui_handler) +``` + +#### 2.2 LogText 组件 + +**职责**: 日志显示和格式化 + +**核心方法**: +```python +def log(self, message: str, level: str = 'INFO') -> None: + """添加日志消息""" + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + log_message = f"[{timestamp}] [{level}] {message}\n" + + # 插入文本并设置颜色 + tag = level.lower() + self.text.insert('end', log_message, (tag,)) + self.text.see('end') # 自动滚动到底部 +``` + +**级别颜色映射**: +```python +LOG_COLORS = { + 'INFO': '#000000', # 黑色 + 'SUCCESS': '#008000', # 绿色 + 'WARNING': '#FF8C00', # 深橙色 + 'ERROR': '#FF0000', # 红色 + 'DEBUG': '#808080', # 灰色 +} +``` + +### 3. Logging 框架层 + +#### 3.1 Log Config (gui/log_config.py) + +**职责**: 全局日志配置 + +```python +def setup_gui_logging(level=logging.INFO): + """初始化 GUI 应用的日志配置""" + logging.basicConfig( + level=level, + format=LOG_FORMAT, # '%(asctime)s [%(levelname)s] %(message)s' + datefmt=DATE_FORMAT, # '%Y-%m-%d %H:%M:%S' + force=True + ) + return logging.getLogger() +``` + +#### 3.2 GuiTextHandler (gui/widgets/log_handler.py) + +**职责**: 桥接 logging 模块和 GUI + +**核心逻辑**: +```python +class GuiTextHandler(logging.Handler): + def emit(self, record: logging.LogRecord): + """实现日志输出""" + if not self.log_text: + return + + try: + # 1. 获取日志级别 + level = self.level_map.get(record.levelno, 'INFO') + + # 2. 获取纯消息内容(不含格式) + message = record.getMessage() + + # 3. 移除冗余级别前缀(如 "[INFO] ") + message = self._strip_redundant_level_prefix(message) + + # 4. 线程安全地更新 GUI + def update(): + self.log_text.log(message, level) + + # 5. 使用 after 确保在主线程更新 + widget.master.after(0, update) + except Exception: + self.handleError(record) +``` + +**清理冗余级别前缀**: +```python +def _strip_redundant_level_prefix(self, message: str) -> str: + """移除消息开头的冗余级别标记""" + level_pattern = r'^\[(?:INFO|WARNING|ERROR|DEBUG|CRITICAL|WARN|SUCCESS)\]\s*' + match = re.match(level_pattern, message) + if match: + return message[match.end():] + return message +``` + +## 日志流程详解 + +### 场景 1: Utils 脚本 → 控制台 → GUI + +```mermaid +sequenceDiagram + participant U as Utils 脚本 + participant L as Logger + participant C as Console + participant G as GuiTextHandler + participant T as LogText + participant UI as 用户界面 + + U->>U: _log("读取文件", "info") + Note over U: 业务逻辑执行 + + U->>L: logger.info("[INFO] 读取文件") + Note over U,L: 1. 控制台输出 + + L->>C: 2026-02-13 21:42:03 [INFO] [INFO] 读取文件 + Note over C: 控制台显示(可能有冗余级别) + + U->>G: progress_callback(log, message, log_level="INFO") + Note over U,G: 2. UI 回调 + + G->>G: _strip_redundant_level_prefix("[INFO] 读取文件") + Note over G: 清理: "[INFO] " -> "" + + G->>T: log("读取文件", "INFO") + Note over G,T: 纯净消息 + + T->>UI: [2026-02-13 21:42:03] [INFO] 读取文件 + Note over UI: GUI 显示(格式统一) +``` + +### 场景 2: GUI 直接调用 → Logging → GUI + +```mermaid +sequenceDiagram + participant Tab as Tab 组件 + participant L as Logger + participant G as GuiTextHandler + participant T as LogText + participant UI as 用户界面 + + Tab->>L: _update_log("开始校验", "INFO") + Note over Tab,L: 用户操作触发 + + L->>L: logger.info("开始校验") + + L->>G: emit(LogRecord) + Note over L,G: Logging 框架分发 + + G->>G: record.getMessage() = "开始校验" + Note over G: 获取纯消息 + + G->>G: _strip_redundant_level_prefix("开始校验") + Note over G: 检查并清理(此处无冗余) + + G->>T: log("开始校验", "INFO") + Note over G,T: 跨线程调用 + + T->>UI: [2026-02-13 21:42:03] [INFO] 开始校验 + Note over UI: GUI 显示 +``` + +## 数据流转分析 + +### 消息内容的变化 + +| 阶段 | 消息内容 | 说明 | +|------|---------|------| +| Utils 原始消息 | `"读取 ProductionID 文件"` | 业务逻辑产生 | +| logger.info() 后 | `"2026-02-13 21:42:03 [INFO] 读取 ProductionID 文件"` | 控制台格式化 | +| progress_callback 传递 | `"读取 ProductionID 文件"` | 原始消息(可能含 `[INFO] ` 前缀) | +| GuiTextHandler 处理后 | `"读取 ProductionID 文件"` | 移除冗余前缀 | +| LogText.log() 添加 | `"[2026-02-13 21:42:03] [INFO] 读取 ProductionID 文件"` | GUI 格式化 | +| 用户界面显示 | `[2026-02-13 21:42:03] [INFO] 读取 ProductionID 文件` | 最终显示 | + +### 级别映射 + +| 层级 | 级别值 | 说明 | +|------|--------|------| +| Utils _log() | `"info"` / `"warn"` / `"error"` | 小写字符串 | +| progress_callback | `"INFO"` / `"WARNING"` / `"ERROR"` | 大写字符串 | +| logging 模块 | `logging.INFO` / `logging.WARNING` / `logging.ERROR` | 整数常量 | +| LogText 组件 | `"INFO"` / `"WARNING"` / `"ERROR"` / `"SUCCESS"` | 字符串 | +| GuiTextHandler level_map | 字典映射 `logging.INFO -> 'INFO'` | 转换逻辑 | + +## 当前问题分析 + +### 问题 1: 双重输出路径 + +**现状**: Utils 脚本同时通过两种方式输出日志 +1. `logger.info(message)` → 控制台 +2. `progress_callback(log, message)` → GUI + +**影响**: +- 控制台日志和 GUI 日志可能不一致 +- 增加维护复杂度 + +**建议**: +- 统一使用 logging 模块 +- GuiTextHandler 自动输出到控制台和 GUI + +### 问题 2: 消息中包含级别前缀 + +**现状**: +```python +# Utils 代码 +logger.info("[INFO] 读取 ProductionID 文件") +``` + +**影响**: +- 消息格式不统一 +- 需要额外的清理逻辑 + +**建议**: +```python +# 推荐做法 +logger.info("读取 ProductionID 文件") # 不包含级别前缀 +``` + +### 问题 3: 线程同步复杂度 + +**现状**: +- Utils 脚本在后台线程执行 +- 使用 `progress_callback` 线程安全地更新 GUI +- GuiTextHandler 也使用 `after()` 确保主线程更新 + +**影响**: +- 两次线程转换 +- 代码路径复杂 + +**建议**: +- 统一使用 logging 模块 +- 利用 logging 的线程安全特性 +- GuiTextHandler 内部处理线程同步 + +## 改进建议 + +### 短期优化(保持兼容) + +1. **统一 Utils 脚本的日志格式** + ```python + # 当前 + def _log(self, message, level="info"): + log_func(message) # 可能包含 "[INFO] " 前缀 + + # 改进 + def _log(self, message, level="info"): + # 确保消息不包含级别前缀 + clean_message = self._strip_level_prefix(message) + log_func(clean_message) + ``` + +2. **简化 progress_callback** + ```python + # 当前 + self._report_progress("log", 0, 0, message, log_level=level.upper()) + + # 改进:移除 log 级别通过 progress_callback 传递 + # 直接使用 logging,GuiTextHandler 会处理 + ``` + +### 长期重构(破坏性变更) + +1. **移除 progress_callback 中的日志路径** + - Utils 脚本只使用 logging 模块 + - GuiTextHandler 统一处理控制台和 GUI 输出 + +2. **配置化日志目标** + ```python + # config.py + LOGGING = { + 'version': 1, + 'handlers': { + 'console': {'class': 'logging.StreamHandler'}, + 'gui': {'class': 'GuiTextHandler', 'log_text': ...} + }, + 'root': { + 'handlers': ['console', 'gui'] + } + } + ``` + +3. **统一级别系统** + - 移除自定义的 "SUCCESS" 级别 + - 使用标准的 logging.INFO + 额外的元数据 + +## 附录 + +### 相关文件清单 + +| 文件路径 | 职责 | +|---------|------| +| `gui/log_config.py` | 日志配置 | +| `gui/widgets/log_handler.py` | GuiTextHandler | +| `gui/widgets/log_text.py` | LogText 组件 | +| `gui/main_window.py` | 初始化日志系统 | +| `gui/material_validation_tab.py` | 物料校验标签页 | +| `gui/data_extraction_tab.py` | 数据提取标签页 | +| `utils/离散备料计划维护数据提取.py` | 业务逻辑 + _log | +| `utils/离散备料计划维护数据清理.py` | 业务逻辑 + _log | +| `utils/material_status_validator.py` | 业务逻辑 + _log | + +### 测试文件 + +| 文件路径 | 说明 | +|---------|------| +| `tests/test_logging_simple.py` | 简单日志测试 | +| `tests/test_logging_system.py` | 完整 GUI 测试 | +| `tests/test_log_handler_fix.py` | 冗余级别清理测试 | + +### 参考文档 + +- [Python logging 模块文档](https://docs.python.org/3/library/logging.html) +- [Tkinter 线程安全最佳实践](https://docs.python.org/3/library/tkinter.html#thread-safety) +- `docs/LOGGING_REFACTORING_SUMMARY.md` - 重构总结文档 diff --git a/docs/LOGGING_REFACTORING_SUMMARY.md b/docs/LOGGING_REFACTORING_SUMMARY.md new file mode 100644 index 0000000..a95b47b --- /dev/null +++ b/docs/LOGGING_REFACTORING_SUMMARY.md @@ -0,0 +1,141 @@ +# GUI 日志系统重构完成总结 + +## 实施概述 + +已成功完成 GUI 日志系统的统一重构,实现了以下目标: + +1. **统一日志格式**:控制台和 UI 日志使用一致的格式和配置 +2. **标准化接口**:使用 Python 标准 `logging` 模块 +3. **代码简化**:减少了重复的日志处理代码 +4. **易扩展性**:后续可轻松添加文件输出、远程日志等功能 + +## 新增文件 + +### 1. `gui/log_config.py` +统一日志配置模块,提供: + +- `LOG_FORMAT`: 日志格式常量 +- `DATE_FORMAT`: 日期格式常量 +- `setup_gui_logging(level=logging.INFO)`: 初始化日志系统 +- `get_logger(name)`: 获取指定名称的 logger + +### 2. `gui/widgets/log_handler.py` +自定义 logging Handler,桥接 logging 模块和 GUI: + +- `GuiTextHandler` 类:将日志输出到 LogText 组件 +- 线程安全设计:使用 `after()` 确保 GUI 更新在主线程 +- 自动级别映射:将 logging 级别映射到 LogText 级别 + +### 3. `tests/test_logging_simple.py` +简单的非 GUI 测试脚本,验证日志配置。 + +### 4. `tests/test_logging_system.py` +完整的 GUI 测试脚本,测试所有日志功能(包括 GUI 界面)。 + +## 修改文件 + +### 1. `gui/main_window.py` +- 导入 `setup_gui_logging` +- 在 `__init__` 中调用 `setup_gui_logging()` 初始化全局日志 + +### 2. `gui/material_validation_tab.py` +- 导入 `logging`, `get_logger`, `GuiTextHandler` +- 在 `__init__` 中初始化 `self.logger` +- 在 `_create_log_panel` 中创建并配置 `GuiTextHandler` +- 更新 `_update_log` 方法使用标准 logging + +### 3. `gui/data_extraction_tab.py` +- 导入 `logging`, `get_logger`, `GuiTextHandler` +- 在 `__init__` 中初始化 `self.logger` +- 在 `_create_log_panel` 中创建并配置 `GuiTextHandler` +- 更新 `_update_log` 方法使用标准 logging + +### 4. `gui/widgets/__init__.py` +- 添加 `GuiTextHandler` 到导出列表 + +## 日志格式 + +统一格式:`%(asctime)s [%(levelname)s] %(message)s` + +示例输出: +``` +2026-02-13 21:36:07 [INFO] 物料校验标签页已就绪 +2026-02-13 21:36:08 [WARNING] 未选择任何负责人 +2026-02-13 21:36:09 [ERROR] 校验过程中发生错误 +``` + +## 使用方式 + +### 在新代码中使用 + +```python +from gui.log_config import get_logger + +# 获取 logger +logger = get_logger(__name__) + +# 输出日志 +logger.info("信息日志") +logger.warning("警告日志") +logger.error("错误日志") +``` + +### 在现有代码中使用 `_update_log` + +保持兼容,`_update_log` 方法自动桥接到 logging: + +```python +self._update_log("消息", "INFO") # → logger.info() +self._update_log("消息", "WARNING") # → logger.warning() +self._update_log("消息", "ERROR") # → logger.error() +self._update_log("消息", "SUCCESS") # → logger.info() (UI 显示为 SUCCESS) +``` + +## 测试验证 + +### 运行简单测试 +```bash +python tests/test_logging_simple.py +``` + +### 运行完整 GUI 测试 +```bash +python tests/test_logging_system.py +``` + +## 优势 + +1. **统一风格**:控制台和 UI 使用相同格式,便于调试 +2. **标准化**:使用 Python 标准 logging 模块,符合最佳实践 +3. **易扩展**:后续可轻松添加文件输出、远程日志等 +4. **代码简化**:减少重复的 `_update_log` 方法实现 +5. **线程安全**:logging 模块内置线程安全支持,GuiTextHandler 额外处理了 GUI 线程安全 + +## 兼容性 + +- 保持向后兼容:`_update_log` 方法仍然可用 +- `SUCCESS` 级别:映射到 `INFO`,但 UI 中仍显示为绿色 SUCCESS +- `DEBUG` 级别:默认不显示,可通过配置启用 + +## 后续改进建议 + +1. **文件输出**:添加 `FileHandler` 将日志保存到文件 +2. **日志轮转**:使用 `RotatingFileHandler` 或 `TimedRotatingFileHandler` +3. **配置化**:通过配置文件控制日志级别和输出目标 +4. **远程日志**:添加 `SyslogHandler` 或自定义网络 Handler +5. **性能监控**:集成性能指标到日志系统 + +## 文件清单 + +### 新增文件 +- `gui/log_config.py` - 日志配置模块 +- `gui/widgets/log_handler.py` - GUI 日志处理器 +- `tests/test_logging_simple.py` - 简单测试脚本 +- `tests/test_logging_system.py` - 完整测试脚本 +- `docs/LOGGING_REFACTORING_SUMMARY.md` - 本文档 + +### 修改文件 +- `gui/main_window.py` - 初始化日志系统 +- `gui/material_validation_tab.py` - 使用统一日志 +- `gui/data_extraction_tab.py` - 使用统一日志 +- `gui/widgets/__init__.py` - 导出 GuiTextHandler diff --git a/gui/data_extraction_tab.py b/gui/data_extraction_tab.py index 83a7efa..7d421f6 100644 --- a/gui/data_extraction_tab.py +++ b/gui/data_extraction_tab.py @@ -9,12 +9,14 @@ import os import sys import threading +import logging import queue import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path -from gui.widgets import FileSelector, LogText, ProductionIdInput +from gui.widgets import FileSelector, LogText, ProductionIdInput, GuiTextHandler from gui.config_manager import ConfigManager +from gui.log_config import setup_gui_logging, get_logger from gui.progress import ProgressInfo, ProgressCalculator from gui.utils import RealtimeOutput @@ -32,6 +34,10 @@ class DataExtractionTab(ttk.Frame): self.progress_calculator = ProgressCalculator() self.progress_queue = queue.Queue() + # 初始化统一日志系统 + self.logger = get_logger(__name__) + self._gui_handler = None # 将在 _create_log_panel 中设置 + self._poll_progress_queue() self.create_widgets() self._apply_ui_config() @@ -116,6 +122,14 @@ class DataExtractionTab(ttk.Frame): self.log_text = LogText(parent, height=15, readonly=True) self.log_text.pack(fill=tk.BOTH, expand=True) + # 设置 GUI 日志处理器,将 logging 输出桥接到 LogText 组件 + self._gui_handler = GuiTextHandler(self.log_text) + self._gui_handler.setFormatter(logging.Formatter( + '%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + )) + self.logger.addHandler(self._gui_handler) + def _apply_ui_config(self): try: font_family = self.config.get("ui.font_family", "Microsoft YaHei UI") @@ -219,15 +233,24 @@ class DataExtractionTab(ttk.Frame): except: pass def _update_log(self, message: str, level: str = "INFO"): - """标准的日志更新方法""" - def update(): - # 即使任务结束,只要是成功/错误消息也强制显示 - if self.extracting or level in ["ERROR", "WARNING", "SUCCESS"]: - if level == "INFO": self.log_text.info(message) - elif level == "SUCCESS": self.log_text.success(message) - elif level == "WARNING": self.log_text.warning(message) - elif level == "ERROR": self.log_text.error(message) - self.after(0, update) + """ + 标准的日志更新方法(兼容接口) + + 通过统一的 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): if self.main_window: diff --git a/gui/log_config.py b/gui/log_config.py new file mode 100644 index 0000000..de05f9b --- /dev/null +++ b/gui/log_config.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +GUI 日志配置模块 +统一配置 GUI 应用和控制台的日志输出 +""" +import logging + +# 日志格式配置 +LOG_FORMAT = '%(asctime)s [%(levelname)s] %(message)s' +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + + +def setup_gui_logging(level=logging.INFO): + """ + 初始化 GUI 应用的日志配置 + + Args: + level: 日志级别,默认为 INFO + + Returns: + logging.Logger: 根 logger + """ + logging.basicConfig( + level=level, + format=LOG_FORMAT, + datefmt=DATE_FORMAT, + force=True # 确保重新配置(即使之前配置过) + ) + return logging.getLogger() + + +def get_logger(name): + """ + 获取指定名称的 logger + + Args: + name: logger 名称,通常使用 __name__ + + Returns: + logging.Logger: logger 实例 + """ + return logging.getLogger(name) diff --git a/gui/main_window.py b/gui/main_window.py index dd1b1c3..3ee21c3 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -9,6 +9,7 @@ ERP 自动化工具的主窗口,包含多个功能标签页。 import tkinter as tk from tkinter import ttk from gui.config_manager import ConfigManager +from gui.log_config import setup_gui_logging from gui.data_extraction_tab import DataExtractionTab from gui.material_validation_tab import MaterialValidationTab from gui.settings_tab import SettingsTab @@ -30,6 +31,9 @@ class MainWindow: self.session_manager = session_manager self.shared_production_ids = [] # 共享的 Production ID 列表 + # 初始化统一日志系统 + setup_gui_logging() + # 设置窗口属性(包含用户信息) user_type_display = "管理员" if session_manager.is_admin() else "用户" self.root.title(f"ERP 自动化工具 v1.0 - {session_manager.get_username()} ({user_type_display})") diff --git a/gui/material_validation_tab.py b/gui/material_validation_tab.py index b7dd954..64f6330 100644 --- a/gui/material_validation_tab.py +++ b/gui/material_validation_tab.py @@ -11,14 +11,16 @@ import os import threading +import logging import tkinter as tk from tkinter import ttk, messagebox, filedialog, simpledialog from pathlib import Path from io import StringIO from contextlib import redirect_stdout from typing import List, Dict -from gui.widgets import FileSelector, LogText +from gui.widgets import FileSelector, LogText, GuiTextHandler 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 db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO import pandas as pd @@ -156,6 +158,10 @@ class MaterialValidationTab(ttk.Frame): self.select_all_managers_var: tk.BooleanVar = tk.BooleanVar(value=True) # 全选复选框状态 self.previously_selected_managers: List[str] = [] # 保存筛选状态 + # 初始化统一日志系统 + self.logger = get_logger(__name__) + self._gui_handler = None # 将在 _create_log_panel 中设置 + self.create_widgets() # 稍后显示就绪消息 @@ -379,6 +385,14 @@ class MaterialValidationTab(ttk.Frame): self.log_text = LogText(parent, height=8, readonly=True) self.log_text.pack(fill=tk.BOTH, expand=True) + # 设置 GUI 日志处理器,将 logging 输出桥接到 LogText 组件 + self._gui_handler = GuiTextHandler(self.log_text) + self._gui_handler.setFormatter(logging.Formatter( + '%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + )) + self.logger.addHandler(self._gui_handler) + def _on_source_mode_change(self): """数据源模式切换""" # PERMISSION CHECK: 仅管理员用户可以选择数据源模式 @@ -895,10 +909,13 @@ class MaterialValidationTab(ttk.Frame): if line.strip(): self._update_log(line, "INFO") - if result_file and results: + if result_file and results and len(results) > 0: self._update_log(f"校验完成,结果已保存到:{output_file}", "SUCCESS") # 加载结果显示(带删除状态) self._load_results_with_deletion_status(result_file) + elif results is not None and len(results) == 0: + # 已经在 validator 中输出详细错误信息,这里只做简单提示 + self._update_log("校验失败:未找到物料记录,请查看上方日志了解详细原因", "ERROR") else: self._update_log("校验失败", "ERROR") @@ -1159,19 +1176,24 @@ class MaterialValidationTab(ttk.Frame): self._initialize_manager_filter() def _update_log(self, message: str, level: str = "INFO"): - """线程安全的日志更新""" + """ + 线程安全的日志更新(兼容接口) - def update(): - if level == "INFO": - self.log_text.info(message) - elif level == "SUCCESS": - self.log_text.success(message) - elif level == "WARNING": - self.log_text.warning(message) - elif level == "ERROR": - self.log_text.error(message) + 通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。 - self.after(0, update) + 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""" diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py index 48992c0..ca2a72a 100644 --- a/gui/widgets/__init__.py +++ b/gui/widgets/__init__.py @@ -7,5 +7,6 @@ GUI 自定义组件模块 from .file_selector import FileSelector from .log_text import LogText from .production_id_input import ProductionIdInput +from .log_handler import GuiTextHandler -__all__ = ['FileSelector', 'LogText', 'ProductionIdInput'] +__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler'] diff --git a/gui/widgets/log_handler.py b/gui/widgets/log_handler.py new file mode 100644 index 0000000..bd0b386 --- /dev/null +++ b/gui/widgets/log_handler.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +自定义 logging Handler,将日志输出到 LogText 组件 +""" +import logging +import re +from typing import Optional +from gui.widgets.log_text import LogText + + +class GuiTextHandler(logging.Handler): + """ + 将日志输出到 GUI LogText 组件的 Handler + + 这个 Handler 桥接了 Python 标准 logging 模块和 GUI 的 LogText 组件, + 使得使用 logging 模块的代码可以自动将日志输出到 GUI 界面。 + """ + + def __init__(self, log_text: Optional[LogText] = None): + """ + 初始化 Handler + + Args: + log_text: LogText 组件实例,可以为 None,稍后通过 set_log_text 设置 + """ + super().__init__() + self.log_text = log_text + + # 映射 logging 级别到 LogText 级别 + self.level_map = { + logging.INFO: 'INFO', + logging.WARNING: 'WARNING', + logging.ERROR: 'ERROR', + logging.DEBUG: 'DEBUG', + logging.CRITICAL: 'ERROR' + } + + def set_log_text(self, log_text: LogText): + """ + 设置或更新 LogText 组件引用 + + Args: + log_text: LogText 组件实例 + """ + self.log_text = log_text + + def emit(self, record: logging.LogRecord): + """ + 实现日志输出 + + Args: + record: logging.LogRecord 对象 + """ + if not self.log_text: + return + + try: + # 获取日志级别 + level = self.level_map.get(record.levelno, 'INFO') + + # 只获取消息内容,不包含时间戳和级别(LogText.log() 会添加) + message = record.getMessage() + + # 移除消息中可能存在的冗余级别标记(如 "[INFO] "、"[ERROR] " 等) + # 这是因为有些代码在消息中已经包含了级别标记 + message = self._strip_redundant_level_prefix(message) + + # 定义更新函数 + def update(): + """在主线程中更新 GUI""" + try: + # LogText.log() 会自动添加时间戳和级别 + self.log_text.log(message, level) + except Exception: + # 如果 log 失败,忽略错误避免递归 + pass + + # 尝试使用 after 确保在主线程更新 + import tkinter as tk + try: + # 尝试获取主窗口 + widget = self.log_text + while widget and widget.master: + if isinstance(widget.master, tk.Tk): + # 找到主窗口,使用 after 调度更新 + widget.master.after(0, update) + return + widget = widget.master + + # 如果找不到主窗口,直接调用(适用于非 GUI 模式或测试) + update() + except Exception: + # 如果线程调度失败,直接调用 + update() + + except Exception: + # 处理错误,避免影响主程序 + self.handleError(record) + + def _strip_redundant_level_prefix(self, message: str) -> str: + """ + 移除消息开头的冗余级别标记 + + 例如:"[INFO] 读取 ProductionID 文件" -> "读取 ProductionID 文件" + "[ERROR] 错误信息" -> "错误信息" + + Args: + message: 原始消息 + + Returns: + 清理后的消息 + """ + # 常见的日志级别标记模式 + level_pattern = r'^\[(?:INFO|WARNING|ERROR|DEBUG|CRITICAL|WARN|SUCCESS)\]\s*' + match = re.match(level_pattern, message) + if match: + # 移除匹配到的级别前缀 + return message[match.end():] + return message diff --git a/utils/material_status_validator.py b/utils/material_status_validator.py index 7f04bdd..1a0aef5 100644 --- a/utils/material_status_validator.py +++ b/utils/material_status_validator.py @@ -466,6 +466,15 @@ class MaterialStatusValidator: dao = DiscreteMaterialPlanDAO() material_records = dao.query_by_source_numbers(source_numbers) self._print(f"[INFO] 获取到 {len(material_records)} 条记录") + + # 如果没有找到物料记录,给出友好提示 + if not material_records: + self._print("\n[ERROR] 校验失败:未找到物料记录") + self._print("[ERROR] 可能原因:") + self._print("[ERROR] 1. 这些生产订单的物料数据还没有提取到数据库") + self._print("[ERROR] 2. 请先运行【正式备料计划数据提取】工具") + self._print("[ERROR] 3. 提取时勾选【持久化到数据库】选项") + self._print("[ERROR] 4. 将这 10 个 ProductionID 的物料数据保存到 DiscreteMaterialPlanData 表") else: raise ValueError("必须指定 full_table=True 或提供 production_id_file 参数")