feat: complete T-533 incremental generation
This commit is contained in:
@@ -230,18 +230,57 @@ def _task_attempt_count(task, field):
|
||||
return 0
|
||||
|
||||
|
||||
def is_generatable_task(task):
|
||||
"""判断任务是否能由② AI生成执行或重试。"""
|
||||
def _has_generated_title(task):
|
||||
return bool(str(getattr(task, "new_title", "") or "").strip())
|
||||
|
||||
|
||||
def _has_generated_cover(task):
|
||||
return bool(str(getattr(task, "new_cover_path", "") or "").strip())
|
||||
|
||||
|
||||
def generation_needs(task, generate_cover=False):
|
||||
"""Return which AI components still need generation for this task."""
|
||||
|
||||
stage = str(getattr(task, "stage", "") or "")
|
||||
status = str(getattr(task, "status", "") or "")
|
||||
if status in {"running", "skipped"}:
|
||||
return False
|
||||
if stage == "collected":
|
||||
return True
|
||||
if stage == "generated" and status == "failed":
|
||||
return _task_attempt_count(task, "apply_attempts") == 0
|
||||
return False
|
||||
return {"title": False, "cover": False}
|
||||
if stage not in {"collected", "generated"}:
|
||||
return {"title": False, "cover": False}
|
||||
if stage == "generated" and _task_attempt_count(task, "apply_attempts") > 0:
|
||||
return {"title": False, "cover": False}
|
||||
needs_title = not _has_generated_title(task)
|
||||
needs_cover = bool(generate_cover) and not _has_generated_cover(task)
|
||||
return {"title": needs_title, "cover": needs_cover}
|
||||
|
||||
|
||||
def is_generatable_task(task, generate_cover=False):
|
||||
"""判断任务是否能由② AI生成执行或重试。"""
|
||||
|
||||
needs = generation_needs(task, generate_cover=generate_cover)
|
||||
return bool(needs["title"] or needs["cover"])
|
||||
|
||||
|
||||
def generation_component_totals(tasks, generate_cover=False):
|
||||
"""Count task and component gaps for a generation run."""
|
||||
|
||||
eligible = [
|
||||
task for task in list(tasks)
|
||||
if is_generatable_task(task, generate_cover=generate_cover)
|
||||
]
|
||||
title_total = 0
|
||||
cover_total = 0
|
||||
for task in eligible:
|
||||
needs = generation_needs(task, generate_cover=generate_cover)
|
||||
if needs["title"]:
|
||||
title_total += 1
|
||||
if needs["cover"]:
|
||||
cover_total += 1
|
||||
return {
|
||||
"total": len(eligible),
|
||||
"title_total": title_total,
|
||||
"cover_total": cover_total,
|
||||
}
|
||||
|
||||
|
||||
def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None):
|
||||
@@ -278,14 +317,27 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
||||
should_stop = should_stop or (lambda: False)
|
||||
eligible = [
|
||||
task for task in list(tasks)
|
||||
if is_generatable_task(task)
|
||||
if is_generatable_task(task, generate_cover=generate_cover)
|
||||
]
|
||||
needs_by_task = {
|
||||
getattr(task, "id", None): generation_needs(task, generate_cover=generate_cover)
|
||||
for task in eligible
|
||||
}
|
||||
title_tasks = [
|
||||
task for task in eligible
|
||||
if needs_by_task.get(getattr(task, "id", None), {}).get("title")
|
||||
]
|
||||
cover_candidates = [
|
||||
task for task in eligible
|
||||
if needs_by_task.get(getattr(task, "id", None), {}).get("cover")
|
||||
]
|
||||
summary = {
|
||||
"ok": True,
|
||||
"total": len(eligible),
|
||||
"title_total": len(title_tasks),
|
||||
"title_done": 0,
|
||||
"cover_done": 0,
|
||||
"cover_total": len(eligible) if generate_cover else 0,
|
||||
"cover_total": len(cover_candidates) if generate_cover else 0,
|
||||
"generated_done": 0,
|
||||
"failed": 0,
|
||||
"cancelled": False,
|
||||
@@ -331,7 +383,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
||||
max_workers=max(1, int(generation_cfg.get("title_concurrency", 1)))
|
||||
) as executor:
|
||||
futures = {}
|
||||
for task in eligible:
|
||||
for task in title_tasks:
|
||||
if should_stop():
|
||||
summary["cancelled"] = True
|
||||
break
|
||||
@@ -361,29 +413,31 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
||||
summary["title_done"] += 1
|
||||
set_step(task, "title_done")
|
||||
_emit_generation_event(on_event, task, "title", "title_done", "success")
|
||||
if not generate_cover:
|
||||
set_step(task, "db_write")
|
||||
_emit_generation_event(on_event, task, "title", "db_write", "start")
|
||||
db.set_generated(task.id, new_title, None, path=db_path)
|
||||
set_step(task, "db_write")
|
||||
_emit_generation_event(on_event, task, "title", "db_write", "start")
|
||||
existing_cover_path = getattr(task, "new_cover_path", None)
|
||||
db.set_generated(task.id, new_title, existing_cover_path, path=db_path)
|
||||
needs_cover = needs_by_task.get(getattr(task, "id", None), {}).get("cover")
|
||||
if not needs_cover:
|
||||
summary["generated_done"] += 1
|
||||
if on_task_update is not None:
|
||||
on_task_update(
|
||||
task.id,
|
||||
{
|
||||
"stage": "generated",
|
||||
"status": "success",
|
||||
"new_title": new_title,
|
||||
"new_cover_path": None,
|
||||
},
|
||||
)
|
||||
_emit_generation_event(
|
||||
on_event,
|
||||
task,
|
||||
"title",
|
||||
"db_write",
|
||||
"success",
|
||||
detail="仅生成标题",
|
||||
if on_task_update is not None:
|
||||
on_task_update(
|
||||
task.id,
|
||||
{
|
||||
"stage": "generated",
|
||||
"status": "success",
|
||||
"new_title": new_title,
|
||||
"new_cover_path": existing_cover_path,
|
||||
},
|
||||
)
|
||||
_emit_generation_event(
|
||||
on_event,
|
||||
task,
|
||||
"title",
|
||||
"db_write",
|
||||
"success",
|
||||
detail="标题已保存,等待封面" if needs_cover else "仅生成标题",
|
||||
)
|
||||
except CancelledError:
|
||||
summary["cancelled"] = True
|
||||
_emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning")
|
||||
@@ -401,9 +455,15 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
||||
summary["ok"] = False
|
||||
return summary
|
||||
|
||||
for task in eligible:
|
||||
task_id = getattr(task, "id", None)
|
||||
if task_id not in title_results and _has_generated_title(task):
|
||||
title_results[task_id] = str(getattr(task, "new_title") or "")
|
||||
_emit_generation_event(on_event, task, "title", "title_submit", "skipped", detail="已有标题")
|
||||
|
||||
cover_tasks = [
|
||||
task for task in eligible
|
||||
if task.id in title_results
|
||||
task for task in cover_candidates
|
||||
if getattr(task, "id", None) in title_results
|
||||
]
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=max(1, int(generation_cfg.get("image_concurrency", 1)))
|
||||
|
||||
+19
-10
@@ -270,6 +270,7 @@ class GenerateTab(QWidget):
|
||||
def _on_generate_cover_toggled(self, checked):
|
||||
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
|
||||
if self._save_generate_cover_setting(show_status=True):
|
||||
self.refresh_tasks()
|
||||
return
|
||||
self.generate_cover_checkbox.blockSignals(True)
|
||||
self.generate_cover_checkbox.setChecked(previous)
|
||||
@@ -470,11 +471,17 @@ class GenerateTab(QWidget):
|
||||
generate_cover = bool(self.generate_cover_checkbox.isChecked())
|
||||
tasks = [
|
||||
task for task in self.model.tasks
|
||||
if ai.is_generatable_task(task)
|
||||
if ai.is_generatable_task(task, generate_cover=generate_cover)
|
||||
]
|
||||
if not tasks:
|
||||
self._set_status("当前筛选结果没有待生成或生成失败可重试任务;请先在①导入采集完成旧数据采集")
|
||||
self._set_status(
|
||||
"当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或勾选“生成封面图片(成本较高)”补封面"
|
||||
)
|
||||
return
|
||||
component_totals = ai.generation_component_totals(
|
||||
tasks,
|
||||
generate_cover=generate_cover,
|
||||
)
|
||||
prompt_values = {
|
||||
"title": self.title_prompt_edit.toPlainText(),
|
||||
"cover": self.cover_prompt_edit.toPlainText(),
|
||||
@@ -502,9 +509,10 @@ class GenerateTab(QWidget):
|
||||
self._update_generate_progress(
|
||||
{
|
||||
"total": len(tasks),
|
||||
"title_total": component_totals["title_total"],
|
||||
"title_done": 0,
|
||||
"cover_done": 0,
|
||||
"cover_total": len(tasks) if generate_cover else 0,
|
||||
"cover_total": component_totals["cover_total"] if generate_cover else 0,
|
||||
"generated_done": 0,
|
||||
"failed": 0,
|
||||
"generate_cover": generate_cover,
|
||||
@@ -681,15 +689,16 @@ class GenerateTab(QWidget):
|
||||
|
||||
def _update_generate_progress(self, payload):
|
||||
total = max(0, int(payload.get("total", 0) or 0))
|
||||
title_total = max(0, int(payload.get("title_total", total) or 0))
|
||||
title_done = max(0, int(payload.get("title_done", 0) or 0))
|
||||
cover_done = max(0, int(payload.get("cover_done", 0) or 0))
|
||||
cover_total = self._cover_total_for_progress(payload, total)
|
||||
failed = max(0, int(payload.get("failed", 0) or 0))
|
||||
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
|
||||
self.title_progress_label.setText(f"标题 {title_done}/{total}")
|
||||
self.title_progress_label.setText(f"标题 {title_done}/{title_total}")
|
||||
self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}")
|
||||
self.failed_progress_label.setText(f"失败 {failed}")
|
||||
self._set_progress_bar(self.title_progress_bar, title_done, total)
|
||||
self._set_progress_bar(self.title_progress_bar, title_done, title_total)
|
||||
self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
|
||||
|
||||
def _reset_cmhub_balance_label(self):
|
||||
@@ -736,12 +745,13 @@ class GenerateTab(QWidget):
|
||||
|
||||
def _generate_progress_text(self, payload):
|
||||
total = max(0, int(payload.get("total", 0) or 0))
|
||||
title_total = max(0, int(payload.get("title_total", total) or 0))
|
||||
cover_total = self._cover_total_for_progress(payload, total)
|
||||
return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format(
|
||||
return "标题{title}/{title_total} · 图片{cover}/{cover_total} · 失败{failed}".format(
|
||||
title=payload.get("title_done", 0),
|
||||
cover=payload.get("cover_done", 0),
|
||||
cover_total=cover_total,
|
||||
total=payload.get("total", 0),
|
||||
title_total=title_total,
|
||||
failed=payload.get("failed", 0),
|
||||
)
|
||||
|
||||
@@ -896,7 +906,8 @@ class GenerateTab(QWidget):
|
||||
if selected_status in (None, "all"):
|
||||
return True
|
||||
if selected_status == "to_generate":
|
||||
return ai.is_generatable_task(task) and task.status != "failed"
|
||||
generate_cover = bool(self.generate_cover_checkbox.isChecked())
|
||||
return ai.is_generatable_task(task, generate_cover=generate_cover) and task.status != "failed"
|
||||
if selected_status == "generated":
|
||||
return task.stage == "generated"
|
||||
if selected_status == "applied":
|
||||
@@ -906,5 +917,3 @@ class GenerateTab(QWidget):
|
||||
if selected_status == "skipped":
|
||||
return task.status == "skipped"
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+18
-8
@@ -37,10 +37,16 @@ class GenerateWorker(BaseWorker):
|
||||
if str(account.alias).strip()
|
||||
}
|
||||
self._account_by_alias = account_by_alias
|
||||
ai_cfg = appconfig.ai_config(self.config)
|
||||
generate_cover = bool(ai_cfg.get("generate_cover", False))
|
||||
eligible = [
|
||||
task for task in self.tasks
|
||||
if ai.is_generatable_task(task)
|
||||
if ai.is_generatable_task(task, generate_cover=generate_cover)
|
||||
]
|
||||
component_totals = ai.generation_component_totals(
|
||||
eligible,
|
||||
generate_cover=generate_cover,
|
||||
)
|
||||
self._eligible_total = len(eligible)
|
||||
self._task_positions = {
|
||||
getattr(task, "id", None): index
|
||||
@@ -48,17 +54,18 @@ class GenerateWorker(BaseWorker):
|
||||
}
|
||||
batch_ids = self._batch_ids(eligible)
|
||||
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||
ai_cfg = appconfig.ai_config(self.config)
|
||||
generate_cover = bool(ai_cfg.get("generate_cover", False))
|
||||
if generate_cover:
|
||||
start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
|
||||
start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
|
||||
total=len(eligible),
|
||||
title_total=component_totals["title_total"],
|
||||
cover_total=component_totals["cover_total"],
|
||||
title_concurrency=ai_cfg.get("title_concurrency", 1),
|
||||
image_concurrency=ai_cfg.get("image_concurrency", 1),
|
||||
)
|
||||
else:
|
||||
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format(
|
||||
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency}".format(
|
||||
total=len(eligible),
|
||||
title_total=component_totals["title_total"],
|
||||
title_concurrency=ai_cfg.get("title_concurrency", 1),
|
||||
)
|
||||
self._log_run_event(start_message)
|
||||
@@ -85,9 +92,10 @@ class GenerateWorker(BaseWorker):
|
||||
"ok": False,
|
||||
"error": error,
|
||||
"total": len(eligible),
|
||||
"title_total": component_totals["title_total"],
|
||||
"title_done": 0,
|
||||
"cover_done": 0,
|
||||
"cover_total": len(eligible) if generate_cover else 0,
|
||||
"cover_total": component_totals["cover_total"] if generate_cover else 0,
|
||||
"generated_done": 0,
|
||||
"failed": len(eligible),
|
||||
"cancelled": self.should_cancel(),
|
||||
@@ -160,6 +168,8 @@ class GenerateWorker(BaseWorker):
|
||||
if phase == "title":
|
||||
if result == "start" and step == "title_submit":
|
||||
return f"[标题] {self._task_progress_label(task)} 开始生成"
|
||||
if result == "skipped":
|
||||
return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文"
|
||||
if result == "success" and step == "title_done":
|
||||
return f"[标题] {self._task_progress_label(task)} 成功"
|
||||
if result == "success" and step == "db_write":
|
||||
@@ -251,12 +261,13 @@ class GenerateWorker(BaseWorker):
|
||||
return f"[完成] AI 生成完成:{progress}"
|
||||
|
||||
def _summary_text(self, summary):
|
||||
title_total = summary.get("title_total", summary.get("total", 0))
|
||||
cover_total = summary.get("cover_total", summary.get("total", 0))
|
||||
return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format(
|
||||
title=summary.get("title_done", 0),
|
||||
cover=summary.get("cover_done", 0),
|
||||
cover_total=cover_total,
|
||||
total=summary.get("total", 0),
|
||||
total=title_total,
|
||||
failed=summary.get("failed", 0),
|
||||
)
|
||||
|
||||
@@ -1994,4 +2005,3 @@ class AIModelTestWorker(BaseWorker):
|
||||
|
||||
def _elapsed_ms(self, started):
|
||||
return _elapsed_ms(started)
|
||||
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@
|
||||
| T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
||||
| T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
||||
| T-532 | ⑤ cmhub 连接成功提示显示账号名 | T-531, T-527, T-528 | 问题:⑤「测试连接/查余额」或刷新别名成功后,界面只提示 `cmhub 连接成功:...`,用户无法确认当前 API Key 实际属于哪个 cmhub 账号,尤其多账号或换 Key 时容易误判。方案:成功 payload 中若 `/api/v1/balance` 返回可识别账号信息,则在结果 label 和状态栏优先显示 `cmhub 账号「<账号名>」连接成功:生文别名 X 个,生图别名 X 个;余额 Y;别名已拉取,记得点『保存设置』持久化`;当前接口结构按 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 兼容,显示名优先取 `account.display_name/name/account_name/username/email/id`,再兜底 `user` 字符串或 `user.name/account_name/username/email/id`、顶层 `display_name/name/username/email/id`;邮箱需要脱敏展示;没有账号字段、仅刷新别名不查余额或接口未返回用户信息时,保留现有 `cmhub 连接成功:...` 文案。不得把 API Key、token、完整邮箱或原始敏感响应写入 GUI、run log、诊断日志;不改变 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成流程或 Shopee/CDP 流程。同步 GUI 单测覆盖有账号名、当前接口结构、只有邮箱、无账号名四种成功提示 | DONE |
|
||||
| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | TODO |
|
||||
| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | DONE |
|
||||
| T-534 | ② 重置增强:多选/筛选范围 + 按组件(标题/封面/全部)重置 | T-533, T-404a, T-509 | 现状:②「重置生成结果」只能对单条、且整条全清(`db.reset_generated` 同时清 `new_title` 和 `new_cover_path` 退回 collected)。真实诉求是精准重置——一批标题不满意但封面 OK(只重标题、保封面、不重扣封面点)、或封面差但标题 OK(只重封面、**保留 `new_title` 保护 T-509 手动标题**、不重扣标题点);且需要作用于多条。方案:与 T-533 的组件模型咬合——① 后端 `db.reset_generated` 加组件开关 `reset_title`/`reset_cover`(`delete_file` 仅在重置封面时可选),只清对应字段与 `last_error`,stage 保持 `generated`,由 T-533 泛化后的 `is_generatable_task`(缺任一必需组件即可生成)识别缺口后由「开始生成」只补被清组件;② UI 重置入口给三选项「重置标题 / 重置封面 / 重置全部」;③ 作用域从单条扩到**多选(表格改 `ExtendedSelection`)或当前筛选结果**,与③「作用于当前筛选结果」心智一致;④ 二次确认框显示将重置的标题/封面条数、其中 `committed=1` 条数,并提示「线上已提交、本地重置不回滚 Shopee、重生成后再更新会再次提交线上」+ 重生成会消耗 cmhub 点数;⑤ 运行中禁用;批量写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel。**只重置封面时绝不改 `new_title`**。不改 cmhub HTTP 协议、Excel、Shopee/CDP 流程。单测覆盖:只重标题保留封面、只重封面保留标题(含手动标题不被清)、重置全部、多选/筛选批量、committed 提示、运行中禁用 | TODO |
|
||||
|
||||
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-1
@@ -1225,4 +1225,13 @@
|
||||
|
||||
- 文档:`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/cmhub-integration-design.md`、`docs/current-state.md` 已记录当前 `/api/v1/balance` 返回结构 `{user, points_balance, account:{username, display_name}}`。
|
||||
- 代码:`SettingsTab` 账号显示名提取优先读取 `account.display_name`,再兜底 `account.username`、`user`、name/account_name/email/id 等字段;邮箱仍脱敏展示。
|
||||
- 测试:补充 GUI worker 与设置页成功提示单测,确认当前结构显示 `主账号` 而不是 `cmhub_user`;未改 cmhub HTTP 协议、配置 schema、AI 生成或 Shopee/CDP 流程。
|
||||
- 测试:补充 GUI worker 与设置页成功提示单测,确认当前结构显示 `主账号` 而不是 `cmhub_user`;未改 cmhub HTTP 协议、配置 schema、AI 生成或 Shopee/CDP 流程。
|
||||
|
||||
## 【2026-07-06】T-533 完成 · ②增量生成按缺失组件补齐
|
||||
|
||||
- 状态:DONE
|
||||
- 代码:`app/ai.py` 新增 `generation_needs()` / `generation_component_totals()`,`is_generatable_task()` 改为按当前封面开关判断标题/封面缺口;`generate_batch()` 对已有 `new_title` 的任务跳过生文,只在缺 `new_cover_path` 且开启封面时补封面,避免覆盖 T-509 手动标题和重复扣标题点。
|
||||
- GUI:②开始生成、待生成筛选、`GenerateWorker` 和底部进度改用组件口径;本轮日志会显示已有标题跳过生文,标题/图片进度分别按 `title_total` / `cover_total` 统计;无缺口时提示先完成①采集或勾选封面补封面。
|
||||
- 边界:未改 cmhub HTTP 协议、配置 schema、DB schema、Excel、Shopee/CDP 流程;全组件齐备任务不纳入本轮生成。
|
||||
- 测试:新增/调整 AI 与 GUI 单测覆盖只补封面、混合标题/封面缺口、封面关闭时标题态任务不纳入、全组件齐备不重复生成、②入口可启动补封面。
|
||||
- 验证:`python -m py_compile app\ai.py app\gui\workers.py app\gui\tabs\generate.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_ai.py` 通过(23 tests);相关 GUI 用例通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(207 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。
|
||||
|
||||
+129
-14
@@ -757,35 +757,150 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_retries_failed_generation_record_after_existing_result(self):
|
||||
def test_generate_batch_fills_missing_cover_without_regenerating_title(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
cfg["ai"]["generate_cover"] = True
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
|
||||
db.set_generated(tasks[0].id, "手动标题", None, path=cfg["db_path"])
|
||||
cover_only_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
progress = []
|
||||
|
||||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(b"jpeg")
|
||||
return out_path
|
||||
|
||||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
|
||||
summary = ai.generate_batch(
|
||||
[cover_only_task],
|
||||
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
on_progress=progress.append,
|
||||
)
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(1, summary["total"])
|
||||
self.assertEqual(0, summary["title_total"])
|
||||
self.assertEqual(0, summary["title_done"])
|
||||
self.assertEqual(1, summary["cover_total"])
|
||||
self.assertEqual(1, summary["cover_done"])
|
||||
self.assertEqual(1, summary["generated_done"])
|
||||
self.assertEqual(0, progress[-1]["title_total"])
|
||||
self.assertEqual(1, progress[-1]["cover_total"])
|
||||
gen_title.assert_not_called()
|
||||
gen_cover.assert_called_once()
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
self.assertEqual("generated", updated.stage)
|
||||
self.assertEqual("success", updated.status)
|
||||
self.assertEqual("手动标题", updated.new_title)
|
||||
self.assertTrue(os.path.exists(updated.new_cover_path))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_handles_mixed_title_and_cover_gaps(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
cfg["ai"]["generate_cover"] = True
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题A", "旧标题B"])
|
||||
db.set_generated(tasks[1].id, "已有标题B", None, path=cfg["db_path"])
|
||||
mixed_tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
cover_prompts = []
|
||||
|
||||
def fake_title(title_prompt, old_title, **kwargs):
|
||||
return "新" + old_title
|
||||
|
||||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||
cover_prompts.append(cover_prompt)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(b"jpeg")
|
||||
return out_path
|
||||
|
||||
with mock.patch("app.ai.gen_title", side_effect=fake_title) as gen_title, \
|
||||
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
|
||||
summary = ai.generate_batch(
|
||||
mixed_tasks,
|
||||
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
)
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(2, summary["total"])
|
||||
self.assertEqual(1, summary["title_total"])
|
||||
self.assertEqual(1, summary["title_done"])
|
||||
self.assertEqual(2, summary["cover_total"])
|
||||
self.assertEqual(2, summary["cover_done"])
|
||||
self.assertEqual(2, summary["generated_done"])
|
||||
self.assertEqual(1, gen_title.call_count)
|
||||
self.assertEqual(2, gen_cover.call_count)
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
by_old_title = {task.old_title: task for task in updated}
|
||||
self.assertEqual("新旧标题A", by_old_title["旧标题A"].new_title)
|
||||
self.assertEqual("已有标题B", by_old_title["旧标题B"].new_title)
|
||||
self.assertTrue(all(os.path.exists(task.new_cover_path) for task in updated))
|
||||
self.assertIn("新旧标题A", "\n".join(cover_prompts))
|
||||
self.assertIn("已有标题B", "\n".join(cover_prompts))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_ignores_title_only_task_when_cover_disabled(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
cfg["ai"]["generate_cover"] = False
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
|
||||
db.set_generated(tasks[0].id, "旧AI标题", "old-new.jpg", path=cfg["db_path"])
|
||||
db.mark_failed(tasks[0].id, "generate", "上次生成失败", path=cfg["db_path"])
|
||||
retry_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_generated(tasks[0].id, "已有标题", None, path=cfg["db_path"])
|
||||
title_only_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
|
||||
with mock.patch("app.ai.gen_title", return_value="重新生成标题") as gen_title, \
|
||||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||
mock.patch("app.ai.gen_cover") as gen_cover:
|
||||
summary = ai.generate_batch(
|
||||
[retry_task],
|
||||
[title_only_task],
|
||||
{"title": "标题提示", "cover": "封面"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
)
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(1, summary["total"])
|
||||
self.assertEqual(1, summary["generated_done"])
|
||||
gen_title.assert_called_once()
|
||||
self.assertEqual(0, summary["total"])
|
||||
self.assertEqual(0, summary["title_total"])
|
||||
self.assertEqual(0, summary["cover_total"])
|
||||
gen_title.assert_not_called()
|
||||
gen_cover.assert_not_called()
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_ignores_complete_generated_task(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
cfg["ai"]["generate_cover"] = True
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
|
||||
db.set_generated(tasks[0].id, "已有标题", "new.jpg", path=cfg["db_path"])
|
||||
complete_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
|
||||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||
mock.patch("app.ai.gen_cover") as gen_cover:
|
||||
summary = ai.generate_batch(
|
||||
[complete_task],
|
||||
{"title": "标题提示", "cover": "封面"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
)
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(0, summary["total"])
|
||||
self.assertEqual(0, summary["title_total"])
|
||||
self.assertEqual(0, summary["cover_total"])
|
||||
gen_title.assert_not_called()
|
||||
gen_cover.assert_not_called()
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
self.assertEqual("generated", updated.stage)
|
||||
self.assertEqual("success", updated.status)
|
||||
self.assertEqual("重新生成标题", updated.new_title)
|
||||
self.assertIsNone(updated.new_cover_path)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
+158
-1
@@ -1356,6 +1356,41 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_refreshes_to_generate_filter_when_cover_toggle_changes(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, "已有标题", None, path=cfg["db_path"])
|
||||
tab = GenerateTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
|
||||
self.assertEqual(0, tab.model.rowCount())
|
||||
|
||||
tab.generate_cover_checkbox.setChecked(True)
|
||||
|
||||
self.assertEqual(1, tab.model.rowCount())
|
||||
self.assertEqual("51100639510", tab.model.task_at(0).item_id)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_manages_prompt_files_and_preview(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -1927,6 +1962,73 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_fills_missing_cover_without_title_call(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["generate_cover"] = 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(
|
||||
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, "手动标题", None, path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
logs = []
|
||||
progress = []
|
||||
|
||||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(b"jpeg")
|
||||
return out_path
|
||||
|
||||
worker = GenerateWorker(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
)
|
||||
worker.log.connect(logs.append)
|
||||
worker.progress.connect(progress.append)
|
||||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
|
||||
summary = worker.execute()
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(1, summary["total"])
|
||||
self.assertEqual(0, summary["title_total"])
|
||||
self.assertEqual(1, summary["cover_total"])
|
||||
self.assertEqual(0, summary["title_done"])
|
||||
self.assertEqual(1, summary["cover_done"])
|
||||
self.assertEqual(1, summary["generated_done"])
|
||||
gen_title.assert_not_called()
|
||||
gen_cover.assert_called_once()
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
self.assertEqual("手动标题", updated.new_title)
|
||||
self.assertTrue(os.path.exists(updated.new_cover_path))
|
||||
self.assertEqual(0, progress[-1]["title_total"])
|
||||
self.assertEqual(1, progress[-1]["cover_total"])
|
||||
joined_logs = "\n".join(logs)
|
||||
self.assertIn("[开始] 本轮生成 1 条:标题0,图片1", joined_logs)
|
||||
self.assertIn("已有标题,跳过生文", joined_logs)
|
||||
self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_does_not_auto_mix_latest_generate_run_log(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -2009,6 +2111,61 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_starts_cover_only_run_for_missing_cover(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["generate_cover"] = 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(
|
||||
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, "手动标题", None, path=cfg["db_path"])
|
||||
tab = GenerateTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
class FakeSignal:
|
||||
def __init__(self):
|
||||
self.callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
self.callbacks.append(callback)
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self):
|
||||
self.finished = FakeSignal()
|
||||
self.started = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
fake_thread = FakeThread()
|
||||
with mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
tab.start_generate()
|
||||
|
||||
self.assertTrue(fake_thread.started)
|
||||
self.assertEqual("标题 0/0", tab.title_progress_label.text())
|
||||
self.assertEqual("图片 0/1", tab.cover_progress_label.text())
|
||||
text = tab.run_log_view.toPlainText()
|
||||
self.assertIn("本轮AI生成开始:任务 1 条", text)
|
||||
self.assertIn("生成封面:是", text)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_explains_collect_failed_records_are_not_generatable(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -2038,7 +2195,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.start_generate()
|
||||
|
||||
self.assertIsNone(tab.generate_worker)
|
||||
self.assertIn("没有待生成或生成失败可重试任务", statuses[-1])
|
||||
self.assertIn("没有可生成的缺失内容", statuses[-1])
|
||||
self.assertIn("①导入采集", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
Reference in New Issue
Block a user