#!/usr/bin/env python # -*- coding: utf-8 -*- """ 日志与进度管理器 将主窗口中的日志区(QTextEdit)、进度条(QProgressBar)和"清空日志"按钮 的 UI 创建与控制逻辑完全封装。 职责: - create_log_panel() → 返回组装好的 QWidget(日志 + 进度条) - 内部订阅 LogMessage / ProgressUpdate 事件,自动更新 UI - 主窗口无需再关心日志/进度的状态同步 订阅的事件: LogMessage → {message, level} 写入日志区 ProgressUpdate → {percentage, message} 更新进度条 """ import html from datetime import datetime from PyQt5.QtCore import QObject from PyQt5.QtGui import QTextCursor from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QTextEdit, QProgressBar, QPushButton, ) from src.gui.core.event_bus import global_event_bus _MAX_LOG_LINES = 2000 # 日志行数上限,防止长时间运行内存耗尽 class LogManager(QObject): """日志与进度管理器。 用法:: log_mgr = LogManager(parent=self) log_panel = log_mgr.create_log_panel() layout.addWidget(log_panel) # 之后所有日志/进度更新由 EventBus 自动驱动,无需手动操作 """ def __init__(self, parent=None): super().__init__(parent) self._log_text: QTextEdit = None self._progress_bar: QProgressBar = None # 订阅事件 global_event_bus.subscribe('LogMessage', self._on_log_message) global_event_bus.subscribe('ProgressUpdate', self._on_progress_update) # ★ 父控件销毁时自动取消订阅,防止回调泄漏 if parent is not None: parent.destroyed.connect(self._cleanup) def _cleanup(self): """清理 EventBus 订阅。""" global_event_bus.unsubscribe('LogMessage', self._on_log_message) global_event_bus.unsubscribe('ProgressUpdate', self._on_progress_update) # ═══════════════════════════════════════════════════════════ # 公开 API # ═══════════════════════════════════════════════════════════ def create_log_panel(self) -> QWidget: """创建并返回日志+进度面板的组装 Widget。 Returns: QWidget: 包含日志区(QGroupBox)和进度条(QGroupBox)的垂直布局容器 """ from src.gui.styles import ModernStylesheet container = QWidget() layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) # ── 日志区 ── log_group = QGroupBox("执行日志") log_group.setStyleSheet(f""" QGroupBox {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border_light']}; border-radius: 5px; margin-top: 8px; padding-top: 15px; padding-left: 9px; padding-right: 9px; padding-bottom: 9px; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 0 5px; font-weight: bold; color: {ModernStylesheet.COLORS['text_primary']}; }} """) log_layout = QVBoxLayout() log_layout.setContentsMargins(5, 5, 5, 5) self._log_text = QTextEdit() self._log_text.setReadOnly(True) self._log_text.setMaximumHeight(200) self._log_text.setStyleSheet(f""" QTextEdit {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; color: {ModernStylesheet.COLORS['text_primary']}; border: 1px solid {ModernStylesheet.COLORS['border']}; border-radius: 4px; padding: 5px; font-family: 'Courier New', monospace; font-size: 10px; }} """) log_layout.addWidget(self._log_text) clear_btn = QPushButton("清空日志") clear_btn.setMaximumWidth(100) clear_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) clear_btn.clicked.connect(self.clear_log) btn_row = QHBoxLayout() btn_row.addWidget(clear_btn) btn_row.addStretch() log_layout.addLayout(btn_row) log_group.setLayout(log_layout) layout.addWidget(log_group, 1) # ── 进度条 ── progress_group = QGroupBox("执行进度") progress_group.setStyleSheet(f""" QGroupBox {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border_light']}; border-radius: 5px; margin-top: 8px; padding-top: 10px; padding-left: 9px; padding-right: 9px; padding-bottom: 9px; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 0 5px; font-weight: bold; color: {ModernStylesheet.COLORS['text_primary']}; }} """) progress_layout = QVBoxLayout() progress_layout.setContentsMargins(5, 5, 5, 5) self._progress_bar = QProgressBar() self._progress_bar.setValue(0) self._progress_bar.setStyleSheet(f""" QProgressBar {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border']}; border-radius: 4px; padding: 2px; text-align: center; height: 20px; }} QProgressBar::chunk {{ background-color: {ModernStylesheet.COLORS['success']}; border-radius: 3px; }} """) progress_layout.addWidget(self._progress_bar) progress_group.setLayout(progress_layout) layout.addWidget(progress_group, 0) container.setLayout(layout) return container def clear_log(self): """清空日志区。""" if self._log_text is not None: self._log_text.clear() def info(self, message: str): """便捷方法:发布 info 级别日志。""" global_event_bus.publish('LogMessage', { 'message': message, 'level': 'info', }) def warning(self, message: str): """便捷方法:发布 warning 级别日志。""" global_event_bus.publish('LogMessage', { 'message': message, 'level': 'warning', }) def error(self, message: str): """便捷方法:发布 error 级别日志。""" global_event_bus.publish('LogMessage', { 'message': message, 'level': 'error', }) @property def progress_bar(self) -> QProgressBar: return self._progress_bar @property def log_text(self) -> QTextEdit: return self._log_text # ═══════════════════════════════════════════════════════════ # EventBus 订阅回调 # ═══════════════════════════════════════════════════════════ def _on_log_message(self, data: dict): """LogMessage 事件回调:写入日志区。""" if self._log_text is None: return if not isinstance(data, dict): return message = data.get('message', '') level = data.get('level', 'info') timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # ★ HTML 转义:防止日志消息中的 < > & 破坏 HTML 渲染 safe_message = html.escape(str(message)) color_map = {'error': 'red', 'warning': 'orange'} color = color_map.get(level, 'black') formatted = f'[{timestamp}] {safe_message}' self._log_text.append(formatted) # ★ 日志行数上限:防止长时间运行内存耗尽 if self._log_text.document().blockCount() > _MAX_LOG_LINES + 100: self._log_text.clear() self._log_text.append( f'[日志已自动清空,达到 {_MAX_LOG_LINES} 行上限]' ) cursor = self._log_text.textCursor() cursor.movePosition(QTextCursor.End) self._log_text.setTextCursor(cursor) def _on_progress_update(self, data: dict): """ProgressUpdate 事件回调:更新进度条。""" if self._progress_bar is None: return percentage = data.get('percentage', 0) self._progress_bar.setValue(percentage)