feat(product-suite): persist context and confirm actions
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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` 均通过。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user