From b5f289fddd6a13ba4ec6f2c3694c2e94f847120d Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 4 Jul 2026 16:47:12 +0800 Subject: [PATCH] feat: show title generation wait timer --- src/app/widgets/ai_outfit_panel.py | 76 +++++++++++++++++++++++++++++- tasks.md | 21 +++++---- tests/test_ai_outfit_panel.py | 50 ++++++++++++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index f3a125c..635a104 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -7,8 +7,9 @@ 通过 Qt signal 回主线程刷新 UI(子线程不直接碰控件)。 """ import logging +import time -from PySide6.QtCore import QObject, QSize, Qt, QThread, QUrl, Signal +from PySide6.QtCore import QObject, QSize, Qt, QThread, QTimer, QUrl, Signal from PySide6.QtGui import QDesktopServices, QIcon, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, @@ -322,6 +323,12 @@ class AiOutfitPanel(QWidget): self._title_worker = None self._title_fail = 0 # running failure count for title stats self._title_model_name = "" # configured 标题模型 name (§17.3 配置定名) + self._title_wait_started_at = 0.0 + self._title_wait_limit_seconds = 0 + self._title_wait_generation = 0 + self._title_wait_timer = QTimer(self) + self._title_wait_timer.setInterval(1000) + self._title_wait_timer.timeout.connect(self._refresh_title_wait_label) self._preview_dialog = None # 最终提示词预览弹窗(§7.3,按需) self._row_to_table = {} # excel row_index -> table row self._failures = [] # list of OutfitResult (failed) @@ -471,7 +478,15 @@ class AiOutfitPanel(QWidget): """ box = QGroupBox() v = QVBoxLayout(box) - v.addWidget(QLabel("标题生成提示词")) + header = QHBoxLayout() + header.addWidget(QLabel("标题生成提示词")) + header.addStretch(1) + self._title_wait_label = QLabel("") + self._title_wait_label.setObjectName("titleWaitLabel") + self._title_wait_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._title_wait_label.setVisible(False) + header.addWidget(self._title_wait_label) + v.addLayout(header) self._title_prompt_edit = QPlainTextEdit() self._title_prompt_edit.setMinimumHeight(110) v.addWidget(self._title_prompt_edit) @@ -987,6 +1002,7 @@ class AiOutfitPanel(QWidget): self._title_fail = 0 self._progress.setValue(0) self._log.clear() + self._start_title_wait_timer(self._title_wait_limit_for_model(model_config)) self._title_worker = _TitleWorker(excel, model_config, prompt) self._title_thread = QThread(self) @@ -1009,6 +1025,10 @@ class AiOutfitPanel(QWidget): self._append_log("标题生成:已加载 {} 行".format(len(tasks))) def _on_title_progress(self, completed, total, result): + if self._title_wait_timer.isActive(): + self._title_wait_timer.stop() + self._set_title_wait_text("写入中 {}/{}".format(completed, total)) + row = self._row_to_table.get(result.task.row_index) if row is not None: if result.success: @@ -1025,6 +1045,10 @@ class AiOutfitPanel(QWidget): def _on_title_finished(self, success, fail): self._set_title_running(False) + if fail: + self._finish_title_wait("完成 {} 条,失败 {}".format(success, fail)) + else: + self._finish_title_wait("完成 {} 条".format(success)) msg = "标题生成结束:成功 {},失败 {}".format(success, fail) self._append_log(msg) self._reload_after_titles() @@ -1037,10 +1061,13 @@ class AiOutfitPanel(QWidget): def _on_title_failed(self, message): self._set_title_running(False) + self._finish_title_wait("生成失败") self._append_log("标题生成无法开始:" + message) QMessageBox.warning(self, "无法开始", message) def _cleanup_title_thread(self): + if self._title_wait_timer.isActive(): + self._reset_title_wait_label() self._title_thread = None self._title_worker = None @@ -1064,6 +1091,51 @@ class AiOutfitPanel(QWidget): self._start_btn.setEnabled(not running) # mutually exclusive with 开始生成 self._excel_edit.setEnabled(not running) + def _title_wait_limit_for_model(self, model_config): + """Return the title request wait ceiling shown in the label (§17.10).""" + try: + override = int(getattr(model_config, "timeout_seconds", 0) or 0) + except (TypeError, ValueError): + override = 0 + if override > 0: + return override + from services.ai_image_service import resolution_timeout + return resolution_timeout("4K") + + def _start_title_wait_timer(self, limit_seconds): + self._title_wait_generation += 1 + self._title_wait_limit_seconds = max(1, int(limit_seconds or 0)) + self._title_wait_started_at = time.monotonic() + self._refresh_title_wait_label() + self._title_wait_timer.start() + + def _refresh_title_wait_label(self): + elapsed = max(0, int(time.monotonic() - self._title_wait_started_at)) + self._set_title_wait_text("等待中 {} / {}".format( + self._format_wait_seconds(elapsed), + self._format_wait_seconds(self._title_wait_limit_seconds))) + + def _set_title_wait_text(self, text): + self._title_wait_label.setText(str(text)) + self._title_wait_label.setVisible(bool(text)) + + def _finish_title_wait(self, text): + self._title_wait_timer.stop() + self._set_title_wait_text(text) + token = self._title_wait_generation + QTimer.singleShot(4000, lambda: self._reset_title_wait_label(token)) + + def _reset_title_wait_label(self, token=None): + if token is not None and token != self._title_wait_generation: + return + self._title_wait_timer.stop() + self._title_wait_label.clear() + self._title_wait_label.setVisible(False) + + def _format_wait_seconds(self, seconds): + total = max(0, int(seconds or 0)) + return "{:02d}:{:02d}".format(total // 60, total % 60) + # -- switch info popups (user-only; §10.2) -------------------------- def _on_resolution_activated(self, _index): diff --git a/tasks.md b/tasks.md index c93e4ad..0e916d8 100644 --- a/tasks.md +++ b/tasks.md @@ -1602,16 +1602,17 @@ 任务: -- [ ] `ai_outfit_panel.py`:在「标题生成提示词」label 右侧新增等待计时 QLabel,默认隐藏或空白,不挤压编辑框 -- [ ] 点击「生成标题」后启动 1 秒计时器,显示 `等待中 mm:ss / MM:SS` -- [ ] 最大等待默认取标题生成 600 秒;若当前标题模型 `timeout_seconds > 0`,显示该覆盖值 -- [ ] 请求返回并进入写回阶段时,文案切换为 `写入中 已完成/总数` 或同等清晰状态;完成后短暂停留并复位 -- [ ] 失败、超时、停止、线程清理时停止计时并复位,避免残留旧状态 -- [ ] 不把该显示做成真实百分比进度,不影响右栏本次进度条和明细表刷新 -- [ ] 测试:离屏断言点击「生成标题」后计时 label 变为等待中;mock 超时值覆盖;完成/失败后复位 +- [x] `ai_outfit_panel.py`:在「标题生成提示词」label 右侧新增等待计时 QLabel,默认隐藏或空白,不挤压编辑框 +- [x] 点击「生成标题」后启动 1 秒计时器,显示 `等待中 mm:ss / MM:SS` +- [x] 最大等待默认取标题生成 600 秒;若当前标题模型 `timeout_seconds > 0`,显示该覆盖值 +- [x] 请求返回并进入写回阶段时,文案切换为 `写入中 已完成/总数` 或同等清晰状态;完成后短暂停留并复位 +- [x] 失败、超时、停止、线程清理时停止计时并复位,避免残留旧状态 +- [x] 不把该显示做成真实百分比进度,不影响右栏本次进度条和明细表刷新 +- [x] 测试:离屏断言点击「生成标题」后计时 label 变为等待中;mock 超时值覆盖;完成/失败后复位 验收: -- [ ] 长时间标题生成期间,用户能看到已等待时间和最长等待时间 -- [ ] 计时文案不暗示模型内部完成百分比 -- [ ] 标题生成和图片生成互斥、原有进度/明细行为不回退 +- [x] 长时间标题生成期间,用户能看到已等待时间和最长等待时间 +- [x] 计时文案不暗示模型内部完成百分比 +- [x] 标题生成和图片生成互斥、原有进度/明细行为不回退 +- [x] 验证:`python -m py_compile src/app/widgets/ai_outfit_panel.py tests/test_ai_outfit_panel.py`、`python tests/test_ai_outfit_panel.py`、离屏启动主窗口通过;全套 `python -m unittest discover -s tests` 仅失败于既有未提交 `packaging/default_config/ai_models.json` 含非空 `api_key`,与本任务无关 diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py index 9287d5f..f584261 100644 --- a/tests/test_ai_outfit_panel.py +++ b/tests/test_ai_outfit_panel.py @@ -3,6 +3,7 @@ import os import shutil import sys import tempfile +import time import unittest from pathlib import Path @@ -114,6 +115,55 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT) + def test_title_wait_label_defaults_hidden(self): + panel = self._panel() + + self.assertTrue(hasattr(panel, "_title_wait_label")) + self.assertTrue(panel._title_wait_label.isHidden()) + self.assertEqual(panel._title_wait_label.text(), "") + + def test_title_wait_timer_formats_elapsed_and_limit(self): + panel = self._panel() + + panel._start_title_wait_timer(600) + panel._title_wait_started_at = time.monotonic() - 95 + panel._refresh_title_wait_label() + + self.assertFalse(panel._title_wait_label.isHidden()) + self.assertEqual(panel._title_wait_label.text(), "等待中 01:35 / 10:00") + self.assertTrue(panel._title_wait_timer.isActive()) + panel._reset_title_wait_label() + + def test_title_wait_limit_uses_model_timeout_override(self): + from services.ai_image_service import AiModelConfig + + panel = self._panel() + default_cfg = AiModelConfig( + url="https://r/v1/chat/completions", model="m", api_key="sk", + api_type="chat", timeout_seconds=0, connect_timeout_seconds=30) + override_cfg = AiModelConfig( + url="https://r/v1/chat/completions", model="m", api_key="sk", + api_type="chat", timeout_seconds=120, connect_timeout_seconds=30) + + self.assertEqual(panel._title_wait_limit_for_model(default_cfg), 600) + self.assertEqual(panel._title_wait_limit_for_model(override_cfg), 120) + + def test_title_progress_switches_wait_label_to_writing(self): + from core.models import OutfitTask, TitleResult + + panel = self._panel() + task = OutfitTask(row_index=2, title="", product_id="SKU", garment_path="a.png") + panel._populate_table([task]) + panel._start_title_wait_timer(600) + + panel._on_title_progress( + 1, 3, TitleResult(task=task, success=True, generated_title="新标题")) + + self.assertFalse(panel._title_wait_timer.isActive()) + self.assertFalse(panel._title_wait_label.isHidden()) + self.assertEqual(panel._title_wait_label.text(), "写入中 1/3") + panel._reset_title_wait_label() + def test_prompt_buttons_save_row_insert_title_and_preview(self): """§19.20: 按钮行含「保存」;编辑框下方保留「插入标题」+「预览最终提示词」。""" from PySide6.QtWidgets import QPushButton