feat(product-suite): persist context and confirm actions

This commit is contained in:
chengma
2026-07-16 19:12:21 +08:00
parent 717ed2e35f
commit c9afaad24b
8 changed files with 459 additions and 36 deletions
+18
View File
@@ -113,6 +113,7 @@ DEFAULT_CONFIG = {
"max_parallel_accounts": 1,
},
"product_suite": {
"last_account_alias": "",
"last_settings": {
"platform": "Shopee",
"country": "中国台湾",
@@ -442,6 +443,9 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
if not isinstance(suite, dict):
suite = {}
config["product_suite"] = suite
suite["last_account_alias"] = _normalize_product_suite_last_account_alias(
suite.get("last_account_alias")
)
suite["last_settings"] = _normalize_product_suite_last_settings(
suite.get("last_settings")
)
@@ -454,6 +458,10 @@ def _normalize_product_suite_last_settings(value):
return product_suite.last_suite_settings(value)
def _normalize_product_suite_last_account_alias(value):
return str(value or "").strip() if isinstance(value, str) else ""
def _clamp_int(value, minimum, maximum, default):
try:
number = int(value)
@@ -706,6 +714,16 @@ def product_suite_last_settings(config=None) -> dict:
return _normalize_product_suite_last_settings(suite.get("last_settings"))
def product_suite_last_account_alias(config=None) -> str:
cfg = _config_or_load(config)
suite = cfg.get("product_suite", {})
if not isinstance(suite, dict):
suite = {}
return _normalize_product_suite_last_account_alias(
suite.get("last_account_alias")
)
def normalize_generate_mode(value=None, generate_cover=None) -> str:
text = str(value or "").strip().lower()
if text in AI_GENERATE_MODES:
+143 -19
View File
@@ -1221,13 +1221,21 @@ class ProductSuiteTab(QWidget):
box.setText(str(message))
box.exec()
def _confirm(self, title, message, *, destructive=False):
def _confirm(
self,
title,
message,
*,
destructive=False,
confirm_text="确认",
cancel_text="取消",
):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
box.setWindowTitle(str(title))
box.setText(str(message))
confirm_button = box.addButton("确认", QMessageBox.AcceptRole)
box.addButton("取消", QMessageBox.RejectRole)
confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole)
box.addButton(str(cancel_text), QMessageBox.RejectRole)
if not destructive:
box.setDefaultButton(confirm_button)
box.exec()
@@ -1302,15 +1310,20 @@ class ProductSuiteTab(QWidget):
if source is not None
else appconfig.product_suite_last_settings(self.config)
)
valid_aliases = {account.alias for account in self.accounts}
recent_alias = appconfig.product_suite_last_account_alias(self.config)
account_alias = source.account_alias if source is not None else recent_alias
if account_alias not in valid_aliases:
account_alias = self.accounts[0].alias if self.accounts else ""
if source is None and account_alias != recent_alias:
self._persist_last_account_alias(account_alias)
state = SuiteTaskState(
key=self._next_key,
serial=self._next_serial,
account_alias=(source.account_alias if source is not None else ""),
account_alias=account_alias,
prompt=(source.prompt if source is not None else ""),
settings=product_suite.normalize_suite_settings(initial_settings),
)
if not state.account_alias and self.accounts:
state.account_alias = self.accounts[0].alias
self._next_key += 1
self._next_serial += 1
return self._append_task_state(state)
@@ -1496,6 +1509,7 @@ class ProductSuiteTab(QWidget):
return
self._clear_project_binding(state)
state.account_alias = alias
self._persist_last_account_alias(alias)
self._update_context_actions(state)
def _update_account_tooltip(self):
@@ -1597,6 +1611,14 @@ class ProductSuiteTab(QWidget):
def _account_for_alias(self, alias):
return next((account for account in self.accounts if account.alias == alias), None)
def _account_context_label(self, state):
account = self._account_for_alias(getattr(state, "account_alias", ""))
alias = str(getattr(state, "account_alias", "") or "")
name = str(getattr(account, "account_name", "") or "").strip()
if name and name != alias:
return "%s(%s)" % (name, alias)
return name or alias or "未选择店铺"
def _state_project(self, state, *, include_deleted=False):
if state is None or state.project_id is None:
return None
@@ -1687,7 +1709,7 @@ class ProductSuiteTab(QWidget):
self._flush_prompt_save(state)
return project
def _create_draft_project(self, state):
def _create_draft_project(self, state, *, announce=True):
if not self._has_account_context(state):
return None
account = self._account_for_alias(state.account_alias)
@@ -1708,7 +1730,8 @@ class ProductSuiteTab(QWidget):
self._set_task_title(state)
if state is self._displayed_state:
self._update_context_actions(state)
self._status("已创建临时草稿,可继续添加本地图片", "info")
if announce:
self._status("已创建临时草稿,可继续添加本地图片", "info")
return project
def _prompt_save_timer(self, state):
@@ -1724,7 +1747,17 @@ class ProductSuiteTab(QWidget):
return timer
def _schedule_prompt_save(self, state):
if state is None or state.project_id is None:
if state is None:
return
if state.project_id is None:
if (
not str(state.prompt or "").strip()
or not state.account_alias
or self._account_for_alias(state.account_alias) is None
):
self._cancel_prompt_save(state)
return
self._prompt_save_timer(state).start()
return
if state.prompt == state.last_saved_prompt:
self._cancel_prompt_save(state)
@@ -1752,9 +1785,19 @@ class ProductSuiteTab(QWidget):
self._persist_prompt(state)
def _persist_prompt(self, state):
if state is None or state.project_id is None:
if state is None:
return True
prompt = str(state.prompt or "")
if state.project_id is None:
if not prompt.strip():
return True
if (
not state.account_alias
or self._account_for_alias(state.account_alias) is None
):
return True
if self._create_draft_project(state, announce=False) is None:
return False
if prompt == state.last_saved_prompt:
return True
try:
@@ -1820,6 +1863,29 @@ class ProductSuiteTab(QWidget):
self.config.clear()
self.config.update(saved)
def _persist_last_account_alias(self, alias):
value = str(alias or "").strip()
if value == appconfig.product_suite_last_account_alias(self.config):
return
try:
if os.path.exists(self.config_path):
saved = appconfig.update_config(
{"product_suite": {"last_account_alias": value}},
path=self.config_path,
)
else:
base = dict(self.config)
suite = base.get("product_suite", {})
suite = dict(suite) if isinstance(suite, dict) else {}
suite["last_account_alias"] = value
base["product_suite"] = suite
saved = appconfig.save_config(base, path=self.config_path)
except Exception as exc:
self._status("商品套图最近店铺保存失败:%s" % _user_error(exc), "danger")
return
self.config.clear()
self.config.update(saved)
def _on_prompt_changed(self):
if self._loading or self._displayed_state is None:
return
@@ -2190,9 +2256,24 @@ class ProductSuiteTab(QWidget):
self._status("当前任务正在拉取蝦皮主图", "info")
return
existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)]
if existing and not self._confirm(
"覆盖拉取蝦皮主图",
"本地已有商品原图。继续拉取会刷新蝦皮原图列表,本地上传图片会保留。确认继续吗?",
message = (
"店铺:%s\n"
"商品ID:%s\n"
"当前可用商品原图:%d张\n\n"
"将读取蝦皮主图并在后台下载,不会修改蝦皮线上商品。"
% (
self._account_context_label(state),
state.item_id,
len(existing),
)
)
if existing:
message += "\n继续拉取会刷新蝦皮原图列表,本地手动添加图片会保留。"
if not self._confirm(
"确认拉取蝦皮主图",
message,
confirm_text="确认拉取",
cancel_text="取消",
):
return
worker = ImageStudioPullImagesWorker(
@@ -2564,6 +2645,7 @@ class ProductSuiteTab(QWidget):
return False
retry_job_id = int(retry_job_id) if retry_job_id is not None else None
retrying = retry_job_id is not None
confirm_batch = specs is None and not retrying
if state is self._displayed_state:
self._save_controls_to_state(state)
template_text = None
@@ -2597,12 +2679,17 @@ class ProductSuiteTab(QWidget):
if not specs:
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
return False
if len(specs) > product_suite.MAX_GENERATION_COUNT_WITHOUT_CONFIRM:
if not self._confirm(
"确认生成数量",
"本轮将生成%d张图片,预计耗时和点数较多。确认继续吗?" % len(specs),
):
return False
if confirm_batch and not self._confirm(
"确认生成商品套图",
self._generation_confirmation_message(
state,
local_assets,
specs,
),
confirm_text="确认生成",
cancel_text="返回修改",
):
return False
self._persist_state(state)
run_token = uuid.uuid4().hex
worker = ProductSuiteGenerateWorker(
@@ -2661,6 +2748,43 @@ class ProductSuiteTab(QWidget):
)
return True
def _generation_confirmation_message(self, state, local_assets, specs):
counts = {}
for spec in specs:
category = str(
spec.get("category")
or spec.get("job_type")
or "未分类"
)
counts[category] = counts.get(category, 0) + 1
ordered = list(product_suite.category_order(state.settings))
ordered.extend(name for name in counts if name not in ordered)
category_lines = [
"%s:%d张" % (name, counts[name])
for name in ordered
if counts.get(name, 0) > 0
]
return (
"店铺:%s\n"
"商品ID:%s\n"
"可用商品原图:%d张\n"
"每张上传图分别作为主图生成:%s\n"
"%s\n"
"图片比例:%s\n"
"生成总数:%d张\n"
"商品卖点:已填写\n\n"
"本次生成会消耗 cmhub 点数。"
% (
self._account_context_label(state),
state.item_id or "未绑定商品",
len(local_assets),
"是" if state.settings.get("per_image_primary") else "否",
"\n".join(category_lines),
state.settings.get("ratio") or "1:1",
len(specs),
)
)
def _generation_signal_token(self, payload=None):
token = str((payload or {}).get("run_token") or "")
sender = self.sender()
+6 -1
View File
@@ -470,6 +470,8 @@ def list_recoverable_draft_projects(path=None, conn=None):
WHERE p.deleted_at IS NULL
AND p.binding_state = ?
AND (
TRIM(COALESCE(p.draft_prompt, '')) <> ''
OR
EXISTS (
SELECT 1 FROM image_studio_assets AS a
WHERE a.project_id = p.id
@@ -493,9 +495,12 @@ def project_has_content(project_id, path=None, conn=None) -> bool:
SELECT 1 FROM image_studio_assets WHERE project_id = ?
) OR EXISTS(
SELECT 1 FROM image_studio_jobs WHERE project_id = ?
) OR EXISTS(
SELECT 1 FROM image_studio_projects
WHERE id = ? AND TRIM(COALESCE(draft_prompt, '')) <> ''
) AS has_content
""",
(int(project_id), int(project_id)),
(int(project_id), int(project_id), int(project_id)),
).fetchone()
return bool(row["has_content"] if row is not None else False)
+5 -2
View File
@@ -137,6 +137,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
"max_parallel_accounts": 1
},
"product_suite": {
"last_account_alias": "",
"last_settings": {
"platform": "Shopee",
"country": "中国台湾",
@@ -169,7 +170,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。
`product_suite.last_settings` 只保存⑥「商品套图」最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。该段不保存账号、商品 ID、提示词、图片路径或密钥。
`product_suite.last_account_alias` 只保存⑥「商品套图」最近一次有效选择的账号别名。软件重启后的第一个新任务优先恢复该账号;账号已删除或不可用时回退到第一个可用账号。已有账号+商品 ID 项目和恢复的临时草稿始终以 SQLite 中自己的 `account_alias` 为准,不被最近账号覆盖。
`product_suite.last_settings` 只保存⑥「商品套图」最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。`product_suite` 配置不保存商品 ID、卖点提示词、图片路径或密钥。
### 5.1b AI 模型清单 `data/config/ai_models.json`
@@ -434,7 +437,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。允许占位符为 `{套图名称}`、`{补充描述}`、平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点及四个只读规则;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
- T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
- ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
- ⑥已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
- T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。
+6 -3
View File
@@ -3,7 +3,7 @@ id: T-641
title: 商品套图最近上下文恢复与执行前确认
phase: 7
deps: [T-640]
status: TODO
status: DONE
created: 2026-07-16
---
@@ -34,7 +34,7 @@ created: 2026-07-16
卖点是商品级业务内容,不能作为全局默认文本复制给其他商品。修改 `app/gui/tabs/product_suite.py`,沿用现有 `image_studio_projects.draft_prompt`:
- 当前任务尚无 `project_id` 时,用户选择了有效店铺且输入的卖点 `strip()` 后非空,在文本稳定约 `800ms` 后创建一次临时草稿并保存 `draft_prompt`。
- 当前任务尚无 `project_id` 时,用户选择了有效店铺且输入的卖点 `strip()` 后非空,沿用现有约 `500ms` 防抖,在文本稳定后创建一次临时草稿并保存 `draft_prompt`。
- 不因第一个字符立即创建项目;连续输入只创建一个草稿、只在防抖稳定后落库。
- 切换任务、关闭任务、关闭软件、开始拉取、添加图片或开始生成前,必须同步刷新卖点;如内容非空且尚无项目,创建草稿并保存,避免用户在防抖到期前关闭造成丢失。
- 空白卖点、无有效店铺或程序化回填不得创建草稿。
@@ -129,4 +129,7 @@ git diff --check
## 执行记录
- 待执行。
- 2026-07-16:`product_suite.last_account_alias` 已加入 `config.json` 归一化与读取 helper。⑥首次新任务会恢复最后有效店铺;同一次运行仍优先继承当前任务;项目/草稿继续使用自己的账号。最近账号失效时回退到第一个可用账号并修正配置,不保存密码、Cookie 或登录态。
- 2026-07-16:卖点防抖沿用现有 500ms。无 `project_id`、店铺有效且卖点非空时自动创建一个静默临时草稿并写入 `draft_prompt`;切换任务、关闭任务和关闭程序前的同步刷新也能补建草稿。仅含卖点的草稿已纳入恢复和 `project_has_content()` 语义;卖点清空且无资产/job 时仍可按空草稿清理。新建不继承任务不会全局带入上一商品卖点。
- 2026-07-16:「拉取蝦皮主图」首次和覆盖拉取均增加上下文确认,展示店铺、商品 ID、当前原图数和只读说明;取消后不创建 worker 或启动 Chrome。「生成套图」在正常整轮构造 specs 后、创建 job 前始终确认,展示原图数、每图主图开关、分类实际数量、比例、总数和 cmhub 点数提示;单张失败重试不走整轮确认。
- 2026-07-16:同步更新架构配置和草稿事实;补齐最近店铺恢复/失效回退、卖点草稿创建与恢复、空文本不建草稿、拉取/生成确认及取消边界测试,并将 T-640 测试中的已销毁 `QThread` 检查改为容忍 Qt 对象已正常释放。相关 73 项通过;商品套图 GUI 36 项在主工作区通过,其余测试文件在短路径隔离 worktree 中逐文件通过,共覆盖 539 项;Ruff、`compileall` 与 `git diff --check` 均通过。
+18
View File
@@ -41,6 +41,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
},
appconfig.product_suite_last_settings(config),
)
self.assertEqual(
"",
appconfig.product_suite_last_account_alias(config),
)
updated = appconfig.update_config(
{"ai": {"resolution": "2k"}},
@@ -84,6 +88,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
"chrome_path": "custom-chrome.exe",
"custom_section": {"keep": True},
"product_suite": {
"last_account_alias": ["invalid"],
"last_settings": {
"platform": "未知平台",
"country": "新加坡",
@@ -108,10 +113,15 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
},
appconfig.product_suite_last_settings(loaded),
)
self.assertEqual(
"",
appconfig.product_suite_last_account_alias(loaded),
)
updated = appconfig.update_config(
{
"product_suite": {
"last_account_alias": " alias-b ",
"last_settings": {
"platform": "Amazon",
"country": "中国台湾",
@@ -125,6 +135,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual("custom-chrome.exe", updated["chrome_path"])
self.assertEqual({"keep": True}, updated["custom_section"])
self.assertEqual(
"alias-b",
appconfig.product_suite_last_account_alias(updated),
)
self.assertEqual("16:9", appconfig.product_suite_last_settings(updated)["ratio"])
with open(config_path, "r", encoding="utf-8") as fh:
persisted = json.load(fh)
@@ -132,6 +146,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
{"platform", "country", "language", "ratio"},
set(persisted["product_suite"]["last_settings"]),
)
self.assertEqual(
"alias-b",
persisted["product_suite"]["last_account_alias"],
)
self.assert_removed(temp_dir)
+11
View File
@@ -237,6 +237,17 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assertTrue(draft.item_id.startswith(image_studio.TEMPORARY_ITEM_PREFIX))
self.assertEqual(draft.item_id, draft.storage_key)
self.assertEqual(image_studio.PROJECT_BINDING_DRAFT, draft.binding_state)
self.assertTrue(image_studio.project_has_content(draft.id, path=db_path))
self.assertEqual(
[draft.id],
[
project.id
for project in image_studio.list_recoverable_draft_projects(
path=db_path
)
],
)
image_studio.update_project_prompt(draft.id, "", path=db_path)
self.assertFalse(image_studio.project_has_content(draft.id, path=db_path))
self.assertEqual([], image_studio.list_recoverable_draft_projects(path=db_path))
before_dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), draft)
+252 -11
View File
@@ -312,7 +312,11 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.addCleanup(dialog.close)
expected = dialog.preview_edit.toPlainText()
with mock.patch.object(tab, "_start_thread", return_value=object()):
with mock.patch.object(
tab,
"_confirm",
return_value=True,
), mock.patch.object(tab, "_start_thread", return_value=object()):
self.assertTrue(tab.start_generation(state))
self.assertEqual(expected, state.worker.job_specs[0]["prompt"])
state.worker = None
@@ -532,6 +536,61 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_recent_account_restores_and_missing_account_falls_back(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
config = appconfig.save_config(self._config(temp_dir), path=config_path)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
accounts.create_account("副店", "alias-b", debug_port=9223, config=config)
first = ProductSuiteTab(
config=config,
config_path=config_path,
db_path=config["db_path"],
)
first.account_combo.setCurrentIndex(
first.account_combo.findData("alias-b")
)
self.app.processEvents()
self.assertEqual(
"alias-b",
appconfig.product_suite_last_account_alias(
appconfig.load_config(config_path)
),
)
first.close()
first.deleteLater()
self.app.processEvents()
second_config = appconfig.load_config(config_path)
second = ProductSuiteTab(
config=second_config,
config_path=config_path,
db_path=second_config["db_path"],
)
self.assertEqual("alias-b", second.account_combo.currentData())
second.close()
second.deleteLater()
self.app.processEvents()
accounts.delete_account("alias-b", config=second_config)
third_config = appconfig.load_config(config_path)
third = ProductSuiteTab(
config=third_config,
config_path=config_path,
db_path=third_config["db_path"],
)
self.addCleanup(third.close)
self.assertEqual("alias-a", third.account_combo.currentData())
self.assertEqual(
"alias-a",
appconfig.product_suite_last_account_alias(
appconfig.load_config(config_path)
),
)
self.assert_removed(temp_dir)
def test_task_tabs_keep_independent_prompt_and_settings(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
@@ -681,24 +740,62 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_prompt_without_project_stays_in_memory_until_draft_creation(self):
def test_prompt_without_project_creates_and_restores_draft(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
tab.prompt_edit.setPlainText("尚未建立项目的卖点")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
draft = tab._create_draft_project(state)
projects = image_studio.list_projects(path=config["db_path"])
self.assertEqual(1, len(projects))
draft = projects[0]
self.assertIsNotNone(draft)
stored = image_studio.get_project(draft.id, path=config["db_path"])
self.assertEqual("尚未建立项目的卖点", stored.draft_prompt)
self.assertEqual("尚未建立项目的卖点", state.last_saved_prompt)
self.assertEqual(
[draft.id],
[
project.id
for project in image_studio.list_recoverable_draft_projects(
path=config["db_path"]
)
],
)
tab.close()
tab.deleteLater()
self.app.processEvents()
restored = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(restored.close)
restored_state = next(
value
for value in restored._states.values()
if value.project_id == draft.id
)
self.assertEqual("尚未建立项目的卖点", restored_state.prompt)
new_state = restored.add_task(inherit=False)
self.assertEqual("", new_state.prompt)
self.assert_removed(temp_dir)
def test_blank_prompt_without_project_does_not_create_draft(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.prompt_edit.setPlainText(" ")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
self.assert_removed(temp_dir)
@@ -1220,14 +1317,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
while state.worker is not None and time.monotonic() < deadline:
QTest.qWait(20)
self.app.processEvents()
while (
generation_thread is not None
and generation_thread.isRunning()
and time.monotonic() < deadline
):
while generation_thread is not None and time.monotonic() < deadline:
try:
running = generation_thread.isRunning()
except RuntimeError:
generation_thread = None
break
if not running:
break
QTest.qWait(20)
self.app.processEvents()
self.assertFalse(generation_thread.isRunning())
if generation_thread is not None:
self.assertFalse(generation_thread.isRunning())
all_jobs = image_studio.list_jobs(project.id, path=config["db_path"])
retry_jobs = [
@@ -1710,6 +1811,146 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_pull_confirmation_always_shows_account_item_and_existing_count(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
tab.item_id_edit.setText("51100639510")
confirmations = []
tab._confirm = lambda title, message, **kwargs: confirmations.append(
(title, message, kwargs)
) and False
tab.pull_main_images()
self.assertIsNone(state.pull_worker)
self.assertEqual("确认拉取蝦皮主图", confirmations[0][0])
self.assertIn("主店(alias-a)", confirmations[0][1])
self.assertIn("商品ID:51100639510", confirmations[0][1])
self.assertIn("当前可用商品原图:0张", confirmations[0][1])
self.assertIn("不会修改蝦皮线上商品", confirmations[0][1])
self.assertEqual("确认拉取", confirmations[0][2]["confirm_text"])
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "existing.png")
self._write_image(source_path)
image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
path=config["db_path"],
)
state.project_id = project.id
state.project_binding_state = project.binding_state
state.item_id = project.item_id
tab.item_id_edit.setText(project.item_id)
tab._refresh_originals(state)
tab.pull_main_images()
self.assertIsNone(state.pull_worker)
self.assertIn("当前可用商品原图:1张", confirmations[1][1])
self.assertIn("本地手动添加图片会保留", confirmations[1][1])
self.assert_removed(temp_dir)
def test_generation_confirmation_prevents_job_creation_and_retry_bypasses_it(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
self._write_image(source_path)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
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.prompt = "轻便耐用,适合日常使用"
state.settings["per_image_primary"] = True
tab._load_state(state)
confirmations = []
tab._confirm = lambda title, message, **kwargs: confirmations.append(
(title, message, kwargs)
) and False
self.assertFalse(tab.start_generation(state))
self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"]))
self.assertEqual("确认生成商品套图", confirmations[0][0])
self.assertIn("主店(alias-a)", confirmations[0][1])
self.assertIn("商品ID:51100639510", confirmations[0][1])
self.assertIn("可用商品原图:1张", confirmations[0][1])
self.assertIn("每张上传图分别作为主图生成:是", confirmations[0][1])
self.assertIn("图片比例:1:1", confirmations[0][1])
self.assertIn("生成总数:", confirmations[0][1])
self.assertIn("消耗 cmhub 点数", confirmations[0][1])
self.assertEqual("确认生成", confirmations[0][2]["confirm_text"])
self.assertEqual("返回修改", confirmations[0][2]["cancel_text"])
failed_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="失败重试",
path=config["db_path"],
)
failed_job = image_studio.update_job_status(
failed_job.id,
"failed",
path=config["db_path"],
)
confirmations.clear()
with mock.patch.object(tab, "_start_thread", return_value=mock.Mock()):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": source.id,
"job_type": failed_job.job_type,
"prompt": failed_job.prompt,
}
],
retry_job_id=failed_job.id,
)
)
self.assertEqual([], confirmations)
state.worker = None
state.thread = None
state.generation_run_token = ""
tab._generation_run_states.clear()
self.assert_removed(temp_dir)
def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self):
original_list = ProductOriginalList()
self.addCleanup(original_list.close)