From 5390debf40c6569c723978b1f552a06fe5bbc0b3 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 27 Jun 2026 15:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90T-205=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=B0=B1=E7=BB=AA=E5=BC=95=E5=AF=BC=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollectWorker 增加采集前账号就绪预检:无账号、匹配账号 CDP 端口未响应或未登录时返回 blocked,不进入逐条采集,也不写 skipped/failed。 CollectTab 收到 blocked 后弹窗汇总原因,并跳转/引导到④账号管理配置账号、启动对应 Chrome、人工登录;MainWindow 为①传入打开④的回调。 新增 GUI 测试覆盖无账号、Chrome 未启动、未登录和弹窗引导;同步 T-205 状态、api、current-state 与 progress。 --- app/gui.py | 123 +++++++++++++++++++++++++++++++++-- docs/06-tasks.md | 2 +- docs/api.md | 2 +- docs/current-state.md | 16 ++--- progress.md | 8 +++ tests/test_gui.py | 145 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 281 insertions(+), 15 deletions(-) diff --git a/app/gui.py b/app/gui.py index 0c12ead..2cecd54 100644 --- a/app/gui.py +++ b/app/gui.py @@ -75,7 +75,7 @@ QTabBar::tab:hover:!selected { if QT_IMPORT_ERROR is None: - from . import accounts, appconfig, db, editor, excel + from . import accounts, appconfig, chrome, db, editor, excel from . import config as account_config @@ -196,11 +196,19 @@ if QT_IMPORT_ERROR is None: class CollectTab(QWidget): """Tab 1: import Excel files and list imported tasks.""" - def __init__(self, parent=None, db_path=None, config=None, status_callback=None): + def __init__( + self, + parent=None, + db_path=None, + config=None, + status_callback=None, + open_accounts_callback=None, + ): super().__init__(parent) self.config = appconfig.load_config() if config is None else config self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback + self.open_accounts_callback = open_accounts_callback self.current_batch_id = None self.has_import_result = False self.last_import_stats = None @@ -235,8 +243,8 @@ if QT_IMPORT_ERROR is None: summary_layout.addWidget(self.show_all_button) summary_layout.addWidget(self.show_unmatched_button) - self.model = TaskTableModel(self) self.table = QTableView() + self.model = TaskTableModel(self.table) self.table.setModel(self.model) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) @@ -272,6 +280,16 @@ if QT_IMPORT_ERROR is None: QMessageBox.warning(self, "导入采集", str(message)) self._set_status(str(message)) + def _show_account_guide(self, message): + full_message = ( + f"{message}\n\n" + "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" + ) + QMessageBox.warning(self, "账号未就绪", full_message) + self._set_status(full_message.replace("\n", " ")) + if self.open_accounts_callback is not None: + self.open_accounts_callback() + def _choose_excel_files(self): files, _selected_filter = QFileDialog.getOpenFileNames( self, @@ -433,6 +451,9 @@ if QT_IMPORT_ERROR is None: def _on_collect_finished(self, payload): self._set_collect_running(False) self.refresh_tasks() + if payload.get("blocked"): + self._show_collect_blocked(payload) + return message = "采集完成:成功{collected},略过{skipped},失败{failed}".format( collected=payload.get("collected", 0), skipped=payload.get("skipped", 0), @@ -450,6 +471,36 @@ if QT_IMPORT_ERROR is None: return self._set_status(message) + def _show_collect_blocked(self, payload): + lines = ["采集前检查未通过。"] + if payload.get("no_accounts"): + lines.append("当前没有配置账号。") + not_running = payload.get("not_running") or [] + if not_running: + lines.append( + "以下账号 Chrome 未启动或调试端口不可访问:" + + "、".join(self._account_label(item) for item in not_running) + ) + logged_out = payload.get("logged_out") or [] + if logged_out: + lines.append( + "以下账号未登录 Shopee:" + + "、".join(self._account_label(item) for item in logged_out) + ) + self._show_account_guide("\n".join(lines)) + + def _account_label(self, item): + if isinstance(item, dict): + name = item.get("account_name") or item.get("alias") or "" + alias = item.get("alias") or "" + reason = item.get("reason") + else: + name = getattr(item, "account_name", "") or getattr(item, "alias", "") + alias = getattr(item, "alias", "") + reason = getattr(item, "reason", None) + label = f"{name}({alias})" if alias and name != alias else (name or alias) + return f"{label}: {reason}" if reason else label + def _on_collect_cancelled(self, payload): self._set_collect_running(False) self.refresh_tasks() @@ -641,11 +692,12 @@ if QT_IMPORT_ERROR is None: class CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" - def __init__(self, tasks, db_path=None, config=None): + def __init__(self, tasks, db_path=None, config=None, preflight=True): super().__init__() self.tasks = list(tasks) self.db_path = db_path self.config = config + self.preflight = preflight def execute(self): account_rows = accounts.list_accounts(path=self.db_path, config=self.config) @@ -664,6 +716,22 @@ if QT_IMPORT_ERROR is None: failed = 0 done = 0 + if self.preflight: + blocked = self._preflight_block(eligible, account_rows, account_by_alias) + if blocked: + blocked.update( + { + "ok": False, + "blocked": True, + "total": total, + "done": 0, + "collected": 0, + "skipped": 0, + "failed": 0, + } + ) + return blocked + for task in eligible: if self.should_cancel(): break @@ -732,6 +800,49 @@ if QT_IMPORT_ERROR is None: "failed": failed, } + def _preflight_block(self, eligible, account_rows, account_by_alias): + if not account_rows: + return { + "reason": "NO_ACCOUNTS", + "no_accounts": True, + } + required_accounts = [] + seen_aliases = set() + for task in eligible: + alias = str(task.alias).strip() + account = account_by_alias.get(alias) + if account is not None and alias not in seen_aliases: + required_accounts.append(account) + seen_aliases.add(alias) + not_running = [] + logged_out = [] + for account in required_accounts: + if not chrome.is_running(account.debug_port): + not_running.append(self._account_payload(account, "CDP 端口未响应")) + continue + status = self._login_status(account) + if not status.get("logged_in"): + logged_out.append( + self._account_payload(account, self._login_skip_reason(status)) + ) + if not_running or logged_out: + return { + "reason": "ACCOUNT_NOT_READY", + "not_running": not_running, + "logged_out": logged_out, + } + return None + + def _account_payload(self, account, reason=None): + payload = { + "account_name": account.account_name, + "alias": account.alias, + "debug_port": account.debug_port, + } + if reason: + payload["reason"] = reason + return payload + def _emit_progress(self, done, total, collected, skipped, failed): self.progress.emit( { @@ -1088,6 +1199,7 @@ if QT_IMPORT_ERROR is None: db_path=self.db_path, config=self.config, status_callback=self.statusBar().showMessage, + open_accounts_callback=lambda: self.open_accounts_tab(), ) if title == "④ 账号管理": return AccountsTab( @@ -1104,6 +1216,9 @@ if QT_IMPORT_ERROR is None: def _on_tab_changed(self, index): self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}") + + def open_accounts_tab(self): + self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理")) else: class MainWindow(QMainWindow): def __init__(self): diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 392546f..1ef22e4 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -53,7 +53,7 @@ | T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001, T-104b | 通过 worker 执行;逐条 set_collected;旧封面下载到 `images//`;未登录/未匹配略过记原因 | 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 | TODO | +| T-205 | 首次未配账号 / Chrome 未启动 / 未登录的引导保护 | T-105, T-203 | 无账号、匹配账号未启动 CDP 端口或未登录时,① 执行按钮禁用或采集前汇总提示,并引导去④;可提供“打开账号管理/启动登录”入口,但不无提示批量启动所有账号 Chrome | DONE | ## Phase 3 · AI 生成(②) diff --git a/docs/api.md b/docs/api.md index 305fdb3..88cfeb7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -304,7 +304,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;每条先检测登录,未登录写 `mark_skipped`,已登录则下载旧封面到 `image_dir//_old.jpg` 并 `db.set_collected()`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 - 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。 - 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。 -- T-205 后,若无账号、当前批次匹配账号未启动 CDP 端口或未登录,① 的采集执行应禁用或在执行前汇总拦截并提示去④账号管理;可以提供跳转/启动登录入口,但不无提示批量启动所有账号 Chrome。 +- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。 ## workers 模块(`app/workers.py`,已建,PySide6) diff --git a/docs/current-state.md b/docs/current-state.md index 4c3ae88..2a1d34a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,9 +6,9 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段: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 采集完成自动回写。 +- 阶段: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 未启动/未登录引导保护。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 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、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 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、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测/excel 导入/gui ① 导入采集/gui ④ 账号管理/worker signal 与线程包装,并对尚未实现的 app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 @@ -32,7 +32,7 @@ | `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集完成自动回写与手动重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | | `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 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task | @@ -40,7 +40,7 @@ | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `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 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/gui/workers;prompts 模块契约占位测试 | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/gui/workers;prompts 模块契约占位测试 | | `app/excel.py` | 已有 | T-201/T-204 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,待提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -57,13 +57,13 @@ 任务状态以 [`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-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-205(首次未配账号 / Chrome 未启动 / 未登录的引导保护)**。 +- 下一个可领取任务:**T-301(确定 AI 服务商/模型并接入 `app/ai.py`)**。 -## 当前已发现待修体验问题 +## 当前已知限制 -- ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;若未启动或未登录,当前会采集失败/略过。已在 T-205 明确处理:禁用或提示,并引导去④账号管理启动登录,不无提示批量启动所有账号 Chrome。 +- ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index cee7854..ea4a874 100644 --- a/progress.md +++ b/progress.md @@ -442,3 +442,11 @@ - 细节:手动「回写旧数据到 Excel」按钮保留,主要作为自动回写失败后的重试入口;若回写 worker 已在运行,会提示“Excel 回写正在进行”,避免重复启动。 - 验证:`python -m unittest discover -s tests -p "test_gui.py"` 通过(14 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(56 tests,skipped=1);`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(34 tests,skipped=4,py -3 环境缺 openpyxl/PySide6,相关测试按设计跳过)。 - 下一步:按任务看板领取 T-205(首次未配账号 / Chrome 未启动 / 未登录的引导保护)。 + +## 【2026-06-27】T-205 首次未配账号 / Chrome 未启动 / 未登录引导保护 + +- 状态:DONE +- 变更:`app/gui.py` 的 `CollectWorker` 增加采集前账号就绪预检:无账号、当前批次匹配账号 CDP 端口未响应、账号未登录时返回 `blocked=True`,不进入逐条采集、不写 skipped/failed;`CollectTab` 收到 blocked 后弹窗汇总原因,并跳转/引导用户去④账号管理配置账号、启动对应 Chrome、人工登录。`MainWindow` 为①传入打开④的回调;GUI 测试增加无账号、Chrome 未启动、未登录、弹窗引导覆盖。 +- 细节:预检只检查当前批次中别名能匹配到账号的任务;别名未匹配的任务仍按既有逻辑在采集 worker 中略过。设计上不无提示批量启动所有账号 Chrome,避免开错账号或启动过多浏览器进程。 +- 验证:`python -m unittest discover -s tests -p "test_gui.py"` 通过(18 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(60 tests,skipped=1);`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(34 tests,skipped=4,py -3 环境缺 openpyxl/PySide6,相关测试按设计跳过)。 +- 下一步:按任务看板领取 T-301(确定 AI 服务商/模型并接入 `app/ai.py`)。 diff --git a/tests/test_gui.py b/tests/test_gui.py index e6e1e99..87931e5 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -33,6 +33,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) + def tearDown(self): + for widget in QApplication.topLevelWidgets(): + widget.close() + widget.deleteLater() + self.app.processEvents() + def make_config(self, temp_dir): return { "chrome_path": "chrome.exe", @@ -369,7 +375,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \ mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect: - summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + summary = CollectWorker( + tasks, + db_path=cfg["db_path"], + config=cfg, + preflight=False, + ).execute() self.assertEqual({"ok": True, "total": 3, "done": 3, "collected": 1, "skipped": 2, "failed": 0}, summary) collect.assert_called_once() @@ -453,6 +464,138 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_collect_worker_preflight_blocks_when_no_accounts(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + + summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["blocked"]) + self.assertTrue(summary["no_accounts"]) + self.assertEqual("NO_ACCOUNTS", summary["reason"]) + + self.assert_removed(temp_dir) + + def test_collect_worker_preflight_blocks_when_chrome_not_running(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + + 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.editor.collect") as collect: + summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["blocked"]) + self.assertEqual("ACCOUNT_NOT_READY", summary["reason"]) + self.assertEqual("alias-a", summary["not_running"][0]["alias"]) + is_running.assert_called_once_with(9222) + detect_login.assert_not_called() + collect.assert_not_called() + + self.assert_removed(temp_dir) + + def test_collect_worker_preflight_blocks_when_account_not_logged_in(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + + with mock.patch("app.gui.chrome.is_running", return_value=True), \ + mock.patch( + "app.gui.accounts.detect_login", + return_value={"logged_in": False, "reason": "LOGIN_PAGE"}, + ), mock.patch("app.gui.editor.collect") as collect: + summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["blocked"]) + self.assertEqual("alias-a", summary["logged_out"][0]["alias"]) + self.assertIn("账号未登录", summary["logged_out"][0]["reason"]) + collect.assert_not_called() + + self.assert_removed(temp_dir) + + def test_collect_tab_blocked_preflight_guides_to_accounts_tab(self): + with self.make_temp_dir() as temp_dir: + opened = [] + statuses = [] + tab = CollectTab( + config=self.make_config(temp_dir), + status_callback=statuses.append, + open_accounts_callback=lambda: opened.append(True), + ) + self.addCleanup(tab.close) + payload = { + "blocked": True, + "not_running": [ + { + "account_name": "主店", + "alias": "alias-a", + "reason": "CDP 端口未响应", + } + ], + "logged_out": [], + } + + with mock.patch("app.gui.QMessageBox.warning") as warning: + tab._on_collect_finished(payload) + + message = warning.call_args[0][2] + self.assertIn("Chrome 未启动", message) + self.assertIn("④ 账号管理", message) + self.assertEqual([True], opened) + self.assertIn("④ 账号管理", statuses[-1]) + + self.assert_removed(temp_dir) + def test_write_back_worker_calls_excel_write_back(self): with mock.patch( "app.gui.excel.write_back",