feat(product-suite): autosave prompt edits

This commit is contained in:
chengma
2026-07-16 16:39:40 +08:00
parent a9acfee460
commit f5892bb310
4 changed files with 433 additions and 10 deletions
+157 -8
View File
@@ -64,8 +64,72 @@ from ..workers import (
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
PROMPT_AUTOSAVE_DELAY_MS = 500
_PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
class AutoHeightPlainTextEdit(QPlainTextEdit):
"""A plain-text editor that delegates scrolling to its containing page."""
def __init__(self, parent=None, minimum_height=96):
super().__init__(parent)
self._minimum_content_height = max(1, int(minimum_height))
self._height_update_pending = False
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(self._minimum_content_height)
self.document().contentsChanged.connect(self.schedule_height_update)
self.document().documentLayout().documentSizeChanged.connect(
self.schedule_height_update
)
self.schedule_height_update()
def schedule_height_update(self, *args):
if self._height_update_pending:
return
self._height_update_pending = True
QTimer.singleShot(0, self.refresh_height)
def refresh_height(self):
self._height_update_pending = False
text_height = 0.0
block = self.document().firstBlock()
while block.isValid():
layout = block.layout()
line_count = max(1, layout.lineCount())
for index in range(line_count):
line = layout.lineAt(index)
text_height += (
line.height()
if line.isValid()
else self.fontMetrics().lineSpacing()
)
block = block.next()
margins = self.contentsMargins()
target = int(
text_height
+ (self.document().documentMargin() * 2)
+ margins.top()
+ margins.bottom()
+ (self.frameWidth() * 2)
+ 6
)
target = max(self._minimum_content_height, target)
if self.height() != target:
self.setFixedHeight(target)
self.updateGeometry()
def resizeEvent(self, event):
super().resizeEvent(event)
self.schedule_height_update()
def showEvent(self, event):
super().showEvent(event)
self.schedule_height_update()
def _asset_usable(asset):
path = str(getattr(asset, "local_path", "") or "")
return (
@@ -618,6 +682,7 @@ class SuiteTaskState:
project_id: int = None
project_binding_state: str = ""
prompt: str = ""
last_saved_prompt: str = ""
settings: dict = field(default_factory=product_suite.default_suite_settings)
current_job_ids: list = field(default_factory=list)
show_history: bool = False
@@ -673,6 +738,7 @@ class ProductSuiteTab(QWidget):
self._next_key = 1
self._next_serial = 1
self._displayed_state = None
self._prompt_save_timers = {}
self._original_list_context = None
self._loading = False
self._result_refresh_pending = False
@@ -985,11 +1051,9 @@ class ProductSuiteTab(QWidget):
self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词")
title_row.addWidget(self.prompt_settings_button)
layout.addLayout(title_row)
self.prompt_edit = QPlainTextEdit()
self.prompt_edit = AutoHeightPlainTextEdit()
self.prompt_edit.setObjectName("suitePromptEdit")
self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素")
self.prompt_edit.setMinimumHeight(96)
self.prompt_edit.setMaximumHeight(112)
layout.addWidget(self.prompt_edit)
helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。")
helper.setWordWrap(True)
@@ -1198,6 +1262,7 @@ class ProductSuiteTab(QWidget):
project_id=int(project.id),
project_binding_state=project.binding_state,
prompt=str(project.draft_prompt or ""),
last_saved_prompt=str(project.draft_prompt or ""),
settings=product_suite.normalize_suite_settings(
image_studio.project_suite_settings(project)
),
@@ -1240,6 +1305,9 @@ class ProductSuiteTab(QWidget):
state = self._states.get(key)
if state is None:
return
if state is self._displayed_state:
self._save_controls_to_state(state)
self._flush_prompt_save(state)
project = self._state_project(state)
draft_action = None
if image_studio.is_draft_project(project) and image_studio.project_has_content(
@@ -1278,6 +1346,7 @@ class ProductSuiteTab(QWidget):
image_studio.discard_empty_draft_project(project.id, path=self.db_path)
except Exception as exc:
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
self._release_prompt_save_timer(state)
self._retired_states.append(state)
self._states.pop(state.key, None)
self.task_tabs.removeTab(index)
@@ -1313,6 +1382,7 @@ class ProductSuiteTab(QWidget):
return
if self._displayed_state is not None:
self._save_controls_to_state(self._displayed_state)
self._flush_prompt_save(self._displayed_state)
state = self._state_for_index(index)
self._displayed_state = state
if state is not None:
@@ -1433,6 +1503,7 @@ class ProductSuiteTab(QWidget):
self._loading = False
self._update_context_actions(state)
return
self._flush_prompt_save(state)
try:
project = image_studio.bind_draft_project(
state.project_id,
@@ -1484,8 +1555,10 @@ class ProductSuiteTab(QWidget):
self._update_context_actions(state)
def _clear_project_binding(self, state):
self._flush_prompt_save(state)
state.project_id = None
state.project_binding_state = ""
state.last_saved_prompt = ""
state.current_job_ids = []
state.done = state.failed = state.total = 0
state.started_at = None
@@ -1547,6 +1620,7 @@ class ProductSuiteTab(QWidget):
if state is None:
return None
if state.project_id is not None:
self._flush_prompt_save(state)
return self._state_project(state)
if state.item_id:
return self._bind_project(state)
@@ -1564,6 +1638,7 @@ class ProductSuiteTab(QWidget):
project = image_studio.create_or_get_project(
account,
item_id=state.item_id,
draft_prompt=state.prompt,
path=self.db_path,
)
except Exception as exc:
@@ -1571,13 +1646,17 @@ class ProductSuiteTab(QWidget):
return None
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
stored_prompt = str(project.draft_prompt or "")
if load_existing and previous_id != state.project_id:
state.prompt = str(project.draft_prompt or "")
state.prompt = stored_prompt
state.settings = product_suite.normalize_suite_settings(
image_studio.project_suite_settings(project)
)
if state is self._displayed_state:
self._load_state(state)
state.last_saved_prompt = stored_prompt
if state.prompt != stored_prompt:
self._flush_prompt_save(state)
return project
def _create_draft_project(self, state):
@@ -1596,6 +1675,7 @@ class ProductSuiteTab(QWidget):
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
state.item_id = ""
state.last_saved_prompt = str(project.draft_prompt or "")
self._persist_state(state)
self._set_task_title(state)
if state is self._displayed_state:
@@ -1603,15 +1683,75 @@ class ProductSuiteTab(QWidget):
self._status("已创建临时草稿,可继续添加本地图片", "info")
return project
def _persist_state(self, state):
if state.project_id is None:
def _prompt_save_timer(self, state):
timer = self._prompt_save_timers.get(state.key)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.setInterval(PROMPT_AUTOSAVE_DELAY_MS)
timer.timeout.connect(
lambda key=state.key: self._on_prompt_autosave_timeout(key)
)
self._prompt_save_timers[state.key] = timer
return timer
def _schedule_prompt_save(self, state):
if state is None or state.project_id is None:
return
if state.prompt == state.last_saved_prompt:
self._cancel_prompt_save(state)
return
self._prompt_save_timer(state).start()
def _cancel_prompt_save(self, state):
timer = self._prompt_save_timers.get(state.key) if state is not None else None
if timer is not None:
timer.stop()
def _release_prompt_save_timer(self, state):
timer = (
self._prompt_save_timers.pop(state.key, None)
if state is not None
else None
)
if timer is not None:
timer.stop()
timer.deleteLater()
def _on_prompt_autosave_timeout(self, state_key):
state = self._states.get(state_key)
if state is not None:
self._persist_prompt(state)
def _persist_prompt(self, state):
if state is None or state.project_id is None:
return True
prompt = str(state.prompt or "")
if prompt == state.last_saved_prompt:
return True
try:
image_studio.update_project_prompt(
project = image_studio.update_project_prompt(
state.project_id,
state.prompt,
prompt,
path=self.db_path,
)
if project is None:
raise image_studio.ImageStudioError("商品套图项目不存在")
except Exception as exc:
self._status("商品卖点自动保存失败:%s" % _user_error(exc), "danger")
return False
state.last_saved_prompt = prompt
return True
def _flush_prompt_save(self, state):
self._cancel_prompt_save(state)
return self._persist_prompt(state)
def _persist_state(self, state):
if state.project_id is None:
return True
prompt_saved = self._flush_prompt_save(state)
try:
image_studio.update_project_suite_settings(
state.project_id,
state.settings,
@@ -1619,6 +1759,8 @@ class ProductSuiteTab(QWidget):
)
except Exception as exc:
self._status("商品套图设置保存失败:%s" % _user_error(exc), "danger")
return False
return prompt_saved
def _on_settings_changed(self, value=None):
if self._loading or self._displayed_state is None:
@@ -1655,6 +1797,7 @@ class ProductSuiteTab(QWidget):
return
state = self._displayed_state
state.prompt = self.prompt_edit.toPlainText()
self._schedule_prompt_save(state)
def open_prompt_settings(self, checked=False):
state = self._displayed_state
@@ -2754,10 +2897,16 @@ class ProductSuiteTab(QWidget):
self._refresh_results(self._displayed_state)
def closeEvent(self, event):
if self._displayed_state is not None:
self._save_controls_to_state(self._displayed_state)
for state in list(self._states.values()):
self._flush_prompt_save(state)
for state in list(self._states.values()) + list(self._retired_states):
for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker):
if worker is not None and hasattr(worker, "cancel"):
worker.cancel()
for worker, thread in list(state.downloads.values()):
worker.cancel()
for state in list(self._states.values()) + list(self._retired_states):
self._release_prompt_save_timer(state)
super().closeEvent(event)
+2 -1
View File
@@ -198,7 +198,8 @@
- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 打开结果文件夹 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。
- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。
+6 -1
View File
@@ -3,7 +3,7 @@ id: T-638
title: 商品套图卖点输入自动保存与自适应高度
phase: 7
deps: [T-637]
status: TODO
status: DONE
created: 2026-07-16
---
@@ -105,3 +105,8 @@ git diff --check
- 不修改 CDP、蝦皮主图拉取、①导入采集、②AI生成、③更新蝦皮、④账号管理或⑤设置。
## 执行记录
- 2026-07-16:⑥商品套图为每个 `SuiteTaskState` 增加独立的 500ms 单次保存定时器和最近成功保存快照;连续输入只写最终内容,定时回调按 `state.key` 找回原任务,不依赖届时显示中的任务。切换任务、关闭任务、关闭程序、切换项目、创建项目和开始本地工作前会停止待执行定时并同步补保存。
- 2026-07-16:未创建项目时卖点只保存在当前任务内存,不因输入文字创建临时草稿;创建正式项目或临时草稿时把当前卖点写入 `draft_prompt`。AI 帮写结果继续通过统一项目持久化入口立即保存,失败时保留内存文本并显示中文 danger 状态。
- 2026-07-16:新增 `AutoHeightPlainTextEdit`,关闭内部横向和纵向滚动条,按每个实际换行的文本行高度动态增高;空内容保持 96px,删除内容可缩回,宽度变化时重新计算,由现有 `suiteConfigScroll` 统一承载页面滚动。同步更新 `docs/routes.md`。
- 2026-07-16:`tests/test_product_suite_gui.py` 新增 6 项场景测试,覆盖防抖去重、任务切换/关闭不串写、无项目不建草稿、保存失败、AI 帮写保存和自适应高度。定向 25 项通过;隔离工作树全量 524 项 unittest、Ruff、compileall、`git diff --check` 全部通过。当前主工作区全量测试仅有 3 项原有封面默认模板 `papa1` 改名导致的失败,该未提交用户改动未纳入本任务。
+268
View File
@@ -20,6 +20,7 @@ from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton
from app.gui.tabs.product_suite import (
AutoHeightPlainTextEdit,
ORIGINAL_CHECK_STATE_ROLE,
ProductOriginalDelegate,
ProductOriginalList,
@@ -555,6 +556,273 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_prompt_autosaves_after_debounce_and_ignores_unchanged_text(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",
draft_prompt="原卖点",
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 = project.draft_prompt
state.last_saved_prompt = project.draft_prompt
tab._load_state(state)
original_update = image_studio.update_project_prompt
with mock.patch(
"app.gui.tabs.product_suite.image_studio.update_project_prompt",
wraps=original_update,
) as update_prompt:
tab.prompt_edit.setPlainText("第一版卖点")
tab.prompt_edit.setPlainText("最终卖点")
QTest.qWait(650)
self.app.processEvents()
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("最终卖点", stored.draft_prompt)
self.assertEqual(1, update_prompt.call_count)
QTest.qWait(600)
self.app.processEvents()
self.assertEqual(1, update_prompt.call_count)
self.assert_removed(temp_dir)
def test_prompt_switch_and_close_flush_to_the_correct_projects(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,
)
first_project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
second_project = image_studio.create_or_get_project(
account,
item_id="51100639511",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
first_state = tab._displayed_state
first_state.account_alias = "alias-a"
first_state.item_id = first_project.item_id
first_state.project_id = first_project.id
first_state.project_binding_state = first_project.binding_state
tab._load_state(first_state)
tab.prompt_edit.setPlainText("商品一卖点")
second_state = tab.add_task(inherit=False)
second_state.account_alias = "alias-a"
second_state.item_id = second_project.item_id
second_state.project_id = second_project.id
second_state.project_binding_state = second_project.binding_state
tab._load_state(second_state)
tab.prompt_edit.setPlainText("商品二卖点")
tab.task_tabs.setCurrentIndex(0)
self.app.processEvents()
self.assertEqual(
"商品一卖点",
image_studio.get_project(
first_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assertEqual(
"商品二卖点",
image_studio.get_project(
second_project.id,
path=config["db_path"],
).draft_prompt,
)
tab.prompt_edit.setPlainText("商品一关闭任务前卖点")
tab.close_task(0)
self.app.processEvents()
self.assertEqual(
"商品一关闭任务前卖点",
image_studio.get_project(
first_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assertIs(second_state, tab._displayed_state)
tab.prompt_edit.setPlainText("商品二关闭程序前卖点")
tab.close()
self.app.processEvents()
self.assertEqual(
"商品二关闭程序前卖点",
image_studio.get_project(
second_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assert_removed(temp_dir)
def test_prompt_without_project_stays_in_memory_until_draft_creation(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
tab.prompt_edit.setPlainText("尚未建立项目的卖点")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
draft = tab._create_draft_project(state)
self.assertIsNotNone(draft)
stored = image_studio.get_project(draft.id, path=config["db_path"])
self.assertEqual("尚未建立项目的卖点", stored.draft_prompt)
self.assertEqual("尚未建立项目的卖点", state.last_saved_prompt)
self.assert_removed(temp_dir)
def test_prompt_autosave_failure_keeps_memory_text_and_reports_chinese_error(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"],
)
status = mock.Mock()
tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
status_callback=status,
)
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
tab._load_state(state)
with mock.patch(
"app.gui.tabs.product_suite.image_studio.update_project_prompt",
side_effect=OSError("disk unavailable"),
):
tab.prompt_edit.setPlainText("保存失败仍保留")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual("保存失败仍保留", state.prompt)
self.assertEqual("", state.last_saved_prompt)
self.assertTrue(
any(
"商品卖点自动保存失败" in str(call.args[0])
for call in status.call_args_list
)
)
self.assertTrue(
any(call.kwargs.get("level") == "danger" for call in status.call_args_list)
)
self.assert_removed(temp_dir)
def test_ai_write_result_saves_prompt_without_an_extra_user_action(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",
draft_prompt="原卖点",
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 = project.draft_prompt
state.last_saved_prompt = project.draft_prompt
state.ai_prompt_snapshot = project.draft_prompt
tab._load_state(state)
tab._on_ai_write_finished(
state,
{"ok": True, "cancelled": False, "text": "AI生成的新卖点"},
)
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("AI生成的新卖点", stored.draft_prompt)
self.assertEqual("AI生成的新卖点", state.last_saved_prompt)
self.assertEqual("AI生成的新卖点", tab.prompt_edit.toPlainText())
self.assert_removed(temp_dir)
def test_prompt_edit_expands_shrinks_and_reflows_without_internal_scrollbars(self):
edit = AutoHeightPlainTextEdit()
self.addCleanup(edit.close)
edit.resize(420, 96)
edit.show()
self.app.processEvents()
self.assertEqual(Qt.ScrollBarAlwaysOff, edit.horizontalScrollBarPolicy())
self.assertEqual(Qt.ScrollBarAlwaysOff, edit.verticalScrollBarPolicy())
self.assertGreaterEqual(edit.height(), 96)
minimum_height = edit.height()
edit.setPlainText("\n".join("第%d行商品卖点" % index for index in range(1, 13)))
QTest.qWait(50)
self.app.processEvents()
expanded_height = edit.height()
self.assertGreater(expanded_height, minimum_height)
edit.clear()
QTest.qWait(50)
self.app.processEvents()
self.assertEqual(minimum_height, edit.height())
edit.setPlainText("这是一段用于测试窗口变窄后自动换行的商品卖点内容。" * 16)
edit.setFixedWidth(420)
QTest.qWait(50)
self.app.processEvents()
wide_height = edit.height()
edit.setFixedWidth(180)
QTest.qWait(50)
self.app.processEvents()
self.assertGreater(edit.height(), wide_height)
def test_project_settings_and_result_history_use_existing_backend(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)