From afd83448ed641caa29d5b5ff58bd9d5db01d69aa Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Wed, 29 Apr 2026 16:43:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=97=A5=E5=BF=97=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/logging_setup.py | 122 +++++++++++++++++++++++++++++ app/services/ai_image.py | 66 ++++++++++++---- app/views/panels/ai_image_panel.py | 10 +-- app/views/pq_log_gui.py | 32 +++++++- pqAutomationApp.py | 14 ++-- settings/pq_config.json | 18 +---- 6 files changed, 216 insertions(+), 46 deletions(-) create mode 100644 app/logging_setup.py diff --git a/app/logging_setup.py b/app/logging_setup.py new file mode 100644 index 0000000..465bbb2 --- /dev/null +++ b/app/logging_setup.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +import os +from datetime import datetime +from typing import Optional + + +# stdlib 级别 → PQLogGUI 标签 +_LEVEL_TO_GUI = { + logging.DEBUG: "debug", + logging.INFO: "info", + logging.WARNING: "warning", + logging.ERROR: "error", + logging.CRITICAL: "error", +} + +# PQLogGUI 标签 → stdlib 级别 +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, +} + +# 用于在 LogRecord 上做 GUI 来源标记,避免回环 +_FROM_GUI_FLAG = "_from_gui" + +# 文件日志:头一行元信息,第二行正文,记录之间空行隔开,方便阅读 +_FILE_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s\n%(message)s\n" +_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +_initialized = False + + +def _resolve_log_dir(log_dir: Optional[str]) -> str: + if log_dir: + return log_dir + # 默认放在工作目录下的 log/,方便用户直接打开查看 + return os.path.join(os.getcwd(), "log") + + +def setup_logging( + level: int = logging.INFO, + log_dir: Optional[str] = None, +) -> str: + """配置全局 logging,返回日志目录。可重复调用,第二次起为空操作。 + + - 不再向终端输出,避免污染控制台。 + - 日志文件按日期命名:``log/YYYY-MM-DD.log``。 + """ + global _initialized + root = logging.getLogger() + if _initialized: + return _resolve_log_dir(log_dir) + + resolved = _resolve_log_dir(log_dir) + os.makedirs(resolved, exist_ok=True) + + file_formatter = logging.Formatter(_FILE_LOG_FORMAT, datefmt=_DATE_FORMAT) + + root.setLevel(level) + + # 移除既有 handler,避免重复 / basicConfig 默认控制台输出 + for handler in list(root.handlers): + root.removeHandler(handler) + + today = datetime.now().strftime("%Y-%m-%d") + log_file = os.path.join(resolved, f"{today}.log") + file_handler = logging.FileHandler(log_file, encoding="utf-8", delay=True) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(file_formatter) + root.addHandler(file_handler) + + _initialized = True + logging.getLogger(__name__).info("日志系统初始化完成 dir=%s", resolved) + return resolved + + +class TkLogHandler(logging.Handler): + """把 stdlib logging 的记录转发到 ``PQLogGUI`` 面板。 + + 带 ``_from_gui`` 标记的记录会被忽略,避免 GUI → file → GUI 回环。 + """ + + def __init__(self, log_gui): + super().__init__() + self._log_gui = log_gui + + def emit(self, record: logging.LogRecord) -> None: # noqa: D401 + if getattr(record, _FROM_GUI_FLAG, False): + return + try: + message = self.format(record) + except Exception: + try: + message = record.getMessage() + except Exception: + return + gui_level = _LEVEL_TO_GUI.get(record.levelno, "info") + try: + # PQLogGUI.log 内部已处理跨线程 + self._log_gui.log(message, level=gui_level, _from_logging=True) + except Exception: + self.handleError(record) + + +def attach_gui_handler(log_gui) -> TkLogHandler: + """把 ``PQLogGUI`` 注册为 root logger 的 handler。已存在则替换。""" + root = logging.getLogger() + # 移除旧的 TkLogHandler,保证只挂一个 + for h in list(root.handlers): + if isinstance(h, TkLogHandler): + root.removeHandler(h) + handler = TkLogHandler(log_gui) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(name)s: %(message)s")) + root.addHandler(handler) + return handler diff --git a/app/services/ai_image.py b/app/services/ai_image.py index 7e762c6..2a7f353 100644 --- a/app/services/ai_image.py +++ b/app/services/ai_image.py @@ -23,6 +23,7 @@ import uuid from io import BytesIO from dataclasses import dataclass, asdict from typing import Callable, List, Optional +from urllib.error import HTTPError from urllib.parse import urlparse from urllib.request import Request, urlopen @@ -41,7 +42,7 @@ _SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp") # 测试环境后端 API_BASE_URL = "http://10.201.44.70:9018/ai-agent/" API_PATH = "api/v1/pqtest/generate" -API_TIMEOUT = 90.0 # 后端最长 60s,留余量 +API_TIMEOUT = 300.0 # 后端最长 60s,留余量 # 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置 _session_id: str = str(uuid.uuid4()) @@ -62,7 +63,6 @@ def set_session_id(session_id: str) -> str: with _session_lock: old = _session_id _session_id = sid - logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id)) return _session_id @@ -72,7 +72,6 @@ def reset_session() -> str: with _session_lock: old = _session_id _session_id = str(uuid.uuid4()) - logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id)) return _session_id @@ -144,27 +143,66 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A "session_id": session_id}, ensure_ascii=False, ).encode("utf-8") + request_headers = { + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + "User-Agent": "pqAutomationApp/1.0", + } endpoint = _api_endpoint() logger.info( "[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r", _mask_sid(session_id), len(user_message or ""), _truncate(user_message), ) - logger.debug("[AIImage] POST %s timeout=%.1fs", endpoint, timeout) + logger.info( + "[AIImage][REQUEST]\\nendpoint=%s\\nmethod=POST\\ntimeout=%.1fs\\nheaders=%s\\nbody=%s", + endpoint, + timeout, + json.dumps(request_headers, ensure_ascii=False), + payload.decode("utf-8", errors="replace"), + ) request = Request( endpoint, data=payload, method="POST", - headers={ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - "User-Agent": "pqAutomationApp/1.0", - }, + headers=request_headers, ) t0 = time.monotonic() try: with urlopen(request, timeout=timeout) as response: raw = response.read() http_status = response.status + response_headers = dict(response.headers.items()) + raw_text = raw.decode("utf-8", errors="replace") + logger.info( + "[AIImage][RESPONSE]\\nstatus=%s\\nheaders=%s\\nbody=%s", + http_status, + json.dumps(response_headers, ensure_ascii=False), + raw_text, + ) + except HTTPError as exc: + elapsed = time.monotonic() - t0 + err_raw = b"" + try: + err_raw = exc.read() or b"" + except Exception: + err_raw = b"" + err_text = err_raw.decode("utf-8", errors="replace") if err_raw else "" + err_headers = {} + try: + if exc.headers is not None: + err_headers = dict(exc.headers.items()) + except Exception: + err_headers = {} + logger.error( + "[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\\nheaders=%s\\nbody=%s", + _mask_sid(session_id), + elapsed, + getattr(exc, "code", "?"), + str(exc), + json.dumps(err_headers, ensure_ascii=False), + err_text, + ) + raise except Exception as exc: elapsed = time.monotonic() - t0 logger.error( @@ -173,15 +211,13 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A ) raise elapsed = time.monotonic() - t0 - logger.debug( - "[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", - http_status, len(raw), elapsed, - ) + logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed) try: result = json.loads(raw.decode("utf-8")) except Exception as exc: - logger.error("[AIImage] 响应解析失败 sid=%s raw=%r", _mask_sid(session_id), raw[:200]) - raise RuntimeError(f"AI 接口返回非 JSON:{raw[:200]!r}") from exc + raw_text = raw.decode("utf-8", errors="replace") + logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text) + raise RuntimeError(f"AI 接口返回非 JSON:{raw_text}") from exc code = result.get("code") message = result.get("message") or "" diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index a0398a6..0c9702b 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -187,11 +187,12 @@ def toggle_ai_image_panel(self): # ---------------- 列表 / 选中 ---------------- -def reload_ai_image_list(self): +def reload_ai_image_list(self, auto_select_first=True): """重新扫描缓存并刷新列表。 按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``), 其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。 + auto_select_first: 是否自动选中第一张图片(默认 True)。 """ self.ai_image_records = _svc.list_records() self.ai_image_listbox.delete(0, tk.END) @@ -222,7 +223,7 @@ def reload_ai_image_list(self): flat.append(rec) # 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等) self.ai_image_records = flat - if self.ai_image_records: + if self.ai_image_records and auto_select_first: # 选中第一张实际记录 for row, ridx in enumerate(self._ai_image_row_map): if ridx is not None: @@ -333,7 +334,7 @@ def _start_new_session(self): return _svc.reset_session() self.ai_image_status_var.set("已开启新对话") - reload_ai_image_list(self) + reload_ai_image_list(self, auto_select_first=False) def _session_id_for_row(self, row: int) -> str: @@ -372,8 +373,7 @@ def _update_request_progress(self): if not getattr(self, "_ai_image_requesting", False): self._ai_image_progress_job = None return - phases = ["后端处理中…", "正在生成图片…", "正在下载结果…", "即将完成…"] - self.ai_image_status_var.set(phases[self._ai_image_progress_phase % len(phases)]) + self.ai_image_status_var.set("正在生成图片…") self._ai_image_progress_phase += 1 self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self)) diff --git a/app/views/pq_log_gui.py b/app/views/pq_log_gui.py index 17e10a6..cc70d5e 100644 --- a/app/views/pq_log_gui.py +++ b/app/views/pq_log_gui.py @@ -1,8 +1,22 @@ +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, +} + + class PQLogGUI(ttk.Frame): VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"} @@ -10,6 +24,8 @@ class PQLogGUI(ttk.Frame): 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): @@ -62,13 +78,25 @@ class PQLogGUI(ttk.Frame): self._configure_tags() self.log_text.config(state=tk.DISABLED) - def log(self, message, level="info"): + 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) + 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) diff --git a/pqAutomationApp.py b/pqAutomationApp.py index d81fbae..3a12b1a 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -1,7 +1,6 @@ import ttkbootstrap as ttk import tkinter as tk from tkinter import messagebox, filedialog -import sys import threading import time import os @@ -23,6 +22,7 @@ from app.views.panels import cct_panel as _ccp from app.views.panels import main_layout as _main from app.views.panels import ai_image_panel as _aip from app.views import panel_manager as PM +from app.logging_setup import setup_logging, attach_gui_handler # Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持 # 对原函数名/方法名的向后兼容(老代码内部仍用 self.calculate_* 调用)。 @@ -877,17 +877,13 @@ class PQAutomationApp: def main(): try: - # 全局日志:默认 INFO 输出到 stderr,便于排查 AI 接口等关键事件 - import logging as _logging - if not _logging.getLogger().handlers: - _logging.basicConfig( - level=_logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", - ) + setup_logging() # root = tk.Tk() root = ttk.Window(themename="yeti") app = PQAutomationApp(root) + # GUI 创建完成后,把 logging 记录同步到日志面板 + if hasattr(app, "log_gui"): + attach_gui_handler(app.log_gui) root.mainloop() except Exception as e: print("程序发生错误:", e) diff --git a/settings/pq_config.json b/settings/pq_config.json index d7b733a..3201522 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -32,13 +32,7 @@ "timing": "DMT 1920x 1080 @ 60Hz", "color_format": "RGB", "bpc": 8, - "colorimetry": "sRGB", - "cct_params": { - "x_ideal": 0.3127, - "x_tolerance": 0.003, - "y_ideal": 0.329, - "y_tolerance": 0.003 - } + "colorimetry": "sRGB" }, "hdr_movie": { "name": "HDR Movie测试", @@ -52,17 +46,11 @@ "timing": "DMT 1920x 1080 @ 60Hz", "color_format": "RGB", "bpc": 8, - "colorimetry": "sRGB", - "cct_params": { - "x_ideal": 0.3127, - "x_tolerance": 0.003, - "y_ideal": 0.329, - "y_tolerance": 0.003 - } + "colorimetry": "sRGB" } }, "device_config": { - "ca_com": "COM3", + "ca_com": "COM1", "ucd_list": "0: UCD-323 [2128C209]", "ca_channel": "0" },