feat: 完成T-501c Shopee更新安全开关

新增 shopee_update 配置段,默认关闭真实提交和封面更新,限制测试商品ID、单次最大更新条数,并支持成功后关闭本轮自动新开编辑页。

Tab⑤ 设置页新增 Shopee 更新安全表单;Tab③ 开始更新前先读取安全配置并阻断未授权真实提交、超量、非测试商品和未允许的封面更新。

ApplyWorker 将 close_success_tab 传入 editor.apply_task;editor 仅在提交成功且页面为本轮自动新开时关闭 tab,失败和复用页面保留。

同步架构、API、路由、任务看板、当前状态与 progress 文档;补充 GUI 与 editor 单测覆盖安全设置保存、共享配置读取、安全拦截和 tab 关闭策略。
This commit is contained in:
chengma
2026-06-29 09:18:03 +08:00
parent 91683abe7d
commit b05a856796
11 changed files with 453 additions and 42 deletions
+7
View File
@@ -40,6 +40,13 @@ DEFAULT_CONFIG = {
"4k": 600,
},
},
"shopee_update": {
"test_item_id": "51100639510",
"allow_real_submit": False,
"allow_cover_update": False,
"max_items_per_run": 1,
"close_success_tab": False,
},
}
DEFAULT_AI_MODELS_CONFIG = {
+18 -2
View File
@@ -472,7 +472,7 @@ def click_update(cdp) -> dict:
}
def apply_task(account, task) -> dict:
def apply_task(account, task, close_success_tab=False) -> dict:
"""Apply generated title/cover to Shopee.
The caller must perform the batch confirmation before calling this function.
@@ -480,6 +480,7 @@ def apply_task(account, task) -> dict:
item_id = _item_id(task)
cdp = open_product(account, item_id)
committed = False
try:
title_result = None
cover_result = None
@@ -494,8 +495,9 @@ def apply_task(account, task) -> dict:
if not cover_result.get("ok"):
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
update_result = click_update(cdp)
committed = bool(update_result.get("clicked", False))
return {
"committed": update_result.get("clicked", False),
"committed": committed,
"error": update_result.get("reason"),
"title": title_result,
"cover": cover_result,
@@ -504,4 +506,18 @@ def apply_task(account, task) -> dict:
except Exception as exc:
return {"committed": False, "error": str(exc)}
finally:
_close_applied_product(cdp, close_success_tab=close_success_tab and committed)
def _close_applied_product(cdp, close_success_tab=False):
target_id = getattr(cdp, "target_id", None)
created_by_app = bool(getattr(cdp, "created_by_app", False))
host = getattr(cdp, "cdp_host", None)
try:
cdp.close()
finally:
if close_success_tab and created_by_app and target_id:
try:
close_tab(target_id, host=host)
except Exception:
pass
+123 -4
View File
@@ -1079,6 +1079,11 @@ if QT_IMPORT_ERROR is None:
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
safety_error = self._update_safety_error(tasks)
if safety_error:
QMessageBox.warning(self, "更新安全开关", safety_error)
self._set_status(safety_error.replace("\n", " "))
return
answer = QMessageBox.question(
self,
"确认开始更新",
@@ -1089,7 +1094,13 @@ if QT_IMPORT_ERROR is None:
if answer != QMessageBox.Yes:
self._set_status("已取消开始更新")
return
worker = ApplyWorker(tasks, db_path=self.db_path, config=self.config)
update_cfg = self._shopee_update_config()
worker = ApplyWorker(
tasks,
db_path=self.db_path,
config=self.config,
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
worker.log.connect(self._set_status)
@@ -1197,15 +1208,60 @@ if QT_IMPORT_ERROR is None:
return True
def _confirmation_message(self, tasks):
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 "否"
return (
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
f"批次:{self._batch_filter_label()}\n"
f"店铺:{self._shop_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}\n\n"
"确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。"
)
def _update_safety_error(self, tasks):
update_cfg = self._shopee_update_config()
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", ""))
for task in tasks
if getattr(task, "new_cover_path", None)
]
if cover_tasks:
return "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。"
return None
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
def _set_apply_running(self, running):
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
@@ -1967,12 +2023,20 @@ if QT_IMPORT_ERROR is None:
class ApplyWorker(BaseWorker):
"""Apply generated title/cover changes to Shopee one task at a time."""
def __init__(self, tasks, db_path=None, config=None, preflight=True):
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
close_success_tab=False,
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.close_success_tab = close_success_tab
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
@@ -2022,7 +2086,11 @@ if QT_IMPORT_ERROR is None:
try:
db.mark_running(task.id, "apply", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
result = editor.apply_task(account, task)
result = editor.apply_task(
account,
task,
close_success_tab=self.close_success_tab,
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
if committed:
@@ -2551,6 +2619,17 @@ if QT_IMPORT_ERROR is None:
self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin")
self.cdp_ready_timeout_spin.setRange(1, 3600)
self.save_config_button = QPushButton("保存设置")
self.test_item_id_edit = QLineEdit()
self.test_item_id_edit.setObjectName("testItemIdEdit")
self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品")
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox")
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.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
form = QFormLayout()
form.addRow("", self.enabled_checkbox)
@@ -2591,6 +2670,13 @@ if QT_IMPORT_ERROR is None:
path_form.addRow("调试端口范围", port_range_layout)
path_form.addRow("CDP就绪超时(秒)", self.cdp_ready_timeout_spin)
update_form = QFormLayout()
update_form.addRow("测试商品ID", self.test_item_id_edit)
update_form.addRow("", self.allow_real_submit_checkbox)
update_form.addRow("", self.allow_cover_update_checkbox)
update_form.addRow("单次最大更新条数", self.max_items_per_run_spin)
update_form.addRow("", self.close_success_tab_checkbox)
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 0, 0, 0)
@@ -2604,6 +2690,9 @@ if QT_IMPORT_ERROR is None:
right_layout.addSpacing(18)
right_layout.addWidget(QLabel("路径与端口"))
right_layout.addLayout(path_form)
right_layout.addSpacing(18)
right_layout.addWidget(QLabel("Shopee 更新安全"))
right_layout.addLayout(update_form)
right_layout.addWidget(self.save_config_button)
right_layout.addStretch(1)
@@ -2817,6 +2906,13 @@ if QT_IMPORT_ERROR is None:
"debug_port_range": [start_port, end_port],
"cdp_ready_timeout": self.cdp_ready_timeout_spin.value(),
"ai": ai_cfg,
"shopee_update": {
"test_item_id": self.test_item_id_edit.text().strip(),
"allow_real_submit": self.allow_real_submit_checkbox.isChecked(),
"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(),
},
}
)
return settings
@@ -2867,8 +2963,31 @@ if QT_IMPORT_ERROR is None:
self.cdp_ready_timeout_spin.setValue(
int(appconfig.cdp_ready_timeout(self.config))
)
update_cfg = self._shopee_update_config()
self.test_item_id_edit.setText(str(update_cfg.get("test_item_id", "")))
self.allow_real_submit_checkbox.setChecked(
bool(update_cfg.get("allow_real_submit", False))
)
self.allow_cover_update_checkbox.setChecked(
bool(update_cfg.get("allow_cover_update", False))
)
self.max_items_per_run_spin.setValue(
max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
)
self.close_success_tab_checkbox.setChecked(
bool(update_cfg.get("close_success_tab", False))
)
self._update_response_timeout_label()
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
def _populate_role_model_combos(self):
ai_cfg = appconfig.ai_config(self.config)
self._populate_role_combo(
+21 -2
View File
@@ -108,6 +108,13 @@ imported → collected → generated → applied
"jpg_quality": 90,
"resolution": "1k",
"resolution_timeouts": { "512": 180, "1k": 240, "2k": 360, "4k": 600 }
},
"shopee_update": {
"test_item_id": "51100639510",
"allow_real_submit": false,
"allow_cover_update": false,
"max_items_per_run": 1,
"close_success_tab": false
}
}
```
@@ -120,6 +127,16 @@ imported → collected → generated → applied
- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。
- 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,本地明文保存、UI 打码、gitignore、不入日志。
`shopee_update` 段放**真实更新前的安全开关**:
- `test_item_id`:允许真实提交的测试商品 ID;默认 `51100639510`。
- `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。
- `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。
- `max_items_per_run`:单次允许更新的最大任务数;默认 `1`,当前筛选结果超过即阻断。
- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。
该段不是 V2 dry-run,也不是替代 ③ 确认弹窗的常驻授权;③ 仍必须先通过安全开关检查,再弹窗确认,用户点是后才真实提交。
### 5.1b AI 模型清单 `config/ai_models.json`
模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。
@@ -315,9 +332,11 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
### 6.3 应用更新(③ Tab)
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。
- 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。
### 6.4 登录检测
@@ -330,7 +349,7 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<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 不关闭 |
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭 |
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
@@ -342,7 +361,7 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
| 更新按钮 | `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 |
| 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 |
高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
## 八、推荐开发顺序
+1 -1
View File
@@ -80,7 +80,7 @@
| --- | --- | --- | --- | --- |
| 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③ 执行前读取并拦截不符合安全配置的更新 | TODO |
| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE |
| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO |
| T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO |
| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO |
+15 -9
View File
@@ -32,7 +32,7 @@ ai_config(config=None) -> dict # default_text_model/default_image
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
```
`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、是否允许真实提交、是否允许封面更新、单次最大更新条数、成功后是否关闭本轮新开编辑页。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。
AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
@@ -217,7 +217,9 @@ collect(account, task) -> dict # -> {old_title, old_cover_path}
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502
click_update(cdp) -> dict # {clicked, reason};禁用则记失败
apply_task(account, task) -> dict # 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已做批量确认)
apply_task(account, task, close_success_tab=False) -> dict
# 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认)
# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab
# -> {committed, error}
```
@@ -228,7 +230,7 @@ apply_task(account, task) -> dict # 对已生成任务:换标题+
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。
- ③ 更新流程暂不自动关闭商品页:成功后是否关闭留待后续体验任务,失败时保留现场便于排查。
- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。
## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP)
@@ -296,11 +298,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生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 确认后串行执行更新
class SettingsTab(QWidget) # ⑤ 设置:AI 模型 master-detail 管理
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) # ③ 后台更新:账号就绪预检 -> editor.apply_task -> 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/阶段;未匹配别名显示“略过”
@@ -333,6 +335,8 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
- 角色与生成参数读写 `config.json`:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
- 分辨率下拉固定 `512/1k/2k/4k`;返回超时标签只读展示 `resolution_timeouts[resolution]`。
- 路径与端口读写 `config.json`:Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、调试端口范围、CDP 就绪超时。保存时校验端口范围和默认端口。
- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。
- 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。
① 导入采集当前要点(T-202/T-202b):
@@ -368,10 +372,12 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。
- 点击「开始更新」会弹窗展示批次/店铺/状态/任务数和提交线上风险;用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。
- 点击「开始更新」先读取 `shopee_update`:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。
- 更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。
- 预检通过后逐条 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
- 预检通过后逐条 `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`,单条失败继续下一条。
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;失败和复用的用户已有 tab 不关闭。
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
- 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。
@@ -409,7 +415,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
- `ApplyWorker` 串行调用 `editor.apply_task()`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。
- `ApplyWorker` 串行调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
+12 -12
View File
@@ -6,10 +6,10 @@
## 当前快照
- 日期:2026-06-29
- 阶段: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-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数。
- 阶段: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-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关。
- 技术栈: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;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`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、批次/账号/任务与阶段写库函数;`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生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理和角色/生成参数设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`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、批次/账号/任务与阶段写库函数;`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生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `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/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;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。
## 既定设计要点(文档已定)
@@ -19,7 +19,7 @@
- 存储:`config.json`(应用设置)+ `config/ai_models.json`(AI 模型清单与本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面)。
- 多账号隔离:每账号独立 user-data-dir(非 profile)。
- 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。
- 执行:多账号串行、单条失败继续;③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量,确认后逐条点「更新」提交线上。
- 执行:多账号串行、单条失败继续;③ 点击「开始更新」后先通过 ⑤ Shopee 更新安全开关(真实提交、封面更新、测试商品 ID、最大条数),再弹窗确认当前筛选范围和任务数量,确认后逐条点「更新」提交线上。
- AI:服务商/模型/Key 由 `config/ai_models.json` 配置;`app/ai.py` 支持 chat JSON 与 images_edits multipart;生成内容直接用于更新,本地留档+回写 Excel 供追溯。
- 登录:人工登录 + 程序检测,不自动登录;无 Shopee tab 时检测入口为 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④。
@@ -32,17 +32,17 @@
| `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/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置 |
| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全设置 |
| `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 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task |
| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 |
| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 |
| `app/appconfig.py` | 已有 | T-002/T-501c 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入 |
| `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 |
| `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 |
| `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/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出: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-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/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` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
@@ -60,16 +60,16 @@
任务状态以 [`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-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)。
- 已完成: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-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)。
- 正在进行:无。
- 下一个可领取任务:**T-501c(Tab⑤ 设置 · Shopee 更新安全开关)**。T-404 真实 Shopee 单条更新冒烟已登记,但依赖 T-501c 设置安全开关完成后再执行。
- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。
## 当前已知限制
- ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。
- T-501/T-501b 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置。
- T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。
- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。
- T-403 已完成③更新结果回写与结束汇总;真实 Shopee 更新冒烟暂不执行,延后到 T-501c 设置安全开关完成后做 T-404 单条验收。T-404 默认先只测标题更新,封面更新作为可选子项。
- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。
## 当前可运行内容
+16 -9
View File
@@ -14,9 +14,9 @@
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 |
| ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 |
| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口等 | — |
| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — |
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须先通过 ⑤ 的 Shopee 更新安全开关,再弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
## 全局 Tab 栏可用性
@@ -91,7 +91,7 @@
```
┌ 更新shopee ───────────────────────────────────────────────────┐
│ 批次[本次▼] 店铺[全部▼] 状态[已生成▼] [筛选] │
│ ⚠ 点击「开始更新」后确认【当前筛选结果】,确认后改标题+换封面并提交 │
│ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
│ └───────────────────────────────────────────────────────────┘ │
@@ -102,10 +102,12 @@
- 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 点击「开始更新」后弹窗展示本次筛选条件、任务数量和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。
- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。
- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
@@ -133,6 +135,9 @@
- 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用。
- 路径与端口(T-501b 已接入):本地图片目录、Chrome 路径、账号数据根目录、DB 路径、默认调试端口、端口范围、CDP 就绪超时。
- Shopee 更新安全(T-501c 已接入):测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。
- 默认关闭真实提交和封面更新,单次最大更新条数默认 1。
- ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。
## 流程导航
@@ -143,11 +148,13 @@
│
② AI生成:提示词 → 生成新标题/新封面(无逐条审核)
│
③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
⑤ 设置:开启测试商品范围内的 Shopee 更新安全开关
│
③ 更新shopee:对已生成任务点击开始更新 → 安全开关检查 → 弹窗确认 → 换标题/允许时换封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
```
- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。
- 已生成的任务即可进 ③;③ 用户确认批量弹窗后提交线上,无常驻提交开关。
- 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。
- 任意步骤失败:记入该任务、日志标明,不影响其他任务。
## 组件建议(PySide6)
@@ -157,12 +164,12 @@
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + Shopee 更新安全拦截 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 |
| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 |
| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task()`、逐条 `set_applied()`,失败继续 |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续 |
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
+10
View File
@@ -595,3 +595,13 @@
- 测试:`tests/test_gui.py` 覆盖角色下拉类别过滤、分辨率返回超时显示、生成参数/路径/端口保存到临时 `config.json`、非法端口范围拦截。
- 文档:`docs/06-tasks.md` 将 T-501b 标为 DONE;同步 `docs/routes.md`、`docs/api.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501c。
- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(38 tests);`python -m unittest discover -s tests` 通过(94 tests)。
## 【2026-06-29】T-501c Tab⑤ Shopee 更新安全开关
- 状态:DONE
- 配置:`app/appconfig.py` 的 `config.json` 默认值新增 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。默认关闭真实提交和封面更新,单次最大更新 1 条。
- GUI:`SettingsTab` 新增 Shopee 更新安全表单并持久化;`ApplyTab.start_update()` 在弹确认前读取安全设置,拦截未开启真实提交、超过最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新的任务。确认弹窗会展示当前安全设置。
- 执行:`ApplyWorker` 将 `close_success_tab` 传给 `editor.apply_task()`;`editor.apply_task()` 在成功提交、且商品页为本轮程序自动新建时,可按设置关闭该 tab。失败任务和复用的用户已有 tab 保留现场。
- 测试:`tests/test_gui.py` 覆盖安全设置默认值、保存到 `config.json`、⑤保存后③立即读取共享安全配置、③安全拦截、最大条数/测试商品 ID 校验、确认后 worker 接收 `close_success_tab`;`tests/test_editor_login.py` 覆盖成功提交后关闭本轮新开 tab、失败时不关闭。
- 文档:`docs/06-tasks.md` 将 T-501c 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-404。
- 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(42 tests);`python -m unittest discover -s tests -p test_editor_login.py` 通过(11 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(100 tests)。未执行真实 Shopee 提交,T-404 仍需在测试商品上手动冒烟。
+49
View File
@@ -244,6 +244,55 @@ class EditorLoginTests(unittest.TestCase):
self.assertTrue(cdp.closed)
close_tab.assert_not_called()
def test_apply_task_closes_auto_created_product_tab_after_success_when_enabled(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
) 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:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=True,
)
self.assertTrue(result["committed"])
change_title.assert_called_once_with(cdp, "新标题")
replace_cover.assert_not_called()
self.assertTrue(cdp.closed)
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):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
), mock.patch(
"app.editor.click_update",
return_value={"clicked": False, "reason": "UPDATE_DISABLED"},
), mock.patch("app.editor.close_tab") as close_tab:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=True,
)
self.assertFalse(result["committed"])
self.assertEqual("UPDATE_DISABLED", result["error"])
self.assertTrue(cdp.closed)
close_tab.assert_not_called()
if __name__ == "__main__":
unittest.main()
+180 -2
View File
@@ -57,6 +57,23 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"config_path": os.path.join(temp_dir, "config.json"),
}
def allow_shopee_update(
self,
cfg,
item_id="51100639510",
allow_cover=True,
max_items=1,
close_success_tab=False,
):
cfg["shopee_update"] = {
"test_item_id": item_id,
"allow_real_submit": True,
"allow_cover_update": allow_cover,
"max_items_per_run": max_items,
"close_success_tab": close_success_tab,
}
return cfg
def test_main_window_has_five_tabs_in_workflow_order(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
@@ -132,6 +149,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(QLineEdit.Password, tab.api_key_edit.echoMode())
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.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.assert_removed(temp_dir)
@@ -319,6 +341,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.debug_port_start_spin.setValue(9300)
tab.debug_port_end_spin.setValue(9350)
tab.cdp_ready_timeout_spin.setValue(45)
tab.test_item_id_edit.setText("51100639510")
tab.allow_real_submit_checkbox.setChecked(True)
tab.allow_cover_update_checkbox.setChecked(True)
tab.max_items_per_run_spin.setValue(2)
tab.close_success_tab_checkbox.setChecked(True)
tab.save_app_settings()
@@ -337,12 +364,50 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(9300, saved["default_debug_port"])
self.assertEqual([9300, 9350], saved["debug_port_range"])
self.assertEqual(45, saved["cdp_ready_timeout"])
self.assertEqual(
{
"test_item_id": "51100639510",
"allow_real_submit": True,
"allow_cover_update": True,
"max_items_per_run": 2,
"close_success_tab": True,
},
saved["shopee_update"],
)
self.assertNotIn("ai_models_path", saved)
self.assertNotIn("config_path", saved)
self.assertIn("设置已保存", statuses[-1])
self.assert_removed(temp_dir)
def test_settings_save_updates_apply_tab_shared_safety_config(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
window = MainWindow(
config=cfg,
config_path=cfg["config_path"],
ai_models_path=cfg["ai_models_path"],
)
self.addCleanup(window.close)
settings_tab = window.tabs.widget(TAB_TITLES.index("⑤ 设置"))
apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新shopee"))
settings_tab.test_item_id_edit.setText("123456789")
settings_tab.allow_real_submit_checkbox.setChecked(True)
settings_tab.allow_cover_update_checkbox.setChecked(True)
settings_tab.max_items_per_run_spin.setValue(3)
settings_tab.close_success_tab_checkbox.setChecked(True)
settings_tab.save_app_settings()
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.assert_removed(temp_dir)
def test_settings_tab_rejects_invalid_port_range(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -651,6 +716,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
@@ -684,6 +750,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("任务数:1", message)
self.assertIn("提交线上", message)
self.assertIn("状态:已生成", message)
self.assertIn("测试商品ID=51100639510", message)
self.assertEqual("已取消开始更新", statuses[-1])
apply_task.assert_not_called()
@@ -692,6 +759,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
def test_apply_tab_start_update_starts_apply_worker_after_confirmation(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg, close_success_tab=True)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
@@ -742,6 +810,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(tab.apply_worker, ApplyWorker)
self.assertIs(tab.apply_thread, fake_thread)
self.assertTrue(fake_thread.started)
self.assertTrue(tab.apply_worker.close_success_tab)
self.assertFalse(tab.start_update_button.isEnabled())
self.assertTrue(tab.stop_update_button.isEnabled())
self.assertEqual("开始更新:1 条", statuses[-1])
@@ -752,6 +821,107 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_blocks_update_when_real_submit_switch_is_off(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"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
with mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
tab.start_update()
message = warning.call_args[0][2]
self.assertIn("允许真实提交线上商品", message)
question.assert_not_called()
run_worker.assert_not_called()
self.assertIn("已阻止本次更新", statuses[-1])
self.assert_removed(temp_dir)
def test_apply_tab_blocks_cover_update_when_cover_switch_is_off(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg, allow_cover=False)
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"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
with mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
tab.start_update()
self.assertIn("允许更新封面", warning.call_args[0][2])
question.assert_not_called()
run_worker.assert_not_called()
self.assert_removed(temp_dir)
def test_apply_tab_safety_error_limits_max_count_and_test_item(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)
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
class Task:
def __init__(self, item_id, new_cover_path=None):
self.item_id = item_id
self.new_cover_path = new_cover_path
max_error = tab._update_safety_error(
[
Task("51100639510"),
Task("51100639510"),
]
)
item_error = tab._update_safety_error([Task("51100639511")])
self.assertIn("超过单次最大更新条数", max_error)
self.assertIn("非测试商品ID", item_error)
self.assert_removed(temp_dir)
def test_apply_worker_applies_success_failure_and_unmatched_serially(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -793,11 +963,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
applied_aliases = []
close_flags = []
progress = []
rows = []
def fake_apply(account, task):
def fake_apply(account, task, close_success_tab=False):
applied_aliases.append(account.alias)
close_flags.append(close_success_tab)
if account.alias == "alias-a":
return {"committed": True, "error": None}
if account.alias == "alias-b":
@@ -809,12 +981,18 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"app.gui.accounts.detect_login",
return_value={"logged_in": True, "reason": None},
), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
worker = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg)
worker = ApplyWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
close_success_tab=True,
)
worker.progress.connect(progress.append)
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
summary = worker.execute()
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
self.assertEqual([True, True], close_flags)
self.assertEqual(
{
"ok": False,