2026-04-29 16:43:31 +08:00
|
|
|
|
import logging
|
2026-04-21 15:31:48 +08:00
|
|
|
|
import threading
|
|
|
|
|
|
from datetime import datetime
|
2026-04-16 16:51:05 +08:00
|
|
|
|
import tkinter as tk
|
|
|
|
|
|
import ttkbootstrap as ttk
|
|
|
|
|
|
|
2026-04-29 16:43:31 +08:00
|
|
|
|
|
|
|
|
|
|
# 与 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 16:41:52 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 16:51:05 +08:00
|
|
|
|
class PQLogGUI(ttk.Frame):
|
2026-04-21 15:31:48 +08:00
|
|
|
|
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
|
|
|
|
|
|
|
2026-04-16 16:51:05 +08:00
|
|
|
|
def __init__(self, parent):
|
|
|
|
|
|
super().__init__(parent)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self._line_count = 0
|
|
|
|
|
|
self._max_lines = 1500
|
2026-04-29 16:43:31 +08:00
|
|
|
|
# 与 stdlib logging 联通的 logger,由 logging_setup 配置 file handler
|
|
|
|
|
|
self._logger = logging.getLogger("pq.gui")
|
2026-04-16 16:51:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-21 15:31:48 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-05-28 16:41:52 +08:00
|
|
|
|
palette = _theme_colors()
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self.log_text = tk.Text(
|
|
|
|
|
|
text_container,
|
|
|
|
|
|
height=10,
|
|
|
|
|
|
width=50,
|
|
|
|
|
|
wrap=tk.WORD,
|
|
|
|
|
|
font=("Consolas", 10),
|
2026-05-28 16:41:52 +08:00
|
|
|
|
bg=palette["text_bg"],
|
|
|
|
|
|
fg=palette["text_fg"],
|
2026-04-21 15:31:48 +08:00
|
|
|
|
relief=tk.FLAT,
|
|
|
|
|
|
bd=0,
|
|
|
|
|
|
padx=10,
|
|
|
|
|
|
pady=8,
|
|
|
|
|
|
spacing1=2,
|
|
|
|
|
|
spacing3=2,
|
2026-05-28 16:41:52 +08:00
|
|
|
|
insertbackground=palette["text_fg"],
|
2026-04-21 15:31:48 +08:00
|
|
|
|
)
|
2026-04-16 16:51:05 +08:00
|
|
|
|
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
log_scrollbar = ttk.Scrollbar(text_container, command=self.log_text.yview)
|
2026-04-16 16:51:05 +08:00
|
|
|
|
log_scrollbar.pack(fill=tk.Y, side=tk.RIGHT)
|
|
|
|
|
|
self.log_text.config(yscrollcommand=log_scrollbar.set)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
self._configure_tags()
|
2026-04-16 16:51:05 +08:00
|
|
|
|
self.log_text.config(state=tk.DISABLED)
|
|
|
|
|
|
|
2026-04-29 16:43:31 +08:00
|
|
|
|
def log(self, message, level="info", _from_logging=False):
|
2026-04-21 15:31:48 +08:00
|
|
|
|
if threading.current_thread() is not threading.main_thread():
|
2026-04-29 16:43:31 +08:00
|
|
|
|
self.after(0, self.log, message, level, _from_logging)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
text = "" if message is None else str(message)
|
|
|
|
|
|
normalized_level = self._normalize_level(level, text)
|
2026-04-29 16:43:31 +08:00
|
|
|
|
|
|
|
|
|
|
# 转发到 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
|
|
|
|
|
|
|
2026-04-16 16:51:05 +08:00
|
|
|
|
self.log_text.config(state=tk.NORMAL)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self._append_message(text, normalized_level)
|
2026-04-16 16:51:05 +08:00
|
|
|
|
self.log_text.see(tk.END)
|
|
|
|
|
|
self.log_text.config(state=tk.DISABLED)
|
|
|
|
|
|
|
|
|
|
|
|
def clear_log(self):
|
2026-04-21 15:31:48 +08:00
|
|
|
|
if threading.current_thread() is not threading.main_thread():
|
|
|
|
|
|
self.after(0, self.clear_log)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-16 16:51:05 +08:00
|
|
|
|
self.log_text.config(state=tk.NORMAL)
|
|
|
|
|
|
self.log_text.delete(1.0, tk.END)
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self.log_text.config(state=tk.DISABLED)
|
|
|
|
|
|
self._line_count = 0
|
|
|
|
|
|
self._update_summary()
|
|
|
|
|
|
|
|
|
|
|
|
def _configure_tags(self):
|
2026-05-28 16:41:52 +08:00
|
|
|
|
palette = _theme_colors()
|
|
|
|
|
|
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"])
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self.log_text.tag_configure("level_debug", foreground="#7c3aed")
|
2026-05-28 16:41:52 +08:00
|
|
|
|
self.log_text.tag_configure("message", foreground=palette["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"])
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self.log_text.tag_configure("message_debug", foreground="#6d28d9")
|
2026-05-28 16:41:52 +08:00
|
|
|
|
self.log_text.tag_configure("separator", foreground=palette["muted"])
|
|
|
|
|
|
self.log_text.tag_configure("traceback", foreground=palette["error"])
|
2026-04-21 15:31:48 +08:00
|
|
|
|
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
|
|
|
|
|
|
|
|
|
|
|
|
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} 条日志")
|