feat(product-suite): support temporary item drafts
This commit is contained in:
@@ -296,6 +296,8 @@ CREATE TABLE IF NOT EXISTS image_studio_projects (
|
|||||||
account_slug TEXT NOT NULL,
|
account_slug TEXT NOT NULL,
|
||||||
account_name TEXT,
|
account_name TEXT,
|
||||||
item_id TEXT NOT NULL,
|
item_id TEXT NOT NULL,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
binding_state TEXT NOT NULL DEFAULT 'bound',
|
||||||
target_main_count INTEGER NOT NULL DEFAULT 9,
|
target_main_count INTEGER NOT NULL DEFAULT 9,
|
||||||
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
||||||
draft_prompt TEXT,
|
draft_prompt TEXT,
|
||||||
@@ -480,6 +482,7 @@ def init_db(path=None, conn=None) -> None:
|
|||||||
_ensure_task_image_task_columns(database)
|
_ensure_task_image_task_columns(database)
|
||||||
_ensure_task_cover_reset_columns(database)
|
_ensure_task_cover_reset_columns(database)
|
||||||
_ensure_image_studio_project_suite_columns(database)
|
_ensure_image_studio_project_suite_columns(database)
|
||||||
|
_ensure_image_studio_project_draft_columns(database)
|
||||||
_ensure_image_studio_job_recovery_columns(database)
|
_ensure_image_studio_job_recovery_columns(database)
|
||||||
|
|
||||||
|
|
||||||
@@ -519,6 +522,32 @@ def _ensure_image_studio_project_suite_columns(database):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image_studio_project_draft_columns(database):
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
|
||||||
|
}
|
||||||
|
if "storage_key" not in columns:
|
||||||
|
database.execute("ALTER TABLE image_studio_projects ADD COLUMN storage_key TEXT")
|
||||||
|
if "binding_state" not in columns:
|
||||||
|
database.execute(
|
||||||
|
"ALTER TABLE image_studio_projects "
|
||||||
|
"ADD COLUMN binding_state TEXT NOT NULL DEFAULT 'bound'"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE image_studio_projects SET storage_key = item_id "
|
||||||
|
"WHERE storage_key IS NULL OR TRIM(storage_key) = ''"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE image_studio_projects SET binding_state = 'bound' "
|
||||||
|
"WHERE binding_state IS NULL OR binding_state NOT IN ('draft', 'bound')"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_image_studio_projects_binding "
|
||||||
|
"ON image_studio_projects(binding_state, updated_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_image_studio_job_recovery_columns(database):
|
def _ensure_image_studio_job_recovery_columns(database):
|
||||||
columns = {
|
columns = {
|
||||||
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||||
|
|||||||
+307
-23
@@ -43,7 +43,7 @@ from PySide6.QtWidgets import (
|
|||||||
from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite
|
from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite
|
||||||
from .. import file_manager
|
from .. import file_manager
|
||||||
from ..image_preview import ImagePreviewDialog
|
from ..image_preview import ImagePreviewDialog
|
||||||
from ..widgets import COLOR_DANGER, _emit_status, run_worker
|
from ..widgets import _emit_status, run_worker
|
||||||
from ..workers import (
|
from ..workers import (
|
||||||
ImageStudioDownloadOriginalWorker,
|
ImageStudioDownloadOriginalWorker,
|
||||||
ImageStudioPullImagesWorker,
|
ImageStudioPullImagesWorker,
|
||||||
@@ -614,6 +614,7 @@ class SuiteTaskState:
|
|||||||
account_alias: str = ""
|
account_alias: str = ""
|
||||||
item_id: str = ""
|
item_id: str = ""
|
||||||
project_id: int = None
|
project_id: int = None
|
||||||
|
project_binding_state: str = ""
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
settings: dict = field(default_factory=product_suite.default_suite_settings)
|
settings: dict = field(default_factory=product_suite.default_suite_settings)
|
||||||
current_job_ids: list = field(default_factory=list)
|
current_job_ids: list = field(default_factory=list)
|
||||||
@@ -624,6 +625,7 @@ class SuiteTaskState:
|
|||||||
pull_thread: object = None
|
pull_thread: object = None
|
||||||
import_worker: object = None
|
import_worker: object = None
|
||||||
import_thread: object = None
|
import_thread: object = None
|
||||||
|
import_created_draft: bool = False
|
||||||
ai_worker: object = None
|
ai_worker: object = None
|
||||||
ai_thread: object = None
|
ai_thread: object = None
|
||||||
download_queue: list = field(default_factory=list)
|
download_queue: list = field(default_factory=list)
|
||||||
@@ -675,7 +677,9 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._connect_signals()
|
self._connect_signals()
|
||||||
self.refresh_accounts()
|
self.refresh_accounts()
|
||||||
self.add_task(inherit=False)
|
self._restore_draft_tasks()
|
||||||
|
if not self._states:
|
||||||
|
self.add_task(inherit=False)
|
||||||
self.elapsed_timer = QTimer(self)
|
self.elapsed_timer = QTimer(self)
|
||||||
self.elapsed_timer.setInterval(1000)
|
self.elapsed_timer.setInterval(1000)
|
||||||
self.elapsed_timer.timeout.connect(self._refresh_elapsed)
|
self.elapsed_timer.timeout.connect(self._refresh_elapsed)
|
||||||
@@ -771,6 +775,13 @@ class ProductSuiteTab(QWidget):
|
|||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
layout.addWidget(self.add_images_button)
|
layout.addWidget(self.add_images_button)
|
||||||
|
self.item_id_hint_label = QLabel("请输入正确的商品ID")
|
||||||
|
self.item_id_hint_label.setObjectName("suiteItemIdHintLabel")
|
||||||
|
self.item_id_hint_label.setStyleSheet("color: #9a6700; font-weight: 600;")
|
||||||
|
self.item_id_hint_label.setToolTip("未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图")
|
||||||
|
self.item_id_hint_label.setAccessibleName("商品ID提示")
|
||||||
|
self.item_id_hint_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||||||
|
layout.addWidget(self.item_id_hint_label)
|
||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
layout.addWidget(QLabel("账号"))
|
layout.addWidget(QLabel("账号"))
|
||||||
self.account_combo = QComboBox()
|
self.account_combo = QComboBox()
|
||||||
@@ -1145,6 +1156,40 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._loading = False
|
self._loading = False
|
||||||
self._update_account_tooltip()
|
self._update_account_tooltip()
|
||||||
|
|
||||||
|
def _append_task_state(self, state, title=None):
|
||||||
|
self._states[state.key] = state
|
||||||
|
index = self.task_tabs.addTab(title or "套图任务 %d" % state.serial)
|
||||||
|
self.task_tabs.setTabData(index, state.key)
|
||||||
|
if self.task_tabs.currentIndex() == index:
|
||||||
|
self._on_task_changed(index)
|
||||||
|
else:
|
||||||
|
self.task_tabs.setCurrentIndex(index)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _restore_draft_tasks(self):
|
||||||
|
try:
|
||||||
|
projects = image_studio.list_recoverable_draft_projects(path=self.db_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._status("临时草稿恢复失败:%s" % _user_error(exc), "danger")
|
||||||
|
return
|
||||||
|
for project in projects:
|
||||||
|
state = SuiteTaskState(
|
||||||
|
key=self._next_key,
|
||||||
|
serial=self._next_serial,
|
||||||
|
account_alias=project.account_alias,
|
||||||
|
project_id=int(project.id),
|
||||||
|
project_binding_state=project.binding_state,
|
||||||
|
prompt=str(project.draft_prompt or ""),
|
||||||
|
settings=product_suite.normalize_suite_settings(
|
||||||
|
image_studio.project_suite_settings(project)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._next_key += 1
|
||||||
|
self._next_serial += 1
|
||||||
|
self._append_task_state(state, "临时草稿 %d" % state.serial)
|
||||||
|
if projects:
|
||||||
|
self._status("已恢复%d个临时草稿" % len(projects), "info")
|
||||||
|
|
||||||
def add_task(self, checked=False, inherit=True):
|
def add_task(self, checked=False, inherit=True):
|
||||||
source = self._displayed_state if inherit else None
|
source = self._displayed_state if inherit else None
|
||||||
initial_settings = (
|
initial_settings = (
|
||||||
@@ -1163,20 +1208,29 @@ class ProductSuiteTab(QWidget):
|
|||||||
state.account_alias = self.accounts[0].alias
|
state.account_alias = self.accounts[0].alias
|
||||||
self._next_key += 1
|
self._next_key += 1
|
||||||
self._next_serial += 1
|
self._next_serial += 1
|
||||||
self._states[state.key] = state
|
return self._append_task_state(state)
|
||||||
index = self.task_tabs.addTab("套图任务 %d" % state.serial)
|
|
||||||
self.task_tabs.setTabData(index, state.key)
|
def _set_task_title(self, state):
|
||||||
if self.task_tabs.currentIndex() == index:
|
for index in range(self.task_tabs.count()):
|
||||||
self._on_task_changed(index)
|
if self.task_tabs.tabData(index) == state.key:
|
||||||
else:
|
title = "临时草稿 %d" % state.serial if self._is_draft_state(state) else "套图任务 %d" % state.serial
|
||||||
self.task_tabs.setCurrentIndex(index)
|
self.task_tabs.setTabText(index, title)
|
||||||
return state
|
return
|
||||||
|
|
||||||
def close_task(self, index):
|
def close_task(self, index):
|
||||||
key = self.task_tabs.tabData(index)
|
key = self.task_tabs.tabData(index)
|
||||||
state = self._states.get(key)
|
state = self._states.get(key)
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
|
project = self._state_project(state)
|
||||||
|
draft_action = None
|
||||||
|
if image_studio.is_draft_project(project) and image_studio.project_has_content(
|
||||||
|
project.id,
|
||||||
|
path=self.db_path,
|
||||||
|
):
|
||||||
|
draft_action = self._draft_close_action()
|
||||||
|
if draft_action == "cancel":
|
||||||
|
return
|
||||||
if state.generation_running():
|
if state.generation_running():
|
||||||
if not self._confirm(
|
if not self._confirm(
|
||||||
"关闭套图任务",
|
"关闭套图任务",
|
||||||
@@ -1189,12 +1243,48 @@ class ProductSuiteTab(QWidget):
|
|||||||
state.ai_worker.cancel()
|
state.ai_worker.cancel()
|
||||||
if state.pull_worker is not None:
|
if state.pull_worker is not None:
|
||||||
state.pull_worker.cancel()
|
state.pull_worker.cancel()
|
||||||
|
if image_studio.is_draft_project(project):
|
||||||
|
if draft_action == "delete":
|
||||||
|
try:
|
||||||
|
image_studio.soft_delete_project(
|
||||||
|
project.id,
|
||||||
|
reason="用户关闭临时草稿",
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._message("删除临时草稿失败", _user_error(exc))
|
||||||
|
return
|
||||||
|
self._status("临时草稿已删除", "success")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
image_studio.discard_empty_draft_project(project.id, path=self.db_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
|
||||||
self._retired_states.append(state)
|
self._retired_states.append(state)
|
||||||
self._states.pop(state.key, None)
|
self._states.pop(state.key, None)
|
||||||
self.task_tabs.removeTab(index)
|
self.task_tabs.removeTab(index)
|
||||||
if self.task_tabs.count() == 0:
|
if self.task_tabs.count() == 0:
|
||||||
self.add_task(inherit=False)
|
self.add_task(inherit=False)
|
||||||
|
|
||||||
|
def _draft_close_action(self):
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setWindowTitle("关闭临时草稿")
|
||||||
|
box.setText("当前临时草稿包含图片或生成记录。请选择关闭后的处理方式。")
|
||||||
|
keep_button = box.addButton("保留草稿", QMessageBox.AcceptRole)
|
||||||
|
delete_button = box.addButton("删除草稿", QMessageBox.DestructiveRole)
|
||||||
|
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||||
|
box.setDefaultButton(keep_button)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is keep_button:
|
||||||
|
return "keep"
|
||||||
|
if clicked is delete_button:
|
||||||
|
return "delete"
|
||||||
|
if clicked is cancel_button:
|
||||||
|
return "cancel"
|
||||||
|
return "cancel"
|
||||||
|
|
||||||
def _state_for_index(self, index):
|
def _state_for_index(self, index):
|
||||||
if index < 0:
|
if index < 0:
|
||||||
return None
|
return None
|
||||||
@@ -1220,6 +1310,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
state.settings = self._settings_from_controls()
|
state.settings = self._settings_from_controls()
|
||||||
|
|
||||||
def _load_state(self, state):
|
def _load_state(self, state):
|
||||||
|
self._sync_state_project_binding(state)
|
||||||
self._loading = True
|
self._loading = True
|
||||||
try:
|
try:
|
||||||
self.custom_category_edit.hide()
|
self.custom_category_edit.hide()
|
||||||
@@ -1269,6 +1360,17 @@ class ProductSuiteTab(QWidget):
|
|||||||
state = self._displayed_state
|
state = self._displayed_state
|
||||||
alias = str(self.account_combo.currentData() or "")
|
alias = str(self.account_combo.currentData() or "")
|
||||||
if state.project_id is not None and alias != state.account_alias:
|
if state.project_id is not None and alias != state.account_alias:
|
||||||
|
if self._is_draft_state(state):
|
||||||
|
self._message(
|
||||||
|
"不能切换账号",
|
||||||
|
"临时草稿已关联当前账号。请先绑定正式商品ID,或关闭草稿后再切换账号。",
|
||||||
|
)
|
||||||
|
self._loading = True
|
||||||
|
try:
|
||||||
|
self._set_combo_value(self.account_combo, state.account_alias)
|
||||||
|
finally:
|
||||||
|
self._loading = False
|
||||||
|
return
|
||||||
if not self._confirm(
|
if not self._confirm(
|
||||||
"切换账号",
|
"切换账号",
|
||||||
"切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?",
|
"切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?",
|
||||||
@@ -1292,10 +1394,57 @@ class ProductSuiteTab(QWidget):
|
|||||||
state = self._displayed_state
|
state = self._displayed_state
|
||||||
item_id = self.item_id_edit.text().strip()
|
item_id = self.item_id_edit.text().strip()
|
||||||
if item_id and not item_id.isdigit():
|
if item_id and not item_id.isdigit():
|
||||||
self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER)
|
self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;")
|
||||||
self._status("商品ID只能输入数字", "warning")
|
self._status("商品ID只能输入数字", "warning")
|
||||||
|
self._update_context_actions(state)
|
||||||
return
|
return
|
||||||
self.item_id_edit.setStyleSheet("")
|
self.item_id_edit.setStyleSheet("")
|
||||||
|
if self._is_draft_state(state):
|
||||||
|
if not item_id:
|
||||||
|
state.item_id = ""
|
||||||
|
self._update_context_actions(state)
|
||||||
|
return
|
||||||
|
if not self._confirm(
|
||||||
|
"绑定正式商品",
|
||||||
|
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
|
||||||
|
):
|
||||||
|
self._loading = True
|
||||||
|
try:
|
||||||
|
self.item_id_edit.setText(state.item_id)
|
||||||
|
finally:
|
||||||
|
self._loading = False
|
||||||
|
self._update_context_actions(state)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
project = image_studio.bind_draft_project(
|
||||||
|
state.project_id,
|
||||||
|
item_id,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except image_studio.ImageStudioProjectConflictError as exc:
|
||||||
|
self._message("商品项目已存在", _user_error(exc))
|
||||||
|
self._loading = True
|
||||||
|
try:
|
||||||
|
self.item_id_edit.setText(state.item_id)
|
||||||
|
finally:
|
||||||
|
self._loading = False
|
||||||
|
self._update_context_actions(state)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self._message("绑定正式商品失败", _user_error(exc))
|
||||||
|
self._loading = True
|
||||||
|
try:
|
||||||
|
self.item_id_edit.setText(state.item_id)
|
||||||
|
finally:
|
||||||
|
self._loading = False
|
||||||
|
self._update_context_actions(state)
|
||||||
|
return
|
||||||
|
state.item_id = project.item_id
|
||||||
|
state.project_binding_state = project.binding_state
|
||||||
|
self._set_task_title(state)
|
||||||
|
self._status("临时草稿已绑定商品%s" % project.item_id, "success")
|
||||||
|
self._update_context_actions(state)
|
||||||
|
return
|
||||||
if state.project_id is not None and item_id != state.item_id:
|
if state.project_id is not None and item_id != state.item_id:
|
||||||
if not self._confirm(
|
if not self._confirm(
|
||||||
"切换商品",
|
"切换商品",
|
||||||
@@ -1310,8 +1459,6 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._clear_project_binding(state)
|
self._clear_project_binding(state)
|
||||||
state.item_id = item_id
|
state.item_id = item_id
|
||||||
if not item_id:
|
if not item_id:
|
||||||
self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER)
|
|
||||||
self._status("商品ID不能为空", "warning")
|
|
||||||
self._update_context_actions(state)
|
self._update_context_actions(state)
|
||||||
return
|
return
|
||||||
if item_id and state.account_alias:
|
if item_id and state.account_alias:
|
||||||
@@ -1320,6 +1467,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
|
|
||||||
def _clear_project_binding(self, state):
|
def _clear_project_binding(self, state):
|
||||||
state.project_id = None
|
state.project_id = None
|
||||||
|
state.project_binding_state = ""
|
||||||
state.current_job_ids = []
|
state.current_job_ids = []
|
||||||
state.done = state.failed = state.total = 0
|
state.done = state.failed = state.total = 0
|
||||||
state.started_at = None
|
state.started_at = None
|
||||||
@@ -1330,17 +1478,62 @@ class ProductSuiteTab(QWidget):
|
|||||||
def _account_for_alias(self, alias):
|
def _account_for_alias(self, alias):
|
||||||
return next((account for account in self.accounts if account.alias == alias), None)
|
return next((account for account in self.accounts if account.alias == alias), None)
|
||||||
|
|
||||||
def _valid_context(self, state, *, show_message=True):
|
def _state_project(self, state, *, include_deleted=False):
|
||||||
if not state.account_alias:
|
if state is None or state.project_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
project = image_studio.get_project(
|
||||||
|
state.project_id,
|
||||||
|
path=self.db_path,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._status("商品套图项目读取失败:%s" % _user_error(exc), "danger")
|
||||||
|
return None
|
||||||
|
if project is not None:
|
||||||
|
state.project_binding_state = project.binding_state
|
||||||
|
return project
|
||||||
|
|
||||||
|
def _sync_state_project_binding(self, state):
|
||||||
|
return self._state_project(state)
|
||||||
|
|
||||||
|
def _is_draft_state(self, state):
|
||||||
|
if state is None:
|
||||||
|
return False
|
||||||
|
if state.project_binding_state in image_studio.PROJECT_BINDING_STATES:
|
||||||
|
return state.project_binding_state == image_studio.PROJECT_BINDING_DRAFT
|
||||||
|
project = self._state_project(state)
|
||||||
|
return image_studio.is_draft_project(project)
|
||||||
|
|
||||||
|
def _has_account_context(self, state, *, show_message=True):
|
||||||
|
if state is None or not state.account_alias:
|
||||||
if show_message:
|
if show_message:
|
||||||
self._message("未选择账号", "请先在顶部选择账号。")
|
self._message("未选择账号", "请先在顶部选择账号。")
|
||||||
return False
|
return False
|
||||||
|
if self._account_for_alias(state.account_alias) is None:
|
||||||
|
if show_message:
|
||||||
|
self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _valid_context(self, state, *, show_message=True):
|
||||||
|
if not self._has_account_context(state, show_message=show_message):
|
||||||
|
return False
|
||||||
if not state.item_id or not state.item_id.isdigit():
|
if not state.item_id or not state.item_id.isdigit():
|
||||||
if show_message:
|
if show_message:
|
||||||
self._message("商品ID无效", "请输入正确的数字商品ID。")
|
self._message("商品ID无效", "请输入正确的数字商品ID。")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _ensure_project_for_local_work(self, state):
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
if state.project_id is not None:
|
||||||
|
return self._state_project(state)
|
||||||
|
if state.item_id:
|
||||||
|
return self._bind_project(state)
|
||||||
|
return self._create_draft_project(state)
|
||||||
|
|
||||||
def _bind_project(self, state, *, load_existing=False):
|
def _bind_project(self, state, *, load_existing=False):
|
||||||
if not self._valid_context(state):
|
if not self._valid_context(state):
|
||||||
return None
|
return None
|
||||||
@@ -1359,6 +1552,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._message("创建套图任务失败", _user_error(exc))
|
self._message("创建套图任务失败", _user_error(exc))
|
||||||
return None
|
return None
|
||||||
state.project_id = int(project.id)
|
state.project_id = int(project.id)
|
||||||
|
state.project_binding_state = project.binding_state
|
||||||
if load_existing and previous_id != state.project_id:
|
if load_existing and previous_id != state.project_id:
|
||||||
state.prompt = str(project.draft_prompt or "")
|
state.prompt = str(project.draft_prompt or "")
|
||||||
state.settings = product_suite.normalize_suite_settings(
|
state.settings = product_suite.normalize_suite_settings(
|
||||||
@@ -1368,6 +1562,29 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._load_state(state)
|
self._load_state(state)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
def _create_draft_project(self, state):
|
||||||
|
if not self._has_account_context(state):
|
||||||
|
return None
|
||||||
|
account = self._account_for_alias(state.account_alias)
|
||||||
|
try:
|
||||||
|
project = image_studio.create_draft_project(
|
||||||
|
account,
|
||||||
|
draft_prompt=state.prompt,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._message("创建临时草稿失败", _user_error(exc))
|
||||||
|
return None
|
||||||
|
state.project_id = int(project.id)
|
||||||
|
state.project_binding_state = project.binding_state
|
||||||
|
state.item_id = ""
|
||||||
|
self._persist_state(state)
|
||||||
|
self._set_task_title(state)
|
||||||
|
if state is self._displayed_state:
|
||||||
|
self._update_context_actions(state)
|
||||||
|
self._status("已创建临时草稿,可继续添加本地图片", "info")
|
||||||
|
return project
|
||||||
|
|
||||||
def _persist_state(self, state):
|
def _persist_state(self, state):
|
||||||
if state.project_id is None:
|
if state.project_id is None:
|
||||||
return
|
return
|
||||||
@@ -1422,20 +1639,55 @@ class ProductSuiteTab(QWidget):
|
|||||||
state.prompt = self.prompt_edit.toPlainText()
|
state.prompt = self.prompt_edit.toPlainText()
|
||||||
|
|
||||||
def _update_context_actions(self, state):
|
def _update_context_actions(self, state):
|
||||||
|
is_draft = self._is_draft_state(state)
|
||||||
self.pull_button.setEnabled(
|
self.pull_button.setEnabled(
|
||||||
self._valid_context(state, show_message=False) and state.pull_worker is None
|
state is not None
|
||||||
|
and state.pull_worker is None
|
||||||
|
and (is_draft or self._valid_context(state, show_message=False))
|
||||||
)
|
)
|
||||||
|
self.pull_button.setToolTip(
|
||||||
|
"需要先绑定正式商品ID" if is_draft else "拉取蝦皮主图"
|
||||||
|
)
|
||||||
|
self._refresh_item_id_hint(state)
|
||||||
self._refresh_add_images_action(state)
|
self._refresh_add_images_action(state)
|
||||||
|
|
||||||
|
def _refresh_item_id_hint(self, state):
|
||||||
|
displayed_item_id = (
|
||||||
|
self.item_id_edit.text().strip()
|
||||||
|
if state is self._displayed_state
|
||||||
|
else str(state.item_id or "").strip()
|
||||||
|
)
|
||||||
|
item_id_invalid = bool(displayed_item_id and not image_studio.is_formal_item_id(displayed_item_id))
|
||||||
|
is_bound = bool(
|
||||||
|
state
|
||||||
|
and state.project_binding_state == image_studio.PROJECT_BINDING_BOUND
|
||||||
|
and image_studio.is_formal_item_id(state.item_id)
|
||||||
|
and not item_id_invalid
|
||||||
|
)
|
||||||
|
self.item_id_hint_label.setVisible(not is_bound)
|
||||||
|
if self._is_draft_state(state):
|
||||||
|
tooltip = "当前为临时草稿,可添加本地图片和生成套图;绑定正式商品ID后才能拉取蝦皮主图"
|
||||||
|
elif item_id_invalid:
|
||||||
|
tooltip = "商品ID只能输入数字;修正后可绑定或拉取蝦皮主图"
|
||||||
|
else:
|
||||||
|
tooltip = "未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图"
|
||||||
|
self.item_id_hint_label.setToolTip(tooltip)
|
||||||
|
|
||||||
def _refresh_add_images_action(self, state):
|
def _refresh_add_images_action(self, state):
|
||||||
asset_count = len(self._original_assets(state, include_missing=True)) if state else 0
|
asset_count = len(self._original_assets(state, include_missing=True)) if state else 0
|
||||||
full = asset_count >= image_studio_images.MAX_ORIGINAL_ASSETS
|
full = asset_count >= image_studio_images.MAX_ORIGINAL_ASSETS
|
||||||
generation_running = bool(state and state.generation_running())
|
generation_running = bool(state and state.generation_running())
|
||||||
importing = bool(state and state.import_worker is not None)
|
importing = bool(state and state.import_worker is not None)
|
||||||
self.add_images_button.setEnabled(
|
self.add_images_button.setEnabled(
|
||||||
state is not None and not full and not generation_running and not importing
|
state is not None
|
||||||
|
and self._has_account_context(state, show_message=False)
|
||||||
|
and not full
|
||||||
|
and not generation_running
|
||||||
|
and not importing
|
||||||
)
|
)
|
||||||
if full:
|
if not self._has_account_context(state, show_message=False):
|
||||||
|
tooltip = "请先选择账号"
|
||||||
|
elif full:
|
||||||
tooltip = "已达到16张商品原图上限"
|
tooltip = "已达到16张商品原图上限"
|
||||||
elif generation_running:
|
elif generation_running:
|
||||||
tooltip = "生成中不能添加商品原图"
|
tooltip = "生成中不能添加商品原图"
|
||||||
@@ -1536,8 +1788,12 @@ class ProductSuiteTab(QWidget):
|
|||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
self._save_controls_to_state(state)
|
self._save_controls_to_state(state)
|
||||||
if state.project_id is None and self._bind_project(state) is None:
|
state.import_created_draft = False
|
||||||
|
created_project = state.project_id is None
|
||||||
|
project = self._ensure_project_for_local_work(state)
|
||||||
|
if project is None:
|
||||||
return
|
return
|
||||||
|
state.import_created_draft = created_project and image_studio.is_draft_project(project)
|
||||||
if state.import_worker is not None:
|
if state.import_worker is not None:
|
||||||
self._status("当前任务正在添加图片,请稍候", "warning")
|
self._status("当前任务正在添加图片,请稍候", "warning")
|
||||||
return
|
return
|
||||||
@@ -1561,9 +1817,29 @@ class ProductSuiteTab(QWidget):
|
|||||||
state.import_thread = self._start_thread(worker, "商品套图添加原图")
|
state.import_thread = self._start_thread(worker, "商品套图添加原图")
|
||||||
self._status("正在后台添加商品原图", "info")
|
self._status("正在后台添加商品原图", "info")
|
||||||
|
|
||||||
|
def _discard_empty_import_draft(self, state):
|
||||||
|
if not state.import_created_draft:
|
||||||
|
return
|
||||||
|
state.import_created_draft = False
|
||||||
|
project_id = state.project_id
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
project = image_studio.discard_empty_draft_project(project_id, path=self.db_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
|
||||||
|
return
|
||||||
|
if project is not None and project.deleted_at is not None:
|
||||||
|
state.project_id = None
|
||||||
|
state.project_binding_state = ""
|
||||||
|
state.item_id = ""
|
||||||
|
self._set_task_title(state)
|
||||||
|
self._status("未添加有效图片,已丢弃空临时草稿", "warning")
|
||||||
|
|
||||||
def _on_import_failed(self, state, error):
|
def _on_import_failed(self, state, error):
|
||||||
state.import_worker = None
|
state.import_worker = None
|
||||||
state.import_thread = None
|
state.import_thread = None
|
||||||
|
self._discard_empty_import_draft(state)
|
||||||
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
|
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
|
||||||
if state is self._displayed_state:
|
if state is self._displayed_state:
|
||||||
self._refresh_add_images_action(state)
|
self._refresh_add_images_action(state)
|
||||||
@@ -1571,6 +1847,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
def _on_import_finished(self, state, result):
|
def _on_import_finished(self, state, result):
|
||||||
state.import_worker = None
|
state.import_worker = None
|
||||||
state.import_thread = None
|
state.import_thread = None
|
||||||
|
self._discard_empty_import_draft(state)
|
||||||
if result.get("ok") is False:
|
if result.get("ok") is False:
|
||||||
self._message("添加商品原图失败", _user_error(result.get("error")))
|
self._message("添加商品原图失败", _user_error(result.get("error")))
|
||||||
else:
|
else:
|
||||||
@@ -1687,6 +1964,12 @@ class ProductSuiteTab(QWidget):
|
|||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
self._save_controls_to_state(state)
|
self._save_controls_to_state(state)
|
||||||
|
if self._is_draft_state(state):
|
||||||
|
self._message(
|
||||||
|
"无法拉取蝦皮主图",
|
||||||
|
"当前为临时项目,无法拉取蝦皮主图。\n请先输入正式商品ID后再试。",
|
||||||
|
)
|
||||||
|
return
|
||||||
if not self._valid_context(state):
|
if not self._valid_context(state):
|
||||||
return
|
return
|
||||||
if state.pull_worker is not None:
|
if state.pull_worker is not None:
|
||||||
@@ -1728,6 +2011,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
project = result.get("project")
|
project = result.get("project")
|
||||||
if project is not None:
|
if project is not None:
|
||||||
state.project_id = int(project.id)
|
state.project_id = int(project.id)
|
||||||
|
state.project_binding_state = project.binding_state
|
||||||
assets = [
|
assets = [
|
||||||
asset
|
asset
|
||||||
for asset in (result.get("assets") or [])
|
for asset in (result.get("assets") or [])
|
||||||
@@ -1938,7 +2222,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
context = (
|
context = (
|
||||||
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
|
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
|
||||||
% (
|
% (
|
||||||
state.item_id or "未填写",
|
state.item_id or "未绑定商品",
|
||||||
state.settings["platform"],
|
state.settings["platform"],
|
||||||
state.settings["country"],
|
state.settings["country"],
|
||||||
state.settings["language"],
|
state.settings["language"],
|
||||||
@@ -2043,7 +2327,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
return False
|
return False
|
||||||
if state is self._displayed_state:
|
if state is self._displayed_state:
|
||||||
self._save_controls_to_state(state)
|
self._save_controls_to_state(state)
|
||||||
if state.project_id is None and self._bind_project(state) is None:
|
if self._ensure_project_for_local_work(state) is None:
|
||||||
return False
|
return False
|
||||||
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||||||
if not local_assets:
|
if not local_assets:
|
||||||
@@ -2056,7 +2340,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
local_assets,
|
local_assets,
|
||||||
state.prompt,
|
state.prompt,
|
||||||
state.settings,
|
state.settings,
|
||||||
state.item_id,
|
state.item_id or "未绑定商品",
|
||||||
))
|
))
|
||||||
if not specs:
|
if not specs:
|
||||||
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
|
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
|
||||||
@@ -2393,7 +2677,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
def open_project_folder(self, checked=False):
|
def open_project_folder(self, checked=False):
|
||||||
state = self._displayed_state
|
state = self._displayed_state
|
||||||
if state is None or state.project_id is None:
|
if state is None or state.project_id is None:
|
||||||
self._message("未绑定商品", "请先选择账号并输入商品ID。")
|
self._message("未创建套图任务", "请先选择账号并添加本地图片,或输入商品ID后拉取蝦皮主图。")
|
||||||
return
|
return
|
||||||
project = image_studio.get_project(state.project_id, path=self.db_path)
|
project = image_studio.get_project(state.project_id, path=self.db_path)
|
||||||
if project is None:
|
if project is None:
|
||||||
|
|||||||
+170
-3
@@ -15,6 +15,10 @@ from .config import make_slug
|
|||||||
|
|
||||||
|
|
||||||
PROJECT_STATUS_ACTIVE = "active"
|
PROJECT_STATUS_ACTIVE = "active"
|
||||||
|
PROJECT_BINDING_DRAFT = "draft"
|
||||||
|
PROJECT_BINDING_BOUND = "bound"
|
||||||
|
PROJECT_BINDING_STATES = {PROJECT_BINDING_DRAFT, PROJECT_BINDING_BOUND}
|
||||||
|
TEMPORARY_ITEM_PREFIX = "draft_"
|
||||||
ASSET_KIND_ORIGINAL = "original"
|
ASSET_KIND_ORIGINAL = "original"
|
||||||
ASSET_STATUS_AVAILABLE = "available"
|
ASSET_STATUS_AVAILABLE = "available"
|
||||||
ASSET_STATUS_MISSING = "missing"
|
ASSET_STATUS_MISSING = "missing"
|
||||||
@@ -39,6 +43,8 @@ class ImageStudioProject:
|
|||||||
account_slug: str
|
account_slug: str
|
||||||
account_name: Optional[str]
|
account_name: Optional[str]
|
||||||
item_id: str
|
item_id: str
|
||||||
|
storage_key: str
|
||||||
|
binding_state: str
|
||||||
target_main_count: int
|
target_main_count: int
|
||||||
target_detail_count: int
|
target_detail_count: int
|
||||||
draft_prompt: Optional[str]
|
draft_prompt: Optional[str]
|
||||||
@@ -106,6 +112,10 @@ 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."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStudioProjectConflictError(ImageStudioError):
|
||||||
|
"""Raised when a draft cannot be bound because the formal project already exists."""
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now().isoformat(timespec="seconds")
|
return datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
@@ -168,6 +178,22 @@ def _normalize_item_id(item_id) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def is_formal_item_id(item_id) -> bool:
|
||||||
|
return str(item_id or "").strip().isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def is_draft_project(project) -> bool:
|
||||||
|
return str(_get(project, "binding_state", "")).strip() == PROJECT_BINDING_DRAFT
|
||||||
|
|
||||||
|
|
||||||
|
def project_storage_key(project) -> str:
|
||||||
|
return str(_get(project, "storage_key") or _get(project, "item_id") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _new_draft_item_id() -> str:
|
||||||
|
return TEMPORARY_ITEM_PREFIX + uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
def _notify_step(callback, step, result="start", detail=None):
|
def _notify_step(callback, step, result="start", detail=None):
|
||||||
if callback is None:
|
if callback is None:
|
||||||
return
|
return
|
||||||
@@ -314,16 +340,18 @@ def create_or_get_project(
|
|||||||
cursor = database.execute(
|
cursor = database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO image_studio_projects
|
INSERT INTO image_studio_projects
|
||||||
(account_alias, account_slug, account_name, item_id,
|
(account_alias, account_slug, account_name, item_id, storage_key, binding_state,
|
||||||
target_main_count, target_detail_count, draft_prompt,
|
target_main_count, target_detail_count, draft_prompt,
|
||||||
status, created_at, updated_at)
|
status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
alias,
|
alias,
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
item,
|
item,
|
||||||
|
item,
|
||||||
|
PROJECT_BINDING_BOUND,
|
||||||
int(target_main_count),
|
int(target_main_count),
|
||||||
int(target_detail_count),
|
int(target_detail_count),
|
||||||
str(draft_prompt or ""),
|
str(draft_prompt or ""),
|
||||||
@@ -336,6 +364,94 @@ def create_or_get_project(
|
|||||||
return get_project(project_id, conn=database)
|
return get_project(project_id, conn=database)
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_project(
|
||||||
|
account=None,
|
||||||
|
*,
|
||||||
|
account_alias=None,
|
||||||
|
account_name=None,
|
||||||
|
account_slug=None,
|
||||||
|
target_main_count=9,
|
||||||
|
target_detail_count=12,
|
||||||
|
draft_prompt="",
|
||||||
|
path=None,
|
||||||
|
conn=None,
|
||||||
|
):
|
||||||
|
"""Create one project-bound local draft without exposing its internal item key."""
|
||||||
|
|
||||||
|
alias, name, slug = _account_fields(
|
||||||
|
account,
|
||||||
|
account_alias=account_alias,
|
||||||
|
account_name=account_name,
|
||||||
|
account_slug=account_slug,
|
||||||
|
)
|
||||||
|
item = _new_draft_item_id()
|
||||||
|
now = _now()
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
with database:
|
||||||
|
cursor = database.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO image_studio_projects
|
||||||
|
(account_alias, account_slug, account_name, item_id, storage_key, binding_state,
|
||||||
|
target_main_count, target_detail_count, draft_prompt,
|
||||||
|
status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
alias,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
item,
|
||||||
|
item,
|
||||||
|
PROJECT_BINDING_DRAFT,
|
||||||
|
int(target_main_count),
|
||||||
|
int(target_detail_count),
|
||||||
|
str(draft_prompt or ""),
|
||||||
|
PROJECT_STATUS_ACTIVE,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
project_id = cursor.lastrowid
|
||||||
|
return get_project(project_id, conn=database)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_draft_project(project_id, item_id, path=None, conn=None):
|
||||||
|
"""Bind one active draft to a formal numeric item ID without moving its files."""
|
||||||
|
|
||||||
|
item = str(item_id or "").strip()
|
||||||
|
if not is_formal_item_id(item):
|
||||||
|
raise ImageStudioError("正式商品ID必须是数字")
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
source = get_project(project_id, conn=database, include_deleted=True)
|
||||||
|
if source is None:
|
||||||
|
raise ImageStudioError("临时草稿不存在")
|
||||||
|
if source.deleted_at is not None:
|
||||||
|
raise ImageStudioError("临时草稿已删除,无法绑定商品")
|
||||||
|
if not is_draft_project(source):
|
||||||
|
if source.item_id == item:
|
||||||
|
return source
|
||||||
|
raise ImageStudioError("当前项目不是临时草稿,不能重新绑定商品")
|
||||||
|
existing = get_project_by_account_item(
|
||||||
|
source.account_alias,
|
||||||
|
item,
|
||||||
|
conn=database,
|
||||||
|
include_deleted=True,
|
||||||
|
)
|
||||||
|
if existing is not None and existing.id != source.id:
|
||||||
|
raise ImageStudioProjectConflictError("该商品项目已存在,不能覆盖或合并")
|
||||||
|
now = _now()
|
||||||
|
with database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE image_studio_projects
|
||||||
|
SET item_id = ?, binding_state = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(item, PROJECT_BINDING_BOUND, now, int(source.id)),
|
||||||
|
)
|
||||||
|
return get_project(source.id, conn=database)
|
||||||
|
|
||||||
|
|
||||||
def list_projects(path=None, conn=None, include_deleted=False):
|
def list_projects(path=None, conn=None, include_deleted=False):
|
||||||
sql = "SELECT * FROM image_studio_projects"
|
sql = "SELECT * FROM image_studio_projects"
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
@@ -345,6 +461,57 @@ def list_projects(path=None, conn=None, include_deleted=False):
|
|||||||
return _fetch_all(database, sql, (), ImageStudioProject)
|
return _fetch_all(database, sql, (), ImageStudioProject)
|
||||||
|
|
||||||
|
|
||||||
|
def list_recoverable_draft_projects(path=None, conn=None):
|
||||||
|
"""Return non-empty drafts that should reappear as product-suite task tabs."""
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT p.*
|
||||||
|
FROM image_studio_projects AS p
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
AND p.binding_state = ?
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM image_studio_assets AS a
|
||||||
|
WHERE a.project_id = p.id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM image_studio_jobs AS j
|
||||||
|
WHERE j.project_id = p.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY p.updated_at DESC, p.id DESC
|
||||||
|
"""
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
return _fetch_all(database, sql, (PROJECT_BINDING_DRAFT,), ImageStudioProject)
|
||||||
|
|
||||||
|
|
||||||
|
def project_has_content(project_id, path=None, conn=None) -> bool:
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
row = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM image_studio_assets WHERE project_id = ?
|
||||||
|
) OR EXISTS(
|
||||||
|
SELECT 1 FROM image_studio_jobs WHERE project_id = ?
|
||||||
|
) AS has_content
|
||||||
|
""",
|
||||||
|
(int(project_id), int(project_id)),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row["has_content"] if row is not None else False)
|
||||||
|
|
||||||
|
|
||||||
|
def discard_empty_draft_project(project_id, reason="空临时草稿", path=None, conn=None):
|
||||||
|
"""Soft-delete a newly created, still empty draft and leave user files untouched."""
|
||||||
|
|
||||||
|
with _connection(conn, path) as database:
|
||||||
|
project = get_project(project_id, conn=database, include_deleted=True)
|
||||||
|
if project is None or project.deleted_at is not None or not is_draft_project(project):
|
||||||
|
return project
|
||||||
|
if project_has_content(project.id, conn=database):
|
||||||
|
return project
|
||||||
|
return soft_delete_project(project.id, reason=reason, conn=database)
|
||||||
|
|
||||||
|
|
||||||
def update_project_prompt(project_id, draft_prompt, path=None, conn=None):
|
def update_project_prompt(project_id, draft_prompt, path=None, conn=None):
|
||||||
with _connection(conn, path) as database:
|
with _connection(conn, path) as database:
|
||||||
with database:
|
with database:
|
||||||
@@ -406,7 +573,7 @@ def project_image_dirs(image_root, project):
|
|||||||
str(image_root or "images"),
|
str(image_root or "images"),
|
||||||
"pool",
|
"pool",
|
||||||
_safe_component(_get(project, "account_slug"), "account"),
|
_safe_component(_get(project, "account_slug"), "account"),
|
||||||
_safe_component(_get(project, "item_id"), "item"),
|
_safe_component(project_storage_key(project), "item"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
|
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
|
||||||
- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。
|
- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。
|
||||||
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
|
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
|
||||||
|
- T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
|
||||||
|
- ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
|
||||||
|
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
|
||||||
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
|
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
|
||||||
|
|
||||||
提示词管理:
|
提示词管理:
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: T-636
|
id: T-636
|
||||||
title: 商品套图支持无商品ID临时草稿与正式绑定
|
title: 商品套图支持无商品ID临时草稿与正式绑定
|
||||||
status: TODO
|
status: DONE
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-622, T-635]
|
deps: [T-622, T-635]
|
||||||
created: 2026-07-16
|
created: 2026-07-16
|
||||||
@@ -163,3 +163,8 @@ git diff --check
|
|||||||
- 不物理删除用户图片目录。
|
- 不物理删除用户图片目录。
|
||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:`image_studio_projects` 增加并迁移 `storage_key`、`binding_state`;实现临时草稿创建、原地正式绑定、冲突检查、空草稿软删除和启动恢复查询。既有正式项目回填为 `bound`,图片目录稳定使用 `storage_key`。
|
||||||
|
- 2026-07-16:⑥商品套图支持无商品 ID 的本地图片导入、AI 帮写和生成;临时草稿不会把内部 `draft_` 标识传入 cmhub 上下文。草稿拉取蝦皮主图在 worker、Chrome/CDP 前阻断;输入数字 ID 后经确认原地绑定,关闭非空草稿支持保留、软删除或取消。
|
||||||
|
- 2026-07-16:补充数据迁移、目录稳定性、导入失败清理、提示词脱敏、草稿恢复和 GUI 绑定/拉取边界测试;在 `1180x760` 进行离屏布局检查,无控件重叠。
|
||||||
|
- 验证(隔离工作树):`py -3.10 -m unittest discover -s tests`(507 通过)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
"account_alias",
|
"account_alias",
|
||||||
"account_slug",
|
"account_slug",
|
||||||
"item_id",
|
"item_id",
|
||||||
|
"storage_key",
|
||||||
|
"binding_state",
|
||||||
"target_main_count",
|
"target_main_count",
|
||||||
"target_detail_count",
|
"target_detail_count",
|
||||||
"suite_settings_json",
|
"suite_settings_json",
|
||||||
@@ -114,6 +116,8 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual("{}", project.suite_settings_json)
|
self.assertEqual("{}", project.suite_settings_json)
|
||||||
self.assertEqual({}, image_studio.project_suite_settings(project))
|
self.assertEqual({}, image_studio.project_suite_settings(project))
|
||||||
|
self.assertEqual("51100639510", project.storage_key)
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, project.binding_state)
|
||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
@@ -152,6 +156,8 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.assertEqual("alias_a_slug", project.account_slug)
|
self.assertEqual("alias_a_slug", project.account_slug)
|
||||||
self.assertEqual("店铺A", project.account_name)
|
self.assertEqual("店铺A", project.account_name)
|
||||||
self.assertEqual("初始提示词", project.draft_prompt)
|
self.assertEqual("初始提示词", project.draft_prompt)
|
||||||
|
self.assertEqual("51100639510", project.storage_key)
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, project.binding_state)
|
||||||
|
|
||||||
updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path)
|
updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path)
|
||||||
self.assertEqual("二次提示词", updated.draft_prompt)
|
self.assertEqual("二次提示词", updated.draft_prompt)
|
||||||
@@ -213,6 +219,68 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_draft_projects_bind_in_place_recover_and_conflict_safely(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
account = SimpleNamespace(
|
||||||
|
alias="alias-a",
|
||||||
|
account_name="主店",
|
||||||
|
slug="alias_a",
|
||||||
|
)
|
||||||
|
draft = image_studio.create_draft_project(
|
||||||
|
account,
|
||||||
|
draft_prompt="临时草稿提示词",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(draft.item_id.startswith(image_studio.TEMPORARY_ITEM_PREFIX))
|
||||||
|
self.assertEqual(draft.item_id, draft.storage_key)
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_DRAFT, draft.binding_state)
|
||||||
|
self.assertFalse(image_studio.project_has_content(draft.id, path=db_path))
|
||||||
|
self.assertEqual([], image_studio.list_recoverable_draft_projects(path=db_path))
|
||||||
|
before_dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), draft)
|
||||||
|
|
||||||
|
original = image_studio.add_asset(
|
||||||
|
draft.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=os.path.join(temp_dir, "draft.png"),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
job = image_studio.create_job(
|
||||||
|
draft.id,
|
||||||
|
source_asset_id=original.id,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
image_studio.replace_selections(draft.id, "main", [original.id], path=db_path)
|
||||||
|
self.assertTrue(image_studio.project_has_content(draft.id, path=db_path))
|
||||||
|
self.assertEqual([draft.id], [project.id for project in image_studio.list_recoverable_draft_projects(path=db_path)])
|
||||||
|
|
||||||
|
conflict = image_studio.create_or_get_project(
|
||||||
|
account,
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
image_studio.soft_delete_project(conflict.id, "历史项目", path=db_path)
|
||||||
|
with self.assertRaises(image_studio.ImageStudioProjectConflictError):
|
||||||
|
image_studio.bind_draft_project(draft.id, "51100639510", path=db_path)
|
||||||
|
|
||||||
|
bound = image_studio.bind_draft_project(draft.id, "51100639511", path=db_path)
|
||||||
|
self.assertEqual(draft.id, bound.id)
|
||||||
|
self.assertEqual("51100639511", bound.item_id)
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, bound.binding_state)
|
||||||
|
self.assertEqual(draft.storage_key, bound.storage_key)
|
||||||
|
self.assertEqual(before_dirs, image_studio.project_image_dirs(os.path.join(temp_dir, "images"), bound))
|
||||||
|
self.assertEqual(job.id, image_studio.list_jobs(bound.id, path=db_path)[0].id)
|
||||||
|
self.assertEqual(original.id, image_studio.list_selections(bound.id, "main", path=db_path)[0].asset_id)
|
||||||
|
self.assertEqual(bound, image_studio.bind_draft_project(bound.id, "51100639511", path=db_path))
|
||||||
|
|
||||||
|
empty = image_studio.create_draft_project(account, path=db_path)
|
||||||
|
discarded = image_studio.discard_empty_draft_project(empty.id, path=db_path)
|
||||||
|
self.assertIsNotNone(discarded.deleted_at)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_asset_crud_parent_status_and_sorting(self):
|
def test_asset_crud_parent_status_and_sorting(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
|||||||
@@ -294,6 +294,48 @@ class ImageStudioImageTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_draft_import_uses_stable_storage_key_after_formal_binding(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
draft = image_studio.create_draft_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
config = {
|
||||||
|
"data_dir": temp_dir,
|
||||||
|
"db_path": db_path,
|
||||||
|
"image_dir": os.path.join(temp_dir, "images"),
|
||||||
|
}
|
||||||
|
|
||||||
|
first = image_studio_images.import_original_bytes(
|
||||||
|
draft.id,
|
||||||
|
self._png_bytes(color=(10, 20, 30, 255)),
|
||||||
|
filename_hint="first.png",
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
first_path = first.local_path
|
||||||
|
bound = image_studio.bind_draft_project(draft.id, "51100639510", path=db_path)
|
||||||
|
second = image_studio_images.import_original_bytes(
|
||||||
|
bound.id,
|
||||||
|
self._png_bytes(color=(40, 50, 60, 255)),
|
||||||
|
filename_hint="second.png",
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, bound.binding_state)
|
||||||
|
self.assertEqual(draft.storage_key, bound.storage_key)
|
||||||
|
self.assertEqual(os.path.dirname(first_path), os.path.dirname(second.local_path))
|
||||||
|
self.assertTrue(os.path.isfile(first_path))
|
||||||
|
self.assertTrue(os.path.isfile(second.local_path))
|
||||||
|
self.assertIn(draft.storage_key, first_path)
|
||||||
|
self.assertNotIn("51100639510", first_path)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_import_original_ignores_missing_history_when_enforcing_limit(self):
|
def test_import_original_ignores_missing_history_when_enforcing_limit(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
|||||||
@@ -651,6 +651,148 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_temporary_draft_allows_local_work_but_blocks_shopee_pull_and_recovers(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
|
||||||
|
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
state = tab._displayed_state
|
||||||
|
|
||||||
|
self.assertTrue(tab.add_images_button.isEnabled())
|
||||||
|
self.assertFalse(tab.item_id_hint_label.isHidden())
|
||||||
|
self.assertEqual("请输入正确的商品ID", tab.item_id_hint_label.text())
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.product_suite.QFileDialog.getOpenFileNames",
|
||||||
|
return_value=([], ""),
|
||||||
|
):
|
||||||
|
tab.choose_images()
|
||||||
|
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
|
||||||
|
|
||||||
|
source_path = os.path.join(temp_dir, "draft-source.png")
|
||||||
|
self._write_image(source_path)
|
||||||
|
with mock.patch.object(tab, "_start_thread", return_value=object()):
|
||||||
|
tab._start_import(file_paths=[source_path])
|
||||||
|
worker = state.import_worker
|
||||||
|
draft = image_studio.get_project(state.project_id, path=config["db_path"])
|
||||||
|
self.assertTrue(image_studio.is_draft_project(draft))
|
||||||
|
self.assertEqual("", state.item_id)
|
||||||
|
self.assertIn("临时草稿", tab.task_tabs.tabText(tab.task_tabs.currentIndex()))
|
||||||
|
self.assertFalse(tab.item_id_hint_label.isHidden())
|
||||||
|
self.assertTrue(tab.pull_button.isEnabled())
|
||||||
|
self.assertEqual("需要先绑定正式商品ID", tab.pull_button.toolTip())
|
||||||
|
tab._on_import_finished(state, worker.execute())
|
||||||
|
self.assertEqual(1, len(image_studio.list_assets(draft.id, path=config["db_path"])))
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _Signal:
|
||||||
|
def connect(self, callback):
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
class _AiWriteWorker:
|
||||||
|
def __init__(self, instruction, context, **kwargs):
|
||||||
|
captured["instruction"] = instruction
|
||||||
|
captured["context"] = context
|
||||||
|
self.finished = _Signal()
|
||||||
|
self.cancelled = _Signal()
|
||||||
|
self.failed = _Signal()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.product_suite.ProductSuiteAiWriteWorker",
|
||||||
|
_AiWriteWorker,
|
||||||
|
), mock.patch.object(tab, "_start_thread", return_value=object()):
|
||||||
|
tab.start_ai_write()
|
||||||
|
self.assertIn("未绑定商品", captured["context"])
|
||||||
|
self.assertNotIn("draft_", captured["context"])
|
||||||
|
state.ai_worker = None
|
||||||
|
state.ai_thread = None
|
||||||
|
state.ai_started_at = None
|
||||||
|
tab._apply_running_state(state)
|
||||||
|
|
||||||
|
pull_message = mock.Mock()
|
||||||
|
with mock.patch.object(tab, "_message", pull_message):
|
||||||
|
tab.pull_main_images()
|
||||||
|
self.assertIsNone(state.pull_worker)
|
||||||
|
self.assertEqual("无法拉取蝦皮主图", pull_message.call_args.args[0])
|
||||||
|
self.assertIn("当前为临时项目", pull_message.call_args.args[1])
|
||||||
|
|
||||||
|
tab.item_id_edit.setText("51100639510")
|
||||||
|
with mock.patch.object(tab, "_confirm", return_value=True):
|
||||||
|
tab._on_item_finished()
|
||||||
|
bound = image_studio.get_project(draft.id, path=config["db_path"])
|
||||||
|
self.assertEqual("51100639510", bound.item_id)
|
||||||
|
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, bound.binding_state)
|
||||||
|
self.assertTrue(tab.item_id_hint_label.isHidden())
|
||||||
|
self.assertIn("套图任务", tab.task_tabs.tabText(tab.task_tabs.currentIndex()))
|
||||||
|
|
||||||
|
draft_state = tab.add_task(inherit=False)
|
||||||
|
draft = tab._create_draft_project(draft_state)
|
||||||
|
image_studio.add_asset(
|
||||||
|
draft.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=source_path,
|
||||||
|
path=config["db_path"],
|
||||||
|
)
|
||||||
|
draft_state.ai_worker = mock.Mock()
|
||||||
|
with mock.patch.object(tab, "_draft_close_action", return_value="cancel"):
|
||||||
|
tab.close_task(tab.task_tabs.currentIndex())
|
||||||
|
draft_state.ai_worker.cancel.assert_not_called()
|
||||||
|
self.assertIn(draft_state.key, tab._states)
|
||||||
|
draft_state.ai_worker = None
|
||||||
|
with mock.patch.object(tab, "_draft_close_action", return_value="keep"):
|
||||||
|
tab.close_task(tab.task_tabs.currentIndex())
|
||||||
|
self.assertIsNotNone(image_studio.get_project(draft.id, path=config["db_path"]))
|
||||||
|
|
||||||
|
tab.close()
|
||||||
|
restored = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(restored.close)
|
||||||
|
restored_state = next(
|
||||||
|
state for state in restored._states.values() if state.project_id == draft.id
|
||||||
|
)
|
||||||
|
self.assertEqual("", restored_state.item_id)
|
||||||
|
self.assertTrue(restored._is_draft_state(restored_state))
|
||||||
|
restored_index = next(
|
||||||
|
index
|
||||||
|
for index in range(restored.task_tabs.count())
|
||||||
|
if restored.task_tabs.tabData(index) == restored_state.key
|
||||||
|
)
|
||||||
|
self.assertIn("临时草稿", restored.task_tabs.tabText(restored_index))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_first_failed_import_discards_new_empty_temporary_draft(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
|
||||||
|
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
bad_path = os.path.join(temp_dir, "not-an-image.png")
|
||||||
|
with open(bad_path, "wb") as handle:
|
||||||
|
handle.write(b"not an image")
|
||||||
|
|
||||||
|
state = tab._displayed_state
|
||||||
|
with mock.patch.object(tab, "_start_thread", return_value=object()):
|
||||||
|
tab._start_import(file_paths=[bad_path])
|
||||||
|
worker = state.import_worker
|
||||||
|
draft_id = state.project_id
|
||||||
|
with mock.patch.object(tab, "_message"):
|
||||||
|
tab._on_import_finished(state, worker.execute())
|
||||||
|
|
||||||
|
discarded = image_studio.get_project(
|
||||||
|
draft_id,
|
||||||
|
path=config["db_path"],
|
||||||
|
include_deleted=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(discarded.deleted_at)
|
||||||
|
self.assertIsNone(state.project_id)
|
||||||
|
self.assertEqual([], image_studio.list_recoverable_draft_projects(path=config["db_path"]))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user