# -*- coding: utf-8 -*- """日志查看器""" import re from datetime import datetime from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QPushButton, QLineEdit, QLabel, QComboBox, QCheckBox, QFileDialog, QMessageBox ) from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtGui import QTextCharFormat, QColor, QFont, QTextCursor class LogViewer(QWidget): """日志查看器""" # 信号 log_cleared = Signal() def __init__(self, parent=None): super().__init__(parent) self.max_lines = 10000 self.auto_scroll = True self.filter_text = "" self.filter_level = "ALL" self._all_logs = [] # 存储所有日志 self._init_ui() self._connect_signals() def _init_ui(self): """初始化界面""" layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(8) # 标题和工具栏 header_layout = QHBoxLayout() title = QLabel("执行日志") title.setProperty("heading", True) header_layout.addWidget(title) header_layout.addStretch() # 日志级别过滤 header_layout.addWidget(QLabel("级别:")) self.level_combo = QComboBox() self.level_combo.addItems(["ALL", "INFO", "WARNING", "ERROR", "DEBUG"]) self.level_combo.setFixedWidth(100) header_layout.addWidget(self.level_combo) # 搜索框 header_layout.addWidget(QLabel("搜索:")) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("输入关键字...") self.search_edit.setFixedWidth(200) header_layout.addWidget(self.search_edit) # 自动滚动 self.auto_scroll_check = QCheckBox("自动滚动") self.auto_scroll_check.setChecked(True) header_layout.addWidget(self.auto_scroll_check) layout.addLayout(header_layout) # 日志文本区域 self.log_text = QPlainTextEdit() self.log_text.setObjectName("logViewer") self.log_text.setReadOnly(True) self.log_text.setFont(QFont("Consolas", 10)) self.log_text.setLineWrapMode(QPlainTextEdit.NoWrap) layout.addWidget(self.log_text, 1) # 底部工具栏 footer_layout = QHBoxLayout() self.line_count_label = QLabel("0 行") self.line_count_label.setProperty("subheading", True) footer_layout.addWidget(self.line_count_label) footer_layout.addStretch() self.copy_btn = QPushButton("复制全部") self.copy_btn.setProperty("secondary", True) footer_layout.addWidget(self.copy_btn) self.export_btn = QPushButton("导出") self.export_btn.setProperty("secondary", True) footer_layout.addWidget(self.export_btn) self.clear_btn = QPushButton("清空") self.clear_btn.setProperty("secondary", True) footer_layout.addWidget(self.clear_btn) layout.addLayout(footer_layout) def _connect_signals(self): """连接信号""" self.level_combo.currentTextChanged.connect(self._apply_filter) self.search_edit.textChanged.connect(self._apply_filter) self.auto_scroll_check.stateChanged.connect(self._toggle_auto_scroll) self.copy_btn.clicked.connect(self._copy_all) self.export_btn.clicked.connect(self._export_log) self.clear_btn.clicked.connect(self._clear_log) @Slot(str) def append_log(self, text: str): """追加日志""" # 添加时间戳(如果没有) if not re.match(r'^\d{4}-\d{2}-\d{2}', text) and not text.startswith('['): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") text = f"[{timestamp}] {text}" # 存储到全部日志 self._all_logs.append(text) # 限制日志行数 if len(self._all_logs) > self.max_lines: self._all_logs = self._all_logs[-self.max_lines:] # 检查是否通过过滤器 if self._matches_filter(text): self._append_formatted_line(text) # 更新行数 self._update_line_count() def _append_formatted_line(self, text: str): """追加格式化的行""" cursor = self.log_text.textCursor() cursor.movePosition(QTextCursor.End) # 设置格式 fmt = QTextCharFormat() text_lower = text.lower() if "[error]" in text_lower or "错误" in text or "失败" in text: fmt.setForeground(QColor("#d93025")) fmt.setFontWeight(QFont.Bold) elif "[warning]" in text_lower or "警告" in text or "warn" in text_lower: fmt.setForeground(QColor("#f9ab00")) elif "[info]" in text_lower: fmt.setForeground(QColor("#1a73e8")) elif "[debug]" in text_lower: fmt.setForeground(QColor("#9aa0a6")) elif "[gui]" in text_lower: fmt.setForeground(QColor("#1e8e3e")) else: fmt.setForeground(QColor("#333333")) cursor.insertText(text + "\n", fmt) # 自动滚动 if self.auto_scroll: self.log_text.verticalScrollBar().setValue( self.log_text.verticalScrollBar().maximum() ) def _matches_filter(self, text: str) -> bool: """检查是否匹配过滤器""" # 级别过滤 if self.filter_level != "ALL": level_marker = f"[{self.filter_level}]" if level_marker.lower() not in text.lower(): return False # 文本过滤 if self.filter_text: if self.filter_text.lower() not in text.lower(): return False return True def _apply_filter(self): """应用过滤器""" self.filter_level = self.level_combo.currentText() self.filter_text = self.search_edit.text().strip() # 重新显示日志 self.log_text.clear() for line in self._all_logs: if self._matches_filter(line): self._append_formatted_line(line) self._update_line_count() def _toggle_auto_scroll(self, state: int): """切换自动滚动""" self.auto_scroll = state == Qt.Checked def _copy_all(self): """复制全部日志""" from PySide6.QtWidgets import QApplication text = self.log_text.toPlainText() QApplication.clipboard().setText(text) QMessageBox.information(self, "提示", "日志已复制到剪贴板") def _export_log(self): """导出日志""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") default_name = f"etl_log_{timestamp}.txt" file_path, _ = QFileDialog.getSaveFileName( self, "导出日志", default_name, "文本文件 (*.txt);;日志文件 (*.log);;所有文件 (*.*)" ) if not file_path: return try: with open(file_path, "w", encoding="utf-8") as f: f.write(self.log_text.toPlainText()) QMessageBox.information(self, "成功", f"日志已导出到:\n{file_path}") except Exception as e: QMessageBox.critical(self, "错误", f"导出失败: {e}") def _clear_log(self): """清空日志""" reply = QMessageBox.question( self, "确认", "确定要清空所有日志吗?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: self._all_logs.clear() self.log_text.clear() self._update_line_count() self.log_cleared.emit() def _update_line_count(self): """更新行数显示""" visible_count = self.log_text.document().blockCount() - 1 total_count = len(self._all_logs) if visible_count < total_count: self.line_count_label.setText(f"{visible_count} / {total_count} 行") else: self.line_count_label.setText(f"{total_count} 行")