From b803a2a15a77dde28c6ca8950a7240eddb92f659 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 18:50:08 +0800 Subject: [PATCH] feat(product-suite): complete multi-source recovery --- app/gui/tabs/product_suite.py | 139 +++++++++++++++++++--- docs/04-architecture.md | 4 +- docs/routes.md | 1 + docs/tasks/T-679c.md | 8 +- docs/ui/README.md | 1 + docs/ui/tab6-suite-recovery-T679c.svg | 15 +++ tests/test_product_suite_gui.py | 159 ++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 docs/ui/tab6-suite-recovery-T679c.svg diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index d9e318d..fcfeb5b 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -676,6 +676,11 @@ class SuiteResultCard(QFrame): title = QLabel(str(getattr(job, "job_type", "套图"))) title.setStyleSheet("font-weight: 600; color: #24292f;") title_row.addWidget(title, 1) + source_label = QLabel(_generation_source_label(job)) + source_label.setObjectName("suiteResultCardSource") + source_label.setStyleSheet("color: #57606a; font-size: 11px;") + source_label.setToolTip("生成来源:%s" % _generation_source_label(job)) + title_row.addWidget(source_label) if asset is not None and _asset_usable(asset): delete_button = QToolButton() delete_button.setText("×") @@ -791,7 +796,10 @@ class SuiteHistoryImageCard(QFrame): ) layout.addWidget(image) - footer_text = self._status_text(status) + footer_text = "%s · %s" % ( + _generation_source_label(job), + self._status_text(status), + ) if self.retry_count: footer_text += " · 重试%d次" % self.retry_count footer = QLabel(footer_text) @@ -803,6 +811,7 @@ class SuiteHistoryImageCard(QFrame): tooltip = [ "类型:%s" % str(getattr(job, "job_type", "套图") or "套图"), + "生成来源:%s" % _generation_source_label(job), "状态:%s" % self._status_text(status), "生成时间:%s" % _history_time_text(getattr(job, "created_at", "")), ] @@ -856,6 +865,33 @@ def _history_time_text(value): return text[:19] +def _generation_source_label(job): + """Return the persisted job source as a user-facing Chinese label.""" + + source = str(getattr(job, "generation_source", "") or "").strip().lower() + provider = str(getattr(job, "provider", "") or "").strip().lower() + if ( + source == image_studio.GENERATION_SOURCE_CMHUB + and provider == image_studio.PROVIDER_CMHUB + ): + return "默认网关" + if ( + source == image_studio.GENERATION_SOURCE_DIRECT + and provider == image_studio.PROVIDER_OPENAI_IMAGES_EDITS + ): + return "自定义网关" + return "来源未知" + + +def _generation_source_summary(jobs): + labels = [] + for job in jobs: + label = _generation_source_label(job) + if label not in labels: + labels.append(label) + return "、".join(labels) if labels else "来源未知" + + class ProductSuiteHistoryDialog(QDialog): """Read-only, project-scoped generation history grouped by persisted rounds.""" @@ -1084,6 +1120,10 @@ class ProductSuiteHistoryDialog(QDialog): "border-radius: 6px; padding: 1px 6px;" ) header.addWidget(legacy) + source_label = QLabel("来源:%s" % _generation_source_summary(jobs)) + source_label.setObjectName("suiteHistoryRoundSource") + source_label.setStyleSheet("color: #57606a;") + header.addWidget(source_label) header.addStretch(1) stats = self._round_stats_text(round_info) stats_label = QLabel(stats) @@ -1268,9 +1308,10 @@ class SuiteGlobalHistoryThumbnail(QLabel): self.setAlignment(Qt.AlignCenter) self.setFixedSize(104, 78) self.setToolTip( - "%s · %s" + "%s · %s · %s" % ( str(getattr(job, "job_type", "套图") or "套图"), + _generation_source_label(job), SuiteHistoryImageCard._status_text(getattr(job, "status", "")), ) ) @@ -1348,6 +1389,10 @@ class SuiteGlobalHistoryRoundRow(QFrame): ) meta.addWidget(QLabel("店铺:%s" % account)) meta.addWidget(QLabel(item_text)) + source_label = QLabel("来源:%s" % _generation_source_summary(self.jobs)) + source_label.setObjectName("suiteGlobalHistorySource") + source_label.setStyleSheet("color: #57606a;") + meta.addWidget(source_label) layout.addLayout(meta, 0) thumbnails = QHBoxLayout() @@ -4227,13 +4272,41 @@ class ProductSuiteTab(QWidget): return False @staticmethod - def _is_default_gateway_job(job): + def _has_default_gateway_source(job): return ( str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub" and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub" - and bool(str(getattr(job, "task_id", "") or "").strip()) ) + @classmethod + def _is_default_gateway_job(cls, job): + return cls._has_default_gateway_source(job) and bool( + str(getattr(job, "task_id", "") or "").strip() + ) + + @staticmethod + def _is_direct_gateway_job(job): + return ( + str(getattr(job, "generation_source", "") or "").strip().lower() + == image_studio.GENERATION_SOURCE_DIRECT + and str(getattr(job, "provider", "") or "").strip().lower() + == image_studio.PROVIDER_OPENAI_IMAGES_EDITS + ) + + def _retry_availability(self, job): + if self._has_default_gateway_source(job): + if self._is_default_gateway(): + return True, "" + return False, "默认网关图片仅能在⑤设置切换为默认网关后重新生成" + if self._is_direct_gateway_job(job): + if not self._is_direct_gateway(): + return False, "自定义网关图片仅能在⑤设置切换为自定义网关后重新生成" + error = self._direct_generation_config_error() + if error: + return False, "自定义网关配置不完整:%s" % error + return True, "" + return False, "当前图片的生成来源无法确认,不能重新生成" + def _resumable_default_gateway_jobs(self, state): if state is None or state.project_id is None: return [] @@ -4648,7 +4721,14 @@ class ProductSuiteTab(QWidget): return self.start_generation(state) - def start_generation(self, state, specs=None, *, retry_job_id=None): + def start_generation( + self, + state, + specs=None, + *, + retry_job_id=None, + confirm_direct_retry=False, + ): if not self._ensure_generation_gateway(): return False if state.generation_running(): @@ -4742,6 +4822,9 @@ class ProductSuiteTab(QWidget): generation_round_key, template_text, ) + if confirm_direct_retry: + if not self._confirm_direct_retry(state, local_assets, specs): + return False return self._start_generation_worker( state, specs, @@ -4750,6 +4833,29 @@ class ProductSuiteTab(QWidget): retry_job_id=retry_job_id, ) + def _confirm_direct_retry(self, state, local_assets, specs): + message = "\n".join( + [ + "这张图片上次通过自定义网关生成但未完成。", + "服务商可能对上次未确认请求已计费,本次重新生成可能再次收费。", + "", + self._generation_confirmation_message( + state, + local_assets, + specs, + estimate=None, + ), + ] + ) + return self._confirm( + "确认重新生成商品套图", + message, + destructive=True, + confirm_text="确认重新生成", + cancel_text="取消", + default_cancel=True, + ) + def _start_generation_worker( self, state, @@ -5759,8 +5865,8 @@ class ProductSuiteTab(QWidget): card.retryRequested.connect(self.retry_job) card.menuRequested.connect(self._show_job_menu) card.deleteRequested.connect(self.delete_job_asset) - if not self._is_default_gateway(): - card.set_retry_enabled(False, "商品套图重新生成仅支持默认网关") + retry_enabled, retry_tooltip = self._retry_availability(job) + card.set_retry_enabled(retry_enabled, retry_tooltip) self.result_grid.addWidget(card, index // columns, index % columns) self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success)) self.undo_button.setVisible(bool(state.undo_records)) @@ -5831,8 +5937,6 @@ class ProductSuiteTab(QWidget): ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec() def retry_job(self, job): - if not self._require_default_gateway("商品套图重新生成"): - return state = self._displayed_state if state is None: return @@ -5846,6 +5950,10 @@ class ProductSuiteTab(QWidget): }: self._status("当前图片无需重试", "warning") return + retry_enabled, retry_tooltip = self._retry_availability(job) + if not retry_enabled: + self._message("当前不可重新生成", retry_tooltip) + return try: reference_asset_ids = image_studio.job_reference_asset_ids(job) except Exception as exc: @@ -5857,7 +5965,12 @@ class ProductSuiteTab(QWidget): "job_type": job.job_type, "prompt": job.prompt, } - self.start_generation(state, specs=[spec], retry_job_id=job.id) + self.start_generation( + state, + specs=[spec], + retry_job_id=job.id, + confirm_direct_retry=self._is_direct_gateway_job(job), + ) def _show_job_menu(self, job, global_position): menu = QMenu(self) @@ -5865,9 +5978,9 @@ class ProductSuiteTab(QWidget): copy_action = menu.addAction("复制路径") folder_action = menu.addAction("打开文件夹") retry_action = menu.addAction("重新生成") - if not self._is_default_gateway(): - retry_action.setEnabled(False) - retry_action.setToolTip("商品套图重新生成仅支持默认网关") + retry_enabled, retry_tooltip = self._retry_availability(job) + retry_action.setEnabled(retry_enabled) + retry_action.setToolTip(retry_tooltip) delete_action = menu.addAction("删除") action = menu.exec(global_position) asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 490e2dc..75ff422 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -452,9 +452,9 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 -- T-679a 后商品套图按 worker 启动时冻结的配置建立并持久化来源:默认网关 job 精确为 `cmhub/cmhub`,保留异步提交、轮询、下载、幂等键和点数账本;自定义网关 job 精确为 `direct/openai_images_edits`,走 T-678 的同步多图编辑、固定 `n=1`、无 `task_id`、无轮询和无自动重试。执行分派只读 job 持久化来源,不能受运行中设置变化影响;不匹配的来源/提供方或 direct job 带 task ID 均安全失败,避免跨来源串路。T-679b 已接通⑥新建入口与确认;重复计费的手动重试、混合来源历史和完整发布验收仍由 T-679c/T-679 完成。 +- T-679a 后商品套图按 worker 启动时冻结的配置建立并持久化来源:默认网关 job 精确为 `cmhub/cmhub`,保留异步提交、轮询、下载、幂等键和点数账本;自定义网关 job 精确为 `direct/openai_images_edits`,走 T-678 的同步多图编辑、固定 `n=1`、无 `task_id`、无轮询和无自动重试。执行分派只读 job 持久化来源,不能受运行中设置变化影响;不匹配的来源/提供方或 direct job 带 task ID 均安全失败,避免跨来源串路。T-679b 已接通⑥新建入口与确认;T-679c 已补齐手动重试与混合来源历史展示,完整发布验收仍由 T-679 完成。 - T-679b 后,⑥在自定义网关下仅本地校验当前已启用图片模型的接口类型、网址、模型 ID、密钥与连接超时;合格后可新建同步 direct job,但不读取 cmhub 模型目录、余额或价格。确认框按冻结 specs 展示数量、主参考图/参考图、逐图模式、请求尺寸和近似比例,并明确“自定义网关不计点数,实际费用以服务商为准”及主体一致性限制。AI帮写仍严格使用默认网关 `vision_alias`。生成期间当前任务输入锁定,任务切换、历史、日志和已完成图片查看不阻塞。 -- direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` job;活动会话跳过,确认陈旧的 job 标为“程序中断,无法确认生成结果,请手动重新生成”。进入项目、切换任务或刷新结果不做该清理。 +- direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` job;活动会话跳过,确认陈旧的 job 标为“程序中断,无法确认生成结果,请手动重新生成”。进入项目、切换任务或刷新结果不做该清理。T-679c 后「继续查询已提交图片」严格只处理 `cmhub/cmhub + task_id`,切换⑤设置不会改变该筛选;direct 永不进入此入口。结果卡、项目历史与全局历史均以 job 的持久化来源显示“默认网关 / 自定义网关”,不读取当前设置。direct 失败只允许用户手动“重新生成”:当前设置仍为自定义网关且配置合格时,必须重新展示完整生成确认,并额外警告服务商可能已经对上次未确认请求计费、本次可能再次收费;确认后始终新建 job,绝不复用旧 job 或自动补发。 - 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。 - 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」仍仅使用默认网关 `vision_alias`。 - T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。 diff --git a/docs/routes.md b/docs/routes.md index e98027d..db459a2 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -176,6 +176,7 @@ - 自定义网关复用既有模型列表、模型详情、默认文本模型与默认图像模型。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②和⑥通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。 - 图片模型按钮显示「检查图片配置」,只在本地检查接口类型、URL、模型 ID、密钥和超时,绝不对 `/images/edits` 发送无图片的测试请求;实际可用性必须以②或后续⑥的正常生成确认判断。允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。 - T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 模型目录、余额或价格。AI帮写仍禁用并说明仅默认网关可用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 + - T-679c 后,「继续查询已提交图片」只显示并处理 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的任务,切换到自定义网关后仍可继续查询旧默认网关任务;direct job 永不进入该入口。结果卡、项目历史和全局历史固定显示 job 已保存的“默认网关 / 自定义网关”,不因⑤设置切换改写;同轮混合来源显示“默认网关、自定义网关”。direct 失败只能手动“重新生成”,并再次显示完整生成确认及“服务商可能已对上次未确认请求计费,本次可能再次收费”的默认取消警告。确认后新建 job,不自动重试或复用旧 job;默认网关历史、导出、删除撤销和继续查询保持原有行为。 - T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可维护并立即保存,切换回默认网关不会清除这些配置。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。自定义模型角色选择只在自定义网关面板显示。 diff --git a/docs/tasks/T-679c.md b/docs/tasks/T-679c.md index 6835a3b..9b3ac1f 100644 --- a/docs/tasks/T-679c.md +++ b/docs/tasks/T-679c.md @@ -3,7 +3,7 @@ id: T-679c title: 商品套图多来源恢复、重试与历史展示 phase: 7 deps: [T-679a, T-679b] -status: TODO +status: DONE created: 2026-07-20 --- @@ -59,4 +59,8 @@ git diff --check ## 执行记录 -(做完在这里写:变更文件、恢复与重试决策、验证命令及结果。) +- 已将「来源」和「是否可继续查询」拆为两个判断:恢复入口仍严格要求已保存的 `cmhub/cmhub + task_id`,因此切换到自定义网关后仍可查询旧默认网关任务,而 direct job 永不进入轮询或恢复。 +- 结果卡、项目历史图片/轮次和全局历史轮次均显示已保存的中文来源;来源只读 job 的 `generation_source/provider`,切换⑤设置不会改写历史显示。混合来源轮次显示两种来源摘要,不暴露模型、URL 或密钥。 +- direct 失败或启动期中断恢复后的卡片允许在当前自定义网关配置合格时手动重新生成;每次先显示完整生成确认,并默认取消且明确提示服务商可能已计费、本次可能再次收费。确认后新建 job,绝不自动补发、复用旧 job 或进入继续查询。默认网关重试、历史、导出、删除撤销和继续查询保持原有语义。 +- 新增来源过滤、持久化来源显示、切换设置不改写历史、direct 二次确认和陈旧 direct 失败重试入口的 GUI 回归测试;更新 `docs/04-architecture.md`、`docs/routes.md`、`docs/ui/tab6-suite-recovery-T679c.svg` 与 UI 索引。 +- 验证:`py -3.10 -m unittest tests.test_product_suite_gui tests.test_image_studio_generation tests.test_gui`(294 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过;完整测试结果见本任务提交前的最终校验。 diff --git a/docs/ui/README.md b/docs/ui/README.md index b27aa6d..b5c24b8 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -18,6 +18,7 @@ | [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | 商品套图(T-622 已实现):从「虾皮圈电商图生成器」商品套餐交互迁移并替换主界面 AI工场入口;单一套图类型,多任务标签,商品原图,平台/国家/语言/比例,AI 帮写,白底/场景/卖点及自定义分类,结果历史、重试、删除撤销与生成进度 | | [tab6-suite-prompt-dialog-T637.svg](tab6-suite-prompt-dialog-T637.svg) | 商品套图(T-637 提案):标题行「AI帮写/取消」紧随 label、最右新增「提示词设置」;提示词设置弹窗——左编辑基础模板(行序对齐调研笔记组装顺序,套图名称+补充描述、插入变量菜单、占位符校验、只读规则占位符内联)、右只读实时最终预览(预览分类下拉)、保存/恢复默认共用原子写盘;第一版不做同类多张序号/差异化 | | [tab6-suite-gateway-T679b.svg](tab6-suite-gateway-T679b.svg) | 商品套图(T-679b 已实现):自定义网关生成确认展示主图/参考图、近似比例尺寸、服务商费用与主体一致性边界;生成中锁定当前任务输入,停止等待已发出同步请求返回,同时仍可切换其它任务与查看历史/结果 | +| [tab6-suite-recovery-T679c.svg](tab6-suite-recovery-T679c.svg) | 商品套图(T-679c 已实现):结果卡和历史按已保存 job 显示默认网关/自定义网关;默认网关任务可继续查询,直连失败图只能手动重新生成并再次确认可能重复收费 | | [workbench-redesign-v1.html](workbench-redesign-v1.html) | 工作台视觉提案(HTML 可交互原型):以当前 6 Tab 信息架构为基础,统一「数据准备区→表格命令区→表格→状态反馈区→完成任务区」页面骨架;命令按作用域分区(导入/筛选/选择集/工作流完成),表格补齐当前行、选择集、三态全选与排序契约;范围确认、部分成功、取消中间态和破坏性确认按干扰层级分配到对话框、信息条与 Toast;覆盖浅色/深色/高对比度近似、键盘(Tab/方向键/F6/Ctrl+F/F5)与 Windows 断点。商品状态相关交互已对齐 T-662b/c/d、T-663~T-665、T-667 定稿(无状态列与状态下拉筛选,③无强制更新入口)。颜色 Token 取自 `app/gui/widgets.py`。使用模拟数据,不接生产接口;页脚「原型说明与验收边界」列出假设与不可仅靠 HTML 验收的项。 | 应用图标(非效果图,是随程序发布的资源):`app/assets/cmshopee-logo.svg` 是可编辑母版,`cmshopee.ico`(16–256 共 9 档)用于 exe 图标和 GUI 窗口左上角,`cmshopee-256.png` 为预览与非 Windows 回退。三者由 `py -3.10 scripts/gen_logo.py` 从同一份几何定义生成,改样式请改脚本后重新生成,不要单独手改某一个文件。 diff --git a/docs/ui/tab6-suite-recovery-T679c.svg b/docs/ui/tab6-suite-recovery-T679c.svg new file mode 100644 index 0000000..46e0a9e --- /dev/null +++ b/docs/ui/tab6-suite-recovery-T679c.svg @@ -0,0 +1,15 @@ + + + + 蝦皮圈優化助手① 导入采集② AI生成③ 更新蝦皮账号管理设置商品套图 + 套图任务 1账号:主店(papa)商品ID:51513423876 + 商品原图原图、卖点与生成设置保持可查看生成设置比例 3:4 逐图主图 未开启商品卖点与要求已保存当前失败图片可单独重新生成套图结构配置白底图 1 张 场景图 2 张 卖点图 2 张 + 生成结果来源按已保存任务显示,切换设置不会改变历史记录 + 白底图默认网关默认网关 · 生成成功 + 场景图自定义网关生成失败自定义网关 · 失败重试 + 卖点图默认网关已提交默认网关 · 可继续查询 + 历史生成2026-07-20 14:20 店铺:主店 商品ID:51513423876来源:默认网关、自定义网关继续查询只显示默认网关且已有任务编号的图片 + 确认重新生成商品套图这张图片上次通过自定义网关生成但未完成。服务商可能对上次未确认请求已计费,本次重新生成可能再次收费。生成来源:自定义网关 图片比例:3:4本次重新生成:场景图 1 张取消确认重新生成 + diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 60486fe..9b20ef2 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -181,6 +181,21 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): path=config["db_path"], ) image_studio.set_job_submitted(job.id, "cmhub-task-1", path=config["db_path"]) + direct_job = image_studio.create_job( + project.id, + source_asset_id=assets[0].id, + job_type="场景图", + prompt="直连失败图片", + generation_source=image_studio.GENERATION_SOURCE_DIRECT, + provider=image_studio.PROVIDER_OPENAI_IMAGES_EDITS, + path=config["db_path"], + ) + image_studio.update_job_status( + direct_job.id, + "failed", + error="程序中断,无法确认生成结果,请手动重新生成", + path=config["db_path"], + ) tab = ProductSuiteTab(config=config, db_path=config["db_path"]) self.addCleanup(tab.close) @@ -196,6 +211,10 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertFalse(tab.ai_write_button.isEnabled()) self.assertFalse(tab.resume_submitted_button.isHidden()) self.assertTrue(tab.resume_submitted_button.isEnabled()) + self.assertEqual( + [job.id], + [resumable.id for resumable in tab._resumable_default_gateway_jobs(state)], + ) self.assertIn("不计点数", tab.generate_button.toolTip()) self.assert_removed(temp_dir) @@ -287,6 +306,146 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_direct_failed_job_retry_requires_repeat_billing_confirmation(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 1) + self._configure_direct_image_gateway(config) + failed_job = image_studio.create_job( + project.id, + source_asset_id=assets[0].id, + job_type="白底图", + prompt="重新生成测试", + generation_source=image_studio.GENERATION_SOURCE_DIRECT, + provider=image_studio.PROVIDER_OPENAI_IMAGES_EDITS, + path=config["db_path"], + ) + failed_job = image_studio.update_job_status( + failed_job.id, + "failed", + error="程序中断,无法确认生成结果,请手动重新生成", + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) and False + with mock.patch.object(tab, "_start_generation_worker") as start_worker: + tab.retry_job(failed_job) + self.assertEqual(1, len(confirmations)) + self.assertEqual("确认重新生成商品套图", confirmations[0][0]) + self.assertIn("可能对上次未确认请求已计费", confirmations[0][1]) + self.assertIn("本次重新生成可能再次收费", confirmations[0][1]) + self.assertIn("生成来源:自定义网关", confirmations[0][1]) + self.assertTrue(confirmations[0][2]["default_cancel"]) + start_worker.assert_not_called() + + tab._confirm = lambda *args, **kwargs: True + with mock.patch.object(tab, "_start_generation_worker", return_value=True) as start_worker: + tab.retry_job(failed_job) + self.assertTrue(start_worker.called) + self.assertTrue(start_worker.call_args.kwargs["retrying"]) + self.assertEqual(failed_job.id, start_worker.call_args.kwargs["retry_job_id"]) + + self.assert_removed(temp_dir) + + def test_generation_source_labels_are_persisted_for_results_and_history(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 1) + cmhub_job = image_studio.create_job( + project.id, + source_asset_id=assets[0].id, + job_type="白底图", + prompt="默认网关图片", + generation_source=image_studio.GENERATION_SOURCE_CMHUB, + provider=image_studio.PROVIDER_CMHUB, + path=config["db_path"], + ) + cmhub_job = image_studio.update_job_status( + cmhub_job.id, + "failed", + error="默认网关失败", + path=config["db_path"], + ) + direct_job = image_studio.create_job( + project.id, + source_asset_id=assets[0].id, + job_type="场景图", + prompt="自定义网关图片", + generation_source=image_studio.GENERATION_SOURCE_DIRECT, + provider=image_studio.PROVIDER_OPENAI_IMAGES_EDITS, + path=config["db_path"], + ) + direct_job = image_studio.update_job_status( + direct_job.id, + "failed", + error="程序中断,无法确认生成结果,请手动重新生成", + path=config["db_path"], + ) + + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.current_job_ids = [cmhub_job.id, direct_job.id] + tab._load_state(state) + tab._refresh_results(state) + + result_sources = { + card.job.id: card.findChild(QLabel, "suiteResultCardSource").text() + for card in tab.findChildren(SuiteResultCard) + } + self.assertEqual("默认网关", result_sources[cmhub_job.id]) + self.assertEqual("自定义网关", result_sources[direct_job.id]) + + self._configure_direct_image_gateway(config) + tab.refresh_gateway_state() + result_sources_after_switch = { + card.job.id: card.findChild(QLabel, "suiteResultCardSource").text() + for card in tab.findChildren(SuiteResultCard) + } + self.assertEqual(result_sources, result_sources_after_switch) + + history = ProductSuiteHistoryDialog(project.id, db_path=config["db_path"], parent=tab) + self.addCleanup(history.close) + self.assertTrue( + any( + label.text() == "来源:默认网关、自定义网关" + for label in history.findChildren(QLabel, "suiteHistoryRoundSource") + ) + ) + history_cards = { + card.job.id: card.toolTip() + for card in history.findChildren(SuiteHistoryImageCard) + } + self.assertIn("生成来源:默认网关", history_cards[cmhub_job.id]) + self.assertIn("生成来源:自定义网关", history_cards[direct_job.id]) + + global_history = ProductSuiteGlobalHistoryDialog(db_path=config["db_path"], parent=tab) + self.addCleanup(global_history.close) + self.assertTrue( + any( + label.text() == "来源:默认网关、自定义网关" + for label in global_history.findChildren(QLabel, "suiteGlobalHistorySource") + ) + ) + + self.assert_removed(temp_dir) + def test_tab_builds_suite_controls_without_old_detail_workspace(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)