42 KiB
模块 / CLI 合约
本项目无后端 API,全部为本地 Python 模块合约。实现前可细化,但不要另起一套不兼容接口。
通用约定
- 形态:本地函数 + 子进程(Chrome)+ CDP(
127.0.0.1:<port>)+ SQLite + openpyxl + AI 服务调用。 - 编码:UTF-8;传 Chrome /
setFileInputFiles的路径为 Windows 绝对路径。 - 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;
config.json、config/ai_models.json、cmshopee.db、chrome_user_data_dir/、images/必须 gitignore;UI 打码显示,不出现在日志/导出。 - 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
appconfig 模块(app/appconfig.py,已建)
读写 config.json(schema 见 架构 5.1)。
class ConfigError(RuntimeError): ...
default_config() -> dict
load_config(path="config.json") -> dict # 不存在则写默认
save_config(config, path="config.json") -> dict
update_config(updates, path="config.json") -> dict
chrome_path(config=None) -> str
user_data_root(config=None) -> str
image_dir(config=None) -> str
db_path(config=None) -> str
default_debug_port(config=None) -> int
debug_port_range(config=None) -> tuple # (start, end)
cdp_ready_timeout(config=None) -> int
ai_config(config=None) -> dict # default_text_model/default_image_model/
# title_concurrency/image_concurrency/retry/jpg_quality/
# resolution/resolution_timeouts
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
default_config() / load_config() 包含 shopee_update 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 dry_run、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。config.json 不保存 AI Key;写入 api_key / *_key / token / *_token / password / *_password 等敏感字段时抛 ConfigError。AI Key 留给 config/ai_models.json。
敏感信息展示/日志辅助:
mask_secret(secret) -> str # 展示用打码
sanitize_for_log(value) -> object # 递归打码 api_key/password/token/*_key/*_token/*_password 字段
redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自由文本中的秘密
AI 模型清单(config/ai_models.json,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
default_ai_models_config() -> dict
load_ai_models_config(path="config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个
save_ai_models_config(config, path="config/ai_models.json") -> dict
list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默认 api_key 打码,含 api_key_set
add_ai_model(model) -> None # name 唯一校验
update_ai_model(name, **fields) -> None
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用
model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits
test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
list_ai_models() 用于 UI/API 展示,默认不返回明文 api_key;get_model() 用于实际调用 AI,返回明文 api_key,调用方不得写日志或导出。url 可填完整 endpoint,也可填 OpenAI-compatible base URL(如 https://.../v1 或 https://.../api/v1),请求前由 model_request_url() 按 api_type 补齐。test_ai_model() 不记录密钥,返回给 GUI 前仍由 worker 过 sanitize_for_log();缺少 url/model/api_key 时直接返回 {ok: False, error: ...}。
db 模块(app/db.py,已建)
SQLite 读写,表见 架构 5.2。
class DbError(RuntimeError): ...
Batch / Account / Task # dataclass,字段同 SQLite schema
RunLog / RunLogEvent # dataclass,运行日志与逐条事件
connect(path=None) -> sqlite3.Connection # 设置 foreign_keys/WAL/busy_timeout/synchronous/row_factory
init_db(path=None)
create_batch(file_paths, note=None, path=None) -> str
get_batch(batch_id, path=None) -> Batch|None
list_batches(status=None, path=None) -> list[Batch]
update_batch(batch_id, **fields) -> None # 支持 status/note/source_files_json
# 账号
list_accounts(path=None) -> list[Account]
get_account_by_alias(alias, path=None) -> Account|None
add_account(account_name, alias, region_host, debug_port, password=None, note=None) -> Account
update_account(alias, **fields) -> None # 支持账号展示字段、端口、密码、note、last_login_at
delete_account(alias) -> None
# 任务 / 各阶段结果
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task]
get_task(task_id, path=None, include_deleted=False) -> Task | None
# 默认排除已软删除批次;include_deleted 仅供内部诊断/测试使用
delete_batch(batch_id, reason=None, path=None) -> dict
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
mark_running(task_id, phase) -> None
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题
update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
# T-404a 已实现:选中记录重置
reset_generated(task_id, delete_file=False) -> dict # 本地清空 AI 结果并退回 collected;默认不删新封面文件
reset_apply_status(task_id) -> dict # 本地退回 generated/pending 供重复更新;保留 new_* 与 committed 历史事实
# 运行日志
create_run_log(run_type, dry_run=False, total=0, options=None) -> int
finish_run_log(run_id, **fields) -> None # status/done/success_count/skipped_count/failed_count/summary_json
add_run_log_event(run_id, message, task_id=None, alias=None, item_id=None, level="info") -> int
list_run_logs(limit=50, run_type=None) -> list[RunLog]
list_run_log_events(run_id=None, limit=200) -> list[RunLogEvent]
stage 表示最后成功业务阶段(imported→collected→generated→applied);status 表示当前处理结果(pending/running/success/failed/skipped/cancelled)。任意步失败写 last_error 且 status=failed,stage 不前进。无 confirmed 阶段。
SQLite 连接规则:
- 每个 worker/线程使用自己的
connect();禁止跨线程共享 connection。 connect()必须设置PRAGMA foreign_keys=ON、journal_mode=WAL、busy_timeout=5000、synchronous=NORMAL。- DB 写入短事务、单条提交;Excel 回写失败不回滚 DB。
excel 模块(app/excel.py,导入与回写已建,依赖 openpyxl)
class ExcelError(RuntimeError): ...
OPENPYXL_IMPORT_ERROR: Exception | None
import_tasks(file_paths, path=None, note=None, write_db=True) -> dict
# file_paths 可传单个路径或多个路径;标准空模板为根目录 shopee待处理任务模板.xlsx。
# 只解析【输入列】:账号名(可选)、别名、商品id;推荐表头固定为 账号名 | 别名 | 商品id,并记录
# source_file/source_file_abs/source_sheet/source_row/row_key。
# 默认 write_db=True:有有效行时创建 batch 并写入 tasks。
# -> {
# "batch_id": str|None,
# "rows": [...],
# "stats": {
# "files": int, "total": int, "valid": int, "invalid": int,
# "inserted": int, "file_errors": [...], "row_errors": [...]
# }
# }
# 必需列缺失(别名/商品id)= 整个文件拒绝并记录到 file_errors,不导入该文件任何行。
# invalid = 单行缺别名/商品id 或商品id 格式错误等脏数据;逐行跳过,不阻塞同文件其他有效行。
# openpyxl 缺失时模块仍可导入,调用 import_tasks 时抛 ExcelError。
match_summary(rows: list[dict], accounts: list) -> dict
# 用 accounts 的别名对 rows 做匹配统计(导入汇总栏用)
# -> {"matched": int, "unmatched": int, "by_account": {别名: 行数}, "unmatched_aliases": [..]}
write_back(batch_id, excel_path=None, path=None) -> dict
# 把【旧标题/旧封面图片路径】批量回写到【原 Excel】
# excel_path 为空则按 source_file_abs 分组回写本批次涉及的所有原文件
# 只写 stage 已到 collected/generated/applied 或已有旧字段值的任务;无可写任务时返回 rows=0
# 原文件被占用(锁) → 抛 ExcelError,调用方提示“请关闭后重试”,或改用 export_copy
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
write_back_results(batch_id, excel_path=None, path=None) -> dict
# 把【新标题/新封面图片路径/更新状态】批量回写到【原 Excel】
# 更新状态:成功 / 失败:原因 / 略过:原因 / 待更新
# 原文件被占用(锁) → 抛 ExcelError;SQLite 更新结果不回滚,GUI 提示关闭后重试
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
export_copy(batch_id, out_dir_or_path, path=None) -> dict
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
列模板见 架构 5.3;别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。shopee待处理任务模板.xlsx 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。
config 模块(app/config.py,已建)
class ConfigPathError(RuntimeError): ...
make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_]
ensure_user_data_dir(slug, root=None, config=None) -> str
# 默认 root = appconfig.user_data_root(config);返回 chrome_user_data_dir/<slug> 绝对路径,按需创建
make_slug() 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 account_<hash>。ensure_user_data_dir() 拒绝非 [a-z0-9_] slug,防止路径穿越。
accounts 模块(app/accounts.py,已建)
账号管理服务层,供 ④ Tab 调用;不操作 QWidget,不自动登录/填密码。
class AccountError(RuntimeError): ...
normalize_region_host(value=None) -> str
normalize_debug_port(value) -> int
preview_user_data_dir(alias, config=None) -> str
mask_password(password) -> str # 展示用,永不返回明文
next_debug_port(config=None, path=None) -> int
list_accounts(path=None, config=None) -> list[db.Account]
get_account(alias, path=None, config=None) -> db.Account
create_account(account_name, alias, region_host=None, debug_port=None,
password=None, note=None, path=None, config=None) -> db.Account
update_account(original_alias, account_name, alias, region_host=None, debug_port=None,
password=None, note=None, path=None, config=None) -> db.Account
delete_account(alias, path=None, config=None) -> None
launch_for_login(account_or_alias, path=None, config=None) -> subprocess.Popen
create_shortcut(account_or_alias, shortcut_path=None, desktop_dir=None,
path=None, config=None) -> str
detect_login(account_or_alias, timeout=8, path=None, config=None) -> dict
login_status_text(status) -> str
规则:
create_account()/update_account()负责生成 slug、创建chrome_user_data_dir/<slug>并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。debug_port在账号服务层按账号唯一校验;默认端口取debug_port_range中第一个未占用端口。delete_account()只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。launch_for_login()只启动带该账号 user-data-dir 和 CDP 端口的 Chrome;不会读取、填写或提交密码。- ③ 更新 shopee 的账号预检不会自动调用
launch_for_login();Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。 create_shortcut()生成.lnk,目标/参数复用chrome.build_launch_args(),不包含密码。detect_login()复用editor.login_status();检测为已登录时更新last_login_at。
chrome 模块(app/chrome.py,已建)
class ChromeLaunchError(RuntimeError): ...
build_launch_args(account, config=None) -> list[str]
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
launch_chrome(account, config=None) -> subprocess.Popen
create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str
wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool
is_running(port, host="127.0.0.1") -> bool
build_launch_args() 接受 dict 或对象形式账号;账号需有 debug_port,并有 user_data_dir 或 slug/alias。端口探测访问 /json/version,显式禁用环境代理。create_shortcut() 使用 PowerShell WScript.Shell.CreateShortcut 生成 .lnk,TargetPath 为 Chrome,Arguments 含 --remote-debugging-port、--remote-allow-origins=*、--user-data-dir=<该账号目录>。
cdp 模块(app/cdp.py,T-000 由根目录 cdp.py 迁入)
CDP_HOST: str
http_get(path, host=None); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close()
class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False
editor 模块(app/editor.py,已建,重构自现有脚本)
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names}
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
open_product(account, item_id) -> CDP # 连端口、导航商品页、等就绪;标记该 tab 是否本轮自动新建
# 采集(只读)
read_title(cdp) -> str
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
download_cover(src, out_path) -> str # 下载旧封面到本地
collect(account, task) -> dict # -> {old_title, old_cover_path}
# 应用
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
replace_cover(cdp, image_win_path, old_cover_path=None) -> dict
# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败
apply_task(account, task, close_success_tab=False) -> dict
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认)
# close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab;确认后跳回商品列表页时,关闭前等待 2 秒
# -> {committed, error}
login_status() 不自动登录;无 Shopee tab 时打开卖家中心根地址 https://<region_host>/(默认 https://seller.shopee.tw/)用于检测/人工登录。判断规则:最终 URL 是登录页 → LOGIN_PAGE;缺少 SPC_ST/SPC_U → NO_SESSION_COOKIE;有会话 Cookie → 已登录。
采集 tab 生命周期:
CDP.close()只断开当前 websocket 控制连接,不关闭 Chrome 页面。open_product()若复用已存在商品 tab,则标记为用户已有页面;若调用create_tab()新建,则记录 target id。collect()结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。- ③ 更新流程默认不关闭商品页;若
close_success_tab=True,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。Shopee 确认成功后可能把当前 tab 跳回/portal/product/list/all?operationSortBy=modified_time,click_update()会把该 URL 记录到post_update.url并标记redirected_to_list=true;若本次会关闭该自动新开 tab,关闭前等待 2 秒。 click_update()的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见.eds-modal__content/.eds-modal__box内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。- T-404/T-502 封面更新删除前,
apply_task()应把任务的old_cover_path传给replace_cover();replace_cover()只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 - T-404 封面上传稳定性:
replace_cover()上传前必须模拟人工路径,先点击.shopee-image-manager__upload上传块,短暂等待并重新获取最新input[type=file]后,再用 CDPDOM.setFileInputFiles注入本地图片并派发input/change。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成susercontentCDN 地址的场景。有1張重複的圖片/重複/重复/duplicate属于封面上传错误,必须立即返回明确失败,不继续等超时。
ai 模块(app/ai.py,已建,外部 AI,通用 HTTP)
class AIError(RuntimeError): ...
gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str
# 文本生成:读取 default_text_model,chat JSON 请求;提示词 + 旧标题 → 新标题
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str
# 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart;
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
# 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
# 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
要点:
- 标题用
default_text_model、封面用default_image_model(appconfig.get_model取定义,含 url/key/api_type)。 api_type=chat/auto走 OpenAI-compatible chat JSON;api_type=images_edits走 multipart form。- 连接超时参考模型
connect_timeout_seconds;返回超时 = 模型timeout_seconds或appconfig.response_timeout()(随分辨率:512/1k/2k/4k → 180/240/360/600)。 - 并发数/重试/分辨率/jpg 质量来自
appconfig.ai_config();Key 本地明文存储,但不入日志、不导出。 - 标题快、图片慢:分两段、各用各自并发数;失败按
retry重试,仍失败记 error 不阻塞其余。 - 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
- 生成结果直接进入 ③ 更新候选;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
prompts 模块(app/prompts.py,已建)
class PromptError(RuntimeError): ...
# 标题提示词:单文件
load_title_prompt(path="title_prompt.txt") -> str # 启动回显;缺失返回 ""
save_title_prompt(text, path="title_prompt.txt") -> None # 「保存」按钮
# 封面提示词:多模板(prompts/cover/<名称>.txt)
list_cover_templates(directory="prompts/cover") -> list[str] # 模板名列表(下拉用)
load_cover_template(name, directory="prompts/cover") -> str
save_cover_template(name, text, directory="prompts/cover") -> None
rename_cover_template(old, new, directory="prompts/cover") -> None
delete_cover_template(name, directory="prompts/cover") -> None
# 变量替换
render_prompt(template_text, task) -> str
# 占位符 {旧标题}/{新标题}/{商品id}/{店铺} → 该任务真实值;预览与生成时调用
要点:
- 「插入标题」在封面提示词光标处插入
{新标题};「预览」对选中任务调用render_prompt后展示。 - 生成封面时
gen_cover的 prompt =render_prompt(当前封面模板, task)。 - 模板与
title_prompt.txt均为可手改的纯文本文件。 list_cover_templates()不会在启动时创建文件;只有保存/新建/另存为才写prompts/cover/*.txt。- 模板名不可为空,不允许路径分隔符、
..或 Windows 非法文件名字符;重命名时目标重名会报错。
gui 模块(app/gui/ 包,已建,PySide6)
# GUI 入口
main() -> int # 创建 QApplication + MainWindow
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 检查本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志
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(close_success_tab=...) -> db.set_applied/mark_skipped
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
class AccountsTab(QWidget) # ④ 账号管理:表格 + 增删改 + 启动登录 + 检测登录 + 快捷方式
class AccountDialog(QDialog) # 账号编辑弹窗;密码 QLineEdit.Password
TAB_TITLES: list[str] # 固定 Tab 顺序
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
T-523 后 GUI 已从旧 app/gui.py 拆为 app/gui/ 包:__init__.py 负责旧导入路径兼容与 main();main_window.py 放 MainWindow;models.py 放 3 个 TableModel;widgets.py 放色板、空状态卡、批次总览和日志 helper;workers.py 放具体 GUI worker;tabs/ 下按 ①~⑤ 拆分各 Tab。对外仍保留 from app import gui、from app.gui import MainWindow/CollectTab/GenerateWorker/...。
MainWindow 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 main() 返回 1 并输出明确提示。
主 Tab 栏必须在 MainWindow 初始化时应用 TAB_STYLE:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
④ 账号管理要点:
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
- 「启动登录」只启动 Chrome,人工登录;「检测登录」用 worker 跑
accounts.detect_login()并刷新状态列;「快捷方式」调用accounts.create_shortcut()生成默认桌面.lnk。
⑤ 设置当前要点(T-501):
SettingsTab使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用QMessageBox.information弹出“设置已保存”轻量提示框,同时保留状态栏提示。- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
- 模型数据读写复用
appconfig.list_ai_models(..., reveal_api_key=True)、add_ai_model()、update_ai_model()、delete_ai_model();保存时保留现有extra_body与timeout_seconds。 - 密钥字段使用
QLineEdit.Password打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的config/ai_models.json,不得进入日志/导出。 - 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。
- 「测试连接」创建
AIModelTestWorker后台调用appconfig.test_ai_model(),GUI 主线程不直接发网络请求。 - 角色与生成参数读写
config.json,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 - 分辨率下拉固定
512/1k/2k/4k;返回超时标签只读展示resolution_timeouts[resolution]。 - 路径与端口读写
config.json,并按 3 个组件一组排列:默认调试端口、调试端口范围、CDP 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。 - Shopee 更新安全读写
config.json的shopee_update段,并按 3 个组件一组排列:允许真实提交、允许更新封面、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。dry_run字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 仅作为历史/调试兼容字段保留,不参与普通正式更新安全检查,普通设置页已隐藏该入口。 - 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。
① 导入采集当前要点(T-202/T-202b):
- 「导入 Excel...」按钮调用
excel.import_tasks(file_paths, path=db_path),导入成功后刷新当前批次任务。 - 导入汇总栏显示:文件数、解析行数、有效/无效、匹配、未匹配;匹配明细按账号展示。
- 「未匹配(n)」可点击筛出别名未匹配账号的任务;「全部」恢复完整列表。
- 任务列表使用
QTableView + TaskTableModel,列为:账号、别名、商品ID、阶段。 - 账号列优先显示匹配到的
accounts.account_name;未匹配账号时保留 Excel 输入账号名。 - 别名未匹配
accounts.alias时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由CollectWorker逐条写库为skipped,原因别名未匹配账号。 - 「采集旧标题/旧封面」通过
CollectWorker后台执行,只处理stage=imported的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回blocked=True,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用editor.collect()下载旧封面到image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg并db.set_collected();别名未匹配任务仍逐条mark_skipped;单条失败mark_failed(..., "collect", error)后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 - 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
- 「停止」调用 worker 的协作式
cancel(),已开始的单条跑到安全边界后结束。 - 「回写旧数据到 Excel」通过
WriteBackWorker后台调用excel.write_back(),把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。 - 采集前由
CollectWorker做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回blocked=True,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。
② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁):
- 左右
QSplitter:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。 - 标题提示词启动时从
title_prompt.txt回显;点击「保存标题提示词」写回该文件。 - 封面提示词模板下拉读取
prompts/cover/*.txt;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 - 「插入标题」在封面提示词光标处插入
{新标题};「预览」使用当前选中任务(无选择则用第一条)调用prompts.render_prompt()并弹窗展示。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自
db.list_batches();店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配item_id,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用
QTableView + GenerateTaskTableModel,列为:店铺、商品ID、旧标题、新标题、状态。stage=collected显示“待生成”,stage=generated显示“已生成”,status=failed/skipped/running优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用db.update_generated_title()写回本地并清空last_error。 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
- 底部「开始生成」只处理当前筛选结果里
stage=collected的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;通过GenerateWorker调ai.generate_batch(),先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。 - 「停止」调用 worker 的协作式
cancel();未开始的 Future 取消,不记失败;已完成的db.set_generated()结果保留,可再次开始生成剩余任务。 - 进度标签显示标题/图片/失败;未勾选生成封面时图片进度显示本轮未生成,标题成功即
db.set_generated(task_id, new_title, None)并进入 generated;勾选时每条封面生成成功后立即db.set_generated(task_id, new_title, new_cover_path)。失败db.mark_failed(..., "generate", error),GUI 刷新任务表。 - T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写
run_type=reset事件。
③ 更新shopee当前要点(T-303b/T-401/T-402/T-403):
ApplyTab顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自db.list_batches();店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配item_id,清空表示全部。- ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:
stage=generated/applied,或已有新字段且status=failed/skipped的任务。 - 状态筛选支持:已生成(默认,
stage=generated且status=success/pending)、失败、已更新、略过、全部状态。 - 任务列表使用
QTableView + ApplyTaskTableModel,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用
editor.apply_task()、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、会更新字段和略过原因。 - 点击「开始更新」先读取
shopee_update:未允许真实提交,或包含新封面但未允许封面更新时,弹警告并阻断;拦截弹窗写明具体设置项并提供「前往设置」跳到⑤;普通正式更新不再检查test_item_id,当前筛选结果可包含多个真实商品 ID;max_items_per_run作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。 - 用户点否/取消时不执行、不改库;用户点是后才创建
ApplyWorker做真实提交。 ApplyWorker只处理当前筛选结果里stage=generated且已有新标题或新封面、状态为success/pending/failed的任务;已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。- 检查本轮更新:不做账号登录预检,不调用
editor.apply_task(),不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 - 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回
blocked=True,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用editor.apply_task()、不写失败状态、不自动启动 Chrome。 - 预检通过后默认串行;若
parallel_accounts=true且max_parallel_accounts>1,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行db.mark_running(..., "apply")→editor.apply_task(account, task, close_success_tab=设置值)→db.set_applied();成功推进stage=applied/status=success/committed=1,失败保持原 stage、status=failed/committed=0/last_error,单条失败继续下一条。 - 检查和真实更新都会创建
run_logs,并把逐条事件写入run_log_events;③ 页面显示最近运行日志。 - 若
close_success_tab=true,editor.apply_task()只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;失败和复用的用户已有 tab 不关闭。 - 别名未匹配账号的任务逐条
db.mark_skipped(),原因别名未匹配账号;「停止」调用 worker 协作式cancel(),已开始单条跑到安全边界后结束。 - T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留
new_title/new_cover_path,本地退回stage=generated/status=pending供重复更新;committed=1时必须提示线上已提交过且不回滚 Shopee,并保留 committed 历史事实/运行日志。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
- 更新完成后自动调用
WriteBackWorker(mode="results")→excel.write_back_results(),把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。 - 自动结果回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;若没有可回写批次,也会弹出更新汇总。
workers 模块(app/workers.py,已建,PySide6)
QT_IMPORT_ERROR: Exception|None
class WorkerError(RuntimeError): ...
# worker 约定
class BaseWorker(QObject):
progress = Signal(dict) # {"done": int, "total": int, ...}
row_updated = Signal(int, dict) # task_id, changed fields
log = Signal(str)
failed = Signal(int, str) # task_id, error
finished = Signal(dict) # summary
cancelled = Signal(dict)
def cancel(self) -> None: ...
def is_cancelled(self) -> bool: ...
def should_cancel(self) -> bool: ...
def execute(self) -> dict|None: ... # 子类实现;不要直接操作 QWidget
def run(self) -> None: ... # QThread.started 触发,统一发 terminal signal
run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
# moveToThread + 绑定 started/finished/cancelled + 收尾 deleteLater;
# 默认立即 start;测试或调用方需要先连额外信号时可 start=False 后手动 thread.start()
要点:
- GUI 线程只操作 Qt widget;后台 worker 不直接访问 QWidget。
workers.py不导入QtWidgets;业务 worker 子类只通过 signal 回传 UI 所需数据。- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建
ApplyWorker。 ApplyWorker支持检查、默认串行和按账号并行;真实更新按max_items_per_run分批调用editor.apply_task(..., close_success_tab=...),逐条set_applied(),失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。WriteBackWorker默认mode="old"回写旧字段;③ 使用mode="results"回写新标题/新封面/更新状态,支持单批次或多批次列表。execute()未捕获异常会发failed(-1, error)与finished({"ok": False, "error": ...});普通单行失败由业务 worker 自己发failed(task_id, error)后继续处理。
启动入口
# main.py
from app.gui import main
raise SystemExit(main())
# app/__main__.py
from .gui import main
raise SystemExit(main())
正式运行入口:python main.py;开发/包入口:python -m app。
CLI / 触发合约(现有脚本,过渡期保留)
python prototypes/demo.py # 单账号闭环:改标题+换封面,不提交
set AUTO=1 && python prototypes/demo.py
set UPDATE=1 && python prototypes/demo.py # 走完点击「更新」提交
环境变量:CDP_HOST、ITEM_ID、IMG_WIN、NEW_TITLE、AUTO、UPDATE。
待实现时确认
- AI 服务商/模型/计费;图像 image-to-image 能力与合规。
- 删除线上第一张封面已在测试商品
29671243750上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。 - 旧封面下载的图片格式/扩展名处理。