Files
pqAutomationApp/app/views/pq_log_gui.py
2026-05-29 08:32:21 +08:00

278 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import threading
from datetime import datetime
import tkinter as tk
import ttkbootstrap as ttk
# 与 app.logging_setup 共享的映射;放在这里避免循环 import
_GUI_LEVEL_TO_LOG = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"separator": logging.INFO,
"blank": logging.DEBUG,
}
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"bg": colors.bg,
"fg": colors.fg,
"muted": colors.secondary,
"accent": colors.info,
"warning": colors.warning,
"error": colors.danger,
"success": colors.success,
"text_bg": colors.inputbg,
"text_fg": colors.inputfg,
}
def _normalize_hex(hex_color: str, fallback: str = "#808080") -> str:
"""把颜色字符串规范化为 #RRGGBB非法输入回退到 fallback。"""
if not isinstance(hex_color, str):
return fallback
c = hex_color.strip()
if not c:
return fallback
if c.startswith("#"):
c = c[1:]
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) != 6:
return fallback
try:
int(c, 16)
except ValueError:
return fallback
return f"#{c}"
def _hex_to_rgb(hex_color: str):
c = _normalize_hex(hex_color, fallback="#808080").lstrip("#")
return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
def _mix(hex_a: str, hex_b: str, ratio: float) -> str:
ratio = max(0.0, min(1.0, ratio))
r1, g1, b1 = _hex_to_rgb(hex_a)
r2, g2, b2 = _hex_to_rgb(hex_b)
r = int(r1 * (1 - ratio) + r2 * ratio)
g = int(g1 * (1 - ratio) + g2 * ratio)
b = int(b1 * (1 - ratio) + b2 * ratio)
return f"#{r:02x}{g:02x}{b:02x}"
def _is_dark(hex_color: str) -> bool:
r, g, b = _hex_to_rgb(hex_color)
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def _auto_text_color(bg_hex: str, fg_hint: str) -> str:
"""根据背景亮度给出稳定可读的文本颜色。"""
bg_hex = _normalize_hex(bg_hex, fallback="#f5f5f5")
fg_hint = _normalize_hex(fg_hint, fallback="#202020")
if _is_dark(bg_hex):
return _mix("#ffffff", fg_hint, 0.25)
return _mix("#000000", fg_hint, 0.25)
class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
def __init__(self, parent):
super().__init__(parent)
self._line_count = 0
self._max_lines = 1500
# 与 stdlib logging 联通的 logger由 logging_setup 配置 file handler
self._logger = logging.getLogger("pq.gui")
self.create_widgets()
def create_widgets(self):
log_frame = ttk.LabelFrame(self, text="测试日志")
log_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
toolbar = ttk.Frame(log_frame)
toolbar.pack(fill=tk.X, padx=6, pady=(6, 2))
self.log_summary_var = tk.StringVar(value="0 条日志")
ttk.Label(
toolbar,
textvariable=self.log_summary_var,
bootstyle="secondary",
).pack(side=tk.LEFT)
ttk.Button(
toolbar,
text="清空日志",
command=self.clear_log,
bootstyle="secondary-outline",
width=10,
).pack(side=tk.RIGHT)
text_container = ttk.Frame(log_frame)
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
palette = _theme_colors()
self.log_text = tk.Text(
text_container,
height=10,
width=50,
wrap=tk.WORD,
font=("Consolas", 10),
bg=palette["text_bg"],
fg=palette["text_fg"],
relief=tk.FLAT,
bd=0,
padx=10,
pady=8,
spacing1=2,
spacing3=2,
insertbackground=palette["text_fg"],
)
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
log_scrollbar = ttk.Scrollbar(text_container, command=self.log_text.yview)
log_scrollbar.pack(fill=tk.Y, side=tk.RIGHT)
self.log_text.config(yscrollcommand=log_scrollbar.set)
self._configure_tags()
self.log_text.config(state=tk.DISABLED)
def log(self, message, level="info", _from_logging=False):
if threading.current_thread() is not threading.main_thread():
self.after(0, self.log, message, level, _from_logging)
return
text = "" if message is None else str(message)
normalized_level = self._normalize_level(level, text)
# 转发到 stdlib logging落到文件_from_logging=True 表示来源已是
# logging不再回写避免重复。带 _from_gui 标记TkLogHandler 会跳过。
if not _from_logging and normalized_level != "blank":
log_level = _GUI_LEVEL_TO_LOG.get(normalized_level, logging.INFO)
try:
self._logger.log(
log_level, text, extra={"_from_gui": True}
)
except Exception:
pass
self.log_text.config(state=tk.NORMAL)
self._append_message(text, normalized_level)
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
def clear_log(self):
if threading.current_thread() is not threading.main_thread():
self.after(0, self.clear_log)
return
self.log_text.config(state=tk.NORMAL)
self.log_text.delete(1.0, tk.END)
self.log_text.config(state=tk.DISABLED)
self._line_count = 0
self._update_summary()
def _configure_tags(self):
palette = _theme_colors()
bg = self.log_text.cget("bg") or palette["text_bg"] or palette["bg"]
base_fg = _auto_text_color(bg, palette["fg"])
muted_fg = _mix(base_fg, bg, 0.45)
debug_level_color = _mix(palette["accent"], base_fg, 0.35)
debug_msg_color = _mix(palette["accent"], base_fg, 0.50)
self.log_text.tag_configure("timestamp", foreground=palette["muted"])
self.log_text.tag_configure("level_info", foreground=palette["accent"])
self.log_text.tag_configure("level_success", foreground=palette["success"])
self.log_text.tag_configure("level_warning", foreground=palette["warning"])
self.log_text.tag_configure("level_error", foreground=palette["error"])
self.log_text.tag_configure("level_debug", foreground=debug_level_color)
self.log_text.tag_configure("message", foreground=base_fg)
self.log_text.tag_configure("message_success", foreground=palette["success"])
self.log_text.tag_configure("message_warning", foreground=palette["warning"])
self.log_text.tag_configure("message_error", foreground=palette["error"])
self.log_text.tag_configure("message_debug", foreground=debug_msg_color)
self.log_text.tag_configure("separator", foreground=muted_fg)
self.log_text.tag_configure("traceback", foreground=palette["error"])
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
def refresh_log_theme(self):
"""主题切换后刷新日志控件的背景和字体颜色。"""
if threading.current_thread() is not threading.main_thread():
self.after(0, self.refresh_log_theme)
return
palette = _theme_colors()
bg = palette["text_bg"]
fg_hint = palette["text_fg"] if palette["text_fg"] else palette["fg"]
fg = _auto_text_color(bg, fg_hint)
self.log_text.configure(
bg=bg,
fg=fg,
insertbackground=fg,
)
self._configure_tags()
def _append_message(self, message, level):
lines = message.splitlines() or [""]
for line in lines:
self._append_line(line, level)
self._trim_excess_lines()
self._update_summary()
def _append_line(self, line, level):
timestamp = datetime.now().strftime("%H:%M:%S")
rendered = "" if line is None else str(line).strip()
if level == "blank" or not rendered:
self.log_text.insert(tk.END, "\n", ("blank",))
self._line_count += 1
return
if level == "separator":
self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",))
self.log_text.insert(tk.END, "[SECTION] ", ("level_info",))
self.log_text.insert(tk.END, rendered + "\n", ("separator",))
self._line_count += 1
return
level_tag = f"level_{level}"
level_label = level.upper().ljust(7)
if level == "error" and rendered.startswith("Traceback"):
message_tag = "traceback"
elif level in {"success", "warning", "error", "debug"}:
message_tag = f"message_{level}"
else:
message_tag = "message"
self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",))
self.log_text.insert(tk.END, f"[{level_label}] ", (level_tag,))
self.log_text.insert(tk.END, rendered + "\n", (message_tag,))
self._line_count += 1
def _normalize_level(self, level, message):
normalized = "info" if level is None else str(level).strip().lower()
if normalized not in self.VALID_LEVELS:
normalized = "info"
if normalized == "info" and (message is None or str(message).strip() == ""):
return "blank"
return normalized
def _trim_excess_lines(self):
overflow = self._line_count - self._max_lines
if overflow <= 0:
return
self.log_text.delete("1.0", f"{overflow + 1}.0")
self._line_count = self._max_lines
def _update_summary(self):
self.log_summary_var.set(f"{self._line_count} 条日志")