T-576 增加生成和更新内容模式

This commit is contained in:
chengma
2026-07-09 17:44:03 +08:00
parent 9827c8c6d8
commit ab87be8223
17 changed files with 679 additions and 183 deletions
+25 -11
View File
@@ -349,9 +349,10 @@ def _has_generated_cover(task):
return bool(str(getattr(task, "new_cover_path", "") or "").strip())
def generation_needs(task, generate_cover=False):
def generation_needs(task, generate_cover=False, generate_mode=None):
"""Return which AI components still need generation for this task."""
mode = appconfig.normalize_generate_mode(generate_mode, generate_cover=generate_cover)
stage = str(getattr(task, "stage", "") or "")
status = str(getattr(task, "status", "") or "")
if status in {"running", "skipped"}:
@@ -364,29 +365,36 @@ def generation_needs(task, generate_cover=False):
and _task_attempt_count(task, "apply_attempts") > 0
):
return {"title": False, "cover": False}
needs_title = not _has_generated_title(task)
needs_cover = bool(generate_cover) and not _has_generated_cover(task)
has_title = _has_generated_title(task)
needs_title = appconfig.generate_mode_includes_title(mode) and not has_title
needs_cover = False
if appconfig.generate_mode_includes_cover(mode) and not _has_generated_cover(task):
if mode == "cover":
needs_cover = has_title
else:
needs_cover = has_title or needs_title
return {"title": needs_title, "cover": needs_cover}
def is_generatable_task(task, generate_cover=False):
def is_generatable_task(task, generate_cover=False, generate_mode=None):
"""判断任务是否能由② AI生成执行或重试。"""
needs = generation_needs(task, generate_cover=generate_cover)
needs = generation_needs(task, generate_cover=generate_cover, generate_mode=generate_mode)
return bool(needs["title"] or needs["cover"])
def generation_component_totals(tasks, generate_cover=False):
def generation_component_totals(tasks, generate_cover=False, generate_mode=None):
"""Count task and component gaps for a generation run."""
mode = appconfig.normalize_generate_mode(generate_mode, generate_cover=generate_cover)
eligible = [
task for task in list(tasks)
if is_generatable_task(task, generate_cover=generate_cover)
if is_generatable_task(task, generate_mode=mode)
]
title_total = 0
cover_total = 0
for task in eligible:
needs = generation_needs(task, generate_cover=generate_cover)
needs = generation_needs(task, generate_mode=mode)
if needs["title"]:
title_total += 1
if needs["cover"]:
@@ -415,6 +423,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"jpg_quality",
"resolution",
"generate_cover",
"generate_mode",
}
}
)
@@ -428,14 +437,18 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
on_error = runtime.get("on_error")
title_prompt = _prompt_value(prompts, "title")
cover_prompt = _prompt_value(prompts, "cover")
generate_cover = bool(generation_cfg.get("generate_cover", False))
generate_mode = appconfig.normalize_generate_mode(
generation_cfg.get("generate_mode"),
generate_cover=generation_cfg.get("generate_cover", False),
)
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
should_stop = should_stop or (lambda: False)
eligible = [
task for task in list(tasks)
if is_generatable_task(task, generate_cover=generate_cover)
if is_generatable_task(task, generate_mode=generate_mode)
]
needs_by_task = {
getattr(task, "id", None): generation_needs(task, generate_cover=generate_cover)
getattr(task, "id", None): generation_needs(task, generate_mode=generate_mode)
for task in eligible
}
title_tasks = [
@@ -457,6 +470,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"failed": 0,
"cancelled": False,
"generate_cover": generate_cover,
"generate_mode": generate_mode,
}
_emit_generation_progress(on_progress, summary)
title_results = {}
+65
View File
@@ -40,6 +40,8 @@ CMHUB_CONFIG_PATH = os.path.join(default_data_dir(), "config", "cmhub.json")
CATEGORIES = {"text", "image"}
API_TYPES = {"chat", "images_edits", "auto"}
AI_BACKENDS = {"direct", "cmhub"}
AI_GENERATE_MODES = {"title", "cover", "title_cover"}
SHOPEE_UPDATE_MODES = {"title", "cover", "title_cover"}
AI_CONCURRENCY_MIN = 1
AI_CONCURRENCY_MAX = 5
AI_RETRY_MIN = 0
@@ -78,6 +80,7 @@ DEFAULT_CONFIG = {
"default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2",
"generate_cover": False,
"generate_mode": "",
"backend": "cmhub",
"cmhub": {
"base_url": "",
@@ -104,6 +107,7 @@ DEFAULT_CONFIG = {
"test_item_id": "51100639510",
"allow_real_submit": False,
"allow_cover_update": False,
"update_mode": "",
"max_items_per_run": 1,
"close_success_tab": False,
"dry_run": False,
@@ -357,6 +361,11 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
return config
ai = config.get("ai")
if isinstance(ai, dict):
ai["generate_mode"] = normalize_generate_mode(
ai.get("generate_mode"),
generate_cover=ai.get("generate_cover", False),
)
ai["generate_cover"] = generate_mode_includes_cover(ai["generate_mode"])
ai["title_concurrency"] = _clamp_int(
ai.get("title_concurrency"),
AI_CONCURRENCY_MIN,
@@ -385,6 +394,13 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
cmhub["download_with_curl"] = _normalize_cmhub_download_with_curl(
cmhub.get("download_with_curl", "auto")
)
update = config.get("shopee_update")
if isinstance(update, dict):
update["update_mode"] = normalize_update_mode(
update.get("update_mode"),
allow_cover_update=update.get("allow_cover_update", False),
)
update["allow_cover_update"] = update_mode_includes_cover(update["update_mode"])
return config
@@ -632,6 +648,55 @@ def ai_config(config=None) -> dict:
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
def normalize_generate_mode(value=None, generate_cover=None) -> str:
text = str(value or "").strip().lower()
if text in AI_GENERATE_MODES:
return text
return "title_cover" if bool(generate_cover) else "title"
def generate_mode_includes_title(mode) -> bool:
return normalize_generate_mode(mode) in {"title", "title_cover"}
def generate_mode_includes_cover(mode) -> bool:
return normalize_generate_mode(mode) in {"cover", "title_cover"}
def ai_generate_mode(config=None) -> str:
ai = ai_config(config)
return normalize_generate_mode(ai.get("generate_mode"), generate_cover=ai.get("generate_cover", False))
def normalize_update_mode(value=None, allow_cover_update=None) -> str:
text = str(value or "").strip().lower()
if text in SHOPEE_UPDATE_MODES:
return text
return "title_cover" if bool(allow_cover_update) else "title"
def update_mode_includes_title(mode) -> bool:
return normalize_update_mode(mode) in {"title", "title_cover"}
def update_mode_includes_cover(mode) -> bool:
return normalize_update_mode(mode) in {"cover", "title_cover"}
def shopee_update_config(config=None) -> dict:
cfg = _config_or_load(config)
loaded = cfg.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = _deep_merge(DEFAULT_CONFIG["shopee_update"], loaded)
merged["update_mode"] = normalize_update_mode(
merged.get("update_mode"),
allow_cover_update=merged.get("allow_cover_update", False),
)
merged["allow_cover_update"] = update_mode_includes_cover(merged["update_mode"])
return merged
def ai_backend(config=None) -> str:
ai = ai_config(config)
+9 -1
View File
@@ -1411,7 +1411,7 @@ def _confirm_update_modal(cdp, timeout=3):
return last_present or {"present": False, "clicked": False, "reason": "NO_UPDATE_CONFIRM_MODAL"}
def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_front=True) -> dict:
def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None) -> dict:
"""Apply generated title/cover to Shopee.
The caller must perform the batch confirmation before calling this function.
@@ -1426,8 +1426,16 @@ def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_fr
try:
title_result = None
cover_result = None
mode = appconfig.normalize_update_mode(
update_mode,
allow_cover_update=bool(_get(task, "new_cover_path")),
)
new_title = _get(task, "new_title")
new_cover_path = _get(task, "new_cover_path")
if not appconfig.update_mode_includes_title(mode):
new_title = None
if not appconfig.update_mode_includes_cover(mode):
new_cover_path = None
if new_title:
current_step = "change_title"
_notify_apply_step(on_step, current_step, "start")
+131 -24
View File
@@ -17,6 +17,11 @@ def WriteBackWorker(*args, **kwargs):
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
UPDATE_MODE_ITEMS = [
("只更新标题", "title"),
("只更新封面", "cover"),
("更新标题和封面", "title_cover"),
]
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
@@ -36,6 +41,7 @@ class ApplyTab(QWidget):
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.config_path = self.config.get("config_path") or appconfig.CONFIG_PATH
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
@@ -110,8 +116,20 @@ class ApplyTab(QWidget):
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
self.update_mode_combo = QComboBox()
self.update_mode_combo.setObjectName("applyUpdateModeCombo")
self.update_mode_combo.setToolTip("选择本轮要更新标题、封面,或同时更新标题和封面")
for label, value in self.UPDATE_MODE_ITEMS:
self.update_mode_combo.addItem(label, value)
self._set_combo_by_data(
self.update_mode_combo,
self._shopee_update_config().get("update_mode", "title"),
)
action_layout = QHBoxLayout()
action_layout.addWidget(QLabel("更新内容"))
action_layout.addWidget(self.update_mode_combo)
action_layout.addSpacing(16)
action_layout.addWidget(self.preview_update_button)
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
@@ -137,6 +155,7 @@ class ApplyTab(QWidget):
self.refresh_button.clicked.connect(self.refresh_tasks)
self.preview_update_button.clicked.connect(self.preview_update)
self.start_update_button.clicked.connect(self.start_update)
self.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed)
self.stop_update_button.clicked.connect(self.stop_update)
self.reset_update_button.clicked.connect(self.reset_apply_status)
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
@@ -227,12 +246,18 @@ class ApplyTab(QWidget):
return
tasks = [
task for task in self.model.tasks
if self._is_actionable_task(task)
if self._is_update_candidate_task(task)
]
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
content_error = self._update_content_error(tasks, update_mode)
if content_error:
QMessageBox.warning(self, "更新内容未生成", content_error)
self._set_status(content_error.replace("\n", " "))
return
dry_run = bool(dry_run)
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
if safety_error:
@@ -256,6 +281,7 @@ class ApplyTab(QWidget):
config=self.config,
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
dry_run=dry_run,
update_mode=update_mode,
parallel_accounts=bool(update_cfg.get("parallel_accounts", False)),
max_parallel_accounts=max(
1,
@@ -368,11 +394,10 @@ class ApplyTab(QWidget):
return self.model.task_at(0)
return None
def _is_actionable_task(self, task):
def _is_update_candidate_task(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
)
def _populate_batch_filter(self, batches, selected_batch):
@@ -464,7 +489,9 @@ class ApplyTab(QWidget):
def _confirmation_message(self, tasks, dry_run=False):
update_cfg = self._shopee_update_config()
cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许"
update_mode = self._current_update_mode()
update_mode_text = self._update_mode_label(update_mode)
cover_warning = "\n本轮将更新线上封面,请确认封面内容无误。" if appconfig.update_mode_includes_cover(update_mode) else ""
close_text = "是" if update_cfg.get("close_success_tab") else "否"
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
@@ -484,13 +511,15 @@ class ApplyTab(QWidget):
+ f"店铺:{self._shop_filter_label()}\n"
+ f"商品ID:{self._item_filter_label()}\n"
+ f"状态:{self._status_label()}\n"
+ f"更新内容:{update_mode_text}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
+ "安全设置:"
+ f"封面更新={cover_text},"
+ f"每批最大更新条数={batch_size},"
+ f"成功后关闭新页={close_text},"
+ f"多账号并行={parallel_text}\n\n"
+ cover_warning
+ ("\n\n" if cover_warning else "")
+ (
"检查只写运行日志,不打开蝦皮、不点击「更新」、不改任务状态。"
if dry_run
@@ -507,17 +536,6 @@ class ApplyTab(QWidget):
"设置未开启「允许真实提交线上商品」,已阻止本次更新。\n"
"请到⑤设置 > 蝦皮更新安全开启该开关后再开始更新。"
)
if not update_cfg.get("allow_cover_update", False):
cover_tasks = [
str(getattr(task, "item_id", ""))
for task in tasks
if getattr(task, "new_cover_path", None)
]
if cover_tasks:
return (
"设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。\n"
"请到⑤设置 > 蝦皮更新安全开启该开关,或先筛掉含新封面的任务。"
)
return None
def _show_update_safety_error(self, message):
@@ -534,13 +552,99 @@ class ApplyTab(QWidget):
self.open_settings_callback()
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
return appconfig.shopee_update_config(self.config)
def _current_update_mode(self):
return appconfig.normalize_update_mode(
self.update_mode_combo.currentData(),
allow_cover_update=False,
)
def _update_mode_label(self, mode):
mode = appconfig.normalize_update_mode(mode)
return {
"title": "只更新标题",
"cover": "只更新封面",
"title_cover": "更新标题和封面",
}.get(mode, "只更新标题")
def _set_combo_by_data(self, combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
def _on_update_mode_changed(self, index=None):
previous = self._shopee_update_config().get("update_mode", "title")
if self._save_update_mode_setting(show_status=True):
return
self.update_mode_combo.blockSignals(True)
self._set_combo_by_data(self.update_mode_combo, previous)
self.update_mode_combo.blockSignals(False)
def _save_update_mode_setting(self, show_status=True):
update_mode = self._current_update_mode()
update_cfg = self._shopee_update_config()
update_cfg["update_mode"] = update_mode
update_cfg["allow_cover_update"] = appconfig.update_mode_includes_cover(update_mode)
payload = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
payload["shopee_update"] = update_cfg
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"更新内容设置保存失败:{exc}")
return False
internal = {
key: value
for key, value in self.config.items()
if key in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
self.config.clear()
self.config.update(saved)
self.config.update(internal)
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
self._set_status(f"更新内容已设置为:{self._update_mode_label(update_mode)}")
return True
def _update_content_error(self, tasks, update_mode):
missing_title = [
task for task in tasks
if appconfig.update_mode_includes_title(update_mode)
and not str(getattr(task, "new_title", "") or "").strip()
]
missing_cover = [
task for task in tasks
if appconfig.update_mode_includes_cover(update_mode)
and not str(getattr(task, "new_cover_path", "") or "").strip()
]
if not missing_title and not missing_cover:
return None
lines = []
mode_text = self._update_mode_label(update_mode)
if missing_title:
lines.append(f"当前筛选结果中有 {len(missing_title)} 条缺少新标题,不能执行“{mode_text}”。")
lines.append("请先回到②AI生成选择“只生成标题”或“生成标题和封面”。")
lines.append(f"示例商品ID:{self._sample_item_ids(missing_title)}")
if missing_cover:
if lines:
lines.append("")
lines.append(f"当前筛选结果中有 {len(missing_cover)} 条缺少新封面,不能执行“{mode_text}”。")
lines.append("请先回到②AI生成选择“只生成封面”或“生成标题和封面”。")
lines.append(f"示例商品ID:{self._sample_item_ids(missing_cover)}")
return "\n".join(lines)
def _sample_item_ids(self, tasks):
values = [
str(getattr(task, "item_id", "") or "").strip()
for task in tasks[:5]
]
values = [value for value in values if value]
suffix = f" 等 {len(tasks)} 条" if len(tasks) > 5 else ""
return "、".join(values) + suffix if values else "无"
def _set_apply_running(self, running):
self.preview_update_button.setEnabled(not running)
@@ -552,6 +656,7 @@ class ApplyTab(QWidget):
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.update_mode_combo.setEnabled(not running)
self._update_write_back_button()
def _set_result_write_back_running(self, running):
@@ -563,6 +668,7 @@ class ApplyTab(QWidget):
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.update_mode_combo.setEnabled(not running)
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
def _forget_apply_thread(self, thread):
@@ -637,8 +743,9 @@ class ApplyTab(QWidget):
def _reset_run_log(self, tasks, dry_run=False, batch_size=1):
self.run_log_view.clear()
action = "检查" if dry_run else "更新"
mode_text = self._update_mode_label(self._current_update_mode())
self._append_run_log(
f"本轮{action}开始:任务 {len(tasks)} 条,每批 {batch_size} 条"
f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条"
)
def _load_latest_run_log(self):
+72 -38
View File
@@ -751,6 +751,7 @@ def _cover_regenerate_config(config):
runtime_config = dict(config or {})
ai_settings = appconfig.ai_config(runtime_config)
ai_settings["generate_cover"] = True
ai_settings["generate_mode"] = "cover"
runtime_config["ai"] = ai_settings
return runtime_config
@@ -842,6 +843,11 @@ class GenerateTab(QWidget):
"cover": "封面",
"all": "标题和封面",
}
GENERATE_MODE_ITEMS = [
("只生成标题", "title"),
("只生成封面", "cover"),
("生成标题和封面", "title_cover"),
]
TABLE_COLUMN_WEIGHTS = (1.0, 0.5, 1.8375, 1.8375, 0.4125, 0.4125)
def __init__(
@@ -910,11 +916,12 @@ class GenerateTab(QWidget):
self.reset_generate_button = QPushButton("重置生成结果")
self.reset_generate_button.setObjectName("resetGenerateButton")
self.stop_generate_button.setEnabled(False)
ai_settings = appconfig.ai_config(self.config)
self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)")
self.generate_cover_checkbox.setObjectName("generateCoverCheckbox")
self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型")
self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False)))
self.generate_mode_combo = QComboBox()
self.generate_mode_combo.setObjectName("generateModeCombo")
self.generate_mode_combo.setToolTip("选择本轮生成标题、封面,或同时生成标题和封面")
for label, value in self.GENERATE_MODE_ITEMS:
self.generate_mode_combo.addItem(label, value)
self._set_combo_by_data(self.generate_mode_combo, appconfig.ai_generate_mode(self.config))
self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0")
self.title_progress_label = QLabel("标题 0/0")
self.title_progress_label.setObjectName("generateTitleProgressLabel")
@@ -1007,7 +1014,7 @@ class GenerateTab(QWidget):
self.empty_state_button.clicked.connect(self.open_accounts_callback)
self.task_table = QTableView()
self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status)
self.model.set_generate_cover_enabled(self.generate_cover_checkbox.isChecked())
self.model.set_generate_cover_enabled(self._current_generate_mode_includes_cover())
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
@@ -1060,7 +1067,8 @@ class GenerateTab(QWidget):
button_layout.addWidget(self.reset_generate_button)
bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.generate_cover_checkbox)
bottom_layout.addWidget(QLabel("生成内容"))
bottom_layout.addWidget(self.generate_mode_combo)
bottom_layout.addLayout(progress_layout, 1)
bottom_layout.addLayout(button_layout)
@@ -1084,7 +1092,7 @@ class GenerateTab(QWidget):
self.delete_cover_template_action.triggered.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled)
self.generate_mode_combo.currentIndexChanged.connect(self._on_generate_mode_changed)
self.generate_button.clicked.connect(self.start_generate)
self.stop_generate_button.clicked.connect(self.stop_generate)
self.reset_generate_button.clicked.connect(self.reset_generated_result)
@@ -1115,11 +1123,11 @@ class GenerateTab(QWidget):
def _show_current_run_log_empty(self):
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
def _reset_generate_run_log(self, tasks, generate_cover):
def _reset_generate_run_log(self, tasks, generate_mode):
self.run_log_view.clear()
cover_text = "是" if generate_cover else "否"
mode_text = self._generate_mode_label(generate_mode)
self._append_generate_log(
f"本轮AI生成开始:任务 {len(tasks)} 条,生成封面:{cover_text}"
f"本轮AI生成开始:任务 {len(tasks)} 条,生成内容:{mode_text}"
)
def _reset_generation_elapsed(self):
@@ -1208,16 +1216,17 @@ class GenerateTab(QWidget):
self._refresh_elapsed_labels()
self._update_elapsed_timer_state()
def _on_generate_cover_toggled(self, checked):
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
if self._save_generate_cover_setting(show_status=True):
self.model.set_generate_cover_enabled(bool(checked))
def _on_generate_mode_changed(self, index=None):
previous = appconfig.ai_generate_mode(self.config)
current = self._current_generate_mode()
if self._save_generate_mode_setting(show_status=True):
self.model.set_generate_cover_enabled(appconfig.generate_mode_includes_cover(current))
self.refresh_tasks()
return
self.generate_cover_checkbox.blockSignals(True)
self.generate_cover_checkbox.setChecked(previous)
self.generate_cover_checkbox.blockSignals(False)
self.model.set_generate_cover_enabled(previous)
self.generate_mode_combo.blockSignals(True)
self._set_combo_by_data(self.generate_mode_combo, previous)
self.generate_mode_combo.blockSignals(False)
self.model.set_generate_cover_enabled(appconfig.generate_mode_includes_cover(previous))
def _apply_task_table_column_widths(self):
if self.model.columnCount() != len(self.TABLE_COLUMN_WEIGHTS):
@@ -1237,9 +1246,11 @@ class GenerateTab(QWidget):
used += section_width
header.resizeSection(column, section_width)
def _save_generate_cover_setting(self, show_status=True):
generate_cover = bool(self.generate_cover_checkbox.isChecked())
def _save_generate_mode_setting(self, show_status=True):
generate_mode = self._current_generate_mode()
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
ai_settings = appconfig.ai_config(self.config)
ai_settings["generate_mode"] = generate_mode
ai_settings["generate_cover"] = generate_cover
payload = {
key: value
@@ -1250,7 +1261,7 @@ class GenerateTab(QWidget):
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"生成封面开关保存失败:{exc}")
self._set_status(f"生成内容设置保存失败:{exc}")
return False
internal = {
key: value
@@ -1263,10 +1274,30 @@ class GenerateTab(QWidget):
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片"
self._set_status(f"AI生成设置已保存:{mode}")
self._set_status(f"AI生成设置已保存:{self._generate_mode_label(generate_mode)}")
return True
def _current_generate_mode(self):
return appconfig.normalize_generate_mode(
self.generate_mode_combo.currentData(),
generate_cover=False,
)
def _current_generate_mode_includes_cover(self):
return appconfig.generate_mode_includes_cover(self._current_generate_mode())
def _generate_mode_label(self, mode):
mode = appconfig.normalize_generate_mode(mode)
return {
"title": "只生成标题",
"cover": "只生成封面",
"title_cover": "生成标题和封面",
}.get(mode, "只生成标题")
def _set_combo_by_data(self, combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
def _load_latest_generate_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path)
@@ -1433,21 +1464,23 @@ class GenerateTab(QWidget):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行...")
return
if not self._save_generate_cover_setting(show_status=False):
if not self._save_generate_mode_setting(show_status=False):
return
generate_cover = bool(self.generate_cover_checkbox.isChecked())
generate_mode = self._current_generate_mode()
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
tasks = [
task for task in self.model.tasks
if ai.is_generatable_task(task, generate_cover=generate_cover)
if ai.is_generatable_task(task, generate_mode=generate_mode)
]
if not tasks:
self._set_status(
"当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或勾选“生成封面图片(成本较高)”补封面"
)
if generate_mode == "cover":
self._set_status("当前筛选结果没有可生成封面的任务,请先生成标题。")
else:
self._set_status("当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或调整生成内容")
return
component_totals = ai.generation_component_totals(
tasks,
generate_cover=generate_cover,
generate_mode=generate_mode,
)
prompt_values = {
"title": self.title_prompt_edit.toPlainText(),
@@ -1466,11 +1499,11 @@ class GenerateTab(QWidget):
worker.failed.connect(self._on_generate_failed)
worker.finished.connect(self._on_generate_finished)
worker.cancelled.connect(self._on_generate_cancelled)
self._reset_generate_run_log(tasks, generate_cover)
self._reset_generate_run_log(tasks, generate_mode)
self._reset_cmhub_balance_label()
self._start_generation_elapsed(
component_totals["title_total"],
component_totals["cover_total"] if generate_cover else 0,
component_totals["cover_total"],
)
thread = run_worker(worker, thread_name="GenerateWorker", start=False)
thread.finished.connect(lambda: self._forget_generate_thread(thread))
@@ -1483,10 +1516,11 @@ class GenerateTab(QWidget):
"title_total": component_totals["title_total"],
"title_done": 0,
"cover_done": 0,
"cover_total": component_totals["cover_total"] if generate_cover else 0,
"cover_total": component_totals["cover_total"],
"generated_done": 0,
"failed": 0,
"generate_cover": generate_cover,
"generate_mode": generate_mode,
}
)
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
@@ -1739,7 +1773,7 @@ class GenerateTab(QWidget):
self.save_cover_template_as_action.setEnabled(not running)
self.rename_cover_template_action.setEnabled(not running)
self.delete_cover_template_action.setEnabled(not running)
self.generate_cover_checkbox.setEnabled(not running)
self.generate_mode_combo.setEnabled(not running)
self._sync_cover_gallery_regenerate_guard()
def _set_cover_gallery_regenerate_running(self, running):
@@ -1984,7 +2018,7 @@ class GenerateTab(QWidget):
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._set_status(f"AI 生成任务读取失败:{exc}")
return
self.model.set_generate_cover_enabled(self.generate_cover_checkbox.isChecked())
self.model.set_generate_cover_enabled(self._current_generate_mode_includes_cover())
self.model.set_tasks(filtered_tasks, accounts_rows)
self._apply_task_table_column_widths()
self.summary_label.setText(
@@ -2082,8 +2116,8 @@ class GenerateTab(QWidget):
if selected_status in (None, "all"):
return True
if selected_status == "to_generate":
generate_cover = bool(self.generate_cover_checkbox.isChecked())
return ai.is_generatable_task(task, generate_cover=generate_cover) and task.status != "failed"
generate_mode = self._current_generate_mode()
return ai.is_generatable_task(task, generate_mode=generate_mode) and task.status != "failed"
if selected_status == "generated":
return task.stage == "generated"
if selected_status == "applied":
+13 -13
View File
@@ -208,6 +208,7 @@ class SettingsTab(QWidget):
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox")
self.allow_cover_update_checkbox.setVisible(False)
self.max_items_per_run_spin = QSpinBox()
self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin")
self.max_items_per_run_spin.setRange(1, 9999)
@@ -294,7 +295,6 @@ class SettingsTab(QWidget):
("每批最大更新条数", self.max_items_per_run_spin),
("", self.allow_real_submit_checkbox),
("", self.close_success_tab_checkbox),
("", self.allow_cover_update_checkbox),
("", self.parallel_accounts_group, 2),
]
)
@@ -519,7 +519,6 @@ class SettingsTab(QWidget):
self.cmhub_check_balance_checkbox,
self.enabled_checkbox,
self.allow_real_submit_checkbox,
self.allow_cover_update_checkbox,
self.close_success_tab_checkbox,
self.parallel_accounts_checkbox,
)
@@ -780,6 +779,14 @@ class SettingsTab(QWidget):
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
try:
current_update_cfg = appconfig.shopee_update_config(appconfig.load_config(self.config_path))
except Exception:
current_update_cfg = self._shopee_update_config()
update_mode = appconfig.normalize_update_mode(
current_update_cfg.get("update_mode"),
allow_cover_update=current_update_cfg.get("allow_cover_update", False),
)
settings.update(
{
"chrome_path": self.chrome_path_edit.text().strip(),
@@ -796,7 +803,8 @@ class SettingsTab(QWidget):
"shopee_update": {
"test_item_id": str(self._compat_test_item_id or ""),
"allow_real_submit": self.allow_real_submit_checkbox.isChecked(),
"allow_cover_update": self.allow_cover_update_checkbox.isChecked(),
"allow_cover_update": appconfig.update_mode_includes_cover(update_mode),
"update_mode": update_mode,
"max_items_per_run": self.max_items_per_run_spin.value(),
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
"dry_run": False,
@@ -912,9 +920,7 @@ class SettingsTab(QWidget):
self.allow_real_submit_checkbox.setChecked(
bool(update_cfg.get("allow_real_submit", False))
)
self.allow_cover_update_checkbox.setChecked(
bool(update_cfg.get("allow_cover_update", False))
)
self.allow_cover_update_checkbox.setChecked(bool(update_cfg.get("allow_cover_update", False)))
self.max_items_per_run_spin.setValue(
max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
)
@@ -931,13 +937,7 @@ class SettingsTab(QWidget):
self._on_backend_changed()
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
return appconfig.shopee_update_config(self.config)
def _populate_role_model_combos(self):
ai_cfg = appconfig.ai_config(self.config)
+54 -10
View File
@@ -14,6 +14,24 @@ _USER_LOG_PATH_RE = re.compile(
)
def _generation_mode_label(mode):
mode = appconfig.normalize_generate_mode(mode)
return {
"title": "只生成标题",
"cover": "只生成封面",
"title_cover": "生成标题和封面",
}.get(mode, "只生成标题")
def _update_mode_label(mode):
mode = appconfig.normalize_update_mode(mode)
return {
"title": "只更新标题",
"cover": "只更新封面",
"title_cover": "更新标题和封面",
}.get(mode, "只更新标题")
class GenerateWorker(BaseWorker):
"""Generate titles and covers for eligible collected or failed generation tasks."""
@@ -53,14 +71,15 @@ class GenerateWorker(BaseWorker):
}
self._account_by_alias = account_by_alias
ai_cfg = appconfig.ai_config(self.config)
generate_cover = bool(ai_cfg.get("generate_cover", False))
generate_mode = appconfig.ai_generate_mode(self.config)
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
eligible = [
task for task in self.tasks
if ai.is_generatable_task(task, generate_cover=generate_cover)
if ai.is_generatable_task(task, generate_mode=generate_mode)
]
component_totals = ai.generation_component_totals(
eligible,
generate_cover=generate_cover,
generate_mode=generate_mode,
)
self._eligible_total = len(eligible)
self._task_positions = {
@@ -69,11 +88,13 @@ class GenerateWorker(BaseWorker):
}
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
mode_text = _generation_mode_label(generate_mode)
if generate_cover:
if appconfig.ai_backend(self.config) == "cmhub":
concurrency = ai.cmhub_image_concurrency_plan(ai_cfg)
start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency};开始时间 {started_at}".format(
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
title_total=component_totals["title_total"],
cover_total=component_totals["cover_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
@@ -83,8 +104,9 @@ class GenerateWorker(BaseWorker):
started_at=self._run_started_at_text,
)
else:
start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency};开始时间 {started_at}".format(
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
title_total=component_totals["title_total"],
cover_total=component_totals["cover_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
@@ -92,8 +114,9 @@ class GenerateWorker(BaseWorker):
started_at=self._run_started_at_text,
)
else:
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency};开始时间 {started_at}".format(
start_message = "[开始] 本轮生成 {total} 条:本轮生成内容:{mode_text};标题{title_total};标题并发{title_concurrency};开始时间 {started_at}".format(
total=len(eligible),
mode_text=mode_text,
title_total=component_totals["title_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
started_at=self._run_started_at_text,
@@ -112,6 +135,7 @@ class GenerateWorker(BaseWorker):
"on_event": self._on_generation_event,
"on_error": self._on_generation_error,
"generate_cover": generate_cover,
"generate_mode": generate_mode,
},
on_progress=self._emit_generate_progress,
should_stop=self._should_stop_generation,
@@ -130,6 +154,7 @@ class GenerateWorker(BaseWorker):
"failed": len(eligible),
"cancelled": self.should_cancel(),
"generate_cover": generate_cover,
"generate_mode": generate_mode,
}
user_error = self._user_log_detail(error) or "未知错误"
self._log_run_event(
@@ -550,6 +575,7 @@ class ApplyWorker(BaseWorker):
preflight=True,
close_success_tab=False,
dry_run=False,
update_mode=None,
parallel_accounts=False,
max_parallel_accounts=1,
batch_size=None,
@@ -562,6 +588,10 @@ class ApplyWorker(BaseWorker):
self.preflight = preflight
self.close_success_tab = close_success_tab
self.dry_run = bool(dry_run)
self.update_mode = appconfig.normalize_update_mode(
update_mode,
allow_cover_update=appconfig.shopee_update_config(config).get("allow_cover_update", False),
)
self.parallel_accounts = bool(parallel_accounts)
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
self.batch_size = None if batch_size is None else max(1, int(batch_size or 1))
@@ -595,8 +625,9 @@ class ApplyWorker(BaseWorker):
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"step=start result=start detail=运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
mode="检查本轮更新" if self.dry_run else "真实更新",
update_mode=_update_mode_label(self.update_mode),
total=total,
batch_size=batch_size,
batch_count=len(batches),
@@ -662,7 +693,16 @@ class ApplyWorker(BaseWorker):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
and (
(
appconfig.update_mode_includes_title(self.update_mode)
and bool(getattr(task, "new_title", None))
)
or (
appconfig.update_mode_includes_cover(self.update_mode)
and bool(getattr(task, "new_cover_path", None))
)
)
)
def _preflight_block(self, eligible, account_rows, account_by_alias):
@@ -823,9 +863,9 @@ class ApplyWorker(BaseWorker):
)
return "skipped"
action_parts = []
if getattr(task, "new_title", None):
if appconfig.update_mode_includes_title(self.update_mode) and getattr(task, "new_title", None):
action_parts.append("标题")
if getattr(task, "new_cover_path", None):
if appconfig.update_mode_includes_cover(self.update_mode) and getattr(task, "new_cover_path", None):
action_parts.append("封面")
action_text = "+".join(action_parts) or "无变更"
self._log_run_event(
@@ -899,6 +939,7 @@ class ApplyWorker(BaseWorker):
close_success_tab=self.close_success_tab,
on_step=on_step,
bring_to_front=bring_to_front,
update_mode=self.update_mode,
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
@@ -1027,6 +1068,7 @@ class ApplyWorker(BaseWorker):
"dry_run": self.dry_run,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
}
)
@@ -1064,6 +1106,7 @@ class ApplyWorker(BaseWorker):
"parallel_accounts": self.parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
"run_id": self._run_id,
}
if blocked:
@@ -1086,6 +1129,7 @@ class ApplyWorker(BaseWorker):
"max_parallel_accounts": self.max_parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
},
path=self.db_path,
)
+3 -3
View File
@@ -42,11 +42,11 @@
| 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 |
| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 |
| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 |
| AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 |
| AI 生成(②) | 输入标题/封面提示词,按②本轮「生成内容」下拉选择只生成标题、只生成封面或生成标题和封面;只生成封面要求任务已有新标题,避免用户无意产生图片模型成本;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 |
| 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 |
| 更新蝦皮(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
| 更新蝦皮(③) | 按批次/店铺/状态筛选;③左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;点击「开始更新」前先校验所选内容已生成,缺失则中文弹窗阻断且不打开 Chrome;确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
+16 -11
View File
@@ -52,7 +52,7 @@ imported → collected → generated → applied
- **imported**:openpyxl 解析输入列入库。
- **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。
- **generated**:AI 用提示词+旧标题生成新标题;封面按②本轮「生成封面图片(成本较高)」开关可选生成,开启时用提示词+旧封面生成新封面(image-to-image)并存本地,关闭时 `new_cover_path=NULL`。**不设逐条人工审核阶段**,标题生成成功即可进入 ③;有新封面时 ③ 可在安全开关允许后换封面,没有新封面时 ③ 只改标题。
- **generated**:AI 按②「生成内容」下拉生成新标题和/或新封面;选择只生成标题时 `new_cover_path=NULL`,选择只生成封面时要求任务已有 `new_title`,选择生成标题和封面时可按缺失组件增量补齐。**不设逐条人工审核阶段**,生成成功即可进入 ③;③ 再按「更新内容」下拉决定只更新标题、只更新封面或更新图文。
- **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。
任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。
@@ -108,6 +108,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
"default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2",
"generate_cover": false,
"generate_mode": "title",
"backend": "cmhub",
"cmhub": {
"base_url": "",
@@ -127,6 +128,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
"test_item_id": "51100639510",
"allow_real_submit": false,
"allow_cover_update": false,
"update_mode": "title",
"max_items_per_run": 1,
"close_success_tab": false,
"dry_run": false,
@@ -141,7 +143,8 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
- `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。
- `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
- `resolution_timeouts`:direct 内部兼容路径使用的分辨率 → **等待大模型返回超时(秒)** 映射。普通默认 `cmhub` 模式下,分辨率只控制生图尺寸;⑤设置页「返回超时」只读展示实际等待口径:标题 600 秒 / 图片 900 秒,不再随分辨率切换显示 180/240/360/600。
- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。
@@ -151,7 +154,8 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
- `test_item_id`:历史/调试兼容字段;默认 `51100639510`。普通正式更新不再以该字段限制商品 ID,也不因当前筛选结果包含非测试商品而阻断;后续如需要调试模式,可单独启用测试商品限制。
- `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。
- `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。
- `update_mode`:③「更新内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只更新标题、只更新封面、更新标题和封面;默认 `title`。
- `allow_cover_update`:旧兼容字段;保存配置时仍写回,值由 `update_mode` 是否包含封面推导。⑤设置页不再展示「允许更新封面」,封面是否参与本轮真实更新由③左下角「更新内容」下拉决定。
- `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。
- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。Shopee 确认成功并跳回商品列表页后,关闭前等待 2 秒,让列表页跳转和页面状态稳定。
- `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「检查本轮更新」按钮触发检查模式,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。
@@ -405,18 +409,18 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。
2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。
2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。选择 `cover` 时仅处理已有 `new_title` 且缺新封面的任务;选择 `title_cover` 时标题和封面按缺失组件增量补齐。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。
- T-545/T-564 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际 submit+poll 在途并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图并发一致,同样最大 5。这样批量时“下一批生图任务”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图任务接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。
- cmhub 生图连接超时取 `ai.cmhub.connect_timeout`(默认 66 秒);异步 submit 读取等待 30 秒,poll 单次读取等待 15 秒,本地总等待预算 900 秒,撞预算或用户停止时保留 `image_task_id/image_task_key` 供下次续查;poll 返回 `failed/expired`(cmhub 已退点)时清空 `image_task_id/image_task_key`,后续重试会生成新的幂等键并重新 submit。下载层读取等待 900 秒,最多安全重试 3 次,只复用同一个 `image_url`,不重新请求 cmhub 生图;下载总耗时超过 20 秒时写“图片下载较慢”警告。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。
- T-548 已实现:cmhub 图片下载后端新增 `ai.cmhub.download_with_curl`(`auto`/`true`/`false`,默认 `auto`)。Windows 且检测到系统 curl 时优先用 curl 下载,否则或 curl 执行失败时回退 requests;生成、models、balance 仍走共享 requests Session。curl 下载前仍执行公网 URL 校验;URL 写入临时 curl 配置文件并通过 `-K` 传入,避免带 token 的 `image_url` 出现在进程命令行;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。
- cmhub 返回的完整 `image_url` 默认只在内存中临时用于下载,不写入 `tasks` 或 `run_log_events`。本机调试时可设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的 URL 调试行,且该行不持久化到 SQLite。
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
3. **若②「生成内容」为只生成标题**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
- 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。
- 标题-only 模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;标题+封面模式在图片成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。两种模式都实时落库,停止或崩溃不丢已生成结果。
- 只生成标题模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;只生成封面模式不调用生文、不覆盖已有标题;生成标题和封面模式在封面成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。三种模式都实时落库,停止或崩溃不丢已生成结果。
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
- 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。
- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
- 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。
- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并选择只更新标题;若后续需要封面,用户可在②选择只生成封面或生成标题和封面补齐。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
- 并发数、重试、分辨率、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”。
提示词管理:
@@ -428,11 +432,12 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.3 应用更新(③ Tab)
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新字段和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式检查当前筛选任务是否已经具备 `new_title` / `new_cover_path`;缺少所选内容时直接中文弹窗阻断,不创建 `ApplyWorker`、不打开 Chrome、不写失败状态。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update`:未开启 `allow_real_submit` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。
- 对确认后的**已生成(generated)任务**:按③「更新内容」模式执行 `open_product` → `change_title(new_title)`(选择标题时)→ `replace_cover(new_cover_path)`(选择封面时)→ `click_update` 提交。选择 `只更新标题` 时即使任务有 `new_cover_path` 也不会替换封面;选择 `只更新封面` 时即使任务有 `new_title` 也不会改标题。
- `open_product` 打开商品详情页失败时,要把页面 toast 中的错误原因上浮到③运行日志和任务失败原因;商品 ID 失效、无权限或店铺不匹配时应能看到 Shopee 原始提示,而不是只看到等待详情页超时;如果此时 tab 是本轮自动新建的,`open_product` 要负责关闭该失败 tab。
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
+12 -12
View File
@@ -29,7 +29,7 @@ prepare_data_dir(...) -> str # 启动时迁移旧布局并检测
default_debug_port(config=None) -> int
debug_port_range(config=None) -> tuple # (start, end)
cdp_ready_timeout(config=None) -> int
ai_config(config=None) -> dict # default_text_model/default_image_model/
ai_config(config=None) -> dict # default_text_model/default_image_model/generate_mode/generate_cover/
# title_concurrency/image_concurrency/retry/jpg_quality/
# resolution/resolution_timeouts
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
@@ -39,7 +39,7 @@ cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /ap
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
```
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、更新内容模式 `update_mode`、旧兼容 `allow_cover_update`、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
敏感信息展示/日志辅助:
@@ -302,9 +302,9 @@ gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=N
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict]
# 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
# 编排:按 ai.generate_mode 跑标题和/或封面;title/title_cover 用 title_concurrency,cover/title_cover 用 image_concurrency
# cmhub 批量生图由 generate_batch 直接走异步 /generate/image/tasks:submit 落库 task_id,再 poll 续查,成功后仍返回本地 JPEG 路径
# 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
# 只生成标题时标题成功即 db.set_generated(task_id, new_title, None);只生成封面不调用生文;生成标题和封面时封面成功后写 new_cover_path;should_stop() 为真则取消未开始项
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
@@ -404,8 +404,8 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、CDP 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
- 蝦皮更新安全读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。
- 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。
- 蝦皮更新安全读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:允许真实提交、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和 `allow_cover_update` 仅作为历史/调试兼容字段保留,普通设置页已隐藏入口。
- 真实提交默认关闭;更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验和真实提交安全开关检查,并弹窗确认后才会创建更新 worker。
① 导入采集当前要点(T-202/T-202b):
@@ -432,22 +432,22 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面,选择只生成封面时任务必须已有 `new_title`;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,按当前 `ai.generate_mode` 并发标题和/或封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。
- 进度标签显示标题/图片/失败;未勾选生成封面时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;勾选时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;包含封面时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`;只生成封面时不调用标题生成、不覆盖已有标题。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式校验是否缺少 `new_title` 或 `new_cover_path`。
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、会更新字段和略过原因。
- 点击「开始更新」先读取 `shopee_update`:未允许真实提交,或包含新封面但未允许封面更新时,弹警告并阻断;拦截弹窗写明具体设置项并提供「前往设置」跳到⑤;普通正式更新不再检查 `test_item_id`,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update`:未允许真实提交时弹警告并阻断;拦截弹窗写明具体设置项并提供「前往设置」跳到⑤;普通正式更新不再检查 `test_item_id`,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated`、状态为 `success/pending/failed`,且满足当前 `update_mode` 所需内容的任务;只更新标题时不替换封面,只更新封面时不改标题。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
- 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
File diff suppressed because one or more lines are too long
+18 -17
View File
@@ -88,7 +88,7 @@
│ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │
│ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │
│ └──────────┘ └───────────────────────────────────────────────┘ │
│ [ ] 生成封面图片(成本较高) 标题30/30 ━━━━━ 生标题用时 18 秒 [▶ 开始生成][■停止][重置生成结果] │
│ 生成内容[只生成标题▼] 标题30/30 ━━━━━ 生标题用时 18 秒 [▶ 开始生成][■停止][重置生成结果] │
│ 图片0/0 ━━━━━ 生图用时 0 秒 │
└───────────────────────────────────────────────────────────────┘
```
@@ -99,9 +99,9 @@
- 变量:标题提示词本阶段只支持 `{旧标题}`;封面提示词支持 `{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。
- 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 生成封面。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
- 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面要求任务已有新标题。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露本轮生成标题/封面/图文的内容模式。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
- 只生成标题时标题成功即写库并进入 `generated`,`new_cover_path` 留空;只生成封面时不调用标题生成、不覆盖已有标题;生成标题和封面时按缺失组件增量补齐。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。
- 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚蝦皮。
- **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。
@@ -110,23 +110,24 @@
```
┌ 更新蝦皮 ───────────────────────────────────────────────────┐
│ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[已生成▼] [筛选] │
│ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │
│ ⚠ 点击「开始更新」后先校验更新内容,再确认【当前筛选结果】并提交 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ 运行日志:检查/真实更新逐条留痕 │
│ [检查本轮更新] [▶ 开始更新] [■停止] [回写结果到 Excel] │
│ 更新内容[只更新标题▼] [检查本轮更新] [▶ 开始更新] [■停止] [回写结果到 Excel] │
└───────────────────────────────────────────────────────────────┘
```
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、会更新标题/封面、略过原因、每批最大条数和预计批次数。
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交,或包含新封面但未允许封面更新时,直接弹警告并阻断;拦截弹窗必须写明具体未开启的设置项,并提供「前往设置」跳到 ⑤;普通正式更新不再以测试商品 ID 限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
- 安全开关通过后,弹窗展示本次筛选条件、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`,缺失时中文弹窗阻断,不打开 Chrome、不改任务状态。
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
- 缺失内容校验通过后,点击「开始更新」读取 ⑤ `shopee_update` 安全设置:未允许真实提交时,直接弹警告并阻断;拦截弹窗必须写明具体未开启的设置项,并提供「前往设置」跳到 ⑤;普通正式更新不再以测试商品 ID 限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
- 安全开关通过后,弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
- 对确认后的**已生成(generated)任务**按当前更新内容执行:打开编辑页换标题和/或换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
- 为降低批量更新时 Chrome 抢前台,③本轮每个账号只在第一条任务主动把 Chrome 提到前台,后续同账号任务后台打开;如果后台态封面上传/拖拽出现疑似遮挡节流失败,程序只对当前 tab 提前台做一次安全恢复,不重新执行完整删图上传流程。
- 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。
@@ -168,9 +169,9 @@
- 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;⑤「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 900 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
- 路径与端口(T-501b/T-506/T-539 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、CDP 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
- 蝦皮更新安全(T-501c/T-506 已接入):组件组改为 3 个组件一组;允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页等短字段三列排列;「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许换到下一行;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。
- 默认关闭真实提交和封面更新,每批最大更新条数默认 1。
- ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。
- 蝦皮更新安全(T-501c/T-506/T-576 已接入):组件组改为 3 个组件一组;允许真实提交、每批最大更新条数、成功后关闭本次新开编辑页等短字段三列排列;「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许换到下一行;测试商品 ID 和 `allow_cover_update` 仅作为历史/调试兼容字段保留,普通设置页已隐藏入口。
- 默认关闭真实提交,③「更新内容」默认只更新标题,每批最大更新条数默认 1。
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再读取这些设置拦截不符合条件的更新,最后弹确认框。
- 更新执行模式(T-504/T-506 已接入):⑤ 只保留多账号并行更新、最大并行账号数等执行设置;“dry-run”不再作为用户可见开关,改到③成为「检查本轮更新」按钮。
- 默认多账号并行关闭;开启多账号并行后同一账号内仍串行。
@@ -181,11 +182,11 @@
│
① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试)
│
② AI生成:提示词 → 生成新标题/可选生成新封面(无逐条审核)
② AI生成:提示词 → 选择生成标题/封面/图文(无逐条审核)
│
⑤ 设置:配置蝦皮更新安全、每批最大更新条数和执行模式
│
③ 更新蝦皮:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
③ 更新蝦皮:选择更新标题/封面/图文 → 对已生成任务点击开始更新 → 缺失内容校验 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 按模式换标题/封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
```
- 未配账号 / 未登录:① 会自动确保匹配账号 Chrome 就绪但不自动登录,未登录账号略过并提示去④;③ 更新前仍只检测账号 Chrome/CDP/登录态,未启动或未登录则中止,不静默启动缺失账号 Chrome。
@@ -198,8 +199,8 @@
| --- | --- | --- |
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成封面图片(成本较高)」开关接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「检查本轮更新」+ 蝦皮更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 蝦皮更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新安全;数据路径字段隐藏但保留配置兼容 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
+159
View File
@@ -0,0 +1,159 @@
---
id: T-576
title: ②③生成/更新内容模式下拉与缺失内容阻断
phase: 7
deps: [T-575]
status: DONE
created: 2026-07-09
---
## 问题 / 背景
当前②AI生成模块用「生成封面图片(成本较高)」checkbox 控制是否生成封面:
- 关闭:只生成标题。
- 开启:生成标题和封面。
这个设计能控制生图成本,但正式使用时不够精确:
- 用户无法只补生成封面;例如已生成标题后,想单独补封面,需要依赖当前增量逻辑和 checkbox 语义,不够直观。
- 「图片」文案不够准确,本模块实际处理的是商品封面,不是商品全部图片。
- ③更新蝦皮目前依赖⑤设置里的「允许更新封面」checkbox。这个开关离真实更新动作太远,用户在③点击开始更新时不一定清楚本轮到底更新标题、封面还是图文。
- 如果②只生成了标题,③却想更新封面,当前需要更明确地在开始前阻断并提示缺少新封面,避免进入 Chrome/CDP 后才失败。
## 产品结论
需求合理,建议做,但需要统一成“内容模式”而不是布尔开关。
②AI生成模块下拉:
- `只生成标题`
- `只生成封面`
- `生成标题和封面`
③更新蝦皮模块下拉:
- `只更新标题`
- `只更新封面`
- `更新标题和封面`
文案统一使用「封面」,不用「图片」,避免用户误解为商品详情图或所有商品图片。
## 方案
### 1. ②AI生成模式
- `app/appconfig.py`
- 增加兼容字段 `ai.generate_mode`,取值:
- `title`
- `cover`
- `title_cover`
- 兼容旧字段 `ai.generate_cover`:
- `false` → `generate_mode=title`
- `true` → `generate_mode=title_cover`
- 保存设置时可继续写回 `generate_cover` 兼容值,但 GUI 主逻辑以 `generate_mode` 为准。
- `app/ai.py`
- `is_generatable_task()`、`generation_component_totals()`、`generate_batch()` 支持三种生成模式。
- `title`:只补缺失标题,不调用生图。
- `cover`:只补缺失封面;需要当前任务已有 `new_title`,否则该任务不进入本轮,并在 GUI 给出可理解提示。
- `title_cover`:缺标题先生成标题,再生成封面;已有标题只补封面,已有封面只补标题或跳过。
- `app/gui/tabs/generate.py`
- 把「生成封面图片(成本较高)」checkbox 改成下拉框,例如 label「生成内容」。
- 下拉选项:
- `只生成标题`
- `只生成封面`
- `生成标题和封面`
- 开始生成时按选项筛选任务和计算标题/封面进度。
- 只生成封面但当前筛选任务都缺少新标题时,中止并中文提示:`当前筛选结果没有可生成封面的任务,请先生成标题。`
- 日志开头写明本轮生成内容,例如:`本轮生成内容:只生成封面`。
### 2. ③更新蝦皮模式
- `app/appconfig.py`
- 增加兼容字段 `shopee_update.update_mode`,取值:
- `title`
- `cover`
- `title_cover`
- 兼容旧字段 `shopee_update.allow_cover_update`:
- `false` → `update_mode=title`
- `true` → 默认 `update_mode=title_cover`,或保留用户当前选择。
- `allow_real_submit` 仍保留在⑤设置里,继续作为真实提交总开关。
- `app/gui/tabs/settings.py`
- 移除普通设置页里的「允许更新封面」用户控件。
- 保存设置时保留旧字段兼容,不破坏已有配置文件。
- `app/gui/tabs/apply.py`
- 在③更新蝦皮左下角增加「更新内容」下拉框。
- 下拉选项:
- `只更新标题`
- `只更新封面`
- `更新标题和封面`
- 原「检查本轮更新」「开始更新」「停止」等按钮向右挪,避免控件拥挤。
- 选择包含封面的模式时,确认弹窗中明确提示:`本轮将更新线上封面`。
- 选择「只更新标题」时,不要求 `new_cover_path`,也不触发封面更新。
- 选择「只更新封面」时,不要求标题改动,但必须有 `new_cover_path`。
- 选择「更新标题和封面」时,必须同时有 `new_title` 和 `new_cover_path`。
### 3. 开始更新前置校验
点击③「开始更新」后,在账号预检和 CDP 打开商品页之前,先按当前更新模式校验当前筛选结果:
- `只更新标题`:缺 `new_title` 的任务阻断。
- `只更新封面`:缺 `new_cover_path` 的任务阻断。
- `更新标题和封面`:缺任一内容的任务阻断。
阻断时不启动 ApplyWorker、不打开 Chrome tab、不改 DB 状态,弹中文提示,例如:
```text
当前筛选结果中有 3 条缺少新封面,不能执行“只更新封面”。
请先回到②AI生成选择“只生成封面”或“生成标题和封面”。
示例商品ID:25120403046、26887160467、28431952912
```
商品 ID 最多展示 5 个,避免弹窗过长。
「检查本轮更新」也应按当前更新模式展示缺失汇总,但检查模式可以只报告、不真实执行。
## 验收要点
- ②AI生成页不再显示「生成封面图片(成本较高)」checkbox,改为「生成内容」下拉。
- ②选择 `只生成标题` 时不调用生图、不要求旧封面/封面提示词。
- ②选择 `只生成封面` 时只补封面;缺新标题的任务不进入本轮,并给中文提示。
- ②选择 `生成标题和封面` 时保持原图文生成闭环,仍支持增量补缺。
- ⑤设置页普通 UI 不再显示「允许更新封面」checkbox。
- ③更新蝦皮左下角新增「更新内容」下拉,按钮布局不拥挤。
- ③选择 `只更新标题` 时允许任务无新封面。
- ③选择 `只更新封面` 时缺新封面必须在开始更新前阻断。
- ③选择 `更新标题和封面` 时缺新标题或新封面都必须阻断。
- 阻断发生在打开 Chrome 商品详情页之前,不写失败状态,不启动真实更新。
- 确认弹窗能按更新模式展示本轮将更新标题/封面/图文。
- 保留敏感和高风险边界:仍需要⑤ `allow_real_submit=true` 和③最终确认弹窗。
- 验证命令:
- `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`
## 边界(不改什么)
- 不改 AI/cmhub HTTP 协议、并发、重试、下载保存逻辑。
- 不改 DB schema,继续使用 `new_title` / `new_cover_path` 判断内容是否就绪。
- 不改 Excel 导入/回写格式。
- 不改 CDP/Shopee 页面操作细节;只在进入真实更新前增加本地前置校验。
- 不增加逐条人工审核流程。
## 执行记录
- 2026-07-09 完成。
- 文档:同步 `docs/04-architecture.md`、`docs/02-requirements.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,明确②「生成内容」下拉、③「更新内容」下拉、旧布尔字段兼容和开始更新前缺失内容阻断。
- 配置:`app/appconfig.py` 新增 `ai.generate_mode` / `shopee_update.update_mode` 归一与兼容 helper;旧 `ai.generate_cover`、`shopee_update.allow_cover_update` 继续写回兼容。
- ②AI生成:`app/gui/tabs/generate.py` 将封面 checkbox 改为「生成内容」下拉;`app/ai.py`、`GenerateWorker` 支持只生成标题、只生成封面、生成标题和封面,进度和日志按模式统计。
- ③更新蝦皮:`app/gui/tabs/apply.py` 新增「更新内容」下拉;开始更新/检查前按模式校验缺少的新标题或新封面,阻断时不启动 `ApplyWorker`、不打开 Chrome、不写失败状态;确认弹窗和运行日志展示更新内容。
- ⑤设置:`app/gui/tabs/settings.py` 从普通设置页移除「允许更新封面」用户入口,仅保留旧字段兼容写回;保存设置时尽量读取磁盘最新更新模式,避免覆盖③下拉选择。
- 更新执行:`app/editor.py`、`ApplyWorker` 按 `update_mode` 只传递本轮需要改的标题/封面,避免只更新标题时误换封面、只更新封面时误改标题。
- 测试:补充/更新 `tests/test_appconfig.py`、`tests/test_ai.py`、`tests/test_gui.py`,覆盖模式兼容、只生成封面、②/③下拉持久化、③缺失内容阻断和 worker 传参。
- 验证通过:`python -m compileall app main.py`;`python -m unittest discover -s tests`(313 tests);`git diff --check`。
+8
View File
@@ -1510,3 +1510,11 @@
- 测试:`tests/test_ai.py` 覆盖 direct/cmhub 两路径的 `{旧标题}` 替换、旧标题不重复追加、旧行为兼容和其它占位符保留;`tests/test_gui.py` 覆盖「插入旧标题」按钮和光标插入行为。
- 文档:`docs/06-tasks.md` 将 T-549 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/cmhub-integration-design.md` 和 `docs/current-state.md`。
- 验证:`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(252 tests)。
## 【2026-07-09】T-576 · ②③生成/更新内容模式下拉与缺失内容阻断
- 需求:②AI生成把「生成封面图片」checkbox 升级为「生成内容」下拉,支持只生成标题、只生成封面、生成标题和封面;③更新蝦皮把封面更新入口从⑤移到③左下角「更新内容」下拉,支持只更新标题、只更新封面、更新标题和封面。
- 配置:`app/appconfig.py` 新增 `ai.generate_mode` 与 `shopee_update.update_mode`,并继续兼容写回旧 `ai.generate_cover` / `shopee_update.allow_cover_update`;⑤普通设置页隐藏「允许更新封面」,仅保留兼容字段。
- 代码:`app/ai.py`、`GenerateWorker`、`GenerateTab` 支持三种生成模式和模式化进度/日志;`ApplyTab` 在开始更新/检查前按当前更新模式校验缺少的新标题或新封面,阻断时不创建 `ApplyWorker`、不打开 Chrome、不写失败状态;`ApplyWorker`/`editor.apply_task()` 按 `update_mode` 只执行本轮需要的标题或封面更新。
- 文档:新增/完成 `docs/tasks/T-576.md`,同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`。
- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(313 tests);`git diff --check` 通过。
+5
View File
@@ -987,6 +987,8 @@ class AITests(TempDirMixin, unittest.TestCase):
cfg, key_path = self._cmhub_config(temp_dir)
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
cfg["image_dir"] = os.path.join(temp_dir, "images")
cfg["ai"]["generate_mode"] = "cover"
cfg["ai"]["generate_mode"] = "cover"
cfg["ai"]["generate_cover"] = True
cfg["ai"]["image_concurrency"] = 10
titles = ["旧标题%s" % index for index in range(7)]
@@ -1599,6 +1601,7 @@ class AITests(TempDirMixin, unittest.TestCase):
cfg = self._config()
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
cfg["image_dir"] = os.path.join(temp_dir, "images")
cfg["ai"]["generate_mode"] = "cover"
cfg["ai"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
db.set_generated(tasks[0].id, "手动标题", None, path=cfg["db_path"])
@@ -1621,6 +1624,8 @@ class AITests(TempDirMixin, unittest.TestCase):
)
self.assertTrue(summary["ok"])
self.assertEqual("cover", summary["generate_mode"])
self.assertTrue(summary["generate_cover"])
self.assertEqual(1, summary["total"])
self.assertEqual(0, summary["title_total"])
self.assertEqual(0, summary["title_done"])
+27
View File
@@ -27,6 +27,9 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual(os.path.join(temp_dir, "config", "cmhub.json"), config["cmhub_config_path"])
self.assertEqual(240, appconfig.response_timeout(config))
self.assertFalse(appconfig.ai_config(config)["generate_cover"])
self.assertEqual("title", appconfig.ai_generate_mode(config))
self.assertEqual("title", appconfig.shopee_update_config(config)["update_mode"])
self.assertFalse(appconfig.shopee_update_config(config)["allow_cover_update"])
updated = appconfig.update_config(
{"ai": {"resolution": "2k"}},
@@ -35,6 +38,30 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual(360, appconfig.response_timeout(updated))
self.assertEqual((9222, 9260), appconfig.debug_port_range(updated))
legacy = appconfig.save_config(
{
"ai": {"generate_cover": True},
"shopee_update": {"allow_cover_update": True},
},
path=config_path,
)
self.assertEqual("title_cover", appconfig.ai_generate_mode(legacy))
self.assertTrue(appconfig.ai_config(legacy)["generate_cover"])
self.assertEqual("title_cover", appconfig.shopee_update_config(legacy)["update_mode"])
self.assertTrue(appconfig.shopee_update_config(legacy)["allow_cover_update"])
cover_only = appconfig.save_config(
{
"ai": {"generate_mode": "cover"},
"shopee_update": {"update_mode": "cover"},
},
path=config_path,
)
self.assertEqual("cover", appconfig.ai_generate_mode(cover_only))
self.assertTrue(appconfig.ai_config(cover_only)["generate_cover"])
self.assertEqual("cover", appconfig.shopee_update_config(cover_only)["update_mode"])
self.assertTrue(appconfig.shopee_update_config(cover_only)["allow_cover_update"])
self.assert_removed(temp_dir)
def test_config_save_does_not_persist_runtime_paths(self):
+53 -35
View File
@@ -19,7 +19,7 @@ if gui.QT_IMPORT_ERROR is not None:
from PySide6.QtCore import QItemSelectionModel, QRect
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
from app.gui import (
AccountDialog,
@@ -850,6 +850,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
self.assertFalse(tab.allow_real_submit_checkbox.isChecked())
self.assertFalse(tab.allow_cover_update_checkbox.isChecked())
self.assertTrue(tab.allow_cover_update_checkbox.isHidden())
self.assertEqual(-1, tab.shopee_update_form_layout.indexOf(tab.allow_cover_update_checkbox))
self.assertEqual(1, tab.max_items_per_run_spin.value())
self.assertFalse(tab.close_success_tab_checkbox.isChecked())
self.assertFalse(tab.parallel_accounts_checkbox.isChecked())
@@ -866,12 +868,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.shopee_update_form_layout,
tab.parallel_accounts_group,
)
cover_row, cover_col, _row_span, _col_span = widget_position(
tab.shopee_update_form_layout,
tab.allow_cover_update_checkbox,
)
self.assertEqual(cover_row, group_row)
self.assertGreater(group_col, cover_col)
self.assertGreaterEqual(group_row, 0)
self.assertGreaterEqual(group_col, 0)
self.assertEqual(4, group_col_span)
group_widgets = [
@@ -1153,7 +1151,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.debug_port_end_spin.setValue(9350)
tab.cdp_ready_timeout_spin.setValue(45)
tab.allow_real_submit_checkbox.setChecked(True)
tab.allow_cover_update_checkbox.setChecked(True)
tab.max_items_per_run_spin.setValue(2)
tab.close_success_tab_checkbox.setChecked(True)
tab.parallel_accounts_checkbox.setChecked(True)
@@ -1183,7 +1180,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
{
"test_item_id": "51100639510",
"allow_real_submit": True,
"allow_cover_update": True,
"allow_cover_update": False,
"update_mode": "title",
"max_items_per_run": 2,
"close_success_tab": True,
"dry_run": False,
@@ -1235,7 +1233,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新蝦皮"))
settings_tab.allow_real_submit_checkbox.setChecked(True)
settings_tab.allow_cover_update_checkbox.setChecked(True)
settings_tab.max_items_per_run_spin.setValue(3)
settings_tab.close_success_tab_checkbox.setChecked(True)
settings_tab.parallel_accounts_checkbox.setChecked(True)
@@ -1247,7 +1244,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
safety_cfg = apply_tab._shopee_update_config()
self.assertEqual("123456789", safety_cfg["test_item_id"])
self.assertTrue(safety_cfg["allow_real_submit"])
self.assertTrue(safety_cfg["allow_cover_update"])
self.assertFalse(safety_cfg["allow_cover_update"])
self.assertEqual("title", safety_cfg["update_mode"])
self.assertEqual(3, safety_cfg["max_items_per_run"])
self.assertTrue(safety_cfg["close_success_tab"])
self.assertFalse(safety_cfg["dry_run"])
@@ -1826,7 +1824,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.task_table, QTableView)
self.assertIsInstance(tab.generate_cover_checkbox, QCheckBox)
self.assertIsInstance(tab.generate_mode_combo, QComboBox)
self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText())
self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText())
self.assertEqual("保存标题提示词", tab.save_title_button.text())
@@ -1834,8 +1832,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("开始生成", tab.generate_button.text())
self.assertEqual("停止", tab.stop_generate_button.text())
self.assertEqual("重置生成结果", tab.reset_generate_button.text())
self.assertEqual("生成封面图片(成本较高)", tab.generate_cover_checkbox.text())
self.assertFalse(tab.generate_cover_checkbox.isChecked())
self.assertEqual(
["只生成标题", "只生成封面", "生成标题和封面"],
[tab.generate_mode_combo.itemText(index) for index in range(tab.generate_mode_combo.count())],
)
self.assertEqual("title", tab.generate_mode_combo.currentData())
self.assertFalse(tab.stop_generate_button.isEnabled())
self.assertEqual("进度:标题0/0 · 图片0/0 · 失败0", tab.progress_label.text())
self.assertIsInstance(tab.title_progress_bar, QProgressBar)
@@ -2179,7 +2180,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_tab_persists_generate_cover_toggle(self):
def test_generate_tab_persists_generate_mode_selection(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
statuses = []
@@ -2187,16 +2188,18 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.addCleanup(tab.close)
self.assertFalse(appconfig.ai_config(cfg)["generate_cover"])
tab.generate_cover_checkbox.setChecked(True)
tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover"))
saved = appconfig.load_config(cfg["config_path"])
self.assertTrue(saved["ai"]["generate_cover"])
self.assertEqual("cover", saved["ai"]["generate_mode"])
self.assertTrue(cfg["ai"]["generate_cover"])
self.assertIn("会同时生成封面图片", statuses[-1])
self.assertEqual("cover", cfg["ai"]["generate_mode"])
self.assertIn("只生成封面", statuses[-1])
self.assert_removed(temp_dir)
def test_generate_tab_refreshes_to_generate_filter_when_cover_toggle_changes(self):
def test_generate_tab_refreshes_to_generate_filter_when_mode_changes_to_cover(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
@@ -2224,7 +2227,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
self.assertEqual(0, tab.model.rowCount())
tab.generate_cover_checkbox.setChecked(True)
tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover"))
self.assertEqual(1, tab.model.rowCount())
self.assertEqual("51100639510", tab.model.task_at(0).item_id)
@@ -3788,7 +3791,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(0, progress[-1]["cover_total"])
self.assertEqual(1, progress[-1]["generated_done"])
joined_logs = "\n".join(logs)
self.assertIn("本轮仅生成标题,不生成图片", joined_logs)
self.assertIn("本轮生成内容:只生成标题", joined_logs)
self.assertIn("已保存,仅生成标题", joined_logs)
self.assertIn("[完成] AI 生成完成:标题1/1,图片0/0,失败0", joined_logs)
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
@@ -3803,6 +3806,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["backend"] = "direct"
cfg["ai"]["generate_mode"] = "cover"
cfg["ai"]["generate_cover"] = True
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
@@ -3860,7 +3864,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(0, progress[-1]["title_total"])
self.assertEqual(1, progress[-1]["cover_total"])
joined_logs = "\n".join(logs)
self.assertIn("[开始] 本轮生成 1 条:标题0,图片1", joined_logs)
self.assertIn("本轮生成内容:只生成封面", joined_logs)
self.assertIn("标题0,图片1", joined_logs)
self.assertIn("已有标题,跳过生文", joined_logs)
self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs)
@@ -3944,7 +3949,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertTrue(fake_thread.started)
self.assertNotIn("上一轮失败日志", text)
self.assertIn("本轮AI生成开始:任务 1 条", text)
self.assertIn("生成封面:否", text)
self.assertIn("生成内容:只生成标题", text)
self.assert_removed(temp_dir)
@@ -3952,6 +3957,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_mode"] = "cover"
cfg["ai"]["generate_cover"] = True
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
@@ -3999,7 +4005,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("图片 0/1", tab.cover_progress_label.text())
text = tab.run_log_view.toPlainText()
self.assertIn("本轮AI生成开始:任务 1 条", text)
self.assertIn("生成封面:是", text)
self.assertIn("生成内容:只生成封面", text)
self.assert_removed(temp_dir)
@@ -4145,6 +4151,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS)
self.assertEqual("检查本轮更新", tab.preview_update_button.text())
self.assertEqual("开始更新", tab.start_update_button.text())
self.assertIsInstance(tab.update_mode_combo, QComboBox)
self.assertEqual(
["只更新标题", "只更新封面", "更新标题和封面"],
[tab.update_mode_combo.itemText(index) for index in range(tab.update_mode_combo.count())],
)
self.assertEqual("applyItemFilter", tab.item_filter.objectName())
self.assertEqual("停止", tab.stop_update_button.text())
self.assertEqual("重置更新状态", tab.reset_update_button.text())
@@ -4293,6 +4304,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("任务数:1", message)
self.assertIn("预计批次:1", message)
self.assertIn("提交线上", message)
self.assertIn("更新内容:更新标题和封面", message)
self.assertIn("本轮将更新线上封面", message)
self.assertIn("状态:已生成", message)
self.assertIn("商品ID:51100639510", message)
self.assertNotIn("测试商品ID", message)
@@ -4360,9 +4373,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertFalse(tab.apply_worker.dry_run)
self.assertFalse(tab.apply_worker.parallel_accounts)
self.assertEqual(1, tab.apply_worker.batch_size)
self.assertEqual("title_cover", tab.apply_worker.update_mode)
log_text = tab.run_log_view.toPlainText()
self.assertNotIn("上一轮更新失败", log_text)
self.assertIn("本轮更新开始:任务 1 条,每批 1 条", log_text)
self.assertIn("本轮更新开始:任务 1 条,更新内容:更新标题和封面,每批 1 条", log_text)
self.assertFalse(tab.preview_update_button.isEnabled())
self.assertFalse(tab.start_update_button.isEnabled())
self.assertTrue(tab.stop_update_button.isEnabled())
@@ -4440,7 +4454,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(2, tab.apply_worker.max_parallel_accounts)
log_text = tab.run_log_view.toPlainText()
self.assertNotIn("上一轮检查失败", log_text)
self.assertIn("本轮检查开始:任务 1 条,每批 1 条", log_text)
self.assertIn("本轮检查开始:任务 1 条,更新内容:只更新标题,每批 1 条", log_text)
self.assertEqual("开始检查本轮更新:1 条", statuses[-1])
self.assert_removed(temp_dir)
@@ -4486,7 +4500,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_blocks_cover_update_when_cover_switch_is_off(self):
def test_apply_tab_blocks_cover_update_when_cover_is_missing_for_selected_mode(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg, allow_cover=False)
@@ -4508,18 +4522,22 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(task.id, "新标题", None, path=cfg["db_path"])
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("cover"))
with mock.patch.object(tab, "_show_update_safety_error") as safety_error, \
with mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
tab.start_update()
message = safety_error.call_args[0][0]
self.assertIn("允许更新封面", message)
self.assertIn("⑤设置", message)
warning.assert_called_once()
self.assertEqual("更新内容未生成", warning.call_args[0][1])
message = warning.call_args[0][2]
self.assertIn("缺少新封面", message)
self.assertIn("只更新封面", message)
self.assertIn("51100639510", message)
question.assert_not_called()
run_worker.assert_not_called()
@@ -4621,7 +4639,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
progress = []
rows = []
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True):
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
applied_aliases.append(account.alias)
close_flags.append(close_success_tab)
foreground_flags.append(bring_to_front)
@@ -4725,7 +4743,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
foreground_flags = []
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True):
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
foreground_flags.append(bring_to_front)
return {"committed": True, "error": None}
@@ -4850,7 +4868,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
thread_names = set()
foreground_by_alias = {}
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True):
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
thread_names.add(threading.current_thread().name)
foreground_by_alias[account.alias] = bring_to_front
started[account.alias].set()
@@ -6716,7 +6734,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
diagnostic_log_dir = os.path.join(temp_dir, "logs")
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True):
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
on_step({"step": "open_product", "result": "start"})
on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"})
return {