diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 4150a49..ea6e996 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -2,7 +2,8 @@ from __future__ import annotations -from PySide6.QtCore import QEvent, QSize +from PySide6.QtCore import QEvent, QRectF, QSize +from PySide6.QtGui import QColor, QPainter from ..models import GenerateTaskTableModel from ..widgets import * @@ -71,6 +72,63 @@ class OriginalImageDialog(QDialog): _center_dialog(self, available) +class _SegmentedProgressBar(QProgressBar): + def __init__(self, parent=None): + super().__init__(parent) + self._segment_success = 0 + self._segment_failed = 0 + self._segment_total = 0 + self.setTextVisible(False) + self.setRange(0, 1) + self.setValue(0) + self.setMinimumHeight(12) + + def set_segments(self, success, failed, total): + total = max(0, int(total or 0)) + success = min(max(0, int(success or 0)), total) + failed = min(max(0, int(failed or 0)), max(0, total - success)) + self._segment_success = success + self._segment_failed = failed + self._segment_total = total + maximum = max(1, total) + self.setRange(0, maximum) + self.setValue(min(maximum, success + failed)) + self.update() + + def segments(self): + return self._segment_success, self._segment_failed, self._segment_total + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor("#f6f8fa")) + painter.drawRoundedRect(rect, 3, 3) + total = max(0, self._segment_total) + if total: + inner = rect.adjusted(1, 1, -1, -1) + unit_width = inner.width() / total if total else 0 + success_width = unit_width * self._segment_success + failed_width = unit_width * self._segment_failed + if success_width > 0: + painter.setBrush(QColor(COLOR_SUCCESS)) + painter.drawRect(QRectF(inner.left(), inner.top(), success_width, inner.height())) + if failed_width > 0: + painter.setBrush(QColor(COLOR_DANGER)) + painter.drawRect( + QRectF( + inner.left() + success_width, + inner.top(), + failed_width, + inner.height(), + ) + ) + painter.setBrush(Qt.NoBrush) + painter.setPen(QColor("#d0d7de")) + painter.drawRoundedRect(rect, 3, 3) + + class CoverGalleryDialog(QDialog): THUMBNAIL_SIZE = 180 OLD_THUMBNAIL_SIZE = 300 @@ -867,11 +925,9 @@ class GenerateTab(QWidget): self.title_progress_bar.setValue(0) self.cover_progress_label = QLabel("图片 0/0") self.cover_progress_label.setObjectName("generateCoverProgressLabel") - self.cover_progress_bar = QProgressBar() + self.cover_progress_bar = _SegmentedProgressBar() self.cover_progress_bar.setObjectName("generateCoverProgressBar") - self.cover_progress_bar.setTextVisible(False) - self.cover_progress_bar.setRange(0, 1) - self.cover_progress_bar.setValue(0) + self.cover_progress_bar.set_segments(0, 0, 0) self.failed_progress_label = QLabel("失败 0") self.failed_progress_label.setObjectName("generateFailedProgressLabel") self.failed_progress_label.setVisible(False) @@ -1732,8 +1788,10 @@ class GenerateTab(QWidget): return if payload.get("error"): self._set_status(f"AI 生成失败:{payload.get('error')}") + self._show_generate_completion_message(payload, error=True) return self._set_status("AI 生成完成:" + self._generate_progress_text(payload)) + self._show_generate_completion_message(payload) def _on_generate_cancelled(self, payload): self._set_generate_running(False) @@ -1746,6 +1804,7 @@ class GenerateTab(QWidget): self._show_billing_error(billing_error) return self._set_status("AI 生成已停止:" + self._generate_progress_text(payload)) + self._show_generate_completion_message(payload, cancelled=True) def _update_generate_progress(self, payload): total = max(0, int(payload.get("total", 0) or 0)) @@ -1754,12 +1813,14 @@ class GenerateTab(QWidget): cover_done = max(0, int(payload.get("cover_done", 0) or 0)) cover_total = self._cover_total_for_progress(payload, total) failed = max(0, int(payload.get("failed", 0) or 0)) + cover_failed = self._cover_failed_for_progress(payload, cover_done, cover_total) self.progress_label.setText("进度:" + self._generate_progress_text(payload)) self.title_progress_label.setText(f"标题 {title_done}/{title_total}") - self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}") + cover_failed_text = f",失败 {cover_failed}" if cover_failed else "" + self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}{cover_failed_text}") self.failed_progress_label.setText(f"失败 {failed}") self._set_progress_bar(self.title_progress_bar, title_done, title_total) - self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total) + self.cover_progress_bar.set_segments(cover_done, cover_failed, cover_total) def _reset_cmhub_balance_label(self): self.cmhub_balance_label.setVisible(False) @@ -1790,6 +1851,44 @@ class GenerateTab(QWidget): QMessageBox.warning(self, "AI生成", message) self._set_status("AI 生成已中止:" + message) + def _show_generate_completion_message(self, payload, cancelled=False, error=False): + failed = max(0, int(payload.get("failed", 0) or 0)) + title_total = max(0, int(payload.get("title_total", payload.get("total", 0)) or 0)) + title_done = max(0, int(payload.get("title_done", 0) or 0)) + cover_total = self._cover_total_for_progress(payload, max(0, int(payload.get("total", 0) or 0))) + cover_done = max(0, int(payload.get("cover_done", 0) or 0)) + elapsed_seconds = self._generation_elapsed_total_seconds() + if cancelled: + title = "AI生成已停止" + message = ( + f"本轮AI生成已停止:标题 {title_done}/{title_total},图片 {cover_done}/{cover_total}," + f"失败 {failed},用时 {elapsed_seconds} 秒。未完成任务可再次点击开始生成继续处理。" + ) + QMessageBox.warning(self, title, message) + return + if error: + title = "AI生成失败" + message = ( + f"本轮AI生成失败:标题 {title_done}/{title_total},图片 {cover_done}/{cover_total}," + f"失败 {failed},用时 {elapsed_seconds} 秒。失败原因请查看下方运行日志。" + ) + QMessageBox.warning(self, title, message) + return + if failed: + title = "AI生成完成,有失败任务" + message = ( + f"本轮AI生成完成:标题 {title_done}/{title_total},图片 {cover_done}/{cover_total}," + f"失败 {failed},用时 {elapsed_seconds} 秒。失败明细请查看下方运行日志,可稍后重新生成失败任务。" + ) + QMessageBox.warning(self, title, message) + return + title = "AI生成完成" + message = ( + f"本轮AI生成完成:标题 {title_done}/{title_total},图片 {cover_done}/{cover_total}," + f"失败 0,用时 {elapsed_seconds} 秒。" + ) + QMessageBox.information(self, title, message) + def _set_progress_bar(self, bar, done, total): maximum = max(1, int(total or 0)) value = min(max(0, int(done or 0)), maximum) @@ -1802,6 +1901,16 @@ class GenerateTab(QWidget): cover_total = total if payload.get("generate_cover", True) else 0 return max(0, int(cover_total or 0)) + def _cover_failed_for_progress(self, payload, cover_done, cover_total): + if cover_total <= 0: + return 0 + failed = max(0, int(payload.get("failed", 0) or 0)) + remaining = max(0, int(cover_total or 0) - max(0, int(cover_done or 0))) + return min(failed, remaining) + + def _generation_elapsed_total_seconds(self): + return max(0, int(self._title_elapsed_seconds or 0)) + max(0, int(self._cover_elapsed_seconds or 0)) + def _generate_progress_text(self, payload): total = max(0, int(payload.get("total", 0) or 0)) title_total = max(0, int(payload.get("title_total", total) or 0)) diff --git a/docs/tasks/T-575.md b/docs/tasks/T-575.md index 00b8554..191f43d 100644 --- a/docs/tasks/T-575.md +++ b/docs/tasks/T-575.md @@ -3,7 +3,7 @@ id: T-575 title: ②AI生成图片失败红色进度段与完成汇总弹窗 phase: 7 deps: [T-574] -status: TODO +status: DONE created: 2026-07-09 --- @@ -71,4 +71,12 @@ created: 2026-07-09 ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、关键决策。) +- 2026-07-09:已完成。 + - `app/gui/tabs/generate.py`:新增 `_SegmentedProgressBar`,图片进度条保持 `QProgressBar` 兼容但改为绿色成功段 + 红色失败段 + 灰白未完成段;②图片进度按 `cover_done` 和保守失败数 `min(failed, cover_total - cover_done)` 显示红色失败区域;本轮完成、失败或停止后弹一次中文汇总框,弹窗只显示汇总,不暴露接口路径或底层异常细节。 + - `tests/test_gui.py`:新增分段图片进度条测试、图片部分失败完成汇总弹窗测试、停止汇总弹窗测试。 + - 验证通过: + - `py -3.10 -m unittest tests.test_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`(通过;仅提示 `docs/tasks-board.md` 未来触碰时 CRLF 会转 LF,非空白错误) diff --git a/tests/test_gui.py b/tests/test_gui.py index f4d7c89..bdc7860 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2048,6 +2048,90 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_cover_progress_bar_marks_failed_segment(self): + with self.make_temp_dir() as temp_dir: + tab = GenerateTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + + tab._update_generate_progress( + { + "total": 13, + "title_total": 13, + "title_done": 13, + "cover_total": 13, + "cover_done": 11, + "failed": 2, + "generate_cover": True, + } + ) + + self.assertEqual("图片 11/13,失败 2", tab.cover_progress_label.text()) + self.assertEqual((11, 2, 13), tab.cover_progress_bar.segments()) + self.assertEqual(13, tab.cover_progress_bar.maximum()) + self.assertEqual(13, tab.cover_progress_bar.value()) + + self.assert_removed(temp_dir) + + def test_generate_finished_shows_chinese_summary_for_failed_images(self): + with self.make_temp_dir() as temp_dir: + tab = GenerateTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + tab._title_elapsed_seconds = 120 + tab._cover_elapsed_seconds = 372 + payload = { + "total": 13, + "title_total": 13, + "title_done": 13, + "cover_total": 13, + "cover_done": 11, + "failed": 2, + "generate_cover": True, + } + + with mock.patch.object(tab, "refresh_tasks"), \ + mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, \ + mock.patch("app.gui.tabs.generate.QMessageBox.information") as information: + tab._on_generate_finished(payload) + + warning.assert_called_once() + information.assert_not_called() + self.assertEqual("AI生成完成,有失败任务", warning.call_args[0][1]) + self.assertIn("标题 13/13", warning.call_args[0][2]) + self.assertIn("图片 11/13", warning.call_args[0][2]) + self.assertIn("失败 2", warning.call_args[0][2]) + self.assertIn("用时 492 秒", warning.call_args[0][2]) + + self.assert_removed(temp_dir) + + def test_generate_cancelled_shows_chinese_summary_dialog(self): + with self.make_temp_dir() as temp_dir: + tab = GenerateTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + tab._title_elapsed_seconds = 80 + tab._cover_elapsed_seconds = 100 + payload = { + "total": 13, + "title_total": 13, + "title_done": 8, + "cover_total": 13, + "cover_done": 5, + "failed": 1, + "generate_cover": True, + "cancelled": True, + } + + with mock.patch.object(tab, "refresh_tasks"), \ + mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning: + tab._on_generate_cancelled(payload) + + warning.assert_called_once() + self.assertEqual("AI生成已停止", warning.call_args[0][1]) + self.assertIn("标题 8/13", warning.call_args[0][2]) + self.assertIn("图片 5/13", warning.call_args[0][2]) + self.assertIn("未完成任务可再次点击开始生成继续处理", warning.call_args[0][2]) + + self.assert_removed(temp_dir) + def test_generate_tab_shows_cmhub_balance_and_billing_error(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)