diff --git a/app/ai.py b/app/ai.py index f53fa21..4198004 100644 --- a/app/ai.py +++ b/app/ai.py @@ -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 = {} diff --git a/app/appconfig.py b/app/appconfig.py index 3d56e39..2c994cc 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -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) diff --git a/app/editor.py b/app/editor.py index fea0e34..743b764 100644 --- a/app/editor.py +++ b/app/editor.py @@ -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") diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index 13ddc69..aa9362f 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -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): diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index ea6e996..218a305 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -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": diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 0f1e763..0f69a50 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -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) diff --git a/app/gui/workers.py b/app/gui/workers.py index a3fd42c..8383f5d 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -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, ) diff --git a/docs/02-requirements.md b/docs/02-requirements.md index afa8c7f..4bb6da5 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -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 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 6fc9a67..7af1a23 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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/`:打包版默认 `/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/`:打包版默认 `/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/`:打包版默认 `/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/`:打包版默认 `/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///__new. # 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///__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///__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///__new. # 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`。 diff --git a/docs/api.md b/docs/api.md index 86f3f1c..a2663f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`,单条失败继续下一条。 diff --git a/docs/current-state.md b/docs/current-state.md index 51bf54b..83777e8 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -19,7 +19,7 @@ - T-549 已完成:②标题提示词支持 `{旧标题}` 占位符;含占位符时替换当前任务旧标题且不重复追加旧标题块,不含时保持旧行为自动追加旧标题;②左侧新增「插入旧标题」按钮。 - 测试卫生修正:`WriteBackWorker` 的 GUI 单测不再使用相对路径 `db.sqlite`,改为临时目录内 SQLite,避免跑测试后在项目根目录重新生成空 `db.sqlite`。 - 技术栈:正式打包固定 Python 3.10(脚本调用 `py -3.10`),CI 使用 Windows + Python 3.11 自动跑 ruff lint、语法和单元/GUI 测试,根目录 `requirements.txt` 锁定运行依赖,`requirements-dev.txt` 锁定开发检查依赖(ruff),PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `data/config.json` + openpyxl + AI(默认 cmhub: `data/config.json` 网关配置 + `data/config/cmhub.json` Key;direct: `data/config/ai_models.json` 通用 HTTP 内部兼容),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 蝦皮 CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、蝦皮更新安全与执行模式默认值,`ai.backend` 默认 `cmhub`,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper、cmhub Base URL 规整与请求 URL 拼接 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,HTTP 404 映射为 `not_found` 并给出中文排障提示,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a/T-534 本地生成结果/更新状态重置函数(②生成结果可按标题/封面组件重置并保持 generated)、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新蝦皮、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录幂等复用已打开 Chrome、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/状态栏语义色 helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`(窗口标题显示「蝦皮圈優化助手」)、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、状态栏语义色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新蝦皮 Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/cmhub余额显示/点数不足弹窗/计费日志/重置生成结果与 `GenerateWorker`,且②重置支持多选/当前筛选结果并可只重置标题、只重置封面或重置全部;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置页 T-529 后默认展示 cmhub 网关 Base URL、API Key、动态生文/生图别名下拉、Base URL 网关根提示、刷新别名与测试连接/查余额,不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉;保存设置固定写 `ai.backend=cmhub`,保存/刷新前会把 Base URL 规整为网关根,允许先保存不完整 cmhub 配置,生成时再提示补齐;direct 模型配置、`AIModelTestWorker` 和 `config/ai_models.json` 仅作内部兼容/手工回滚;通用生成参数、Chrome 路径和端口、蝦皮更新安全与多账号并行设置继续持久化 `config.json`,数据路径字段在普通 UI 隐藏但保存时保留配置兼容,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「蝦皮更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 蝦皮 CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、蝦皮更新安全与执行模式默认值,`ai.backend` 默认 `cmhub`,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper、cmhub Base URL 规整与请求 URL 拼接 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,HTTP 404 映射为 `not_found` 并给出中文排障提示,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_mode` 选择只生成标题、只生成封面或生成标题和封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a/T-534 本地生成结果/更新状态重置函数(②生成结果可按标题/封面组件重置并保持 generated)、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新蝦皮、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录幂等复用已打开 Chrome、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/状态栏语义色 helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`(窗口标题显示「蝦皮圈優化助手」)、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、状态栏语义色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新蝦皮 Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成内容下拉/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/cmhub余额显示/点数不足弹窗/计费日志/重置生成结果与 `GenerateWorker`,且②重置支持多选/当前筛选结果并可只重置标题、只重置封面或重置全部;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置页 T-529 后默认展示 cmhub 网关 Base URL、API Key、动态生文/生图别名下拉、Base URL 网关根提示、刷新别名与测试连接/查余额,不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉;保存设置固定写 `ai.backend=cmhub`,保存/刷新前会把 Base URL 规整为网关根,允许先保存不完整 cmhub 配置,生成时再提示补齐;direct 模型配置、`AIModelTestWorker` 和 `config/ai_models.json` 仅作内部兼容/手工回滚;通用生成参数、Chrome 路径和端口、蝦皮更新安全与多账号并行设置继续持久化 `config.json`,数据路径字段在普通 UI 隐藏但保存时保留配置兼容,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「蝦皮更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest,T-525 后 CI 先运行 ruff 安全类 lint;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts(含 T-105b 启动登录复用已运行 Chrome)/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新蝦皮批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、蝦皮更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock、T-528 ②cmhub余额显示/计费日志/点数不足中止提示、T-529 默认 cmhub/隐藏 AI 后端选择/保存固定 cmhub/direct 兼容、T-530 cmhub Base URL 规整/404 not_found 明确提示、T-531 设置页未保存状态/离开确认/放弃还原、T-532 cmhub连接成功账号名提示、T-533 增量生成组件口径、T-534 ②按标题/封面组件重置与多选/筛选范围重置、T-535 cmhub标题请求读取等待固定600秒、T-536 GUI按钮全局基础样式与语义色按钮叠加样式、T-537 主窗口标题显示中文品牌、T-539 设置页隐藏数据路径字段并保留配置值、T-543 状态栏语义色与颜色重置、T-544 启动版本检查/强制升级阻断/失败放行、T-548 cmhub 图片下载 curl 后端参数安全/回退 requests/SSRF 顺序和配置归一、T-549 标题提示词 `{旧标题}` 占位符和插入按钮;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 - 数据:T-538 后默认用户数据根为 `data/`(整体 gitignore);旧布局的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`、`build/`、`dist/` 仍由 `.gitignore` 排除以支持迁移前安全;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `data/config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 @@ -54,19 +54,19 @@ | `.github/workflows/tests.yml` | 已有 | T-522/T-525 产出:push / pull_request 自动在 `windows-latest` + Python 3.11 安装 `requirements.txt` 与 `requirements-dev.txt`,设置 `QT_QPA_PLATFORM=offscreen`,运行 ruff lint、`python -m compileall app main.py` 与 `python -m unittest discover -s tests` | | `app/version.py` | 已有 | T-540 产出:统一维护 `APP_NAME`、`APP_CODE_NAME`、`APP_VERSION` 和窗口显示名;GUI 标题、打包脚本、release 目录、portable zip 和 `version.txt` 共用该版本源 | | `cmshopee.spec` / `scripts/build_exe.ps1` | 已有 | T-524/T-540 产出:PyInstaller onedir 构建配置与发布目录校验脚本;`cmshopee.spec` 不声明本地数据 `datas`,脚本固定使用 `py -3.10`,先检查 `dist\\cmshopee` 不含配置、DB、图片、日志、Chrome 登录态或提示词,再组装 `release\\cmshopee-\\` 与 `release\\cmshopee--portable.zip` | -| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-527/T-528/T-529/T-530/T-531/T-532/T-536/T-537/T-539/T-540/T-541/T-542/T-545/T-547/T-549 产出:PySide6 `QMainWindow` + 五 Tab;窗口标题显示「蝦皮圈優化助手 v」,启动时按屏幕可用区域限制尺寸、居中并保证标题栏可见;顶部 Tab 栏防误点样式;全局 `QPushButton` 基础样式统一 4px 圆角、hover/pressed/disabled/focus 状态;统一语义色板、①②③状态列前景色、③开始更新 warning 语义色、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除/未匹配按钮 danger 语义色样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、标题提示词「插入旧标题」按钮、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置普通默认展示 cmhub 网关 Base URL、API Key、生文/生图动态别名下拉、Base URL 网关根提示、刷新别名、测试连接/查余额并保存 `config/cmhub.json` Key,不再显示 AI 后端选择、direct 模型选择/详情/角色下拉;direct 配置仅内部兼容/手工回滚;通用生成参数里标题/图片并发可选 1..5,失败重试可选 0..10,分辨率只控制 cmhub 生图尺寸,返回超时标签展示实际等待口径「标题 600 秒 / 图片 650 秒」,direct 兼容路径仍显示分辨率映射;Chrome 路径和端口配置、三列 蝦皮更新安全与执行模式设置仍保留,数据路径输入框已隐藏但保留配置兼容,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态;②cmhub模式会显示生成后的剩余点数,记录 points_cost/call_id 计费日志,并在点数不足时弹提示且中止未开始任务;②cmhub 生图会额外记录“cmhub 已返回 image_url / 图片下载完成 / 本地保存完成”的分段耗时和下载/保存大小,开始日志会显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,便于区分服务端生成慢、下载慢或本地保存慢,下载超过 20 秒会显示“图片下载较慢”警告;设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1` 时,②本轮可见运行日志会额外显示脱敏后的 cmhub 图片 URL 调试行,该行不写入 SQLite `run_log_events`;⑤设置页已显示“● 未保存更改”,切 Tab/关闭窗口时拦截保存/放弃/取消,放弃会从 `config.json` 与 `config/cmhub.json` 重新回填,刷新别名/测试连接不自动保存并提醒点保存;cmhub 测试连接/查余额成功时会优先显示 cmhub 账号名或脱敏邮箱,缺账号信息时保留普通成功提示 | +| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-527/T-528/T-529/T-530/T-531/T-532/T-536/T-537/T-539/T-540/T-541/T-542/T-545/T-547/T-549/T-576 产出:PySide6 `QMainWindow` + 五 Tab;窗口标题显示「蝦皮圈優化助手 v」,启动时按屏幕可用区域限制尺寸、居中并保证标题栏可见;顶部 Tab 栏防误点样式;全局 `QPushButton` 基础样式统一 4px 圆角、hover/pressed/disabled/focus 状态;统一语义色板、①②③状态列前景色、③开始更新 warning 语义色、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除/未匹配按钮 danger 语义色样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、标题提示词「插入旧标题」按钮、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、「生成内容」下拉(只生成标题/只生成封面/生成标题和封面)、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏、任务列表、「更新内容」下拉(只更新标题/只更新封面/更新标题和封面)、缺失内容前置阻断、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置普通默认展示 cmhub 网关 Base URL、API Key、生文/生图动态别名下拉、Base URL 网关根提示、刷新别名、测试连接/查余额并保存 `config/cmhub.json` Key,不再显示 AI 后端选择、direct 模型选择/详情/角色下拉;direct 配置仅内部兼容/手工回滚;通用生成参数里标题/图片并发可选 1..5,失败重试可选 0..10,分辨率只控制 cmhub 生图尺寸,返回超时标签展示实际等待口径「标题 600 秒 / 图片 650 秒」,direct 兼容路径仍显示分辨率映射;Chrome 路径和端口配置、三列 蝦皮更新安全与执行模式设置仍保留,数据路径输入框已隐藏但保留配置兼容,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run`/`allow_cover_update` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成内容」下拉默认只生成标题并持久化到 `ai.generate_mode`,继续写回旧兼容 `ai.generate_cover`;②cmhub模式会显示生成后的剩余点数,记录 points_cost/call_id 计费日志,并在点数不足时弹提示且中止未开始任务;②cmhub 生图会额外记录“cmhub 已返回 image_url / 图片下载完成 / 本地保存完成”的分段耗时和下载/保存大小,开始日志会显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,便于区分服务端生成慢、下载慢或本地保存慢,下载超过 20 秒会显示“图片下载较慢”警告;设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1` 时,②本轮可见运行日志会额外显示脱敏后的 cmhub 图片 URL 调试行,该行不写入 SQLite `run_log_events`;⑤设置页已显示“● 未保存更改”,切 Tab/关闭窗口时拦截保存/放弃/取消,放弃会从 `config.json` 与 `config/cmhub.json` 重新回填,刷新别名/测试连接不自动保存并提醒点保存;cmhub 测试连接/查余额成功时会优先显示 cmhub 账号名或脱敏邮箱,缺账号信息时保留普通成功提示 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502/T-404b + T-404 补丁产出:登录状态检测、打开商品页、详情页加载失败 toast 捕获与商品失效归因、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | -| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526/T-527/T-538/T-547/T-548 产出:`data/config.json` 默认值、读写、更新、路径/端口/AI 参数读取、`data_dir` 路径解析、旧顶层用户数据迁移、`data/` 可写性检测、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`data/config/cmhub.json` Key helper、蝦皮更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;AI 标题/图片并发加载/保存时夹紧到 1..5,失败重试夹紧到 0..10;`ai.cmhub.download_with_curl` 归一为 auto/true/false;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | +| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526/T-527/T-538/T-547/T-548/T-576 产出:`data/config.json` 默认值、读写、更新、路径/端口/AI 参数读取、`data_dir` 路径解析、旧顶层用户数据迁移、`data/` 可写性检测、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`data/config/cmhub.json` Key helper、蝦皮更新安全与 dry-run/多账号并行默认值,②生成内容模式 `ai.generate_mode`(title/cover/title_cover)并兼容旧 `ai.generate_cover`,③更新内容模式 `shopee_update.update_mode` 并兼容旧 `allow_cover_update`;AI 标题/图片并发加载/保存时夹紧到 1..5,失败重试夹紧到 0..10;`ai.cmhub.download_with_curl` 归一为 auto/true/false;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁 + T-505 产出:本地 `data/logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | -| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526/T-528/T-533/T-535/T-545/T-548/T-549 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、标题提示词 `{旧标题}` 占位符共用组装、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现、`fetch_cmhub_balance()` 余额查询和 metadata 事件;cmhub 标题请求连接超时取 `ai.cmhub.connect_timeout`、读取等待固定 600 秒;cmhub 生图请求和图片下载读取等待固定 650 秒;cmhub 生图实际请求并发 = `min(ai.image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载并发同样最大 5;图片下载默认在 Windows 检测到系统 curl 时优先用 curl,URL 通过临时配置文件传入且 `use_system_proxy=false` 时加 `--noproxy "*"`,非 Windows/无 curl/curl 失败回退 requests;下载失败最多安全重试 3 次,仍失败只记该任务失败、不重发 cmhub 生图请求;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,开启封面时按缺失组件增量补齐,已有标题不重生、不覆盖手动标题,已有封面不重生;进度回调按标题/封面组件统计,逐条落库、失败标记、事件/错误回调、停止取消未开始项 | +| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526/T-528/T-533/T-535/T-545/T-548/T-549/T-576 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、标题提示词 `{旧标题}` 占位符共用组装、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现、`fetch_cmhub_balance()` 余额查询和 metadata 事件;cmhub 标题请求连接超时取 `ai.cmhub.connect_timeout`、读取等待固定 600 秒;cmhub 生图请求和图片下载读取等待固定 650 秒;cmhub 生图实际请求并发 = `min(ai.image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载并发同样最大 5;图片下载默认在 Windows 检测到系统 curl 时优先用 curl,URL 通过临时配置文件传入且 `use_system_proxy=false` 时加 `--noproxy "*"`,非 Windows/无 curl/curl 失败回退 requests;下载失败最多安全重试 3 次,仍失败只记该任务失败、不重发 cmhub 生图请求;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成按 `ai.generate_mode` 支持只生成标题、只生成封面、生成标题和封面,继续兼容旧 `ai.generate_cover`,并按缺失组件增量补齐,已有标题不重生、不覆盖手动标题,已有封面不重生;进度回调按标题/封面组件统计,逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-206/T-404a/T-504/T-509/T-534 产出:batches/accounts/tasks schema;batches 软删除字段与默认业务查询过滤;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置,②生成结果支持按标题/封面组件清空并保持 generated;`update_generated_title()` 本地新标题微调;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/image_paths.py` | 已有 | 本地图片路径 helper:T-538 后新采集旧封面和新生成封面默认写入 `data/images///__old/new.jpg`;历史 DB 已存路径继续按原路径读取 | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-523/T-523a/T-404b/T-524/T-526/T-527/T-528/T-538/T-540/T-541/T-542 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers/packaging,含 `data_dir` 路径解析、旧布局迁移、可写性检测、版本源、GUI 标题版本、打包 release 组装脚本校验和主窗口小屏适配 | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-523/T-523a/T-404b/T-524/T-526/T-527/T-528/T-538/T-540/T-541/T-542/T-576 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers/packaging,含 `data_dir` 路径解析、旧布局迁移、可写性检测、版本源、GUI 标题版本、打包 release 组装脚本校验、主窗口小屏适配、②生成内容模式、③更新内容模式和缺失内容阻断 | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `data/` | 本地存在或按需生成,已忽略 | T-538 后统一用户数据根,包含 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt` 等配置、密钥、业务、登录态、图片、本地诊断日志和用户提示词;打包更新时保留,不提交版本库 | @@ -86,7 +86,7 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-105b(④启动登录复用已打开 Chrome)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ 蝦皮更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新蝦皮生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)、T-527(⑤设置 cmhub 网关面板)、T-528(②计费错误提示 + 余额展示)、T-529(默认 cmhub 网关并隐藏 AI 后端选择)、T-530(cmhub Base URL 规整 + 404 明确提示)、T-531(⑤设置未保存状态追踪 + 离开确认)、T-532(⑤cmhub连接成功提示显示账号名)。 -- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)、T-525(引入 ruff lint/format 工程检查)、T-545(②cmhub 生图和下载并发上限 5 + 独立下载线程池)、T-547(⑤并发/重试上限收紧)、T-548(cmhub 图片下载优先系统 curl 并安全回退 requests)、T-549(②标题提示词支持 `{旧标题}` 占位符)。 +- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)、T-525(引入 ruff lint/format 工程检查)、T-545(②cmhub 生图和下载并发上限 5 + 独立下载线程池)、T-547(⑤并发/重试上限收紧)、T-548(cmhub 图片下载优先系统 curl 并安全回退 requests)、T-549(②标题提示词支持 `{旧标题}` 占位符)、T-576(②生成内容下拉 + ③更新内容下拉 + 缺失内容前置阻断)。 - 下一个可领取任务:暂无正式 TODO;后续可从 Backlog 选择新任务或重新打包验证生产版。 ## 当前已知限制 @@ -109,13 +109,14 @@ - T-517 已完成:⑤设置页将「蝦皮更新安全 / 执行模式」前置为独立分区,将「基础设施(路径与端口)」后置;`test_item_id`、`dry_run` 不再有用户可操作控件,保存设置仍保留 `test_item_id` 兼容字段并固定 `dry_run=false`,③「检查本轮更新」语义不变。 - T-518 已完成:②AI生成左栏保留模板选择、编辑内容、新建/保存等高频入口,把封面模板「另存为 / 重命名 / 删除」收敛进②本页「模板操作」菜单;动作仍调用原有保存/重命名/删除方法,删除二次确认、错误处理和 prompts 文件结构不变,不涉及 AI/Shopee/CDP。 - T-519 已完成:②AI生成底部增加标题/图片两条独立进度条,现有「AI生成运行日志」改为用户可读、自动滚动、脱敏的长任务日志;日志显示本轮开始、标题/图片开始与成功、商品ID/店铺、调用失败重试、失败、停止请求和完成汇总,不显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt,不改变 AI/DB/Excel/Shopee/CDP 核心流程。 -- T-520 已完成:②AI生成页增加「生成封面图片(成本较高)」开关,默认关闭以避免无意产生图片模型成本;关闭时标题生成成功即可写库进入 generated,`new_cover_path=NULL`,③只更新标题且不受「允许更新封面」阻断;开启时保持标题后图片两段生成流程。2026-07-06 运行修正:②AI生成页会把失败按阶段显示为“采集失败 / 生成失败 / 更新失败”;「开始生成」只处理已采集待生成或生成失败可重试任务,采集失败会提示先回到①完成旧数据采集,更新失败不会被②误重试。未改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程。 +- T-520 已完成,T-576 已升级入口:②AI生成页原「生成封面图片(成本较高)」开关已改为「生成内容」下拉,默认只生成标题以避免无意产生图片模型成本;也可只生成封面或生成标题和封面。只生成标题时写库进入 generated 且 `new_cover_path=NULL`;只生成封面要求已有 `new_title`,不调用生文、不覆盖标题。2026-07-06 运行修正:②AI生成页会把失败按阶段显示为“采集失败 / 生成失败 / 更新失败”;「开始生成」只处理已采集待生成或生成失败可重试任务,采集失败会提示先回到①完成旧数据采集,更新失败不会被②误重试。未改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程。 - T-528 已完成:②AI生成页在 cmhub backend 下显示生成后的剩余点数;GenerateWorker 读取计费 metadata,把 points_cost、points_balance、call_id 写入脱敏运行日志;遇到 CMHubError.code=insufficient_points 时弹出「点数不足,请先充值」并中止本轮未开始任务,不靠中文错误字符串匹配。未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-529 已完成:普通默认 `ai.backend=cmhub`;⑤设置页不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉,直接展示 cmhub 网关配置;保存设置固定写 `ai.backend=cmhub` 且允许先保存不完整 cmhub 配置,②生成时仍由 `app/ai.py` 提示补齐 Base URL/API Key/别名。direct 代码和 `data/config/ai_models.json` 保留为内部兼容/手工回滚,不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-530 已完成:`appconfig.normalize_cmhub_base_url()` / `cmhub_request_url()` 会把 cmhub Base URL 规整到 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;⑤设置页输入框提示只填网关根,保存/刷新前同步规整;cmhub HTTP 404 统一映射为 `CMHubError(code="not_found")`,显示“cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models”。未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-531 已完成:⑤设置页增加未保存状态追踪与离开确认。用户修改设置后显示“● 未保存更改”;`save_app_settings()` 成功写入 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty;切换 Tab/关闭窗口时提供保存、放弃、取消三选一,且使用自定义中文按钮避免系统默认英文按钮;放弃会重新从 `data/config.json` 与 `data/config/cmhub.json` 回填控件;程序化回填和刷新别名写入下拉不置脏;`currentChanged` 回退使用 guard 防递归;刷新别名/测试连接成功只提醒保存,不自动落盘。 - T-532 已完成:⑤设置页 cmhub 测试连接/查余额成功后,会从 `/balance` 返回的 `account.display_name/account.username/user` 以及 name/account_name/email/id 等字段提取 cmhub 账号身份,并显示 `cmhub 账号「<账号名>」连接成功:...`;当前接口结构 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 会优先显示 `主账号`;邮箱只显示脱敏形式(如 `o***r@example.com`),无账号信息时保留 `cmhub 连接成功:...` 兜底。`sanitize_for_log()` 同步脱敏 email 字段,避免 run log summary 保存完整邮箱;未改 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成或 Shopee/CDP 流程。 -- T-533 已完成:②AI生成已从整条任务生成改为按缺失组件增量补齐。标题已存在但封面缺失、且本轮开启“生成封面图片(成本较高)”时,只补封面,不再调用生文、不覆盖手动标题;组件全齐时不纳入本轮生成;标题/图片进度分别按 `title_total` / `cover_total` 统计。未改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 +- T-533 已完成,T-576 已升级入口:②AI生成已从整条任务生成改为按缺失组件增量补齐。标题已存在但封面缺失、且本轮「生成内容」选择只生成封面或生成标题和封面时,只补封面,不再调用生文、不覆盖手动标题;组件全齐时不纳入本轮生成;标题/图片进度分别按 `title_total` / `cover_total` 统计。未改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 +- T-576 已完成:③更新蝦皮左下角新增「更新内容」下拉,支持只更新标题、只更新封面、更新标题和封面;⑤普通设置页不再展示「允许更新封面」,旧 `allow_cover_update` 仅兼容写回。点击「开始更新」或「检查本轮更新」前先按当前模式校验缺失的新标题/新封面,缺失时弹「更新内容未生成」并阻断,不启动 `ApplyWorker`、不打开 Chrome、不写失败状态;真实提交仍需要⑤「允许真实提交线上商品」和③最终确认弹窗。 - T-535 已完成:cmhub 生文 `title_request` 的读取等待固定为 600 秒,不再跟随当前分辨率的 `resolution_timeouts`;连接超时仍取 `ai.cmhub.connect_timeout`,重试次数仍取 `ai.retry`。2026-07-07 补充:cmhub 生图请求和图片下载读取等待统一固定为 650 秒;⑤设置页「返回超时」标签在 cmhub 模式下固定展示「标题 600 秒 / 图片 650 秒」,分辨率只控制生图尺寸;direct 兼容路径不变。 - T-536 已完成:`MainWindow` 全局应用 `BUTTON_BASE_STYLE`,所有 `QPushButton` 统一 4px 圆角、基础边框、hover/pressed/disabled/focus 状态;③「开始更新」、①「删除批次」、④「删除账号」和①「未匹配」按钮只叠加 warning/danger 语义颜色,不再各自硬写圆角或内边距。未改任何按钮行为、启用/禁用逻辑、DB、Excel、Shopee/CDP。 - T-537 已完成:产品显示名定为「蝦皮圈優化助手」;主窗口标题、PySide6 缺失启动提示、根 README、入口/导航/愿景文档和 UI 线框图显示中文品牌;`cmshopee` 继续作为仓库、包名、exe、数据库、日志、配置和兼容文件名等技术代号保留。 diff --git a/docs/routes.md b/docs/routes.md index 0bb167a..52825e9 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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` 使用 | diff --git a/docs/tasks/T-576.md b/docs/tasks/T-576.md new file mode 100644 index 0000000..76b7f60 --- /dev/null +++ b/docs/tasks/T-576.md @@ -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`。 diff --git a/progress.md b/progress.md index b48a7a5..1ba6ab9 100644 --- a/progress.md +++ b/progress.md @@ -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` 通过。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 7a78309..be267d3 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -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"]) diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 0233200..7126ddd 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -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): diff --git a/tests/test_gui.py b/tests/test_gui.py index bdc7860..6c09769 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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 {