feat: 完成T-205账号就绪引导保护
CollectWorker 增加采集前账号就绪预检:无账号、匹配账号 CDP 端口未响应或未登录时返回 blocked,不进入逐条采集,也不写 skipped/failed。 CollectTab 收到 blocked 后弹窗汇总原因,并跳转/引导到④账号管理配置账号、启动对应 Chrome、人工登录;MainWindow 为①传入打开④的回调。 新增 GUI 测试覆盖无账号、Chrome 未启动、未登录和弹窗引导;同步 T-205 状态、api、current-state 与 progress。
This commit is contained in:
+119
-4
@@ -75,7 +75,7 @@ QTabBar::tab:hover:!selected {
|
|||||||
|
|
||||||
|
|
||||||
if QT_IMPORT_ERROR is None:
|
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
|
from . import config as account_config
|
||||||
|
|
||||||
|
|
||||||
@@ -196,11 +196,19 @@ if QT_IMPORT_ERROR is None:
|
|||||||
class CollectTab(QWidget):
|
class CollectTab(QWidget):
|
||||||
"""Tab 1: import Excel files and list imported tasks."""
|
"""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)
|
super().__init__(parent)
|
||||||
self.config = appconfig.load_config() if config is None else config
|
self.config = appconfig.load_config() if config is None else config
|
||||||
self.db_path = _database_path(db_path, self.config)
|
self.db_path = _database_path(db_path, self.config)
|
||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
|
self.open_accounts_callback = open_accounts_callback
|
||||||
self.current_batch_id = None
|
self.current_batch_id = None
|
||||||
self.has_import_result = False
|
self.has_import_result = False
|
||||||
self.last_import_stats = None
|
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_all_button)
|
||||||
summary_layout.addWidget(self.show_unmatched_button)
|
summary_layout.addWidget(self.show_unmatched_button)
|
||||||
|
|
||||||
self.model = TaskTableModel(self)
|
|
||||||
self.table = QTableView()
|
self.table = QTableView()
|
||||||
|
self.model = TaskTableModel(self.table)
|
||||||
self.table.setModel(self.model)
|
self.table.setModel(self.model)
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
@@ -272,6 +280,16 @@ if QT_IMPORT_ERROR is None:
|
|||||||
QMessageBox.warning(self, "导入采集", str(message))
|
QMessageBox.warning(self, "导入采集", str(message))
|
||||||
self._set_status(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):
|
def _choose_excel_files(self):
|
||||||
files, _selected_filter = QFileDialog.getOpenFileNames(
|
files, _selected_filter = QFileDialog.getOpenFileNames(
|
||||||
self,
|
self,
|
||||||
@@ -433,6 +451,9 @@ if QT_IMPORT_ERROR is None:
|
|||||||
def _on_collect_finished(self, payload):
|
def _on_collect_finished(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
|
if payload.get("blocked"):
|
||||||
|
self._show_collect_blocked(payload)
|
||||||
|
return
|
||||||
message = "采集完成:成功{collected},略过{skipped},失败{failed}".format(
|
message = "采集完成:成功{collected},略过{skipped},失败{failed}".format(
|
||||||
collected=payload.get("collected", 0),
|
collected=payload.get("collected", 0),
|
||||||
skipped=payload.get("skipped", 0),
|
skipped=payload.get("skipped", 0),
|
||||||
@@ -450,6 +471,36 @@ if QT_IMPORT_ERROR is None:
|
|||||||
return
|
return
|
||||||
self._set_status(message)
|
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):
|
def _on_collect_cancelled(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
@@ -641,11 +692,12 @@ if QT_IMPORT_ERROR is None:
|
|||||||
class CollectWorker(BaseWorker):
|
class CollectWorker(BaseWorker):
|
||||||
"""Collect old title and cover for imported tasks."""
|
"""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__()
|
super().__init__()
|
||||||
self.tasks = list(tasks)
|
self.tasks = list(tasks)
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.preflight = preflight
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
||||||
@@ -664,6 +716,22 @@ if QT_IMPORT_ERROR is None:
|
|||||||
failed = 0
|
failed = 0
|
||||||
done = 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:
|
for task in eligible:
|
||||||
if self.should_cancel():
|
if self.should_cancel():
|
||||||
break
|
break
|
||||||
@@ -732,6 +800,49 @@ if QT_IMPORT_ERROR is None:
|
|||||||
"failed": failed,
|
"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):
|
def _emit_progress(self, done, total, collected, skipped, failed):
|
||||||
self.progress.emit(
|
self.progress.emit(
|
||||||
{
|
{
|
||||||
@@ -1088,6 +1199,7 @@ if QT_IMPORT_ERROR is None:
|
|||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
status_callback=self.statusBar().showMessage,
|
status_callback=self.statusBar().showMessage,
|
||||||
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
)
|
)
|
||||||
if title == "④ 账号管理":
|
if title == "④ 账号管理":
|
||||||
return AccountsTab(
|
return AccountsTab(
|
||||||
@@ -1104,6 +1216,9 @@ if QT_IMPORT_ERROR is None:
|
|||||||
|
|
||||||
def _on_tab_changed(self, index):
|
def _on_tab_changed(self, index):
|
||||||
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
|
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
|
||||||
|
|
||||||
|
def open_accounts_tab(self):
|
||||||
|
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
|
||||||
else:
|
else:
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@
|
|||||||
| T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001, T-104b | 通过 worker 执行;逐条 set_collected;旧封面下载到 `images/<slug>/`;未登录/未匹配略过记原因 | DONE |
|
| T-203 | 采集旧标题+旧封面(只读),下载图片,立即写库 | T-202, T-001, T-104b | 通过 worker 执行;逐条 set_collected;旧封面下载到 `images/<slug>/`;未登录/未匹配略过记原因 | DONE |
|
||||||
| T-204 | 回写旧字段到原 Excel(含文件锁处理) | T-203, T-201 | `excel.write_back()` 按源文件/工作表/行号回写旧标题、旧封面;`export_copy()` 另存副本;Tab① 用 `WriteBackWorker` 后台回写,文件被占用时提示关闭后重试 | 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-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 生成(②)
|
## Phase 3 · AI 生成(②)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -304,7 +304,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
|
|||||||
- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;每条先检测登录,未登录写 `mark_skipped`,已登录则下载旧封面到 `image_dir/<slug>/<item_id>_old.jpg` 并 `db.set_collected()`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;每条先检测登录,未登录写 `mark_skipped`,已登录则下载旧封面到 `image_dir/<slug>/<item_id>_old.jpg` 并 `db.set_collected()`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
||||||
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
||||||
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
|
- 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
|
||||||
- T-205 后,若无账号、当前批次匹配账号未启动 CDP 端口或未登录,① 的采集执行应禁用或在执行前汇总拦截并提示去④账号管理;可以提供跳转/启动登录入口,但不无提示批量启动所有账号 Chrome。
|
- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。
|
||||||
|
|
||||||
## workers 模块(`app/workers.py`,已建,PySide6)
|
## workers 模块(`app/workers.py`,已建,PySide6)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-06-27
|
- 日期: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(已定)。
|
- 技术栈: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 改动仍需测试商品手动验证。
|
- 测试:`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。
|
- 数据:`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` |
|
| `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 转发排查记录 |
|
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
|
||||||
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
|
| `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/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
|
||||||
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
|
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
|
||||||
| `app/editor.py` | 已有 | T-001/T-103 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
|
| `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/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
|
||||||
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
|
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
|
||||||
| `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 |
|
| `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 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 |
|
| `app/excel.py` | 已有 | T-201/T-204 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 |
|
||||||
| `shopee待处理任务模板.xlsx` | 已有,待提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 |
|
| `shopee待处理任务模板.xlsx` | 已有,待提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 |
|
||||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
|
| `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)。
|
任务状态以 [`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。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -442,3 +442,11 @@
|
|||||||
- 细节:手动「回写旧数据到 Excel」按钮保留,主要作为自动回写失败后的重试入口;若回写 worker 已在运行,会提示“Excel 回写正在进行”,避免重复启动。
|
- 细节:手动「回写旧数据到 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,相关测试按设计跳过)。
|
- 验证:`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 未启动 / 未登录的引导保护)。
|
- 下一步:按任务看板领取 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`)。
|
||||||
|
|||||||
+144
-1
@@ -33,6 +33,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.app = QApplication.instance() or QApplication([])
|
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):
|
def make_config(self, temp_dir):
|
||||||
return {
|
return {
|
||||||
"chrome_path": "chrome.exe",
|
"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), \
|
with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \
|
||||||
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect:
|
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)
|
self.assertEqual({"ok": True, "total": 3, "done": 3, "collected": 1, "skipped": 2, "failed": 0}, summary)
|
||||||
collect.assert_called_once()
|
collect.assert_called_once()
|
||||||
@@ -453,6 +464,138 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
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):
|
def test_write_back_worker_calls_excel_write_back(self):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"app.gui.excel.write_back",
|
"app.gui.excel.write_back",
|
||||||
|
|||||||
Reference in New Issue
Block a user