Files
feiqiu-ETL/etl_billiards/gui/widgets/log_viewer.py
2026-01-27 22:14:01 +08:00

248 lines
8.3 KiB
Python

# -*- 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}")