feat(product-suite): confirm prior successful generations

This commit is contained in:
chengma
2026-07-17 10:47:44 +08:00
parent 9166cb7010
commit 9f55dcd4f0
8 changed files with 349 additions and 4 deletions
+91 -3
View File
@@ -1395,7 +1395,14 @@ class ProductSuiteGlobalHistoryDialog(QDialog):
PAGE_SIZE = 30 PAGE_SIZE = 30
def __init__(self, *, current_project_id=None, db_path=None, parent=None): def __init__(
self,
*,
current_project_id=None,
current_project_only=False,
db_path=None,
parent=None,
):
super().__init__(parent) super().__init__(parent)
self.db_path = db_path self.db_path = db_path
self.current_project_id = None self.current_project_id = None
@@ -1490,6 +1497,7 @@ class ProductSuiteGlobalHistoryDialog(QDialog):
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter) layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
self.set_current_project(current_project_id, refresh=False) self.set_current_project(current_project_id, refresh=False)
self.set_current_project_filter(current_project_only, refresh=False)
self.refresh_history() self.refresh_history()
def set_current_project(self, project_id, *, refresh=True): def set_current_project(self, project_id, *, refresh=True):
@@ -1512,6 +1520,15 @@ class ProductSuiteGlobalHistoryDialog(QDialog):
if refresh and (changed or self.current_project_checkbox.isChecked()): if refresh and (changed or self.current_project_checkbox.isChecked()):
self.refresh_history() self.refresh_history()
def set_current_project_filter(self, enabled, *, refresh=True):
should_filter = bool(enabled) and self.current_project_id is not None
changed = self.current_project_checkbox.isChecked() != should_filter
previous = self.current_project_checkbox.blockSignals(True)
self.current_project_checkbox.setChecked(should_filter)
self.current_project_checkbox.blockSignals(previous)
if refresh and changed:
self.refresh_history()
def refresh_history(self, checked=False): def refresh_history(self, checked=False):
scroll_value = self.scroll.verticalScrollBar().value() scroll_value = self.scroll.verticalScrollBar().value()
self._clear_history_content() self._clear_history_content()
@@ -2309,6 +2326,40 @@ class ProductSuiteTab(QWidget):
box.exec() box.exec()
return box.clickedButton() is confirm_button return box.clickedButton() is confirm_button
def _confirm_new_generation_history(self, state, summary):
item_id = str(state.item_id or "临时草稿")
account_text = self._account_context_label(state)
latest = str(summary.latest_succeeded_at or "").replace("T", " ")
lines = [
"%s" % account_text,
"商品ID:%s" % item_id,
"该商品已有成功套图:%d轮,%d张。"
% (
int(summary.successful_round_count),
int(summary.successful_image_count),
),
]
if latest:
lines.append("最近成功时间:%s" % latest)
lines.append("继续会创建新一轮生成,已有历史结果会保留。")
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("已有套图生成记录")
box.setText("\n".join(lines))
history_button = box.addButton("查看历史", QMessageBox.ActionRole)
continue_button = box.addButton("继续生成新一轮", QMessageBox.AcceptRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(cancel_button)
box.setEscapeButton(cancel_button)
box.exec()
clicked = box.clickedButton()
if clicked is history_button:
return "history"
if clicked is continue_button:
return "continue"
return "cancel"
def refresh_accounts(self): def refresh_accounts(self):
selected = self.account_combo.currentData() selected = self.account_combo.currentData()
try: try:
@@ -4192,6 +4243,31 @@ class ProductSuiteTab(QWidget):
spec["generation_slot_index"] = original_job.generation_slot_index spec["generation_slot_index"] = original_job.generation_slot_index
generation_round_key = str(original_job.generation_round_key or "") generation_round_key = str(original_job.generation_round_key or "")
else: else:
if confirm_batch:
try:
history_summary = image_studio.get_successful_generation_history_summary(
state.project_id,
path=self.db_path,
)
except Exception:
self._message(
"读取套图历史失败",
"暂时无法确认该商品是否已有成功套图,请稍后重试。",
)
return False
if history_summary.successful_round_count:
decision = self._confirm_new_generation_history(
state,
history_summary,
)
if decision == "history":
self.open_history_dialog(
current_project_only=True,
current_project_id=state.project_id,
)
return False
if decision != "continue":
return False
generation_round_key = uuid.uuid4().hex generation_round_key = uuid.uuid4().hex
for slot_index, spec in enumerate(specs): for slot_index, spec in enumerate(specs):
spec["generation_round_key"] = generation_round_key spec["generation_round_key"] = generation_round_key
@@ -4910,14 +4986,25 @@ class ProductSuiteTab(QWidget):
self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success)) self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success))
self.undo_button.setVisible(bool(state.undo_records)) self.undo_button.setVisible(bool(state.undo_records))
def open_history_dialog(self, checked=False): def open_history_dialog(
self,
checked=False,
*,
current_project_only=False,
current_project_id=None,
):
state = self._displayed_state state = self._displayed_state
current_project_id = state.project_id if state is not None else None if current_project_id is None:
current_project_id = state.project_id if state is not None else None
dialog = self._history_dialog dialog = self._history_dialog
if dialog is not None: if dialog is not None:
try: try:
dialog.set_current_project(current_project_id, refresh=False) dialog.set_current_project(current_project_id, refresh=False)
dialog.set_current_project_filter(
current_project_only,
refresh=False,
)
dialog.refresh_history() dialog.refresh_history()
dialog.show() dialog.show()
dialog.raise_() dialog.raise_()
@@ -4929,6 +5016,7 @@ class ProductSuiteTab(QWidget):
dialog = ProductSuiteGlobalHistoryDialog( dialog = ProductSuiteGlobalHistoryDialog(
current_project_id=current_project_id, current_project_id=current_project_id,
current_project_only=current_project_only,
db_path=self.db_path, db_path=self.db_path,
parent=self, parent=self,
) )
+60
View File
@@ -151,6 +151,16 @@ class ImageStudioHistoryRound:
is_legacy: bool is_legacy: bool
@dataclass(frozen=True)
class ImageStudioSuccessfulGenerationHistorySummary:
"""Successful persisted output summary for one active product project."""
project_id: int
successful_round_count: int
successful_image_count: int
latest_succeeded_at: Optional[str]
class ImageStudioError(RuntimeError): class ImageStudioError(RuntimeError):
"""Raised when the AI image studio service cannot complete an operation.""" """Raised when the AI image studio service cannot complete an operation."""
@@ -1319,6 +1329,56 @@ def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn=
return rounds return rounds
def get_successful_generation_history_summary(project_id, path=None, conn=None):
"""Summarize successful effective generation output for one active project."""
try:
project_id = int(project_id)
except (TypeError, ValueError) as exc:
raise db.DbError("商品项目无效") from exc
sql = """
WITH latest_round_jobs AS (
SELECT project_id,
generation_round_key,
generation_slot_index,
MAX(id) AS latest_id
FROM image_studio_jobs
WHERE generation_round_key IS NOT NULL
GROUP BY project_id, generation_round_key, generation_slot_index
),
effective_jobs AS (
SELECT jobs.*
FROM image_studio_jobs AS jobs
LEFT JOIN latest_round_jobs AS latest
ON latest.latest_id = jobs.id
WHERE jobs.generation_round_key IS NULL OR latest.latest_id IS NOT NULL
)
SELECT COUNT(DISTINCT CASE
WHEN jobs.status = 'succeeded'
THEN COALESCE(jobs.generation_round_key, '__legacy__')
END) AS successful_round_count,
SUM(CASE WHEN jobs.status = 'succeeded' THEN 1 ELSE 0 END)
AS successful_image_count,
MAX(CASE
WHEN jobs.status = 'succeeded'
THEN COALESCE(jobs.finished_at, jobs.updated_at, jobs.created_at)
END) AS latest_succeeded_at
FROM image_studio_projects AS projects
LEFT JOIN effective_jobs AS jobs ON jobs.project_id = projects.id
WHERE projects.id = ?
AND projects.deleted_at IS NULL
"""
with _connection(conn, path) as database:
row = database.execute(sql, (project_id,)).fetchone()
return ImageStudioSuccessfulGenerationHistorySummary(
project_id=project_id,
successful_round_count=int(row["successful_round_count"] or 0) if row else 0,
successful_image_count=int(row["successful_image_count"] or 0) if row else 0,
latest_succeeded_at=row["latest_succeeded_at"] if row else None,
)
def list_global_generation_rounds( def list_global_generation_rounds(
*, *,
account_query="", account_query="",
+1
View File
@@ -446,6 +446,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。 - T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。
- T-643 后 `image_studio_projects.current_generation_round_key` 是⑥主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。 - T-643 后 `image_studio_projects.current_generation_round_key` 是⑥主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。
- T-646 后⑥主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。 - T-646 后⑥主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。
- T-648 后,用户发起一轮常规「生成套图」前,服务层仅根据 SQLite 中当前项目每个稳定槽位的最新有效成功 job(NULL 旧版轮次仍按一轮兼容)汇总成功轮数、图片数和最近成功时间;不扫描生成目录。若摘要非空,GUI 先显示默认取消的「已有套图生成记录」三选一确认:查看历史仅打开同一全局历史窗口并临时勾选“仅当前商品”,不创建轮次或请求;继续生成新一轮才进入原有费用确认,并在最终确认后生成新的 UUID 轮次;取消或关闭不写 job、asset、轮次或计费。单张重试、恢复未完成任务及只有失败/取消记录的项目不经过该确认。
提示词管理: 提示词管理:
+2
View File
@@ -382,6 +382,7 @@ create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) ->
list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob] list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob]
list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob] list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob]
list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound] list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound]
get_successful_generation_history_summary(project_id, path=None) -> ImageStudioSuccessfulGenerationHistorySummary
list_global_generation_rounds(account_query="", item_query="", project_id=None, list_global_generation_rounds(account_query="", item_query="", project_id=None,
limit=None, offset=0, path=None) -> list[ImageStudioHistoryRound] limit=None, offset=0, path=None) -> list[ImageStudioHistoryRound]
list_generation_round_current_jobs(project_id, generation_round_key, path=None) -> list[ImageStudioJob] list_generation_round_current_jobs(project_id, generation_round_key, path=None) -> list[ImageStudioJob]
@@ -422,6 +423,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None,
- `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。 - `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。
- T-643 后 `image_studio_projects.current_generation_round_key` 记录项目主界面的当前生成轮次;`image_studio_jobs.generation_round_key` 与 `generation_slot_index` 记录整轮归属和稳定展示槽位。`promote_generation_round_if_success()` 只会在同项目、同轮已存在成功 job 时原子切换当前轮;`list_generation_rounds()` 返回轮次摘要,`list_generation_round_current_jobs()` 返回每个槽位最新尝试,`list_generation_round_attempts()` 返回完整尝试历史。旧 job 保持 NULL 并按“旧版历史记录”兼容,不猜测轮次边界。 - T-643 后 `image_studio_projects.current_generation_round_key` 记录项目主界面的当前生成轮次;`image_studio_jobs.generation_round_key` 与 `generation_slot_index` 记录整轮归属和稳定展示槽位。`promote_generation_round_if_success()` 只会在同项目、同轮已存在成功 job 时原子切换当前轮;`list_generation_rounds()` 返回轮次摘要,`list_generation_round_current_jobs()` 返回每个槽位最新尝试,`list_generation_round_attempts()` 返回完整尝试历史。旧 job 保持 NULL 并按“旧版历史记录”兼容,不猜测轮次边界。
- T-646 后 `list_global_generation_rounds()` 是⑥跨项目历史的唯一查询入口:仅返回未软删除项目的轮次摘要,按创建时间倒序并支持账号/店铺、商品 ID、项目 ID、`limit`/`offset` 筛选;正常轮次只统计每个稳定槽位的最新有效 job,完整尝试数仅用于计算重试数,NULL 轮次保留“旧版历史记录”语义。`ProductSuiteGlobalHistoryDialog` 组合该查询和 `list_generation_round_current_jobs()`,默认读取30轮,显示受限缩略图并可按轮预览;窗口不写业务数据,也不改变当前轮次。`ProductSuiteHistoryExportWorker` 在后台调用 `export_generation_round()`,只复制该轮成功且存在的输出 asset 到用户选择目录中新建的安全子目录;同名目录追加稳定序号,不覆盖外部文件,部分文件失败只汇总中文结果,源 asset 保持不变。 - T-646 后 `list_global_generation_rounds()` 是⑥跨项目历史的唯一查询入口:仅返回未软删除项目的轮次摘要,按创建时间倒序并支持账号/店铺、商品 ID、项目 ID、`limit`/`offset` 筛选;正常轮次只统计每个稳定槽位的最新有效 job,完整尝试数仅用于计算重试数,NULL 轮次保留“旧版历史记录”语义。`ProductSuiteGlobalHistoryDialog` 组合该查询和 `list_generation_round_current_jobs()`,默认读取30轮,显示受限缩略图并可按轮预览;窗口不写业务数据,也不改变当前轮次。`ProductSuiteHistoryExportWorker` 在后台调用 `export_generation_round()`,只复制该轮成功且存在的输出 asset 到用户选择目录中新建的安全子目录;同名目录追加稳定序号,不覆盖外部文件,部分文件失败只汇总中文结果,源 asset 保持不变。
- T-648 后 `get_successful_generation_history_summary()` 是⑥常规新一轮生成前的只读判据:它只查询未软删除当前项目的最新有效成功 job,返回成功轮数、成功图片数和最近成功时间;正常轮次按稳定槽位取最新尝试,NULL 旧版轮次整体按一轮计数,只有失败/取消/进行中的 job 返回零摘要。该 API 不扫描文件目录、不创建 job/asset/轮次,也不改变当前轮;GUI 的“查看历史”仅调用全局历史窗口并临时启用当前商品筛选。
- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
+1
View File
@@ -206,6 +206,7 @@
- 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
- 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
- T-648 后,常规「生成套图」在原图、卖点和数量校验通过后、费用确认前,若 SQLite 记录显示当前商品已有成功套图,会出现「已有套图生成记录」确认框:用户可查看仅当前商品的全局历史、继续生成新一轮或取消,默认取消;查看历史和取消都不提交任务,继续仍须通过原费用确认后才创建新轮次。失败图片重试、恢复未完成任务和仅失败/已取消历史不出现该确认。
- T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。窗口支持店铺/账号、商品 ID 关键字和“仅当前商品”快捷筛选,但默认不限制当前任务;一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。 - T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。窗口支持店铺/账号、商品 ID 关键字和“仅当前商品”快捷筛选,但默认不限制当前任务;一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。
- AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。 - AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。
- ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
+5 -1
View File
@@ -3,7 +3,7 @@ id: T-648
title: 商品套图已有成功历史时确认新一轮生成 title: 商品套图已有成功历史时确认新一轮生成
phase: 7 phase: 7
deps: [T-643, T-646] deps: [T-643, T-646]
status: TODO status: DONE
created: 2026-07-17 created: 2026-07-17
--- ---
@@ -91,3 +91,7 @@ git diff --check
## 执行记录 ## 执行记录
- 2026-07-17:根据“已有成功套图时先提醒再新建一轮”的产品讨论创建任务。待实现。 - 2026-07-17:根据“已有成功套图时先提醒再新建一轮”的产品讨论创建任务。待实现。
- 2026-07-17:新增 `get_successful_generation_history_summary()`,仅按未软删除项目的 SQLite 有效 job 汇总成功轮数、成功图片数与最近成功时间;正常轮次按稳定槽位的最新尝试统计,NULL 旧版轮次整体兼容为一轮,目录与图片文件不参与判断。
- 2026-07-17:常规「生成套图」在原有费用确认前增加“已有套图生成记录”三选一确认;查看历史仅打开并临时筛选当前商品,继续才进入既有费用确认并创建新 UUID 轮次,取消不写入任务。单张失败重试继续绕过该流程。
- 2026-07-17:全局历史窗口增加受控的当前商品筛选设置,普通「历史生成」入口保持全局默认视图;同步更新架构、API 和流程文档。
- 验证通过:`py -3.10 -m unittest discover -s tests`(569 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+51
View File
@@ -867,12 +867,48 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
image_studio.ASSET_KIND_ORIGINAL, image_studio.ASSET_KIND_ORIGINAL,
path=db_path, path=db_path,
) )
other_source = image_studio.add_asset(
other_project.id,
image_studio.ASSET_KIND_ORIGINAL,
path=db_path,
)
other_first = image_studio.create_job(
other_project.id,
source_asset_id=other_source.id,
task_key="other-first-generation-round",
generation_round_key="other-failed-round",
generation_slot_index=0,
path=db_path,
)
image_studio.update_job_status(
other_first.id,
"succeeded",
path=db_path,
)
other_failed = image_studio.create_job(
other_project.id,
source_asset_id=other_source.id,
task_key="other-failed-generation-round",
generation_round_key="other-failed-round",
generation_slot_index=0,
path=db_path,
)
image_studio.update_job_status(
other_failed.id,
"failed",
path=db_path,
)
legacy = image_studio.create_job( legacy = image_studio.create_job(
project.id, project.id,
source_asset_id=source.id, source_asset_id=source.id,
task_key="legacy-generation-round", task_key="legacy-generation-round",
path=db_path, path=db_path,
) )
legacy = image_studio.update_job_status(
legacy.id,
"succeeded",
path=db_path,
)
round_one = "round-one" round_one = "round-one"
first = image_studio.create_job( first = image_studio.create_job(
project.id, project.id,
@@ -1026,6 +1062,21 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assertEqual(1, summary.failed_count) self.assertEqual(1, summary.failed_count)
self.assertTrue(rounds[-1].is_legacy) self.assertTrue(rounds[-1].is_legacy)
success_summary = image_studio.get_successful_generation_history_summary(
project.id,
path=db_path,
)
self.assertEqual(project.id, success_summary.project_id)
self.assertEqual(2, success_summary.successful_round_count)
self.assertEqual(3, success_summary.successful_image_count)
self.assertTrue(success_summary.latest_succeeded_at)
other_summary = image_studio.get_successful_generation_history_summary(
other_project.id,
path=db_path,
)
self.assertEqual(0, other_summary.successful_round_count)
self.assertEqual(0, other_summary.successful_image_count)
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_global_generation_rounds_filter_paginate_and_exclude_deleted_projects(self): def test_global_generation_rounds_filter_paginate_and_exclude_deleted_projects(self):
+138
View File
@@ -2118,9 +2118,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog) self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog)
self.assertEqual(project.id, dialog.current_project_id) self.assertEqual(project.id, dialog.current_project_id)
self.assertTrue(dialog.isVisible()) self.assertTrue(dialog.isVisible())
self.assertFalse(dialog.current_project_checkbox.isChecked())
tab.open_history_dialog(
current_project_only=True,
current_project_id=project.id,
)
self.app.processEvents()
self.assertTrue(dialog.current_project_checkbox.isChecked())
tab.open_history_dialog() tab.open_history_dialog()
self.assertIs(dialog, tab._history_dialog) self.assertIs(dialog, tab._history_dialog)
self.assertFalse(dialog.current_project_checkbox.isChecked())
tab.close_task(0) tab.close_task(0)
self.app.processEvents() self.app.processEvents()
self.assertIs(dialog, tab._history_dialog) self.assertIs(dialog, tab._history_dialog)
@@ -2924,6 +2933,135 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_successful_history_requires_decision_before_new_generation(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
self._write_image(source_path)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
path=config["db_path"],
)
history_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="已有成功历史",
generation_round_key="history-round",
generation_slot_index=0,
path=config["db_path"],
)
image_studio.update_job_status(
history_job.id,
"succeeded",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = "轻便耐用,适合日常使用"
tab._load_state(state)
history_confirmations = []
cost_confirmations = []
tab._confirm_new_generation_history = (
lambda _state, summary: history_confirmations.append(summary) or "cancel"
)
tab._confirm = lambda title, message, **kwargs: cost_confirmations.append(
(title, message, kwargs)
) and False
self.assertFalse(tab.start_generation(state))
self.assertEqual(1, len(history_confirmations))
self.assertEqual(1, history_confirmations[0].successful_round_count)
self.assertEqual(1, history_confirmations[0].successful_image_count)
self.assertEqual([], cost_confirmations)
self.assertEqual(
[history_job.id],
[job.id for job in image_studio.list_jobs(project.id, path=config["db_path"])],
)
tab._confirm_new_generation_history = lambda _state, _summary: "history"
tab.open_history_dialog = mock.Mock()
self.assertFalse(tab.start_generation(state))
tab.open_history_dialog.assert_called_once_with(
current_project_only=True,
current_project_id=project.id,
)
self.assertEqual([], cost_confirmations)
tab._confirm_new_generation_history = lambda _state, _summary: "continue"
tab._confirm = lambda title, message, **kwargs: cost_confirmations.append(
(title, message, kwargs)
) or True
with mock.patch.object(tab, "_start_thread", return_value=mock.Mock()):
self.assertTrue(tab.start_generation(state))
self.assertEqual(1, len(cost_confirmations))
self.assertEqual("确认生成商品套图", cost_confirmations[0][0])
self.assertNotEqual("history-round", state.generation_round_key)
self.assertTrue(state.generation_round_key)
state.worker = None
state.thread = None
state.generation_run_token = ""
tab._generation_run_states.clear()
failed_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="失败重试",
generation_round_key="retry-round",
generation_slot_index=0,
path=config["db_path"],
)
failed_job = image_studio.update_job_status(
failed_job.id,
"failed",
path=config["db_path"],
)
history_decision = mock.Mock(return_value="cancel")
tab._confirm_new_generation_history = history_decision
tab._confirm = mock.Mock(return_value=True)
with mock.patch.object(tab, "_start_thread", return_value=mock.Mock()):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": source.id,
"job_type": failed_job.job_type,
"prompt": failed_job.prompt,
}
],
retry_job_id=failed_job.id,
)
)
history_decision.assert_not_called()
tab._confirm.assert_not_called()
state.worker = None
state.thread = None
state.generation_run_token = ""
tab._generation_run_states.clear()
self.assert_removed(temp_dir)
def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self): def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self):
original_list = ProductOriginalList() original_list = ProductOriginalList()
self.addCleanup(original_list.close) self.addCleanup(original_list.close)