From 483bec427c697f5530c2dd8d1270ff4288d4e8f0 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 1 Jul 2026 08:35:46 +0800 Subject: [PATCH] fix: stabilize Shopee cover replacement --- app/ai.py | 15 +- app/editor.py | 176 +++++++++++++--------- app/gui.py | 281 +++++++++++++++++++++-------------- app/image_paths.py | 48 ++++++ docs/00-ai-start-here.md | 2 +- docs/02-requirements.md | 19 +-- docs/04-architecture.md | 48 +++--- docs/05-coding-rules.md | 4 +- docs/06-tasks.md | 12 +- docs/api.md | 52 +++---- docs/current-state.md | 33 ++-- docs/html/有x张重复图片.html | 9 ++ docs/routes.md | 51 ++++--- progress.md | 84 +++++++++++ tests/test_ai.py | 13 +- tests/test_editor_login.py | 119 ++++++++++++++- tests/test_gui.py | 108 ++++++++++---- tests/test_image_paths.py | 38 +++++ 18 files changed, 782 insertions(+), 330 deletions(-) create mode 100644 app/image_paths.py create mode 100644 docs/html/有x张重复图片.html create mode 100644 tests/test_image_paths.py diff --git a/app/ai.py b/app/ai.py index c14cb89..e3ba634 100644 --- a/app/ai.py +++ b/app/ai.py @@ -12,9 +12,9 @@ import urllib.error import urllib.request import uuid -from . import appconfig, db, diagnostics +from . import appconfig, db, diagnostics, image_paths from . import prompts as prompt_module -from .config import make_slug + class AIError(RuntimeError): @@ -390,16 +390,7 @@ def _account_name(task, account_by_alias): def _new_cover_path(task, account_by_alias, image_root): alias = str(getattr(task, "alias", "") or "").strip() account = account_by_alias.get(alias) - slug = getattr(account, "slug", None) if account is not None else None - if not slug: - slug = make_slug(alias or getattr(task, "account_name", "") or "unknown") - return os.path.abspath( - os.path.join( - image_root, - slug, - "%s_new.jpg" % getattr(task, "item_id", ""), - ) - ) + return image_paths.task_image_path(image_root, task, account, "new") def _mark_generate_failed(task, exc, db_path, on_task_update): diff --git a/app/editor.py b/app/editor.py index cf68498..c600e21 100644 --- a/app/editor.py +++ b/app/editor.py @@ -5,6 +5,7 @@ import os import time from urllib.parse import urlparse +from . import image_paths from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get @@ -55,8 +56,8 @@ JS_UPLOAD_STATE = ( "var upload=manager?manager.querySelector('.shopee-image-manager__upload input[type=file]'):null;" "var uploadBox=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;" "var busy=manager?[].slice.call(manager.querySelectorAll('[class*=loading],[class*=Loading],[class*=spinner],[class*=Spinner],[class*=progress],[class*=Progress],[class*=uploading],[class*=Uploading]')).filter(visible):[];" - "var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|error|fail|invalid|unsupported)/i;" - "var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过)/i;" + "var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|重複|重复|duplicate|error|fail|invalid|unsupported)/i;" + "var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过|重複|重复|duplicate)/i;" "var texts=manager?[].slice.call(manager.querySelectorAll('*')).filter(visible).map(text).filter(Boolean):[];" "var errors=texts.filter(function(t){return errRe.test(t);}).slice(0,8);" "var roots=[].slice.call(document.querySelectorAll('.eds-modal__content,.eds-modal__box,[role=dialog]')).filter(visible);" @@ -84,6 +85,19 @@ JS_UPLOAD_STATE = ( "});})()" ) +JS_CLICK_UPLOAD_TILE = ( + "(function(){" + "function visible(e){if(!e)return false;var r=e.getBoundingClientRect();var s=getComputedStyle(e);" + "return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}" + "var manager=document.querySelector('.shopee-image-manager');" + "var box=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;" + "if(!box)return JSON.stringify({clicked:false,reason:'NO_UPLOAD_TILE'});" + "if(!visible(box))return JSON.stringify({clicked:false,reason:'UPLOAD_TILE_HIDDEN'});" + "box.scrollIntoView({block:'center',inline:'center'});" + "box.click();" + "return JSON.stringify({clicked:true,reason:null});" + "})()" +) JS_TITLE_STATE = ( "(function(){" f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," @@ -317,6 +331,14 @@ def _upload_state(cdp): return _json_value(cdp, JS_UPLOAD_STATE, default={}) or {} +def _click_upload_tile(cdp): + return _json_value( + cdp, + JS_CLICK_UPLOAD_TILE, + default={"clicked": False, "reason": "NO_UPLOAD_TILE"}, + ) or {"clicked": False, "reason": "NO_UPLOAD_TILE"} + + def _post_update_state(cdp): return _json_value(cdp, JS_POST_UPDATE_STATE, default=None) or {} @@ -431,9 +453,24 @@ def _wait_image_manager_stable( } +def _has_duplicate_upload_error(state): + texts = [] + for key in ("errors", "error_toasts", "toasts", "page_error_toasts"): + values = (state or {}).get(key) or [] + texts.extend(str(value) for value in values) + return any( + token in text.lower() + for text in texts + for token in ("重複", "重复", "duplicate") + ) + + def _cover_upload_error_message(result): reason = result.get("reason") or "COVER_UPDATE_FAILED" state = result.get("upload_state") or {} + if reason == "UPLOAD_DUPLICATE_IMAGE": + details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or state.get("page_error_toasts") or [] + return "新封面与现有商品图片重复:" + ";".join(map(str, details[:3])) if details else "新封面与现有商品图片重复" if reason == "UPLOAD_PAGE_ERROR": details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or [] return "新封面上传失败:" + ";".join(map(str, details[:3])) if details else "新封面上传失败" @@ -443,6 +480,8 @@ def _cover_upload_error_message(result): return "新封面上传仍在处理中,未取得 Shopee CDN 地址" if reason == "UPLOAD_TIMEOUT": return "新封面上传超时,未取得 Shopee CDN 地址" + if reason == "UPLOAD_TILE_NOT_READY": + return "新封面上传入口未可点击,未开始上传新封面" if reason == "IMAGE_MANAGER_BUSY": return "商品图片区域仍在加载,未开始上传新封面" if reason == "IMAGE_COUNT_NOT_READY": @@ -634,7 +673,7 @@ def collect(account, task, on_step=None) -> dict: old_cover_src = read_cover_src(cdp) out_path = _get(task, "old_cover_path") if not out_path: - out_path = os.path.join(_image_root(account), f"{item_id}_old.jpg") + out_path = image_paths.task_image_path("images", task, account, "old") _notify_collect_step(on_step, "download_cover") old_cover_path = download_cover(old_cover_src, out_path) return { @@ -694,8 +733,8 @@ def change_title(cdp, new_title) -> dict: def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict: """Upload an image and drag it to the first position. - When the image manager is full, only delete the current first image if the - old cover backup from the collect stage exists locally. + Cover replacement always deletes the current first Shopee image first, and + only proceeds when the old cover backup from the collect stage exists. """ image_win_path = os.path.abspath(str(image_win_path)) @@ -726,61 +765,52 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict before = stable.get("rects") or _image_rects(cdp) count_before = len(before) - delete_result = None - if len(before) >= 9: - backup_path = _validated_old_cover_backup(old_cover_path) - if not backup_path: - return { - "ok": False, - "reason": "OLD_COVER_BACKUP_MISSING", - "count_before": count_before, - "old_cover_path": old_cover_path, - } - delete_result = _delete_first_cover(cdp, before) - if not delete_result.get("ok"): - return { - "ok": False, - "reason": delete_result.get("reason"), - "count_before": count_before, - "delete": delete_result, - } - stable = _wait_image_manager_stable( - cdp, - expected_count=delete_result.get("count_after"), - timeout=30, - settle_seconds=2.0, - require_upload_input=True, - ) - if not stable.get("ok"): - return { - "ok": False, - "reason": stable.get("reason"), - "count_before": count_before, - "count_after": stable.get("count_after"), - "delete": delete_result, - "upload_state": stable.get("upload_state"), - "stable": stable, - } - before = stable.get("rects") or _image_rects(cdp) - else: - stable = _wait_image_manager_stable( - cdp, - expected_count=len(before), - timeout=20, - settle_seconds=1.0, - require_upload_input=True, - ) - if not stable.get("ok"): - return { - "ok": False, - "reason": stable.get("reason"), - "count_before": count_before, - "count_after": stable.get("count_after"), - "upload_state": stable.get("upload_state"), - "stable": stable, - } - before = stable.get("rects") or _image_rects(cdp) + backup_path = _validated_old_cover_backup(old_cover_path) + if not backup_path: + return { + "ok": False, + "reason": "OLD_COVER_BACKUP_MISSING", + "count_before": count_before, + "old_cover_path": old_cover_path, + } + delete_result = _delete_first_cover(cdp, before) + if not delete_result.get("ok"): + return { + "ok": False, + "reason": delete_result.get("reason"), + "count_before": count_before, + "delete": delete_result, + } + stable = _wait_image_manager_stable( + cdp, + expected_count=delete_result.get("count_after"), + timeout=30, + settle_seconds=2.0, + require_upload_input=True, + ) + if not stable.get("ok"): + return { + "ok": False, + "reason": stable.get("reason"), + "count_before": count_before, + "count_after": stable.get("count_after"), + "delete": delete_result, + "upload_state": stable.get("upload_state"), + "stable": stable, + } + before = stable.get("rects") or _image_rects(cdp) before_srcs = {r.get("src") for r in before} + upload_click = _click_upload_tile(cdp) + if not upload_click.get("clicked"): + return { + "ok": False, + "reason": "UPLOAD_TILE_NOT_READY", + "count_before": count_before, + "delete": delete_result, + "upload_click": upload_click, + "upload_state": _upload_state(cdp), + } + time.sleep(1) oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')") if not oid: return { @@ -788,6 +818,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result, + "upload_click": upload_click, "upload_state": _upload_state(cdp), } @@ -807,6 +838,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "reason": "UPLOAD_INPUT_NOT_READY", "count_before": count_before, "delete": delete_result, + "upload_click": upload_click, "upload_state": last_state, } blob_seen = bool(last_state.get("blob_count")) @@ -816,6 +848,18 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict cur = _image_rects(cdp) last_state = _upload_state(cdp) blob_seen = blob_seen or any(str(r.get("src") or "").startswith("blob:") for r in cur) + if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"): + reason = "UPLOAD_DUPLICATE_IMAGE" if _has_duplicate_upload_error(last_state) else "UPLOAD_PAGE_ERROR" + return { + "ok": False, + "reason": reason, + "count_before": count_before, + "count_after": len(cur), + "delete": delete_result, + "upload_click": upload_click, + "upload_state": last_state, + "file_size": file_size, + } ready = [ r for r in cur if r.get("src") not in before_srcs @@ -826,16 +870,6 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict if ready and (len(cur) > len(before) or len(ready) == 1): new_src = ready[-1]["src"] break - if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"): - return { - "ok": False, - "reason": "UPLOAD_PAGE_ERROR", - "count_before": count_before, - "count_after": len(cur), - "delete": delete_result, - "upload_state": last_state, - "file_size": file_size, - } if last_state.get("crop_modal"): return { "ok": False, @@ -843,6 +877,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "count_before": count_before, "count_after": len(cur), "delete": delete_result, + "upload_click": upload_click, "upload_state": last_state, "file_size": file_size, } @@ -856,6 +891,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "count_before": count_before, "count_after": len(cur), "delete": delete_result, + "upload_click": upload_click, "upload_state": last_state, "blob_seen": blob_seen, "file_size": file_size, @@ -881,6 +917,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "count_before": count_before, "count_after": len(after), "delete": delete_result, + "upload_click": upload_click, } @@ -1069,6 +1106,7 @@ def _close_applied_product(cdp, close_success_tab=False): finally: if close_success_tab and created_by_app and target_id: try: + time.sleep(2) close_tab(target_id, host=host) except Exception: pass diff --git a/app/gui.py b/app/gui.py index 61ae937..f1a2988 100644 --- a/app/gui.py +++ b/app/gui.py @@ -85,7 +85,7 @@ QTabBar::tab:hover:!selected { if QT_IMPORT_ERROR is None: - from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, prompts + from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts from . import config as account_config @@ -1157,7 +1157,7 @@ if QT_IMPORT_ERROR is None: filter_layout.addWidget(self.refresh_button) self.summary_label = QLabel("任务 0 条") - self.risk_label = QLabel("点击「开始更新」后会先确认当前筛选范围;确认后才允许后续任务提交线上。") + self.risk_label = QLabel("可先点击「预览本轮更新」检查当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。") self.task_table = QTableView() self.model = ApplyTaskTableModel(self.task_table) self.task_table.setModel(self.model) @@ -1172,6 +1172,8 @@ if QT_IMPORT_ERROR is None: self.run_log_view.setMaximumHeight(128) self.run_log_view.setPlaceholderText("运行日志") + self.preview_update_button = QPushButton("预览本轮更新") + self.preview_update_button.setObjectName("previewUpdateButton") self.start_update_button = QPushButton("开始更新") self.stop_update_button = QPushButton("停止") self.reset_update_button = QPushButton("重置更新状态") @@ -1181,6 +1183,7 @@ if QT_IMPORT_ERROR is None: self.write_back_button.setEnabled(False) action_layout = QHBoxLayout() + action_layout.addWidget(self.preview_update_button) action_layout.addWidget(self.start_update_button) action_layout.addWidget(self.stop_update_button) action_layout.addWidget(self.reset_update_button) @@ -1202,6 +1205,7 @@ if QT_IMPORT_ERROR is None: self.item_filter.textChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) 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.stop_update_button.clicked.connect(self.stop_update) self.reset_update_button.clicked.connect(self.reset_apply_status) @@ -1249,6 +1253,12 @@ if QT_IMPORT_ERROR is None: self._update_write_back_button() def start_update(self, checked=False): + self._start_update(dry_run=False) + + def preview_update(self, checked=False): + self._start_update(dry_run=True) + + def _start_update(self, dry_run=False): if self.apply_thread is not None: self._set_status("更新正在进行...") return @@ -1260,7 +1270,7 @@ if QT_IMPORT_ERROR is None: self._set_status("当前筛选结果没有可更新任务") return update_cfg = self._shopee_update_config() - dry_run = bool(update_cfg.get("dry_run", False)) + dry_run = bool(dry_run) safety_error = self._update_safety_error(tasks, dry_run=dry_run) if safety_error: QMessageBox.warning(self, "更新安全开关", safety_error) @@ -1268,14 +1278,15 @@ if QT_IMPORT_ERROR is None: return answer = QMessageBox.question( self, - "确认开始更新", - self._confirmation_message(tasks), + "确认预览本轮更新" if dry_run else "确认开始更新", + self._confirmation_message(tasks, dry_run=dry_run), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: - self._set_status("已取消开始更新") + self._set_status("已取消预览本轮更新" if dry_run else "已取消开始更新") return + batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) worker = ApplyWorker( tasks, db_path=self.db_path, @@ -1287,6 +1298,7 @@ if QT_IMPORT_ERROR is None: 1, int(update_cfg.get("max_parallel_accounts", 1) or 1), ), + batch_size=batch_size, ) worker.progress.connect(self._on_apply_progress) worker.row_updated.connect(self._on_apply_row_updated) @@ -1301,9 +1313,9 @@ if QT_IMPORT_ERROR is None: self._set_apply_running(True) self.run_log_view.clear() if dry_run: - self._set_status(f"开始 dry-run 预览:{len(tasks)} 条") + self._set_status(f"开始预览本轮更新:{len(tasks)} 条") else: - self._set_status(f"开始更新:{len(tasks)} 条") + self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行") thread.start() def stop_update(self, checked=False): @@ -1466,34 +1478,39 @@ if QT_IMPORT_ERROR is None: return task.status == "skipped" return True - def _confirmation_message(self, tasks): + def _confirmation_message(self, tasks, dry_run=False): update_cfg = self._shopee_update_config() cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许" close_text = "是" if update_cfg.get("close_success_tab") else "否" - dry_run_text = "开启" if update_cfg.get("dry_run") 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 parallel_text = ( f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号" if update_cfg.get("parallel_accounts") else "关闭" ) + intro = ( + "即将预览当前筛选结果。\n\n" + if dry_run + else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n" + ) return ( - "即将按当前筛选结果开始更新 Shopee 线上商品。\n\n" - f"批次:{self._batch_filter_label()}\n" - f"店铺:{self._shop_filter_label()}\n" - f"商品ID:{self._item_filter_label()}\n" - f"状态:{self._status_label()}\n" - f"任务数:{len(tasks)}\n\n" - "安全设置:" - f"测试商品ID={update_cfg.get('test_item_id') or '未配置'}," - f"封面更新={cover_text}," - f"最大条数={update_cfg.get('max_items_per_run', 1)}," - f"成功后关闭新页={close_text}," - f"dry-run={dry_run_text}," - f"多账号并行={parallel_text}\n\n" + intro + + f"批次:{self._batch_filter_label()}\n" + + f"店铺:{self._shop_filter_label()}\n" + + f"商品ID:{self._item_filter_label()}\n" + + f"状态:{self._status_label()}\n" + + f"任务数:{len(tasks)}\n" + + f"预计批次:{batch_count}\n\n" + + "安全设置:" + + f"封面更新={cover_text}," + + f"每批最大更新条数={batch_size}," + + f"成功后关闭新页={close_text}," + + f"多账号并行={parallel_text}\n\n" + ( - "dry-run 开启时只写运行日志和预览,不打开 Shopee、不点击「更新」、不改任务状态。" - if update_cfg.get("dry_run") - else "确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。" + "预览只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。" + if dry_run + else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。" ) ) @@ -1503,20 +1520,6 @@ if QT_IMPORT_ERROR is None: return None if not update_cfg.get("allow_real_submit", False): return "设置未开启「允许真实提交线上商品」,已阻止本次更新。" - max_items = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) - if len(tasks) > max_items: - return f"当前筛选结果有 {len(tasks)} 条,超过单次最大更新条数 {max_items}。" - test_item_id = str(update_cfg.get("test_item_id", "")).strip() - if not test_item_id: - return "未配置测试商品ID,已阻止真实提交。" - mismatched = [ - str(getattr(task, "item_id", "")) - for task in tasks - if str(getattr(task, "item_id", "")) != test_item_id - ] - if mismatched: - shown = "、".join(mismatched[:5]) - return f"当前任务包含非测试商品ID:{shown}。只允许更新测试商品 {test_item_id}。" if not update_cfg.get("allow_cover_update", False): cover_tasks = [ str(getattr(task, "item_id", "")) @@ -1537,6 +1540,7 @@ if QT_IMPORT_ERROR is None: return merged def _set_apply_running(self, running): + self.preview_update_button.setEnabled(not running) self.start_update_button.setEnabled(not running) self.stop_update_button.setEnabled(running) self.reset_update_button.setEnabled(not running) @@ -1548,6 +1552,7 @@ if QT_IMPORT_ERROR is None: self._update_write_back_button() def _set_result_write_back_running(self, running): + self.preview_update_button.setEnabled(not running) self.start_update_button.setEnabled(not running) self.reset_update_button.setEnabled(not running) self.refresh_button.setEnabled(not running) @@ -1588,7 +1593,7 @@ if QT_IMPORT_ERROR is None: self._show_apply_blocked(payload) return self.last_apply_summary = dict(payload) - prefix = "dry-run 预览完成:" if payload.get("dry_run") else "更新完成:" + prefix = "预览本轮更新完成:" if payload.get("dry_run") else "更新完成:" message = prefix + self._apply_progress_text(payload) batch_ids = payload.get("batch_ids") or self._active_batch_ids() if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids: @@ -1665,6 +1670,7 @@ if QT_IMPORT_ERROR is None: def _show_account_guide(self, message): full_message = ( f"{message}\n\n" + "本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n" "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" ) QMessageBox.warning(self, "账号未就绪", full_message) @@ -1775,14 +1781,14 @@ if QT_IMPORT_ERROR is None: def _show_apply_summary(self, apply_summary, write_back_payload=None): QMessageBox.information( self, - "dry-run 预览完成" if apply_summary.get("dry_run") else "更新完成", + "预览本轮更新完成" if apply_summary.get("dry_run") else "更新完成", self._apply_summary_message(apply_summary, write_back_payload), ) def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None): dry_run = bool(apply_summary.get("dry_run")) lines = [ - "dry-run 预览完成,未打开 Shopee、未提交线上、未改任务状态。" + "预览本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。" if dry_run else "更新完成。", "{success_label}:{applied},失败:{failed},略过:{skipped}".format( @@ -1933,6 +1939,7 @@ if QT_IMPORT_ERROR is None: def _show_account_guide(self, message): full_message = ( f"{message}\n\n" + "本轮采集已中止,不会自动打开账号 Chrome。\n" "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" ) QMessageBox.warning(self, "账号未就绪", full_message) @@ -2577,6 +2584,7 @@ if QT_IMPORT_ERROR is None: dry_run=False, parallel_accounts=False, max_parallel_accounts=1, + batch_size=None, ): super().__init__() self.tasks = list(tasks) @@ -2587,6 +2595,9 @@ if QT_IMPORT_ERROR is None: self.dry_run = bool(dry_run) 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)) + self._current_batch_size = None + self._batch_count = 0 self._progress_lock = threading.Lock() self._run_id = None @@ -2600,21 +2611,23 @@ if QT_IMPORT_ERROR is None: eligible = [task for task in self.tasks if self._is_actionable_task(task)] batch_ids = self._batch_ids(eligible) total = len(eligible) - applied = 0 - skipped = 0 - failed = 0 - done = 0 + batch_size = self._effective_batch_size(total) + batches = self._task_batches(eligible, batch_size) + self._current_batch_size = batch_size + self._batch_count = len(batches) counters = { - "done": done, - "applied": applied, - "skipped": skipped, - "failed": failed, + "done": 0, + "applied": 0, + "skipped": 0, + "failed": 0, } self._run_id = self._create_run_log(eligible, batch_ids) self._log_run_event( - "运行开始:{mode},任务{total},{parallel}".format( - mode="dry-run 预览" if self.dry_run else "真实更新", + "运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format( + mode="预览本轮更新" if self.dry_run else "真实更新", total=total, + batch_size=batch_size, + batch_count=len(batches), parallel=( f"多账号并行最多{self.max_parallel_accounts}" if self.parallel_accounts @@ -2637,20 +2650,24 @@ if QT_IMPORT_ERROR is None: self._finish_run_log("blocked", summary) return summary - if self.dry_run: - for task in eligible: - if self.should_cancel(): - break - outcome = self._preview_task(task, account_by_alias) - self._record_outcome(counters, total, outcome) - elif self.parallel_accounts and self.max_parallel_accounts > 1: - self._run_parallel_by_account(eligible, account_by_alias, counters, total) - else: - for task in eligible: - if self.should_cancel(): - break - outcome = self._apply_one_task(task, account_by_alias) - self._record_outcome(counters, total, outcome) + for batch_index, batch_tasks in enumerate(batches, start=1): + if self.should_cancel(): + break + self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total) + if self.dry_run: + for task in batch_tasks: + if self.should_cancel(): + break + outcome = self._preview_task(task, account_by_alias) + self._record_outcome(counters, total, outcome) + elif self.parallel_accounts and self.max_parallel_accounts > 1: + self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total) + else: + for task in batch_tasks: + if self.should_cancel(): + break + outcome = self._apply_one_task(task, account_by_alias) + self._record_outcome(counters, total, outcome) summary = self._summary( ok=counters["failed"] == 0, @@ -2729,6 +2746,27 @@ if QT_IMPORT_ERROR is None: ) return duplicates + def _effective_batch_size(self, total): + if self.batch_size is None: + return max(1, int(total or 1)) + return self.batch_size + + def _task_batches(self, tasks, batch_size): + if not tasks: + return [] + return [ + tasks[index:index + batch_size] + for index in range(0, len(tasks), batch_size) + ] + + def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total): + first = counters["done"] + 1 + last = min(first + len(batch_tasks) - 1, total) + label = "预览批次" if self.dry_run else "更新批次" + self._log_run_event( + f"{label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}" + ) + def _run_parallel_by_account(self, eligible, account_by_alias, counters, total): groups = self._group_tasks_by_alias(eligible) max_workers = min(self.max_parallel_accounts, len(groups)) @@ -2773,7 +2811,7 @@ if QT_IMPORT_ERROR is None: if account is None: reason = "别名未匹配账号" self._log_run_event( - f"dry-run:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", + f"预览:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", task=task, level="warning", ) @@ -2785,7 +2823,7 @@ if QT_IMPORT_ERROR is None: action_parts.append("封面") action_text = "+".join(action_parts) or "无变更" self._log_run_event( - "dry-run:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( + "预览:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( task_id=task.id, item_id=task.item_id, alias=account.alias, @@ -2903,6 +2941,8 @@ if QT_IMPORT_ERROR is None: "skipped": skipped, "failed": failed, "dry_run": self.dry_run, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, } ) @@ -2938,6 +2978,8 @@ if QT_IMPORT_ERROR is None: "batch_ids": batch_ids, "dry_run": self.dry_run, "parallel_accounts": self.parallel_accounts, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, "run_id": self._run_id, } if blocked: @@ -2958,6 +3000,8 @@ if QT_IMPORT_ERROR is None: "dry_run": self.dry_run, "parallel_accounts": self.parallel_accounts, "max_parallel_accounts": self.max_parallel_accounts, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, }, path=self.db_path, ) @@ -3284,13 +3328,7 @@ if QT_IMPORT_ERROR is None: def _old_cover_path(self, account, task): image_root = appconfig.image_dir(self.config) - return os.path.abspath( - os.path.join( - image_root, - account.slug, - f"{task.item_id}_old.jpg", - ) - ) + return image_paths.task_image_path(image_root, task, account, "old") def _batch_ids(self, tasks): batch_ids = [] @@ -3630,6 +3668,7 @@ if QT_IMPORT_ERROR is None: self.save_config_button = QPushButton("保存设置") self.test_item_id_edit = QLineEdit() self.test_item_id_edit.setObjectName("testItemIdEdit") + self.test_item_id_edit.setVisible(False) self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品") self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox") self.allow_cover_update_checkbox = QCheckBox("允许更新封面") @@ -3637,15 +3676,27 @@ if QT_IMPORT_ERROR is None: self.max_items_per_run_spin = QSpinBox() self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin") self.max_items_per_run_spin.setRange(1, 9999) + self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。") self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页") self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox") - self.dry_run_checkbox = QCheckBox("dry-run 只预览不提交") + self.dry_run_checkbox = QCheckBox("预览本轮更新") self.dry_run_checkbox.setObjectName("dryRunCheckbox") + self.dry_run_checkbox.setVisible(False) self.parallel_accounts_checkbox = QCheckBox("多账号并行更新") self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox") self.max_parallel_accounts_spin = QSpinBox() self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin") self.max_parallel_accounts_spin.setRange(1, 16) + self.max_parallel_accounts_label = QLabel("最大并行账号数") + self.parallel_accounts_group = QWidget() + self.parallel_accounts_group.setObjectName("parallelAccountsGroup") + parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group) + parallel_accounts_layout.setContentsMargins(0, 0, 0, 0) + parallel_accounts_layout.setSpacing(12) + parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox) + parallel_accounts_layout.addWidget(self.max_parallel_accounts_label) + parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin) + parallel_accounts_layout.addStretch(1) model_picker_layout = QHBoxLayout() model_picker_layout.addWidget(self.model_combo, 1) @@ -3657,7 +3708,7 @@ if QT_IMPORT_ERROR is None: action_layout.addWidget(self.test_connection_button) action_layout.addStretch(1) - form = self._two_column_form( + form = self._three_column_form( [ ("状态", self.enabled_checkbox), ("服务商名", self.name_edit), @@ -3670,7 +3721,7 @@ if QT_IMPORT_ERROR is None: ] ) - ai_form = self._two_column_form( + ai_form = self._three_column_form( [ ("标题大模型", self.default_text_model_combo), ("图片大模型", self.default_image_model_combo), @@ -3691,7 +3742,7 @@ if QT_IMPORT_ERROR is None: port_range_widget = QWidget() port_range_widget.setLayout(port_range_layout) - path_form = self._two_column_form( + path_form = self._three_column_form( [ ("Chrome路径", self.chrome_path_edit, True), ("账号数据根目录", self.user_data_root_edit), @@ -3703,22 +3754,20 @@ if QT_IMPORT_ERROR is None: ] ) - update_form = self._two_column_form( + self.shopee_update_form_layout = self._three_column_form( [ - ("测试商品ID", self.test_item_id_edit), - ("单次最大更新条数", self.max_items_per_run_spin), + ("每批最大更新条数", self.max_items_per_run_spin), ("", self.allow_real_submit_checkbox), - ("", self.allow_cover_update_checkbox), ("", self.close_success_tab_checkbox), - ("", self.dry_run_checkbox), - ("", self.parallel_accounts_checkbox), - ("最大并行账号数", self.max_parallel_accounts_spin), + ("", self.allow_cover_update_checkbox), + ("", self.parallel_accounts_group, 2), ] ) panel = QWidget() + panel.setMaximumWidth(1800) panel_layout = QVBoxLayout(panel) - panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setContentsMargins(13, 18, 13, 18) panel_layout.addWidget(QLabel("AI 模型")) panel_layout.addLayout(model_picker_layout) panel_layout.addSpacing(14) @@ -3734,13 +3783,19 @@ if QT_IMPORT_ERROR is None: panel_layout.addLayout(path_form) panel_layout.addSpacing(18) panel_layout.addWidget(QLabel("Shopee 更新安全")) - panel_layout.addLayout(update_form) + panel_layout.addLayout(self.shopee_update_form_layout) panel_layout.addWidget(self.save_config_button) panel_layout.addStretch(1) scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setWidget(panel) + scroll_content = QWidget() + scroll_layout = QHBoxLayout(scroll_content) + scroll_layout.setContentsMargins(0, 0, 0, 0) + scroll_layout.addStretch(1) + scroll_layout.addWidget(panel) + scroll_layout.addStretch(1) + scroll.setWidget(scroll_content) layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) @@ -3759,46 +3814,43 @@ if QT_IMPORT_ERROR is None: self.refresh_models() self._populate_app_settings() - def _two_column_form(self, fields): + def _three_column_form(self, fields): layout = QGridLayout() layout.setHorizontalSpacing(18) layout.setVerticalSpacing(8) - layout.setColumnStretch(1, 1) - layout.setColumnStretch(3, 1) + for column in (1, 3, 5): + layout.setColumnStretch(column, 1) row = 0 column_pair = 0 for field in fields: label = field[0] widget = field[1] - full_width = len(field) > 2 and bool(field[2]) - if full_width and column_pair: + span_pairs = self._form_field_span_pairs(field) + if span_pairs > 3 - column_pair: row += 1 column_pair = 0 column = column_pair * 2 - self._add_form_field(layout, row, column, label, widget, full_width) - if full_width: - row += 1 - column_pair = 0 - elif column_pair == 0: - column_pair = 1 - else: + self._add_form_field(layout, row, column, label, widget, span_pairs) + column_pair += span_pairs + if column_pair >= 3: row += 1 column_pair = 0 return layout - def _add_form_field(self, layout, row, column, label, widget, full_width): - if full_width: - if label: - layout.addWidget(QLabel(label), row, 0) - layout.addWidget(widget, row, 1, 1, 3) - else: - layout.addWidget(widget, row, 0, 1, 4) - return + def _form_field_span_pairs(self, field): + if len(field) <= 2: + return 1 + span = field[2] + if isinstance(span, bool): + return 3 if span else 1 + return max(1, min(3, int(span or 1))) + + def _add_form_field(self, layout, row, column, label, widget, span_pairs): if label: layout.addWidget(QLabel(label), row, column) - layout.addWidget(widget, row, column + 1) + layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1) else: - layout.addWidget(widget, row, column, 1, 2) + layout.addWidget(widget, row, column, 1, span_pairs * 2) def _set_status(self, message): if self.status_callback is not None: @@ -3939,6 +3991,7 @@ if QT_IMPORT_ERROR is None: self._replace_config(saved) self._populate_app_settings() self._set_status("设置已保存") + QMessageBox.information(self, "保存设置", "设置已保存") def _app_settings_values(self): start_port = self.debug_port_start_spin.value() @@ -3991,7 +4044,7 @@ if QT_IMPORT_ERROR is None: "allow_cover_update": self.allow_cover_update_checkbox.isChecked(), "max_items_per_run": self.max_items_per_run_spin.value(), "close_success_tab": self.close_success_tab_checkbox.isChecked(), - "dry_run": self.dry_run_checkbox.isChecked(), + "dry_run": False, "parallel_accounts": self.parallel_accounts_checkbox.isChecked(), "max_parallel_accounts": self.max_parallel_accounts_spin.value(), }, @@ -4059,7 +4112,7 @@ if QT_IMPORT_ERROR is None: self.close_success_tab_checkbox.setChecked( bool(update_cfg.get("close_success_tab", False)) ) - self.dry_run_checkbox.setChecked(bool(update_cfg.get("dry_run", False))) + self.dry_run_checkbox.setChecked(False) self.parallel_accounts_checkbox.setChecked( bool(update_cfg.get("parallel_accounts", False)) ) diff --git a/app/image_paths.py b/app/image_paths.py new file mode 100644 index 0000000..70986e0 --- /dev/null +++ b/app/image_paths.py @@ -0,0 +1,48 @@ +import os + +from .config import make_slug + + +def task_image_path(image_root, task, account=None, suffix="old", ext=".jpg"): + batch_id = _safe_component(_get(task, "batch_id"), "unknown_batch") + slug = _account_slug(account, task) + task_id = _safe_component(_get(task, "id"), "task") + item_id = _safe_component(_get(task, "item_id"), "item") + suffix = _safe_component(suffix, "image") + ext = str(ext or ".jpg") + if not ext.startswith("."): + ext = "." + ext + return os.path.abspath( + os.path.join( + str(image_root or "images"), + batch_id, + slug, + f"{task_id}_{item_id}_{suffix}{ext}", + ) + ) + + +def _account_slug(account, task): + slug = _get(account, "slug") + if slug: + return _safe_component(slug, "unknown_account") + alias = _get(task, "alias") or _get(account, "alias") or _get(task, "account_name") or _get(account, "account_name") + if alias: + return _safe_component(make_slug(alias), "unknown_account") + return "unknown_account" + + +def _get(obj, name, default=None): + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(name, default) + return getattr(obj, name, default) + + +def _safe_component(value, default): + text = str(value or "").strip() + if not text: + text = str(default) + safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_") + return safe or str(default) \ No newline at end of file diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 9640c30..04acea8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -67,7 +67,7 @@ cmshopee 是一个 Windows 本地桌面自动化工具(PySide6,5 Tab), - 5 Tab 流水线:① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置。 - GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。 -- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,⑤ 可开启 dry-run 预览或按账号并行。 +- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「预览本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。 - Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。 - AI 生成标题/封面,不设逐条确认阶段。 - ③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;用户确认后才批量提交线上。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 6610bab..cef31a3 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -45,11 +45,11 @@ | AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题、据提示词+旧封面生成新封面 | P0 | | 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | | 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 | -| 更新 shopee(③) | 按批次/店铺/状态筛选;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务打开编辑页换标题+封面,并逐条点「更新」提交线上;可按状态=失败重试 | P0 | -| dry-run / 运行日志 / 多账号并行(③/⑤) | dry-run 只预览不打开 Shopee、不提交、不改任务状态;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入 | +| 更新 shopee(③) | 按批次/店铺/状态筛选;可先「预览本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | +| 预览本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「预览本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,预览按钮待改名接入 | | 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | -| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,返回超时随分辨率自动);并发/重试/jpg质量;图片目录/Chrome 路径/端口 | P0 | -| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④ | P0 | +| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,返回超时随分辨率自动);并发/重试/jpg质量;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 | +| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 | ### 后续迭代 @@ -63,7 +63,7 @@ 2. 我为某账号点击「启动并登录」,工具用它专属的配置目录打开 Chrome,我手动登录一次 Shopee 卖家中心。 3. 之后我选中该账号、填入商品 ID,工具自动打开该商品详情页(带登录态)。 4. 工具自动把标题改成目标值,并上传我指定的图片、把它拖到第一位设为封面。 -5. 我在 ③ 按批次/店铺/状态筛选已生成任务,点击「开始更新」后看到批量确认弹窗;确认后工具逐条提交线上,取消则不执行。 +5. 我在 ③ 按批次/店铺/状态筛选已生成任务,点击「开始更新」后看到批量确认弹窗;若本轮需要的账号 Chrome 未启动或未登录,工具先列出账号并中止;确认且账号均就绪后工具逐条提交线上,取消则不执行。 6. 当账号尚未登录、商品页加载失败或封面已满 9 张时,工具给出明确提示并安全处理。 ## 五、验收标准(V1) @@ -74,7 +74,7 @@ - **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。 - **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。 - **换封面**:上传成功后图片张数 +1(满 9 张时先确认旧封面本地备份存在,再删第一张并上传;备份缺失则拒绝删除);目标图被拖到第一位成为封面。 -- **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;用户确认后才逐条点「更新」,按钮禁用则不点并记录失败原因。 +- **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;真实提交前必须检查本轮需要的账号 Chrome 已启动且已登录,缺失则弹窗列出账号并中止本轮、不自动打开 Chrome;用户确认且账号就绪后才逐条点「更新」,按钮禁用则不点并记录失败原因。 - **安全**:任意失败路径不崩溃、不误删、不在缺少批量确认的情况下提交线上。 ## 六、范围边界与决策 @@ -87,7 +87,8 @@ | 存储 | 应用设置用 `config.json`;账号/任务/结果用 SQLite;Excel 读写用 openpyxl | | 账号↔任务绑定 | 以 Excel“别名”列为权威(非文件名);匹配不到的略过并最后弹窗汇总 | | 是否提交更新 | ③ 点击「开始更新」后弹窗确认;确认后对当前筛选结果逐条点「更新」提交线上;无常驻提交开关、无逐条人工审核 | -| 执行方式 | 默认多账号串行;⑤ 可开启多账号并行,不同账号可同时跑、同账号内仍串行;单条失败继续 | +| 执行方式 | 默认多账号串行;⑤ 可开启多账号并行,不同账号可同时跑、同账号内仍串行;③ 按当前筛选结果全部更新,普通正式更新不再以测试商品 ID 阻断,超过每批最大条数时自动分批;单条失败继续 | +| 更新前账号就绪 | ③ 真实更新前检查当前筛选结果需要的账号;Chrome 未启动、CDP 端口不可达或未登录时整体中止并提示去④,不静默自动启动账号浏览器 | | 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 | | 新标题/新封面 | AI 生成(输出列),直接用于 ③;不设逐条确认阶段,本地留档+回写 Excel 供追溯 | | AI 服务 | 文本+图像生成,服务商/模型待定;Key 本地明文存于 `config/ai_models.json`,首次保存/变更时提示,UI 打码、不入日志;见 [技术栈](03-tech-stack.md) | @@ -106,8 +107,8 @@ - **AI Key 安全**:Key 本地明文存于 `config/ai_models.json`,保存/变更时提示,UI 打码,不写日志,不提交版本库。 - **第三方平台风险**:Shopee 页面结构、class 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。 - **自动化边界风险**:③ 确认后会自动改标题、上传图片、拖拽并点「更新」提交线上;点「开始更新」并确认前需自行确保筛选范围、任务来源与 AI 产出可接受。 -- **dry-run 边界**:⑤ 开启 dry-run 后,③ 只预览当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;关闭 dry-run 后才可能真实提交。 +- **预览边界**:③「预览本轮更新」只预览当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;点击「开始更新」并确认后才可能真实提交。 - **多账号并行风险**:⑤ 开启多账号并行后,不同账号可同时执行更新;同一账号内仍串行。执行前检查本轮账号调试端口,端口冲突时阻断真实更新。 - **合规风险**:仅在自有/授权账号上操作;遵守 Shopee 卖家条款;不绕过任何平台限制。 - **GUI 选型**:V1 固定为 PySide6;后台任务通过 QThread/signal 回传进度,见 [技术栈](03-tech-stack.md) 与 [架构](04-architecture.md)。 -- **多账号并行默认关闭**:默认仍串行;只在⑤明确开启后并行,建议先 dry-run 预览并确认账号 Chrome 均已登录。 +- **多账号并行默认关闭**:默认仍串行;只在⑤明确开启后并行,建议先用「预览本轮更新」确认账号、店铺和任务范围。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 2ce586b..9a67adf 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -59,7 +59,7 @@ imported → collected → generated → applied ## 三、职责划分 -**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用或执行前拦截,并提示去 ④。** +**GUI(5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用或执行前拦截,并提示去 ④;③ 不静默自动打开账号 Chrome。** **核心模块** @@ -132,16 +132,16 @@ imported → collected → generated → applied `shopee_update` 段放**真实更新前的安全开关**: -- `test_item_id`:允许真实提交的测试商品 ID;默认 `51100639510`。 +- `test_item_id`:历史/调试兼容字段;默认 `51100639510`。普通正式更新不再以该字段限制商品 ID,也不因当前筛选结果包含非测试商品而阻断;后续如需要调试模式,可单独启用测试商品限制。 - `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。 - `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。 -- `max_items_per_run`:单次允许更新的最大任务数;默认 `1`,当前筛选结果超过即阻断。 -- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。 -- `dry_run`:只预览当前筛选任务,写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。 +- `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。 +- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。Shopee 确认成功并跳回商品列表页后,关闭前等待 2 秒,让列表页跳转和页面状态稳定。 +- `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「预览本轮更新」按钮触发预览,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。 - `parallel_accounts`:是否按账号并行执行③真实更新;默认 `false`,即保持串行。 - `max_parallel_accounts`:最多同时执行的账号数;同一账号内仍按任务串行,默认 `2`。 -该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。 +该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。普通正式更新的安全检查不读取 `test_item_id` 做阻断。 ### 5.1b AI 模型清单 `config/ai_models.json` @@ -337,11 +337,11 @@ CREATE TABLE run_log_events ( ### 5.4 本地图片目录 ```text -images//_old. # 采集下载的旧封面 -images//_new. # AI 生成的新封面 +images///__old. # 采集下载的旧封面 +images///__new. # AI 生成的新封面 ``` -路径记入 DB;上传新封面用本地 `_new` 文件(Windows 绝对路径传 setFileInputFiles)。 +路径记入 DB 后以 `old_cover_path/new_cover_path` 为权威;上传新封面使用 DB 中的本地绝对路径(Windows 绝对路径传 `setFileInputFiles`)。历史数据里的 `images//_*.jpg` 路径继续有效,不做强制迁移;新采集/新生成才使用按批次细分的目录。 ## 六、关键流程细节 @@ -358,7 +358,7 @@ images//_new. # AI 生成的新封面 - 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 -- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images//_old`。 +- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 - 采集任务结束且本轮有成功采集行时,自动触发当前批次旧字段回写;原 Excel 被锁时不影响 SQLite 结果,提示关闭后重试,并保留手动「回写旧数据到 Excel」入口。 @@ -367,7 +367,7 @@ images//_new. # AI 生成的新封面 单个「开始生成」按钮,**两段式、各自并发**(标题快、图片慢,分开并发更高效): 1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。 -2. **接着并发生成图片**:线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images//_new.jpg`。 +2. **接着并发生成图片**:线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images///__new.jpg`。 - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。 - 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。 @@ -386,20 +386,24 @@ images//_new. # AI 生成的新封面 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 -- 弹确认前先读取 `config.json` 的 `shopee_update`:`dry_run=false` 且未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。`dry_run=true` 时只预览,不受真实提交开关限制。 +- ③ 提供「预览本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;预览展示总数、店铺分布、每批最大条数、预计批次数、更新字段和略过原因。 +- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `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` 提交。 - `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。 -- 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。 +- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。 - 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。 -- `dry_run=true` 时只写 `run_logs/run_log_events` 和弹窗/状态栏预览,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。 -- 真实更新每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与 dry-run 都写运行日志,日志 payload 走脱敏工具。 +- 「预览本轮更新」只写 `run_logs/run_log_events` 和弹窗/状态栏预览,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。 +- 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与预览都写运行日志,日志 payload 走脱敏工具。 +- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。 - T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交,并保留 committed 历史事实/运行日志。 -- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。 +- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;若确认后已跳回商品列表页,关闭前等待 2 秒;失败任务和复用的用户已有 tab 不关闭。 ### 6.4 登录检测 - 无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。 +- ④「启动登录」只负责打开该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。①/③ 的预检只检测,不自动启动缺失浏览器。 ## 七、CDP 已验证事实(务必遵守) @@ -408,19 +412,19 @@ images//_new. # AI 生成的新封面 | Chrome 启动参数 | 全关后带 `--remote-debugging-port= --remote-allow-origins=* --user-data-dir=`;缺 allow-origins 则 WebSocket 403 | | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | WebSocket Origin | `websocket-client` `suppress_origin=True` | -| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭 | +| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭;确认成功跳回商品列表页时,关闭前等待 2 秒 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | | 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | -| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 | -| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理 | +| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` | +| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | -| 满 9 张 | 上限 9;T-502 换封面时先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程 | -| 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `更新`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 | +| 换封面删除 | 更新封面统一先删当前第一张,不再只限满 9 张;先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程,8 图商品也按同一替换语义删除第一张后再上传 | +| 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `更新`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果;若随后要关闭本轮自动新开 tab,必须先暂停 2 秒再关闭。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 | | 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 | -高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。删满 9 张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 +高风险动作(删除线上封面、点更新提交、AI 图上线)先在测试商品验证。删除线上第一张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 ## 八、推荐开发顺序 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 4c561d0..eb44eac 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -54,7 +54,7 @@ - [ ] `python -m compileall app main.py` 通过(T-000 前仅文档改动不要求)。 - [ ] T-006 完成后,纯逻辑改动有对应 `unittest`,至少覆盖正常路径和一个失败路径。 - [ ] 涉及 CDP 的改动,在测试商品(ITEM_ID 51100639510)上实跑验证。 -- [ ] 涉及满 9 张封面删除时,必须先验证该任务 `old_cover_path` 本地备份存在;备份缺失不得删除线上图片。 +- [ ] 涉及更新封面删除线上第一张图时,必须先验证该任务 `old_cover_path` 本地备份存在;备份缺失不得删除线上图片。 - [ ] 涉及 DB 的改动,覆盖 schema 初始化、重复初始化、短事务写入、失败状态写入。 - [ ] 涉及 Excel 的改动,覆盖 source_file_abs/source_sheet/source_row 回写定位。 - [ ] 对得上需求验收标准(如标题 `value`+`modelvalue` 双等于、封面在第一位)。 @@ -89,7 +89,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交) - 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 - 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏;`logs/` 本地诊断日志必须 gitignore,不提交版本库。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 -- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 +- 高风险动作(删除线上封面、点击更新)必须有显式确认,并先在测试商品验证;删除线上第一张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。 ## 9. 拿不准就问 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 95bff86..df5cad9 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -50,7 +50,7 @@ | T-201 | `app/excel.py` 导入:解析多文件输入列入库 | T-003 | 按模板解析账号名/别名/商品id;记录 source_file_abs/source_sheet/source_row/row_key;缺必需列则拒绝整文件并记 file_errors;脏行逐行跳过计 invalid;写 batches/tasks | DONE | | T-202 | Tab① 任务列表 + 导入按钮 + 别名匹配标记 | T-201, T-105 | `QTableView` 显示账号/别名/商品id/阶段;未匹配标“略过” | DONE | | T-202b | Tab① 导入汇总栏 | T-202 | 导入后显示 文件数/解析行数/有效/无效/匹配(按账号)/未匹配;未匹配可点击筛出 | DONE | -| T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001, T-104b | 通过 worker 执行;逐条 set_collected;旧封面下载到 `images//`;别名未匹配逐条略过记原因;匹配账号未登录由 T-205 预检阻断,不写 skipped/failed | DONE | +| T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001, T-104b | 通过 worker 执行;逐条 set_collected;旧封面下载到 `images///__old.jpg`;别名未匹配逐条略过记原因;匹配账号未登录由 T-205 预检阻断,不写 skipped/failed | DONE | | T-204 | 回写旧字段到原 Excel(含文件锁处理) | T-203, T-201 | `excel.write_back()` 按源文件/工作表/行号回写旧标题、旧封面;`export_copy()` 另存副本;Tab① 用 `WriteBackWorker` 后台回写,文件被占用时提示关闭后重试 | DONE | | T-204b | 采集完成后自动回写旧字段到 Excel | T-204 | `CollectWorker` 完成后自动触发 `excel.write_back()` 回写当前批次旧字段;成功时状态栏/日志提示“已回写”;原文件被锁时不影响 SQLite,提示关闭后点「回写旧数据到 Excel」手动重试或另存副本 | DONE | | T-205 | 首次未配账号 / Chrome 未启动 / 未登录的引导保护 | T-105, T-203 | 无账号、匹配账号未启动 CDP 端口或未登录时,① 执行按钮禁用或采集前汇总提示,并引导去④;可提供“打开账号管理/启动登录”入口,但不无提示批量启动所有账号 Chrome | DONE | @@ -73,10 +73,10 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | -| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE | +| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未确认时不调用 apply;真实更新前检查本轮账号 Chrome/CDP/登录态,未启动或未登录时整体阻断并提示去④,不自动打开 Chrome | DONE | | T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE | | T-404a | ②/③ 选中记录重置(联调重试辅助) | T-303, T-403, T-504 | 现状:② AI生成和③ 更新shopee列表没有重置选中记录的功能,生成/上传/提交成功后难以用同一商品重复测试。方案:② 增加「重置生成结果」按钮,仅作用当前选中单条,确认后清空本地 `new_title/new_cover_path`、清空 `last_error`,把任务退回 `stage=collected/status=success`,默认不删除本地新封面文件,可在确认框提示路径;③ 增加「重置更新状态」按钮,仅作用当前选中单条,保留 `new_title/new_cover_path`,确认后把本地状态退回可更新(`stage=generated/status=pending`、清空 `last_error`),用于重复测试上传/提交。若记录 `committed=1`,确认框必须醒目提示“线上已提交过,本地重置不会回滚 Shopee,重复更新会再次提交线上”,并保留 committed 历史事实/运行日志;运行中禁用;两类重置都写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel,后续由手动回写同步本地状态 | DONE | -| T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 程序侧二次确认和设置安全开关。2026-06-29 实测标题/封面已替换、页面主「更新」已点击,但 Shopee 又弹出站点侧确认框 `確定您要更新商品嗎?`,旧代码未点弹窗主按钮导致未真正提交;当前已补代码与 mock 单测,完成 T-404 前仍必须在测试商品实测验证:检测可见 `.eds-modal__content` / `.eds-modal__box` 标题匹配该确认框,只点击 footer 主按钮 `更新`(`eds-button--primary`),不点击 `立即優化`,确认框未处理成功则任务失败且不得写 `committed=1`。验证线上提交结果、Excel 更新状态、失败/取消语义;2026-06-30 又补上传前/删图后图片管理器稳定等待;随后发现并修复回归:满 9 张时删除前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;又发现并修复物流/备货页面级校验 toast 被误判为封面上传失败:上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;原因是提交后观测把残留 error toast 优先于列表页跳转。已修正为列表页跳转优先判成功,错误 toast 仅在未跳转且持续存在时判失败;仍需真机复跑确认,未通过前不得扩大批量真实更新 | TODO | +| T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 程序侧二次确认和设置安全开关。2026-06-29 实测标题/封面已替换、页面主「更新」已点击,但 Shopee 又弹出站点侧确认框 `確定您要更新商品嗎?`,旧代码未点弹窗主按钮导致未真正提交;当前已补代码与 mock 单测,完成 T-404 前仍必须在测试商品实测验证:检测可见 `.eds-modal__content` / `.eds-modal__box` 标题匹配该确认框,只点击 footer 主按钮 `更新`(`eds-button--primary`),不点击 `立即優化`,确认框未处理成功则任务失败且不得写 `committed=1`。验证线上提交结果、Excel 更新状态、失败/取消语义;2026-06-30 又补上传前/删图后图片管理器稳定等待;随后发现并修复回归:满 9 张时删除前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;又发现并修复物流/备货页面级校验 toast 被误判为封面上传失败:上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;原因是提交后观测把残留 error toast 优先于列表页跳转。已修正为列表页跳转优先判成功,错误 toast 仅在未跳转且持续存在时判失败;2026-06-30 商品 26887160467 复跑发现手动上传 220KB 新图成功,但代码直接 `DOM.setFileInputFiles` 后上传组件长期转圈,方案改为上传前先点击上传块模拟人工入口、短暂等待、重新获取 input 后再注入文件;25120403046 只有 8 张商品图时未删除第一张导致 Shopee 提示 `有1張重複的圖片`,方案改为更新封面统一先删当前第一张再上传,并把 `重複/重复/duplicate` toast 识别为封面上传失败;确认后若返回我的商品列表页且设置了成功后关闭本次新开编辑页,则关闭 tab 前等待 2 秒;仍需真机复跑确认,未通过前不得扩大批量真实更新 | TODO | ## Phase 5 · 设置与收尾 @@ -84,11 +84,13 @@ | --- | --- | --- | --- | --- | | T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE | | T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE | -| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE | -| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE | +| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID(历史/调试字段)、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE | +| T-502 | 换封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;更新封面统一先删当前第一张,不再只限满 9 张;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE | | T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | DONE | | T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | DONE | | T-505 | 全流程诊断日志扩展 | T-207, T-504 | 在 T-207 采集日志机制稳定后,扩展到② AI生成、Excel 导入/回写、③ 更新shopee步骤级日志、④ Chrome 启动/登录检测、⑤ AI模型测试连接;数据库日志保存业务可读步骤和错误,本地 log 保存脱敏 traceback;不得记录 Cookie、密码、API Key、token | TODO | +| T-506 | 正式使用批量更新体验:③ 预览本轮更新 + 分批更新全部 + ⑤ 设置页三列布局 | T-501c, T-504 | ③ 增加「预览本轮更新」按钮替代用户可见 dry-run;「开始更新」对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示总数/每批/预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页使用居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/Shopee 更新安全均使用响应式三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」合并为同一个横向组件,最大并行账号数紧跟其后且不换行 | DONE | +| T-507 | 正式批量更新:移除普通流程测试商品 ID 限制 | T-506 | 普通正式更新不再以 `test_item_id` 阻断当前筛选结果;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,仅保留历史/调试兼容字段;仍保留允许真实提交、允许更新封面、分批、二次确认、账号就绪预检和运行日志 | DONE | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index 8c403b9..a25b52c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,7 +32,7 @@ ai_config(config=None) -> dict # default_text_model/default_image response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率) ``` -`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:测试商品 ID、是否允许真实提交、是否允许封面更新、单次最大更新条数、成功后是否关闭本轮新开编辑页、dry-run、多账号并行、最大并行账号数。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。 +`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。 敏感信息展示/日志辅助: @@ -193,6 +193,7 @@ login_status_text(status) -> str - `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。 - `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。 - `launch_for_login()` 只启动带该账号 user-data-dir 和 CDP 端口的 Chrome;不会读取、填写或提交密码。 +- ③ 更新 shopee 的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。 - `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。 - `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。 @@ -235,12 +236,11 @@ collect(account, task) -> dict # -> {old_title, old_cover_path} # 应用 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 replace_cover(cdp, image_win_path, old_cover_path=None) -> dict -# 上传→等 Shopee CDN 地址→拖第一位;失败返回 upload_state 诊断;满9张时先确认 old_cover_path 非空且文件存在, -# 再点第一张删除和可见确认框;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片 +# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片 click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败 apply_task(account, task, close_success_tab=False) -> dict # 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认) -# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab +# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab;确认后跳回商品列表页时,关闭前等待 2 秒 # -> {committed, error} ``` @@ -251,9 +251,10 @@ apply_task(account, task, close_success_tab=False) -> dict - `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。 - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。 -- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`。 +- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次会关闭该自动新开 tab,关闭前等待 2 秒。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。 -- T-502 满 9 张封面删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。 +- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 +- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。 ## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP) @@ -265,7 +266,7 @@ gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str # 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart; -# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径 +# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir///__new.jpg`,历史 DB 已存路径继续按原路径读取 generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict # 编排:先以 title_concurrency 线程池并发跑 gen_title,再以 image_concurrency 并发跑 gen_cover @@ -322,11 +323,11 @@ main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 -class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后 dry-run/真实更新 + 运行日志 -class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全 +class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 预览本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志 +class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 响应式三列布局 + 角色/生成参数/路径端口 + Shopee 更新安全 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 -class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> dry-run 预览或 editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped +class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 预览或按批调用 editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” @@ -338,7 +339,7 @@ TAB_TITLES: list[str] # 固定 Tab 顺序 TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态 ``` -`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与确认后 dry-run/真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 +`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与预览/确认后分批真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 @@ -350,16 +351,16 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: ⑤ 设置当前要点(T-501): -- `SettingsTab` 使用单列滚动布局:AI 模型下拉与新增/删除按钮位于“模型详情”上方;详情与设置表单尽量两项一行,长字段独占整行。 -- 详情字段:启用、服务商名、类别(text/image)、api_type、模型 ID、网址、密钥、连接超时。 +- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。 +- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 - 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。 - 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `config/ai_models.json`,不得进入日志/导出。 - 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。 - 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。 -- 角色与生成参数读写 `config.json`:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 +- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 - 分辨率下拉固定 `512/1k/2k/4k`;返回超时标签只读展示 `resolution_timeouts[resolution]`。 -- 路径与端口读写 `config.json`:Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、调试端口范围、CDP 就绪超时。保存时校验端口范围和默认端口。 -- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页、dry-run、多账号并行、最大并行账号数。 +- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、CDP 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。 +- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「预览本轮更新」按钮触发预览;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。 - 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。 ① 导入采集当前要点(T-202/T-202b): @@ -370,7 +371,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 -- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir//_old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 +- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir///__old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 - 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。 - 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。 - 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。 @@ -397,14 +398,15 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 -- 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/商品ID/状态/任务数、提交线上风险和当前安全设置。 -- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。 +- 「预览本轮更新」只读取当前筛选结果并创建预览运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;预览内容包含任务总数、店铺分布、每批最大条数、预计批次数、会更新字段和略过原因。 +- 点击「开始更新」先读取 `shopee_update`:未允许真实提交,或包含新封面但未允许封面更新时,弹警告并阻断;普通正式更新不再检查 `test_item_id`,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。 +- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。 - `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 -- dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 -- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。 +- 预览本轮更新:不做账号登录预检,不调用 `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`,单条失败继续下一条。 -- dry-run 和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;③ 页面显示最近运行日志。 -- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;失败和复用的用户已有 tab 不关闭。 +- 预览和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;③ 页面显示最近运行日志。 +- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;失败和复用的用户已有 tab 不关闭。 - 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 - T-404a 已实现:「重置更新状态」读取当前选中单条;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚 Shopee,并保留 committed 历史事实/运行日志。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 @@ -443,7 +445,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread - 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。 - 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。 - ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。 -- `ApplyWorker` 支持 dry-run、默认串行和按账号并行;真实更新调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交。 +- `ApplyWorker` 支持预览、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。 - `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。 - `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。 @@ -476,5 +478,5 @@ set UPDATE=1 && python prototypes/demo.py # 走完点击「更新」提交 ## 待实现时确认 - AI 服务商/模型/计费;图像 image-to-image 能力与合规。 -- 满 9 张删除封面已在测试商品 `29671243750` 上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。 +- 删除线上第一张封面已在测试商品 `29671243750` 上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。 - 旧封面下载的图片格式/扩展名处理。 diff --git a/docs/current-state.md b/docs/current-state.md index cdaa2f6..2a2aa2d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-30 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、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-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、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⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、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-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、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⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复、上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:满 9 张删除前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-404a 本地生成结果/更新状态重置函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/重置更新状态/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` dry-run 预览或真实更新/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、两列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/满格上传入口初始不可用/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览与重置生成结果/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、重置更新状态、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-404a 本地生成结果/更新状态重置函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/重置更新状态/更新安全开关拦截/「预览本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框,`dry_run` 仅保留内部兼容字段,普通设置页已隐藏测试商品 ID 入口;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览与重置生成结果/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、重置更新状态、Shopee 更新安全拦截、`ApplyWorker` 串行/预览/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、预览按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -19,9 +19,9 @@ - 存储:`config.json`(应用设置)+ `config/ai_models.json`(AI 模型清单与本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。 - 多账号隔离:每账号独立 user-data-dir(非 profile)。 - 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。 -- 执行:默认多账号串行、单条失败继续;⑤ 可开启 dry-run 只预览不提交、不改任务状态,可开启多账号并行(不同账号并行、同账号内串行)。③ 点击「开始更新」后先按模式检查 ⑤ 设置,再弹窗确认当前筛选范围和任务数量;真实更新确认后逐条点「更新」提交线上。 +- 执行:默认多账号串行、单条失败继续;③ 提供「预览本轮更新」按钮,预览只写运行日志和汇总,不打开 Shopee、不提交线上、不改任务状态;⑤ 可开启多账号并行(不同账号并行、同账号内串行)并设置每批最大更新条数。③ 点击「开始更新」后先按真实更新安全设置弹窗确认当前筛选范围、任务数量、每批大小和预计批次;确认后做本轮账号 Chrome/CDP/登录态预检,未启动或未登录则弹窗列出账号并中止、不自动打开 Chrome;账号就绪后按批逐条点「更新」提交线上,点击停止后不再开始下一条或下一批。 - AI:服务商/模型/Key 由 `config/ai_models.json` 配置;`app/ai.py` 支持 chat JSON 与 images_edits multipart;生成内容直接用于更新,本地留档+回写 Excel 供追溯。 -- 登录:人工登录 + 程序检测,不自动登录;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④。 +- 登录:人工登录 + 程序检测,不自动登录;④「启动登录」只是打开对应账号浏览器供人工登录,④「检测登录」和①/③预检只验证登录态;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④,③ 不静默启动缺失账号 Chrome。 ## 当前目录要点 @@ -33,18 +33,19 @@ | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `docs/troubleshooting.md` | 已有 | 常见问题排查;已记录 `config/ai_models.json` 缺失 `category` 导致 GUI 启动报 “AI 模型 category 必须是 text 或 image” 的原因、修复和验证步骤 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、重置更新状态、Shopee 更新安全拦截、开始更新确认弹窗、`ApplyWorker` 串行/dry-run/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全与执行模式设置 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、重置更新状态、Shopee 更新安全拦截、「预览本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 预览/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,dry_run 用户入口改为③预览按钮,测试商品 ID 普通入口已隐藏且不再阻断正式更新 | | `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-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、上传并检测 CDN 后拖封面、满 9 张先校验旧封面备份再删第一张并等上传入口恢复、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | +| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 + T-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | -| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | +| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-404a/T-504 产出:batches/accounts/tasks schema;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | +| `app/image_paths.py` | 已有 | 本地图片路径 helper:新采集旧封面和新生成封面统一写入 `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-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 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/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 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | @@ -55,27 +56,29 @@ - 找到/新建商品详情页 tab,等编辑器就绪。 - 改标题:原生 setter + 派发事件,`value` 与 `modelvalue` 双等于新值。 - 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → 等 1 秒 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`);使用 `1_TY030.jpg` 在测试商品验证通过。 -- 「更新」按钮:可点才点,禁用态识别;③ 未批量确认前不提交。2026-06-29 测试商品实测:页面主「更新」后会出现 Shopee 站点侧确认框 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,底部 `立即優化` / 主按钮 `更新`;2026-06-30 用户复跑确认点击弹窗主按钮后提交成功,并跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time`,代码已记录该跳转结果。 +- 「更新」按钮:可点才点,禁用态识别;③ 未批量确认前不提交。2026-06-29 测试商品实测:页面主「更新」后会出现 Shopee 站点侧确认框 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,底部 `立即優化` / 主按钮 `更新`;2026-06-30 用户复跑确认点击弹窗主按钮后提交成功,并跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time`,代码已记录该跳转结果;若设置开启且该 tab 是本轮自动新开,关闭前等待 2 秒。 - Tab① 真机冒烟(2026-06-27):账号 `papa`,导入临时 Excel 5 行,采集 5 条旧标题/旧封面,下载 5 张旧封面并回写临时 Excel 成功。 ## 任务看板状态 任务状态以 [`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-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、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-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⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)。 -- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟)**。T-206 删除批次依赖 T-404,T-505 全流程诊断日志扩展依赖 T-207/T-504。 +- 已完成: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-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、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-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⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)。 +- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。T-206 删除批次依赖 T-404,T-505 全流程诊断日志扩展依赖 T-207/T-504。 ## 当前已知限制 - ① 采集已接入 `run_type=collect`,② AI生成已接入 `run_type=generate` 与本地 `logs/cmshopee.log`;T-505 仍待把同类步骤级诊断扩展到 Excel 导入/回写、③更新、Chrome/登录检测与 AI 模型测试连接。 -- ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 +- ① 采集和③真实更新都依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205/T-402 已在执行前拦截未配置账号、Chrome 未启动、CDP 端口不可访问、未登录,并引导去④账号管理。③ 点击「开始更新」后若本轮需要账号未就绪,必须中止本轮更新,不自动打开账号 Chrome、不提交任何商品。 - T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 +- T-506 已完成:③ 已将用户可见 `dry-run` 改为「预览本轮更新」按钮;真实更新对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示任务数和预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页已改为居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/Shopee 更新安全使用三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」已合并为同一个横向组件,最大并行账号数紧跟其后且不换行。 +- T-507 已完成:普通正式更新移除 `test_item_id` 商品 ID 限制;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,只作为历史/调试兼容字段保留;仍保留允许真实提交、允许更新封面、每批最大条数、二次确认、账号就绪预检、多账号并行上限、运行日志和 Excel 回写。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 -- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;2026-06-29 已用测试商品跑到真实编辑页并完成标题/封面替换、页面主「更新」点击,但被 Shopee 站点侧确认框拦住。当前代码已补确认框处理、确认后列表页跳转观测、上传状态诊断、删除后稳定等待和 mock 单测;2026-06-30 用户复跑发现稳定等待顺序回归:满 9 张时删除前要求上传 input 可用,导致不删除第一张图;当前已修正为删除前只等图片列表稳定、删除后再等上传入口恢复,并补 mock 回归测试;2026-06-30 又发现物流/备货页面级校验 toast 会被误判成封面上传失败,当前已修复为上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;已修正提交后观测优先级:列表页跳转优先判成功,残留 error toast 不再覆盖成功跳转,错误 toast 仅在未跳转且持续存在时判失败。当前已完成 T-404a 选中记录重置,下一步复跑 T-404 单条真实提交验收。 +- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;2026-06-29 已用测试商品跑到真实编辑页并完成标题/封面替换、页面主「更新」点击,但被 Shopee 站点侧确认框拦住。当前代码已补确认框处理、确认后列表页跳转观测、上传状态诊断、删除后稳定等待和 mock 单测;2026-06-30 用户复跑发现稳定等待顺序回归:满 9 张时删除前要求上传 input 可用,导致不删除第一张图;当前已修正为删除前只等图片列表稳定、删除后再等上传入口恢复,并补 mock 回归测试;2026-06-30 又发现物流/备货页面级校验 toast 会被误判成封面上传失败,当前已修复为上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;已修正提交后观测优先级:列表页跳转优先判成功,残留 error toast 不再覆盖成功跳转,错误 toast 仅在未跳转且持续存在时判失败。当前又发现商品 26887160467 手动上传 220KB 新图成功,但代码直接注入文件后上传组件长期转圈;T-404 当前补丁已让 `replace_cover()` 上传前先点击上传块模拟人工入口、短暂等待、重新获取 input 后再 `DOM.setFileInputFiles`,并补 mock 回归测试。25120403046 只有 8 张商品图时未删除第一张导致 Shopee 提示 `有1張重複的圖片`;当前已落地为更新封面统一先删当前第一张再上传,并把 `重複/重复/duplicate` toast 识别为封面上传失败。新采集/新生成图片路径已改为按批次细分,避免同一账号多批次图片混放;历史 DB 已存路径继续可用。确认后若返回我的商品列表页且设置了成功后关闭本次新开编辑页,关闭前等待 2 秒。当前已完成 T-404a 选中记录重置,下一步复跑 T-404 单条真实提交验收。 - T-404a 已完成:②「重置生成结果」清空本地 AI 结果并退回 collected,默认不删除本地新封面文件;③「重置更新状态」保留新标题/新封面并退回 generated/pending;两者只作用当前选中单条、运行中禁用、写 `run_type=reset` 运行日志。③ 对 `committed=1` 会提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交。 -- T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。 +- T-502 换封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;更新封面统一先删当前第一张,删除前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片;mock 已覆盖 8 张图也先删第一张、重复图片 toast 立即失败。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。`images/` 新路径按 `batch_id/slug/task_id_item_id` 细分,旧 DB 路径兼容不迁移。 ## 当前可运行内容 diff --git a/docs/html/有x张重复图片.html b/docs/html/有x张重复图片.html new file mode 100644 index 0000000..a1f2c15 --- /dev/null +++ b/docs/html/有x张重复图片.html @@ -0,0 +1,9 @@ +
+ + + +
有1張重複的圖片
+
\ No newline at end of file diff --git a/docs/routes.md b/docs/routes.md index bccd735..baada40 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -31,6 +31,7 @@ - ① 导入采集 与 ③ 更新shopee 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 - 当无账号 / 对应账号 Chrome 未启动 / 账号未登录时:相关执行按钮**禁用或在执行前汇总拦截**,并提示「请先到『账号管理』配置账号并登录」。 +- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。 - 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;不要无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。 - 老用户账号已就绪则无感。 @@ -52,7 +53,7 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 -- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到本地图片目录,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 +- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 - 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。 @@ -97,23 +98,26 @@ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ │ └───────────────────────────────────────────────────────────┘ │ -│ 运行日志:dry-run/真实更新逐条留痕 │ -│ [▶ 开始更新] [■停止] [重置更新状态] [回写结果到 Excel] │ +│ 运行日志:预览/真实更新逐条留痕 │ +│ [预览本轮更新] [▶ 开始更新] [■停止] [重置更新状态] [回写结果到 Excel] │ └───────────────────────────────────────────────────────────────┘ ``` - 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 -- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。 -- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 +- 「预览本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、会更新标题/封面、略过原因、每批最大条数和预计批次数。 +- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交,或包含新封面但未允许封面更新时,直接弹警告并阻断;普通正式更新不再以测试商品 ID 限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。 +- 安全开关通过后,弹窗展示本次筛选条件、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 +- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。 - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。 -- 满 9 张封面时,删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 +- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 - 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。 -- dry-run 只写运行日志与预览汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。 -- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;dry-run 和真实更新都会写 `run_logs/run_log_events`。 -- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 -- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。 +- 「预览本轮更新」只写运行日志与预览汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。 +- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;预览和真实更新都会写 `run_logs/run_log_events`。 +- 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。 +- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去④账号管理,不进入逐条提交,也不自动打开账号 Chrome。 +- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;失败任务和用户原本打开的 tab 不关闭。 - 「重置更新状态」仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交;不得静默清除 committed 历史事实。 - 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 - 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 @@ -132,21 +136,22 @@ ## ⑤ 设置 -- AI 模型管理(T-501 已接入):同列上下布局,AI 模型下拉 + 新增/删除在“模型详情”上方;详情与设置表单尽量两项一行,长字段独占整行。 +- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。 +- AI 模型管理(T-501 已接入,待布局优化):模型详情组件组改为 3 个组件一组;启用/类别/api_type/连接超时等短字段一格,服务商名/模型 ID 可一格或两格,URL 与 API Key 跨整行;AI 模型下拉 + 新增/删除仍在“模型详情”上方。 - 字段:启用、服务商名、类别(文本/图像)、api_type、模型ID、网址、密钥、连接超时。网址可填完整 endpoint,也可填 OpenAI-compatible base URL(如 `https://.../v1`),请求时按 `api_type` 自动补齐。 - 密钥存在 `config/ai_models.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - 保存复用 `appconfig` 模型清单后端,保持 name 唯一、至少 text/image 各一个模型。 - 「测试连接」通过后台 worker 调 `appconfig.test_ai_model()`,不阻塞主线程。 -- AI 生成参数(T-501b 已接入):标题/图片模型角色下拉、标题并发数、图片并发数、失败重试次数、分辨率、jpg 质量。 +- AI 生成参数(T-501b 已接入,待布局优化):角色与生成参数改为 3 个组件一组;标题模型、图片模型、分辨率、标题并发、图片并发、失败重试、jpg 质量等短字段按三列排列。 - 标题模型下拉只列 text 类模型;图片模型下拉只列 image 类模型。 - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用。 -- 路径与端口(T-501b 已接入):本地图片目录、Chrome 路径、账号数据根目录、DB 路径、默认调试端口、端口范围、CDP 就绪超时。 -- Shopee 更新安全(T-501c 已接入):测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。 - - 默认关闭真实提交和封面更新,单次最大更新条数默认 1。 +- 路径与端口(T-501b/T-506 已接入):组件组改为 3 个组件一组;默认端口、端口起止、CDP 就绪超时等短字段三列排列;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。 +- Shopee 更新安全(T-501c/T-506 已接入):组件组改为 3 个组件一组;允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页等短字段三列排列;「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许换到下一行;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。 + - 默认关闭真实提交和封面更新,每批最大更新条数默认 1。 - ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。 -- 更新执行模式(T-504 已接入):dry-run 只预览不提交、多账号并行更新、最大并行账号数。 - - 默认 dry-run 关闭、多账号并行关闭;开启多账号并行后同一账号内仍串行。 +- 更新执行模式(T-504/T-506 已接入):⑤ 只保留多账号并行更新、最大并行账号数等执行设置;“dry-run”不再作为用户可见开关,改到③成为「预览本轮更新」按钮。 + - 默认多账号并行关闭;开启多账号并行后同一账号内仍串行。 ## 流程导航 @@ -157,12 +162,12 @@ │ ② AI生成:提示词 → 生成新标题/新封面(无逐条审核) │ -⑤ 设置:开启测试商品范围内的 Shopee 更新安全开关 +⑤ 设置:配置 Shopee 更新安全、每批最大更新条数和执行模式 │ -③ 更新shopee:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 +③ 更新shopee:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 ``` -- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。 +- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④;③ 不静默启动缺失账号 Chrome。 - 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。 - 任意步骤失败:记入该任务、日志标明,不影响其他任务。 @@ -173,12 +178,12 @@ | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | -| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + Shopee 更新安全拦截 + 开始更新确认 + dry-run/真实更新运行日志 + 结果回写与结束汇总 | +| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「预览本轮更新」+ Shopee 更新安全拦截 + 分批开始更新确认 + 预览/真实更新运行日志 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | -| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 | +| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 响应式三列设置表单 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | -| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、dry-run 预览、按账号并行或串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续,写运行日志 | +| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、预览本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续,写运行日志 | | `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | diff --git a/progress.md b/progress.md index c722260..d020223 100644 --- a/progress.md +++ b/progress.md @@ -812,3 +812,87 @@ - 修复:`_wait_post_update()` 改为先判断 `redirected_to_list` 或成功 toast;错误 toast 只记录为 `last_error_state`,观测窗口结束仍没有成功信号时才返回 `POST_UPDATE_ERROR`。 - 测试:`tests/test_editor_login.py` 新增 `test_click_update_treats_list_redirect_as_success_even_with_stale_error_toast`,保留 `test_click_update_reports_logistics_validation_after_confirm` 覆盖未跳转时错误仍失败;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(28 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(135 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。 - 下一步:用户复跑真实 T-404 链路,确认 GUI 与浏览器成功状态一致。 +## 【2026-06-30】文档补充 · ③ 更新前账号 Chrome 就绪预检 + +- 背景:讨论 ③「开始更新」后如果本轮需要的账号 Chrome 未打开,是否应由程序自动打开。产品判断为:真实更新是线上高风险动作,不应静默启动多个账号浏览器并继续提交。 +- 决策:③ 点击「开始更新」并通过安全开关/确认后,真实提交前必须检查当前筛选结果涉及的账号 Chrome/CDP/登录态;若任一账号 Chrome 未启动、CDP 端口不可访问、未登录或端口冲突,弹窗列出账号和原因,整体中止本轮更新,不调用 `editor.apply_task()`,不写失败状态,不自动打开 Chrome。 +- ④ 边界:④「启动登录」只打开对应账号浏览器供人工登录;④「检测登录」与①/③预检只验证当前环境是否可用,不自动登录、不填密码。 +- 文档:同步 `docs/02-requirements.md`、`docs/routes.md`、`docs/api.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/current-state.md`。本轮只更新文档,未改代码。 +## 【2026-06-30】代码补齐 · ③ 账号未就绪时明确中止更新 + +- 代码:`app/gui.py` 的 ③ 更新账号未就绪弹窗新增明确文案:本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品;① 采集账号未就绪文案单独保持为“本轮采集已中止”,避免把更新语义带到采集模块。 +- 测试:`tests/test_gui.py` 覆盖 ③ 账号 Chrome 未启动时 `ApplyWorker` 不调用 `accounts.launch_for_login()`、不调用 `editor.apply_task()`、不写任务失败状态;覆盖弹窗包含“本轮更新已中止 / 不会自动打开账号 Chrome / 不会提交任何商品”。同步修正①采集阻断文案断言。 +- 验证:`python -m py_compile app/gui.py tests/test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(54 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(135 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。 +## 【2026-06-30】文档补充 · 正式使用批量更新与设置页布局 + +- 背景:单条真实更新已人工测试通过,正式使用需要支持当前筛选结果无论多少条都能更新到 Shopee,同时降低误筛选风险,并整理⑤设置页视觉密度。 +- 决策:保留预览能力但不再对用户显示 `dry-run` 技术词,③ 增加「预览本轮更新」按钮;「开始更新」按每批最大更新条数自动分批,确认弹窗显示总数/每批/预计批次;点击停止为协作式停止,当前商品跑到安全边界后不再开始新商品或下一批。 +- 设置页:⑤ 内容区居中并加大左右留白到当前约 250% 的视觉尺度;模型详情、角色与生成参数、路径与端口、Shopee 更新安全均改为响应式三列布局,短字段一格,URL/API Key/路径等长字段跨列;测试商品 ID 后续移入高级/调试区或弱化展示。 +- 文档:同步 `docs/02-requirements.md`、`docs/routes.md`、`docs/api.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/current-state.md`。本轮只更新文档,未改代码。 +## 【2026-06-30】代码补齐 · T-506 正式使用批量更新体验 + +- 代码:③ 更新shopee新增「预览本轮更新」按钮,预览不再依赖⑤里的 dry-run 开关;正式「开始更新」固定走真实更新安全开关和确认弹窗,弹窗展示任务数、每批最大更新条数和预计批次。 +- 分批:`ApplyWorker` 新增 `batch_size`,真实更新按当前筛选全部可更新记录切批执行;每批写运行日志,summary/progress/run_log options 记录 `batch_size` 和 `batch_count`;点击停止为协作式停止,不再开始下一条或下一批。 +- 设置页:⑤ 隐藏用户可见 dry-run 开关,保存设置时将 `dry_run` 固定为 `False`;模型详情、角色与生成参数、路径与端口、Shopee 更新安全改为三列布局,内容区居中并加大左右留白。 +- 测试:更新 `tests/test_gui.py` 覆盖预览按钮、不再按总数阻断、Worker 分批日志、设置页 dry-run 隐藏与保存为 False。 +- 文档:`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/routes.md` 同步新语义;`docs/06-tasks.md` 将 T-506 标记 DONE,`docs/current-state.md` 更新当前实现快照。 +- 验证:`python -m py_compile app/gui.py tests/test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(54 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(135 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。 +## 【2026-06-30】设置页微调 · 左右留白缩短为约 40% + +- 背景:用户反馈 ⑤ 设置模块两边空白需要缩短为当前约 40%。 +- 代码:`app/gui.py` 中 SettingsTab 外层 `layout.setContentsMargins(45, 18, 45, 18)` 调整为 `18, 18, 18, 18`,中间内容区 `panel_layout.setContentsMargins(32, 18, 32, 18)` 调整为 `13, 18, 13, 18`;保留居中内容区和三列表单,当前 `panel.setMaximumWidth(1800)` 用于减少宽屏下的左右空白。 +- 文档:同步 `docs/02-requirements.md`、`docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`,将 250% 留白描述改为约 40% / 适度留白。 +## 【2026-06-30】设置页微调 · 保存成功提示框 + +- 背景:⑤ 设置点击「保存设置」成功后原来只写状态栏,用户不容易感知保存结果。 +- 文档:先同步 `docs/02-requirements.md`、`docs/routes.md`、`docs/api.md`、`docs/current-state.md`,明确保存成功后保留状态栏提示并弹轻量提示框。 +- 代码:`SettingsTab.save_app_settings()` 在保存、替换配置、回填表单并设置状态栏后,调用 `QMessageBox.information(self, "保存设置", "设置已保存")`。 +- 测试:更新 `tests/test_gui.py` 的设置保存用例,mock 成功提示框并断言标题/文案,避免模态框阻塞测试。 +## 【2026-06-30】设置页微调 · 最大并行账号数移到多账号并行后 + +- 文档:先同步 `docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`,明确 Shopee 更新安全三列布局中「最大并行账号数」必须位于「多账号并行更新」后面同一行。 +- 代码:`SettingsTab` 的 Shopee 更新安全字段顺序调整为第二行「允许更新封面 / 多账号并行更新 / 最大并行账号数」,「成功后关闭本次新开编辑页」移动到下一行;同时保留 `self.shopee_update_form_layout` 供测试定位。 +- 测试:`tests/test_gui.py` 新增布局位置断言,确认 `max_parallel_accounts_spin` 与 `parallel_accounts_checkbox` 同行且在其后。 +## 【2026-06-30】设置页微调 · 并行账号数与并行开关合并同一组件 + +- 背景:用户确认「最大并行账号数」不是只放到同一网格行,而是必须紧跟「多账号并行更新」后面,不能显示到下面。 +- 文档:先同步 `docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`,明确「多账号并行更新」与「最大并行账号数」合并为同一个横向组件,最大并行账号数紧跟其后且不换行。 +- 代码:`SettingsTab` 新增 `parallel_accounts_group`,内部用 `QHBoxLayout` 顺序放置 `parallel_accounts_checkbox`、`max_parallel_accounts_label`、`max_parallel_accounts_spin`;Shopee 更新安全表单支持字段跨 2 个组件位,并把该组合组件放在「允许更新封面」后面。 +- 测试:更新 `tests/test_gui.py`,断言组合组件与「允许更新封面」同一行,跨 2 个组件位,且内部 widget 顺序为多账号并行开关、最大并行账号数标签、输入框。 +## 【2026-06-30】文档补充 · T-507 移除普通流程测试商品 ID 限制 + +- 背景:正式批量更新时,③「开始更新」当前仍因任务包含非测试商品 ID 阻断,只允许更新 ⑤ 配置的测试商品 ID。这适合开发测试阶段,但不适合正式批量更新真实商品。 +- 决策:新增 T-507。普通正式更新不再以 `test_item_id` 阻断当前筛选结果;当前筛选结果可包含多个真实商品 ID。`test_item_id` 仅保留为历史/调试兼容字段,后续如需要调试模式再单独启用。 +- 保留安全闸:允许真实提交线上商品、允许更新封面、每批最大更新条数、开始更新二次确认、账号 Chrome/CDP/登录态预检、多账号并行上限、运行日志和 Excel 回写。 +- 文档:同步 `docs/02-requirements.md`、`docs/routes.md`、`docs/api.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/current-state.md`。本轮只更新文档,代码尚未改;下一步代码更新前仍会因非测试商品 ID 阻断。 +## 【2026-06-30】代码更新 · T-507 移除普通流程测试商品 ID 限制 + +- 代码:`ApplyTab._update_safety_error()` 移除 `test_item_id` 非空与商品 ID 比对阻断;普通正式更新只继续检查「允许真实提交线上商品」和「允许更新封面」。 +- 代码:③「开始更新」确认弹窗不再显示测试商品 ID;当前筛选结果可包含多个真实商品 ID,仍按每批最大更新条数分批执行。 +- 代码:⑤ 设置页隐藏普通用户可见的测试商品 ID 输入入口,但保留 `test_item_id` 字段加载/保存兼容旧配置和后续调试模式。 +- 测试:更新 `tests/test_gui.py`,覆盖确认弹窗不含测试商品 ID、非测试商品 ID/空测试商品 ID 不再阻断、设置页测试商品 ID 入口隐藏且配置兼容。 +- 验证:`python -m py_compile app/gui.py tests/test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(54 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(135 tests)。 +## 【2026-06-30】T-404 补丁 · 上传前点击上传块模拟人工路径 + +- 现象:商品 26887160467 更新封面时,代码删除第一张图片后直接 `DOM.setFileInputFiles`,Shopee 图片组件长期转圈;同一张约 220KB 图片在浏览器手动上传成功。 +- 分析:文件大小和格式不是主因;差异在于人工流程会先点击「新增图片」上传块,Shopee 前端可能在点击时初始化上传队列,而旧代码直接给隐藏 input 注入文件,可能导致队列未完整初始化。 +- 文档:先同步 `docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`,将封面上传策略改为“点击上传块 → 短暂等待 → 重新获取 input → `DOM.setFileInputFiles` → 派发 input/change”。 +- 代码:`app/editor.py` 新增 `JS_CLICK_UPLOAD_TILE` 和 `_click_upload_tile()`;`replace_cover()` 在上传前先点击 `.shopee-image-manager__upload`,等待 1 秒后重新获取 `input[type=file]`,再注入本地图片;失败时返回 `UPLOAD_TILE_NOT_READY` 并带 `upload_click` 诊断。 +- 测试:`tests/test_editor_login.py` 扩展 `FakeCoverCDP`,新增上传块点击成功和上传块不可点击的回归测试,确认执行 `DOM.setFileInputFiles` 时上传块已先被点击。 +- 验证:`python -m py_compile app/editor.py tests/test_editor_login.py` 通过;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(30 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(137 tests)。T-404 仍需真实商品复跑确认。 +## 【2026-06-30】T-404 补丁 · 返回商品列表后延迟关闭 tab + +- 背景:真实流程为点击页面主「更新」后出现 Shopee 确认弹窗,点击弹窗主按钮「更新」后跳回我的商品列表页;如果此时立即关闭本轮新开的 tab,可能不给页面跳转与状态稳定留时间。 +- 文档:先同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/06-tasks.md`、`docs/current-state.md`,明确 `close_success_tab=true` 且成功提交、tab 为本轮自动新开时,确认后跳回商品列表页场景关闭前等待 2 秒。 +- 代码:`app/editor.py` 的 `_close_applied_product()` 在真正调用 `close_tab(target_id, host=...)` 前执行 `time.sleep(2)`;失败任务、复用的用户已有 tab、未开启 `close_success_tab` 的任务不等待关闭。 +- 测试:更新 `tests/test_editor_login.py`,成功提交并关闭自动新开 tab 的路径断言 `time.sleep(2)`;失败提交路径断言不关闭且不等待。 +- 验证:`python -m py_compile app/editor.py tests/test_editor_login.py` 通过;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(30 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(137 tests)。T-404 仍需真实商品复跑确认。 + +## 【2026-06-30】T-404/T-502 补丁 · 8张图也先删第一张 + 图片路径按批次细分 + +- 第1点文档:同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/05-coding-rules.md`、`docs/06-tasks.md`、`docs/current-state.md`,明确更新封面统一按“替换第一张”执行,不再只在满 9 张时删除;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 作为封面上传错误立即失败。 +- 第1点代码:`app/editor.py` 的 `replace_cover()` 改为只要有新封面就先校验 `old_cover_path` 本地备份,再删除当前第一张、等待稳定、点击上传块并上传新图;上传循环优先处理错误 toast,重复图返回 `UPLOAD_DUPLICATE_IMAGE`。`tests/test_editor_login.py` 覆盖 8 张图也删除第一张、重复图片 toast 和旧备份缺失保护。 +- 第2点文档:同步本地图片目录规则,新采集/新生成图片改为 `images///__old/new.jpg`;DB 已存旧路径继续作为权威路径,不迁移、不推断。 +- 第2点代码:新增 `app/image_paths.py` 统一图片路径 helper;`CollectWorker` 的旧封面目标路径、`ai.generate_batch()` 的新封面目标路径、`editor.collect()` 兜底旧封面路径均接入该规则。历史 `old_cover_path/new_cover_path` 仍按 DB 记录读取。 +- 测试:新增 `tests/test_image_paths.py`;更新 `tests/test_ai.py`、`tests/test_gui.py`、`tests/test_editor_login.py`。验证通过:`python -m py_compile app/image_paths.py app/ai.py app/gui.py app/editor.py tests/test_image_paths.py tests/test_ai.py tests/test_gui.py tests/test_editor_login.py`;`python -m unittest discover -s tests -p "test_image_paths.py"`(2 tests);`python -m unittest discover -s tests -p "test_ai.py"`(7 tests);`python -m unittest discover -s tests -p "test_gui.py"`(54 tests);`python -m unittest discover -s tests -p "test_editor_login.py"`(32 tests);`python -m compileall app main.py`;`python -m unittest discover -s tests`(141 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。 +- 下一步:用户复跑 T-404 真实商品链路,重点确认 8 张图商品先删第一张、重复图片提示不再拖到超时、新图片上传成功后可提交。 \ No newline at end of file diff --git a/tests/test_ai.py b/tests/test_ai.py index 0ca10b9..ca52c05 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -277,7 +277,18 @@ class AITests(TempDirMixin, unittest.TestCase): updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in updated)) self.assertEqual({"新旧标题A", "新旧标题B"}, {task.new_title for task in updated}) - self.assertTrue(all(task.new_cover_path.endswith("_new.jpg") for task in updated)) + expected_cover_paths = { + os.path.abspath( + os.path.join( + cfg["image_dir"], + str(task.batch_id), + "main", + f"{task.id}_{task.item_id}_new.jpg", + ) + ) + for task in updated + } + self.assertEqual(expected_cover_paths, {task.new_cover_path for task in updated}) self.assertTrue(all(os.path.exists(task.new_cover_path) for task in updated)) self.assertIn("主店", "\n".join(cover_prompts)) self.assertIn("新旧标题A", "\n".join(cover_prompts)) diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 0086310..663bca7 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -169,6 +169,8 @@ class FakeCoverCDP: upload_input_ready=True, restore_upload_after_delete=False, page_error_toasts=None, + upload_tile_click=True, + require_upload_tile_click=False, ): self.initial = cover_rects(count) self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1) @@ -189,10 +191,14 @@ class FakeCoverCDP: self.upload_input_ready = upload_input_ready self.restore_upload_after_delete = restore_upload_after_delete self.page_error_toasts = page_error_toasts or [] + self.upload_tile_click = upload_tile_click + self.require_upload_tile_click = require_upload_tile_click self.delete_clicked = False self.confirm_clicked = False + self.upload_tile_clicked = False self.uploaded = False self.uploaded_after_stable = None + self.upload_tile_clicked_before_file_set = None self.dragged = False self.sent = [] self.drag_calls = [] @@ -244,6 +250,12 @@ class FakeCoverCDP: return json.dumps(self._state(busy_count=1)) if self.uploaded and self.upload_mode == "error": return json.dumps(self._state(errors=["圖片格式錯誤"])) + if self.uploaded and self.upload_mode == "duplicate": + state = self._state() + state["toasts"] = ["有1張重複的圖片"] + state["error_toasts"] = ["有1張重複的圖片"] + state["page_error_toasts"] = ["有1張重複的圖片"] + return json.dumps(state) if self.confirm_clicked and not self.uploaded and self.post_delete_busy_polls > 0: self.post_delete_busy_polls -= 1 return json.dumps(self._state(busy_count=1)) @@ -258,14 +270,22 @@ class FakeCoverCDP: return json.dumps({"clicked": False, "reason": "NO_CONFIRM_BUTTON"}) self.confirm_clicked = True return json.dumps({"clicked": True, "reason": None, "text": "刪除"}) + if expr == editor.JS_CLICK_UPLOAD_TILE: + if not self.upload_tile_click: + return json.dumps({"clicked": False, "reason": "NO_UPLOAD_TILE"}) + self.upload_tile_clicked = True + return json.dumps({"clicked": True, "reason": None}) return True def object_id(self, expr): + if self.require_upload_tile_click and not self.upload_tile_clicked: + return None return "upload-input" if self._upload_ready() else None def send(self, method, params=None): self.sent.append((method, params or {})) if method == "DOM.setFileInputFiles": + self.upload_tile_clicked_before_file_set = self.upload_tile_clicked self.uploaded_after_stable = self.post_delete_busy_polls == 0 self.uploaded = True return {} @@ -577,7 +597,9 @@ class EditorLoginTests(unittest.TestCase): ) as change_title, mock.patch("app.editor.replace_cover") as replace_cover, mock.patch( "app.editor.click_update", return_value={"clicked": True, "reason": None}, - ), mock.patch("app.editor.close_tab", return_value=True) as close_tab: + ), mock.patch("app.editor.close_tab", return_value=True) as close_tab, mock.patch( + "app.editor.time.sleep" + ) as sleep: result = editor.apply_task( {"debug_port": 9222}, {"item_id": "51100639510", "new_title": "新标题"}, @@ -588,6 +610,7 @@ class EditorLoginTests(unittest.TestCase): change_title.assert_called_once_with(cdp, "新标题") replace_cover.assert_not_called() self.assertTrue(cdp.closed) + sleep.assert_called_once_with(2) close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") def test_apply_task_keeps_product_tab_after_failed_update(self): @@ -602,7 +625,9 @@ class EditorLoginTests(unittest.TestCase): ), mock.patch( "app.editor.click_update", return_value={"clicked": False, "reason": "UPDATE_DISABLED"}, - ), mock.patch("app.editor.close_tab") as close_tab: + ), mock.patch("app.editor.close_tab") as close_tab, mock.patch( + "app.editor.time.sleep" + ) as sleep: result = editor.apply_task( {"debug_port": 9222}, {"item_id": "51100639510", "new_title": "新标题"}, @@ -613,6 +638,7 @@ class EditorLoginTests(unittest.TestCase): self.assertEqual("UPDATE_DISABLED", result["error"]) self.assertTrue(cdp.closed) close_tab.assert_not_called() + sleep.assert_not_called() def test_apply_task_passes_old_cover_backup_to_replace_cover(self): cdp = FakeProductCDP("ws-new") @@ -640,8 +666,8 @@ class EditorLoginTests(unittest.TestCase): old_cover_path="old.jpg", ) - def test_replace_cover_full_slots_requires_old_cover_backup(self): - cdp = FakeCoverCDP(count=9) + def test_replace_cover_requires_old_cover_backup_before_delete(self): + cdp = FakeCoverCDP(count=8) missing_backup = os.path.join(os.path.dirname(__file__), "missing-old-cover.jpg") with mock.patch("app.editor.time.sleep"): @@ -678,6 +704,61 @@ class EditorLoginTests(unittest.TestCase): self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"]) + def test_replace_cover_deletes_first_for_non_full_image_slots(self): + cdp = FakeCoverCDP(count=8) + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertTrue(result["ok"]) + self.assertTrue(cdp.delete_clicked) + self.assertTrue(cdp.confirm_clicked) + self.assertTrue(cdp.uploaded) + self.assertTrue(cdp.dragged) + self.assertEqual(8, result["count_before"]) + self.assertEqual(8, result["count_after"]) + self.assertEqual(7, result["delete"]["count_after"]) + + def test_replace_cover_clicks_upload_tile_before_setting_files(self): + cdp = FakeCoverCDP(count=9, require_upload_tile_click=True) + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertTrue(result["ok"]) + self.assertTrue(cdp.upload_tile_clicked) + self.assertTrue(cdp.uploaded) + self.assertIn("DOM.setFileInputFiles", [method for method, _params in cdp.sent]) + self.assertTrue(cdp.upload_tile_clicked_before_file_set) + self.assertEqual({"clicked": True, "reason": None}, result["upload_click"]) + + + def test_replace_cover_reports_upload_tile_not_ready(self): + cdp = FakeCoverCDP(count=8, upload_tile_click=False) + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertFalse(result["ok"]) + self.assertEqual("UPLOAD_TILE_NOT_READY", result["reason"]) + self.assertFalse(cdp.uploaded) + self.assertEqual("NO_UPLOAD_TILE", result["upload_click"]["reason"]) + def test_replace_cover_full_slots_deletes_before_upload_input_is_ready(self): cdp = FakeCoverCDP( count=9, @@ -742,7 +823,12 @@ class EditorLoginTests(unittest.TestCase): cdp = FakeCoverCDP(count=8, upload_mode="processing") with mock.patch("app.editor.time.sleep"): - result = editor.replace_cover(cdp, __file__, timeout=0.01) + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=0.01, + ) self.assertFalse(result["ok"]) self.assertEqual("UPLOAD_STILL_PROCESSING", result["reason"]) @@ -754,12 +840,33 @@ class EditorLoginTests(unittest.TestCase): cdp = FakeCoverCDP(count=8, upload_mode="error") with mock.patch("app.editor.time.sleep"): - result = editor.replace_cover(cdp, __file__, timeout=1) + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) self.assertFalse(result["ok"]) self.assertEqual("UPLOAD_PAGE_ERROR", result["reason"]) self.assertIn("圖片格式錯誤", result["upload_state"]["errors"]) + def test_replace_cover_reports_duplicate_upload_toast(self): + cdp = FakeCoverCDP(count=8, upload_mode="duplicate") + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertFalse(result["ok"]) + self.assertEqual("UPLOAD_DUPLICATE_IMAGE", result["reason"]) + self.assertIn("有1張重複的圖片", result["upload_state"]["error_toasts"]) + self.assertIn("新封面与现有商品图片重复", editor._cover_upload_error_message(result)) + def test_apply_task_cover_failure_returns_readable_upload_error(self): cdp = FakeProductCDP("ws-new") diff --git a/tests/test_gui.py b/tests/test_gui.py index e5b5412..ce0b590 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -152,14 +152,49 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(11, tab.connect_timeout_spin.value()) self.assertFalse(tab.delete_model_button.isEnabled()) self.assertEqual("51100639510", tab.test_item_id_edit.text()) + self.assertTrue(tab.test_item_id_edit.isHidden()) self.assertFalse(tab.allow_real_submit_checkbox.isChecked()) self.assertFalse(tab.allow_cover_update_checkbox.isChecked()) self.assertEqual(1, tab.max_items_per_run_spin.value()) self.assertFalse(tab.close_success_tab_checkbox.isChecked()) self.assertFalse(tab.dry_run_checkbox.isChecked()) + self.assertTrue(tab.dry_run_checkbox.isHidden()) self.assertFalse(tab.parallel_accounts_checkbox.isChecked()) self.assertEqual(2, tab.max_parallel_accounts_spin.value()) + def widget_position(layout, widget): + for index in range(layout.count()): + item = layout.itemAt(index) + if item is not None and item.widget() is widget: + return layout.getItemPosition(index) + self.fail(f"Widget not found in layout: {widget.objectName()}") + + group_row, group_col, _row_span, group_col_span = widget_position( + 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.assertEqual(4, group_col_span) + + group_widgets = [ + tab.parallel_accounts_group.layout().itemAt(index).widget() + for index in range(tab.parallel_accounts_group.layout().count()) + if tab.parallel_accounts_group.layout().itemAt(index).widget() is not None + ] + self.assertEqual( + [ + tab.parallel_accounts_checkbox, + tab.max_parallel_accounts_label, + tab.max_parallel_accounts_spin, + ], + group_widgets, + ) + self.assert_removed(temp_dir) def test_settings_tab_adds_saves_and_deletes_model(self): @@ -357,12 +392,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.allow_cover_update_checkbox.setChecked(True) tab.max_items_per_run_spin.setValue(2) tab.close_success_tab_checkbox.setChecked(True) - tab.dry_run_checkbox.setChecked(True) tab.parallel_accounts_checkbox.setChecked(True) tab.max_parallel_accounts_spin.setValue(3) - tab.save_app_settings() + with mock.patch("app.gui.QMessageBox.information") as info: + tab.save_app_settings() + info.assert_called_once_with(tab, "保存设置", "设置已保存") saved = appconfig.load_config(config_path) self.assertEqual("Text B", saved["ai"]["default_text_model"]) self.assertEqual("Image B", saved["ai"]["default_image_model"]) @@ -385,7 +421,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "allow_cover_update": True, "max_items_per_run": 2, "close_success_tab": True, - "dry_run": True, + "dry_run": False, "parallel_accounts": True, "max_parallel_accounts": 3, }, @@ -414,18 +450,19 @@ class GuiTests(TempDirMixin, unittest.TestCase): 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.dry_run_checkbox.setChecked(True) settings_tab.parallel_accounts_checkbox.setChecked(True) settings_tab.max_parallel_accounts_spin.setValue(4) - settings_tab.save_app_settings() + with mock.patch("app.gui.QMessageBox.information") as info: + settings_tab.save_app_settings() + info.assert_called_once_with(settings_tab, "保存设置", "设置已保存") 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.assertEqual(3, safety_cfg["max_items_per_run"]) self.assertTrue(safety_cfg["close_success_tab"]) - self.assertTrue(safety_cfg["dry_run"]) + self.assertFalse(safety_cfg["dry_run"]) self.assertTrue(safety_cfg["parallel_accounts"]) self.assertEqual(4, safety_cfg["max_parallel_accounts"]) @@ -897,6 +934,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIsInstance(tab.task_table, QTableView) self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS) + self.assertEqual("预览本轮更新", tab.preview_update_button.text()) self.assertEqual("开始更新", tab.start_update_button.text()) self.assertEqual("applyItemFilter", tab.item_filter.objectName()) self.assertEqual("停止", tab.stop_update_button.text()) @@ -1032,10 +1070,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): message = question.call_args[0][2] self.assertIn("任务数:1", message) + self.assertIn("预计批次:1", message) self.assertIn("提交线上", message) self.assertIn("状态:已生成", message) self.assertIn("商品ID:51100639510", message) - self.assertIn("测试商品ID=51100639510", message) + self.assertNotIn("测试商品ID", message) self.assertEqual("已取消开始更新", statuses[-1]) apply_task.assert_not_called() @@ -1098,9 +1137,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(tab.apply_worker.close_success_tab) self.assertFalse(tab.apply_worker.dry_run) self.assertFalse(tab.apply_worker.parallel_accounts) + self.assertEqual(1, tab.apply_worker.batch_size) + self.assertFalse(tab.preview_update_button.isEnabled()) self.assertFalse(tab.start_update_button.isEnabled()) self.assertTrue(tab.stop_update_button.isEnabled()) - self.assertEqual("开始更新:1 条", statuses[-1]) + self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1]) apply_task.assert_not_called() unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", unchanged.stage) @@ -1108,7 +1149,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_apply_tab_dry_run_starts_without_real_submit_switch(self): + def test_apply_tab_preview_starts_without_real_submit_switch(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["shopee_update"] = { @@ -1117,7 +1158,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "allow_cover_update": False, "max_items_per_run": 1, "close_success_tab": False, - "dry_run": True, + "dry_run": False, "parallel_accounts": True, "max_parallel_accounts": 2, } @@ -1165,13 +1206,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): return_value=gui.QMessageBox.Yes, ), mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.run_worker", return_value=fake_thread): - tab.start_update() + tab.preview_update() warning.assert_not_called() self.assertTrue(tab.apply_worker.dry_run) self.assertTrue(tab.apply_worker.parallel_accounts) self.assertEqual(2, tab.apply_worker.max_parallel_accounts) - self.assertEqual("开始 dry-run 预览:1 条", statuses[-1]) + self.assertEqual("开始预览本轮更新:1 条", statuses[-1]) self.assert_removed(temp_dir) @@ -1252,7 +1293,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_apply_tab_safety_error_limits_max_count_and_test_item(self): + def test_apply_tab_safety_error_allows_more_than_batch_size_and_non_test_items(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) self.allow_shopee_update(cfg, item_id="51100639510", max_items=1) @@ -1264,16 +1305,19 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.item_id = item_id self.new_cover_path = new_cover_path - max_error = tab._update_safety_error( + count_error = tab._update_safety_error( [ Task("51100639510"), Task("51100639510"), ] ) - item_error = tab._update_safety_error([Task("51100639511")]) + non_test_error = tab._update_safety_error([Task("51100639511")]) + cfg["shopee_update"]["test_item_id"] = "" + missing_test_id_error = tab._update_safety_error([Task("26887160467")]) - self.assertIn("超过单次最大更新条数", max_error) - self.assertIn("非测试商品ID", item_error) + self.assertIsNone(count_error) + self.assertIsNone(non_test_error) + self.assertIsNone(missing_test_id_error) self.assert_removed(temp_dir) @@ -1341,6 +1385,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): db_path=cfg["db_path"], config=cfg, close_success_tab=True, + batch_size=1, ) worker.progress.connect(progress.append) worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) @@ -1357,6 +1402,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual([batch_id], summary["batch_ids"]) self.assertFalse(summary["dry_run"]) self.assertFalse(summary["parallel_accounts"]) + self.assertEqual(1, summary["batch_size"]) + self.assertEqual(3, summary["batch_count"]) self.assertIsNotNone(summary["run_id"]) self.assertEqual(3, progress[-1]["done"]) self.assertEqual(3, progress[-1]["total"]) @@ -1364,6 +1411,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(1, progress[-1]["skipped"]) self.assertEqual(1, progress[-1]["failed"]) self.assertFalse(progress[-1]["dry_run"]) + self.assertEqual(1, progress[-1]["batch_size"]) + self.assertEqual(3, progress[-1]["batch_count"]) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) by_alias = {task.alias: task for task in updated} self.assertEqual("applied", by_alias["alias-a"].stage) @@ -1380,10 +1429,9 @@ class GuiTests(TempDirMixin, unittest.TestCase): run_logs = db.list_run_logs(run_type="apply", path=cfg["db_path"]) self.assertEqual(1, len(run_logs)) self.assertEqual("done", run_logs[0].status) - self.assertGreaterEqual( - len(db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])), - 3, - ) + events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"]) + self.assertGreaterEqual(len(events), 3) + self.assertTrue(any("更新批次 1/3" in event.message for event in events)) self.assert_removed(temp_dir) @@ -1445,7 +1493,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in unchanged)) self.assertTrue(all(task.status == "success" for task in unchanged)) - self.assertTrue(any("dry-run" in line for line in logs)) + self.assertTrue(any("预览" in line for line in logs)) run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0] self.assertEqual(1, run_log.dry_run) self.assertEqual("done", run_log.status) @@ -1690,6 +1738,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \ mock.patch("app.gui.accounts.detect_login") as detect_login, \ + mock.patch("app.gui.accounts.launch_for_login") as launch_for_login, \ mock.patch("app.gui.editor.apply_task") as apply_task: summary = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() @@ -1698,6 +1747,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("alias-a", summary["not_running"][0]["alias"]) is_running.assert_called_once_with(9222) detect_login.assert_not_called() + launch_for_login.assert_not_called() apply_task.assert_not_called() unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", unchanged.stage) @@ -1733,8 +1783,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): message = warning.call_args[0][2] self.assertIn("Chrome 未启动", message) + self.assertIn("本轮更新已中止", message) + self.assertIn("不会自动打开账号 Chrome", message) + self.assertIn("不会提交任何商品", message) self.assertIn("④ 账号管理", message) self.assertEqual([True], opened) + self.assertIn("本轮更新已中止", statuses[-1]) self.assertIn("④ 账号管理", statuses[-1]) self.assert_removed(temp_dir) @@ -2164,6 +2218,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + expected_old_cover = os.path.join(str(batch_id), logged.slug, f"{tasks[0].id}_51100639510_old.jpg") def fake_login(account, path=None, config=None): self.assertEqual(cfg["db_path"], path) @@ -2176,9 +2231,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): def fake_collect(account, task, on_step=None): self.assertEqual(logged.alias, account.alias) self.assertEqual("51100639510", task["item_id"]) - self.assertTrue(task["old_cover_path"].endswith( - os.path.join(logged.slug, "51100639510_old.jpg") - )) + self.assertTrue(task["old_cover_path"].endswith(expected_old_cover)) self.assertTrue(callable(on_step)) on_step("download_cover") return { @@ -2209,7 +2262,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("collected", by_alias["alias-a"].stage) self.assertEqual("success", by_alias["alias-a"].status) self.assertEqual("旧标题", by_alias["alias-a"].old_title) - self.assertTrue(by_alias["alias-a"].old_cover_path.endswith("51100639510_old.jpg")) + self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover)) self.assertEqual("imported", by_alias["alias-b"].stage) self.assertEqual("skipped", by_alias["alias-b"].status) self.assertIn("账号未登录", by_alias["alias-b"].last_error) @@ -2528,8 +2581,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): message = warning.call_args[0][2] self.assertIn("Chrome 未启动", message) + self.assertIn("本轮采集已中止", message) + self.assertIn("不会自动打开账号 Chrome", message) self.assertIn("④ 账号管理", message) self.assertEqual([True], opened) + self.assertIn("本轮采集已中止", statuses[-1]) self.assertIn("④ 账号管理", statuses[-1]) self.assert_removed(temp_dir) diff --git a/tests/test_image_paths.py b/tests/test_image_paths.py new file mode 100644 index 0000000..a5df7c3 --- /dev/null +++ b/tests/test_image_paths.py @@ -0,0 +1,38 @@ +import os +import sys +import unittest +from types import SimpleNamespace + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app import image_paths +from app.config import make_slug + + +class ImagePathTests(unittest.TestCase): + def test_task_image_path_uses_batch_slug_task_and_item(self): + root = os.path.abspath(os.path.join("tmp", "images")) + task = SimpleNamespace(id=42, batch_id=7, item_id="51100639510", alias="alias-a") + account = SimpleNamespace(slug="main_shop") + + path = image_paths.task_image_path(root, task, account, "new") + + self.assertEqual( + os.path.join(root, "7", "main_shop", "42_51100639510_new.jpg"), + path, + ) + + def test_task_image_path_falls_back_to_account_alias_slug(self): + root = os.path.abspath(os.path.join("tmp", "images")) + task = {"id": 5, "batch_id": 3, "item_id": "ITEM/1", "alias": "alias-a"} + + path = image_paths.task_image_path(root, task, None, "old") + + self.assertEqual( + os.path.join(root, "3", make_slug("alias-a"), "5_ITEM_1_old.jpg"), + path, + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file