From 81401249546d51bf6816c18b91db8e2fb4e89f7e Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 8 Jul 2026 11:54:08 +0800 Subject: [PATCH] feat: show generate elapsed timings --- app/gui/tabs/generate.py | 126 ++++++++++++++++++++++++++++++++++++--- app/gui/widgets.py | 2 +- docs/tasks/T-555.md | 9 ++- tests/test_gui.py | 73 ++++++++++++++++++++++- 4 files changed, 198 insertions(+), 12 deletions(-) diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 9c1cf0e..ae3a7d4 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -26,7 +26,7 @@ class GenerateTab(QWidget): "cover": "封面", "all": "标题和封面", } - TABLE_COLUMN_WEIGHTS = (1.0, 0.5, 1.0, 1.0, 1.25, 1.25) + TABLE_COLUMN_WEIGHTS = (1.0, 0.5, 1.8375, 1.8375, 0.4125, 0.4125) def __init__( self, @@ -54,6 +54,10 @@ class GenerateTab(QWidget): self.current_cover_template = None self.generate_worker = None self.generate_thread = None + self._title_elapsed_start = None + self._cover_elapsed_start = None + self._title_elapsed_seconds = 0 + self._cover_elapsed_seconds = 0 self.title_prompt_edit = QPlainTextEdit() self.title_prompt_edit.setObjectName("titlePromptEdit") @@ -110,8 +114,23 @@ class GenerateTab(QWidget): self.cover_progress_bar.setValue(0) self.failed_progress_label = QLabel("失败 0") self.failed_progress_label.setObjectName("generateFailedProgressLabel") + self.failed_progress_label.setVisible(False) self.cmhub_balance_label = QLabel("cmhub余额:未获取") self.cmhub_balance_label.setObjectName("generateCmhubBalanceLabel") + self.cmhub_balance_label.setVisible(False) + self.title_elapsed_label = QLabel("生标题用时 0 秒") + self.title_elapsed_label.setObjectName("generateTitleElapsedLabel") + self.cover_elapsed_label = QLabel("生图用时 0 秒") + self.cover_elapsed_label.setObjectName("generateCoverElapsedLabel") + elapsed_width = max( + self.title_elapsed_label.fontMetrics().horizontalAdvance("生标题用时 9999 秒"), + self.cover_elapsed_label.fontMetrics().horizontalAdvance("生图用时 9999 秒"), + ) + 12 + self.title_elapsed_label.setFixedWidth(elapsed_width) + self.cover_elapsed_label.setFixedWidth(elapsed_width) + self._generate_elapsed_timer = QTimer(self) + self._generate_elapsed_timer.setInterval(1000) + self._generate_elapsed_timer.timeout.connect(self._refresh_elapsed_labels) left_panel = QWidget() left_layout = QVBoxLayout(left_panel) @@ -209,11 +228,11 @@ class GenerateTab(QWidget): title_progress_layout = QHBoxLayout() title_progress_layout.addWidget(self.title_progress_label) title_progress_layout.addWidget(self.title_progress_bar, 1) + title_progress_layout.addWidget(self.title_elapsed_label) cover_progress_layout = QHBoxLayout() cover_progress_layout.addWidget(self.cover_progress_label) cover_progress_layout.addWidget(self.cover_progress_bar, 1) - cover_progress_layout.addWidget(self.failed_progress_label) - cover_progress_layout.addWidget(self.cmhub_balance_label) + cover_progress_layout.addWidget(self.cover_elapsed_label) progress_layout = QVBoxLayout() progress_layout.addLayout(title_progress_layout) progress_layout.addLayout(cover_progress_layout) @@ -258,6 +277,7 @@ class GenerateTab(QWidget): self.refresh_cover_templates() self.refresh_tasks() self._show_current_run_log_empty() + self._reset_generation_elapsed() self._update_cmhub_balance_label({}) def resizeEvent(self, event): @@ -286,6 +306,92 @@ class GenerateTab(QWidget): f"本轮AI生成开始:任务 {len(tasks)} 条,生成封面:{cover_text}" ) + def _reset_generation_elapsed(self): + self._generate_elapsed_timer.stop() + self._title_elapsed_start = None + self._cover_elapsed_start = None + self._title_elapsed_seconds = 0 + self._cover_elapsed_seconds = 0 + self._refresh_elapsed_labels() + + def _start_generation_elapsed(self, title_total, cover_total): + self._reset_generation_elapsed() + now = time.monotonic() + if int(title_total or 0) > 0: + self._title_elapsed_start = now + elif int(cover_total or 0) > 0: + self._cover_elapsed_start = now + self._refresh_elapsed_labels() + self._update_elapsed_timer_state() + + def _elapsed_seconds(self, start, frozen_seconds): + if start is None: + return max(0, int(frozen_seconds or 0)) + return max(0, int(time.monotonic() - start)) + + def _refresh_elapsed_labels(self): + title_seconds = self._elapsed_seconds( + self._title_elapsed_start, + self._title_elapsed_seconds, + ) + cover_seconds = self._elapsed_seconds( + self._cover_elapsed_start, + self._cover_elapsed_seconds, + ) + self.title_elapsed_label.setText(f"生标题用时 {title_seconds} 秒") + self.cover_elapsed_label.setText(f"生图用时 {cover_seconds} 秒") + + def _update_elapsed_timer_state(self): + if self._title_elapsed_start is not None or self._cover_elapsed_start is not None: + if not self._generate_elapsed_timer.isActive(): + self._generate_elapsed_timer.start() + return + self._generate_elapsed_timer.stop() + + def _freeze_title_elapsed(self): + if self._title_elapsed_start is None: + return + self._title_elapsed_seconds = self._elapsed_seconds( + self._title_elapsed_start, + self._title_elapsed_seconds, + ) + self._title_elapsed_start = None + + def _freeze_cover_elapsed(self): + if self._cover_elapsed_start is None: + return + self._cover_elapsed_seconds = self._elapsed_seconds( + self._cover_elapsed_start, + self._cover_elapsed_seconds, + ) + self._cover_elapsed_start = None + + def _sync_generation_elapsed(self, payload): + title_total = max(0, int(payload.get("title_total", 0) or 0)) + title_done = max(0, int(payload.get("title_done", 0) or 0)) + cover_total = self._cover_total_for_progress(payload, int(payload.get("total", 0) or 0)) + cover_done = max(0, int(payload.get("cover_done", 0) or 0)) + if title_total > 0 and title_done >= title_total: + self._freeze_title_elapsed() + if ( + cover_total > 0 + and cover_done < cover_total + and self._cover_elapsed_start is None + and self._cover_elapsed_seconds == 0 + and (title_total == 0 or title_done >= title_total) + ): + self._cover_elapsed_start = time.monotonic() + if cover_total > 0 and cover_done >= cover_total: + self._freeze_cover_elapsed() + self._refresh_elapsed_labels() + self._update_elapsed_timer_state() + + def _finish_generation_elapsed(self, payload=None): + self._freeze_title_elapsed() + self._freeze_cover_elapsed() + self._refresh_elapsed_labels() + self._update_elapsed_timer_state() + def _on_generate_cover_toggled(self, checked): previous = bool(appconfig.ai_config(self.config).get("generate_cover", False)) if self._save_generate_cover_setting(show_status=True): @@ -543,6 +649,10 @@ class GenerateTab(QWidget): worker.cancelled.connect(self._on_generate_cancelled) self._reset_generate_run_log(tasks, generate_cover) self._reset_cmhub_balance_label() + self._start_generation_elapsed( + component_totals["title_total"], + component_totals["cover_total"] if generate_cover else 0, + ) thread = run_worker(worker, thread_name="GenerateWorker", start=False) thread.finished.connect(lambda: self._forget_generate_thread(thread)) self.generate_worker = worker @@ -829,6 +939,7 @@ class GenerateTab(QWidget): def _on_generate_progress(self, payload): self._update_generate_progress(payload) + self._sync_generation_elapsed(payload) self._update_cmhub_balance_label(payload) self._set_status("生成进度:" + self._generate_progress_text(payload)) @@ -842,6 +953,7 @@ class GenerateTab(QWidget): self._set_generate_running(False) self.refresh_tasks() self._update_generate_progress(payload) + self._finish_generation_elapsed(payload) self._update_cmhub_balance_label(payload) billing_error = payload.get("billing_error") if billing_error: @@ -856,6 +968,7 @@ class GenerateTab(QWidget): self._set_generate_running(False) self.refresh_tasks() self._update_generate_progress(payload) + self._finish_generation_elapsed(payload) self._update_cmhub_balance_label(payload) billing_error = payload.get("billing_error") if billing_error: @@ -878,17 +991,16 @@ class GenerateTab(QWidget): self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total) def _reset_cmhub_balance_label(self): + self.cmhub_balance_label.setVisible(False) if self._is_cmhub_backend(): - self.cmhub_balance_label.setVisible(True) self.cmhub_balance_label.setText("cmhub余额:生成后刷新") else: - self.cmhub_balance_label.setVisible(False) + self.cmhub_balance_label.setText("") def _update_cmhub_balance_label(self, payload): + self.cmhub_balance_label.setVisible(False) if not self._is_cmhub_backend(): - self.cmhub_balance_label.setVisible(False) return - self.cmhub_balance_label.setVisible(True) balance = payload.get("points_balance") if isinstance(payload, dict) else None if balance is None: if not self.cmhub_balance_label.text(): diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 355ccd1..9a42b8f 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -9,7 +9,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed try: - from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt + from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, diff --git a/docs/tasks/T-555.md b/docs/tasks/T-555.md index 3ac7026..a1fc4b0 100644 --- a/docs/tasks/T-555.md +++ b/docs/tasks/T-555.md @@ -3,7 +3,7 @@ id: T-555 title: ②AI生成列宽再分配与标题/生图用时计时 phase: 7 deps: [T-554, T-519, T-523] -status: TODO +status: DONE created: 2026-07-08 --- @@ -61,3 +61,10 @@ T-554 已把②「AI生成」任务表格从一个「状态」列拆成「标题 只改②AI生成 GUI 展示和测试;不改 AI 生成流程、cmhub HTTP、并发策略、DB schema、运行日志持久化、计费 metadata、Excel、Shopee/CDP。失败数量和 cmhub 余额不从系统中删除,只是不再显示在两条进度条右侧。 +## 执行记录 + +- 2026-07-08:完成②AI生成表格列宽再分配,权重调整为 `店铺=1.0 / 商品ID=0.5 / 旧标题=1.8375 / 新标题=1.8375 / 标题状态=0.4125 / 图片状态=0.4125`。 +- 2026-07-08:底部两条进度条右侧改为固定宽度用时标签 `生标题用时 N 秒` / `生图用时 N 秒`,由 GUI 层 `QTimer` 每秒刷新;开始生成时重置,标题阶段结束后冻结标题用时,图片阶段开始后计生图用时,完成/停止/计费中止后冻结。 +- 2026-07-08:`失败 X` 与 `cmhub余额:X` 不再占用进度条右侧;失败数仍保留在进度摘要、表格状态、运行日志和结束汇总,cmhub 余额文本仍在内部按 payload 更新但隐藏展示,后续仍可通过设置页/日志入口查看。 +- 2026-07-08:更新 GUI 单测,覆盖列宽比例、初始用时标签、计时启动/切换/冻结,以及隐藏失败数/余额标签。 +- 验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 7922dcc..6442cd8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1628,9 +1628,15 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("标题 0/0", tab.title_progress_label.text()) self.assertEqual("图片 0/0", tab.cover_progress_label.text()) self.assertEqual("失败 0", tab.failed_progress_label.text()) + self.assertTrue(tab.failed_progress_label.isHidden()) + self.assertEqual("generateTitleElapsedLabel", tab.title_elapsed_label.objectName()) + self.assertEqual("generateCoverElapsedLabel", tab.cover_elapsed_label.objectName()) + self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) + self.assertEqual(tab.title_elapsed_label.width(), tab.cover_elapsed_label.width()) self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName()) self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) - self.assertFalse(tab.cmhub_balance_label.isHidden()) + self.assertTrue(tab.cmhub_balance_label.isHidden()) self.assertEqual(0, tab.title_progress_bar.value()) self.assertEqual(0, tab.cover_progress_bar.value()) self.assertEqual(1, tab.title_progress_bar.maximum()) @@ -1763,7 +1769,67 @@ class GuiTests(TempDirMixin, unittest.TestCase): header.sectionSize(5), delta=5, ) - self.assertGreater(header.sectionSize(4), header.sectionSize(0)) + self.assertAlmostEqual( + header.sectionSize(2), + header.sectionSize(3), + delta=5, + ) + self.assertLess(header.sectionSize(4), header.sectionSize(1)) + self.assertGreater(header.sectionSize(2), header.sectionSize(0)) + + self.assert_removed(temp_dir) + + def test_generate_elapsed_timer_tracks_title_then_cover(self): + with self.make_temp_dir() as temp_dir: + tab = GenerateTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + + self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) + self.assertFalse(tab._generate_elapsed_timer.isActive()) + + with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=100.0): + tab._start_generation_elapsed(title_total=2, cover_total=1) + self.assertTrue(tab._generate_elapsed_timer.isActive()) + + with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=103.0): + tab._refresh_elapsed_labels() + self.assertEqual("生标题用时 3 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) + + with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=105.0): + tab._sync_generation_elapsed( + { + "total": 3, + "title_total": 2, + "title_done": 2, + "cover_total": 1, + "cover_done": 0, + "generate_cover": True, + } + ) + self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) + + with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=109.0): + tab._refresh_elapsed_labels() + self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 4 秒", tab.cover_elapsed_label.text()) + + with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=112.0): + tab._finish_generation_elapsed( + { + "total": 3, + "title_total": 2, + "title_done": 2, + "cover_total": 1, + "cover_done": 1, + "generate_cover": True, + } + ) + self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) + self.assertEqual("生图用时 7 秒", tab.cover_elapsed_label.text()) + self.assertFalse(tab._generate_elapsed_timer.isActive()) self.assert_removed(temp_dir) @@ -1776,7 +1842,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) - self.assertFalse(tab.cmhub_balance_label.isHidden()) + self.assertTrue(tab.cmhub_balance_label.isHidden()) self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) tab._on_generate_progress( @@ -1791,6 +1857,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): } ) self.assertEqual("cmhub余额:88", tab.cmhub_balance_label.text()) + self.assertTrue(tab.cmhub_balance_label.isHidden()) with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_generate_finished(