fix(product-suite): own original image drag lifecycle
This commit is contained in:
@@ -12,6 +12,7 @@ from PySide6.QtCore import (
|
|||||||
QByteArray,
|
QByteArray,
|
||||||
QBuffer,
|
QBuffer,
|
||||||
QIODevice,
|
QIODevice,
|
||||||
|
QMimeData,
|
||||||
QPointF,
|
QPointF,
|
||||||
QRect,
|
QRect,
|
||||||
QRegularExpression,
|
QRegularExpression,
|
||||||
@@ -23,6 +24,7 @@ from PySide6.QtCore import (
|
|||||||
)
|
)
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
|
QDrag,
|
||||||
QFont,
|
QFont,
|
||||||
QIcon,
|
QIcon,
|
||||||
QImage,
|
QImage,
|
||||||
@@ -283,10 +285,12 @@ class ProductOriginalDelegate(QStyledItemDelegate):
|
|||||||
class ProductOriginalList(QListWidget):
|
class ProductOriginalList(QListWidget):
|
||||||
MAX_VISIBLE_ASSETS = 16
|
MAX_VISIBLE_ASSETS = 16
|
||||||
EMPTY_STATE_TEXT = "暂无商品原图"
|
EMPTY_STATE_TEXT = "暂无商品原图"
|
||||||
|
INTERNAL_MIME_TYPE = "application/x-cmshopee-product-original"
|
||||||
|
|
||||||
filesDropped = Signal(list)
|
filesDropped = Signal(list)
|
||||||
clipboardImage = Signal(bytes)
|
clipboardImage = Signal(bytes)
|
||||||
orderChanged = Signal(list)
|
orderChanged = Signal(list)
|
||||||
|
orderRestoreRequested = Signal()
|
||||||
deleteRequested = Signal(int)
|
deleteRequested = Signal(int)
|
||||||
deleteAssetsRequested = Signal(list)
|
deleteAssetsRequested = Signal(list)
|
||||||
checkedAssetsChanged = Signal(list)
|
checkedAssetsChanged = Signal(list)
|
||||||
@@ -369,7 +373,12 @@ class ProductOriginalList(QListWidget):
|
|||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
return
|
return
|
||||||
if event.source() is self and self._reordering_enabled and self._dragged_item is not None:
|
if (
|
||||||
|
event.source() is self
|
||||||
|
and event.mimeData().hasFormat(self.INTERNAL_MIME_TYPE)
|
||||||
|
and self._reordering_enabled
|
||||||
|
and self._dragged_item is not None
|
||||||
|
):
|
||||||
event.setDropAction(Qt.MoveAction)
|
event.setDropAction(Qt.MoveAction)
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
@@ -382,7 +391,11 @@ class ProductOriginalList(QListWidget):
|
|||||||
if not self._reordering_enabled:
|
if not self._reordering_enabled:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
if event.source() is self and self._dragged_item is not None:
|
if (
|
||||||
|
event.source() is self
|
||||||
|
and event.mimeData().hasFormat(self.INTERNAL_MIME_TYPE)
|
||||||
|
and self._dragged_item is not None
|
||||||
|
):
|
||||||
event.setDropAction(Qt.MoveAction)
|
event.setDropAction(Qt.MoveAction)
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
@@ -397,7 +410,10 @@ class ProductOriginalList(QListWidget):
|
|||||||
if not self._reordering_enabled:
|
if not self._reordering_enabled:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
if event.source() is not self:
|
if (
|
||||||
|
event.source() is not self
|
||||||
|
or not event.mimeData().hasFormat(self.INTERNAL_MIME_TYPE)
|
||||||
|
):
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
if not self._move_dragged_item_to(event.position().toPoint()):
|
if not self._move_dragged_item_to(event.position().toPoint()):
|
||||||
@@ -410,10 +426,22 @@ class ProductOriginalList(QListWidget):
|
|||||||
item = self.currentItem()
|
item = self.currentItem()
|
||||||
if not self._begin_internal_drag(item):
|
if not self._begin_internal_drag(item):
|
||||||
return
|
return
|
||||||
|
drag = QDrag(self)
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setData(
|
||||||
|
self.INTERNAL_MIME_TYPE,
|
||||||
|
QByteArray(str(int(item.data(Qt.UserRole))).encode("ascii")),
|
||||||
|
)
|
||||||
|
drag.setMimeData(mime_data)
|
||||||
|
pixmap = item.icon().pixmap(self.iconSize())
|
||||||
|
if not pixmap.isNull():
|
||||||
|
drag.setPixmap(pixmap)
|
||||||
|
drag.setHotSpot(pixmap.rect().center())
|
||||||
|
completed_action = Qt.IgnoreAction
|
||||||
try:
|
try:
|
||||||
super().startDrag(Qt.MoveAction)
|
completed_action = drag.exec(Qt.MoveAction, Qt.MoveAction)
|
||||||
finally:
|
finally:
|
||||||
self._finish_internal_drag()
|
self._finish_internal_drag(completed_action == Qt.MoveAction)
|
||||||
|
|
||||||
def _begin_internal_drag(self, item):
|
def _begin_internal_drag(self, item):
|
||||||
if (
|
if (
|
||||||
@@ -430,7 +458,7 @@ class ProductOriginalList(QListWidget):
|
|||||||
self._pending_order = None
|
self._pending_order = None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _finish_internal_drag(self):
|
def _finish_internal_drag(self, move_completed=True):
|
||||||
pending_order = self._pending_order
|
pending_order = self._pending_order
|
||||||
drag_start_ids = self._drag_start_ids
|
drag_start_ids = self._drag_start_ids
|
||||||
self._dragged_item = None
|
self._dragged_item = None
|
||||||
@@ -440,11 +468,13 @@ class ProductOriginalList(QListWidget):
|
|||||||
return False
|
return False
|
||||||
current_ids = self.asset_ids()
|
current_ids = self.asset_ids()
|
||||||
if (
|
if (
|
||||||
current_ids != pending_order
|
not move_completed
|
||||||
|
or current_ids != pending_order
|
||||||
or len(pending_order) != len(set(pending_order))
|
or len(pending_order) != len(set(pending_order))
|
||||||
or len(pending_order) != len(drag_start_ids)
|
or len(pending_order) != len(drag_start_ids)
|
||||||
or set(pending_order) != set(drag_start_ids)
|
or set(pending_order) != set(drag_start_ids)
|
||||||
):
|
):
|
||||||
|
self.orderRestoreRequested.emit()
|
||||||
return False
|
return False
|
||||||
self.orderChanged.emit(list(pending_order))
|
self.orderChanged.emit(list(pending_order))
|
||||||
return True
|
return True
|
||||||
@@ -2574,6 +2604,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
self.original_list.filesDropped.connect(self.import_files)
|
self.original_list.filesDropped.connect(self.import_files)
|
||||||
self.original_list.clipboardImage.connect(self.import_clipboard_image)
|
self.original_list.clipboardImage.connect(self.import_clipboard_image)
|
||||||
self.original_list.orderChanged.connect(self.reorder_originals)
|
self.original_list.orderChanged.connect(self.reorder_originals)
|
||||||
|
self.original_list.orderRestoreRequested.connect(self._restore_original_order_after_drag)
|
||||||
self.original_list.deleteRequested.connect(self.delete_original)
|
self.original_list.deleteRequested.connect(self.delete_original)
|
||||||
self.original_list.deleteAssetsRequested.connect(self.delete_originals)
|
self.original_list.deleteAssetsRequested.connect(self.delete_originals)
|
||||||
self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed)
|
self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed)
|
||||||
@@ -3738,6 +3769,13 @@ class ProductSuiteTab(QWidget):
|
|||||||
self._status("已更新商品原图顺序,第一张图片为主图", "success")
|
self._status("已更新商品原图顺序,第一张图片为主图", "success")
|
||||||
self._refresh_originals(state)
|
self._refresh_originals(state)
|
||||||
|
|
||||||
|
def _restore_original_order_after_drag(self):
|
||||||
|
state = self._displayed_state
|
||||||
|
if state is None or state.project_id is None:
|
||||||
|
return
|
||||||
|
self._refresh_originals(state)
|
||||||
|
self._status("原图排序未完成,已恢复原顺序,请重试", "warning")
|
||||||
|
|
||||||
def _asset_for_item(self, item):
|
def _asset_for_item(self, item):
|
||||||
state = self._displayed_state
|
state = self._displayed_state
|
||||||
if state is None or item is None:
|
if state is None or item is None:
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
- 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。
|
- 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。
|
||||||
- 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」仍仅使用默认网关 `vision_alias`。
|
- 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」仍仅使用默认网关 `vision_alias`。
|
||||||
- T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。
|
- T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。
|
||||||
- T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。商品原图 UI 的第1张明确显示「主图」,内部排序由列表记录单一拖拽源、按 drop 坐标计算插入行,并以 `takeItem()` + `insertItem()` 移动同一个项;不得调用 Qt 默认的“投放到图片上”复制路径。`dropEvent()` 只记录待提交顺序,必须等 `QDrag` 返回并清理拖拽上下文后才发送一次排序信号,避免写库后的列表重建与仍在运行的拖拽事务重入。最终 ID 顺序唯一且确有变化时才由事务更新 `source_order`,排序只影响之后新建的一轮 job,不回写既有 job 快照。外部文件 URL 继续走导入。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。
|
- T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。商品原图 UI 的第1张明确显示「主图」。内部排序由列表自行创建 `QDrag`,不得调用 `QAbstractItemView.startDrag(MoveAction)` 的源端清理;`dropEvent()` 按坐标以 `takeItem()` + `insertItem()` 唯一移动同一个项,不调用 Qt 默认 model drop,也不走“投放到图片上”的复制路径。`QDrag` 返回并清理拖拽上下文后才发送一次排序信号;结束时数量或 ID 集合异常则不写库,并从 SQLite 恢复界面。最终 ID 顺序唯一且确有变化时才由事务更新 `source_order`,排序只影响之后新建的一轮 job,不回写既有 job 快照。外部文件 URL 继续走导入。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。
|
||||||
- T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。
|
- T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。
|
||||||
- T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。
|
- T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。
|
||||||
- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。模板校验仅针对模板文本;替换值是业务数据,商品卖点与要求、分类名等可原样包含 `{}`,不再二次作为变量解析。`product_suite_reference_rule()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启时为单图主参考规则;未开启时固定为“使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随实际参考图数量变化。提示词弹窗与 job 共用 renderer;实际提交的参考图数量仍由冻结的 `reference_asset_ids` 决定。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。
|
- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。模板校验仅针对模板文本;替换值是业务数据,商品卖点与要求、分类名等可原样包含 `{}`,不再二次作为变量解析。`product_suite_reference_rule()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启时为单图主参考规则;未开启时固定为“使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随实际参考图数量变化。提示词弹窗与 job 共用 renderer;实际提交的参考图数量仍由冻结的 `reference_asset_ids` 决定。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@
|
|||||||
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。内部生成目录不提供顶部直达入口,正式取图统一通过历史窗口的「导出本轮」选择外部目录。
|
- 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。内部生成目录不提供顶部直达入口,正式取图统一通过历史窗口的「导出本轮」选择外部目录。
|
||||||
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。商品ID输入框只接受数字;提交时清除不可见格式字符,失焦、拉取、添加原图和生成共用提交校验。非数字输入只提示并保留在输入框,不覆盖当前已绑定项目;合法切换商品继续弹窗确认。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。
|
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。商品ID输入框只接受数字;提交时清除不可见格式字符,失焦、拉取、添加原图和生成共用提交校验。非数字输入只提示并保留在输入框,不覆盖当前已绑定项目;合法切换商品继续弹窗确认。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。
|
||||||
- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
|
- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。
|
||||||
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;内部排序只移动当前拖拽的同一个图片项,不走 Qt 默认的投放复制行为。第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。拖放期间只记录待提交顺序,待 Qt 拖拽完全结束后才写库并刷新,连续换主图时不得清除仍被拖拽持有的列表项。最终顺序必须唯一且实际变化才回写;外部文件拖入只添加图片。历史失效远程图不占有效名额;第1张是主参考图。
|
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序;内部排序由列表自建拖拽对象并只移动当前图片一次,不调用 Qt 默认 Move 的源项删除或投放复制行为。第1张以「主图」文字、粗体和语义色标识,拖动任意图片到第一位即可设为主图,排序成功提示“已更新商品原图顺序,第一张图片为主图”。拖放期间只记录待提交顺序,待拖拽完全结束后才写库并刷新;数量或 ID 集合异常时自动按 SQLite 恢复并提示重试。最终顺序必须唯一且实际变化才回写;外部文件拖入只添加图片。历史失效远程图不占有效名额;第1张是主参考图。
|
||||||
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
|
- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。删除会继续保护终选直接引用、运行中和可继续查询/重试 job 的主图及参考图;已成功且不可重试的历史 job 不阻断删除,删除后可按剩余原图开始新一轮生成。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。
|
||||||
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作,但商品原图列表仅锁定内部拖拽排序,并用 tooltip 说明“正在拉取蝦皮主图,完成后可调整图片顺序”;文件拖入、预览等非冲突操作不因此整页禁用。
|
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作,但商品原图列表仅锁定内部拖拽排序,并用 tooltip 说明“正在拉取蝦皮主图,完成后可调整图片顺序”;文件拖入、预览等非冲突操作不因此整页禁用。
|
||||||
- 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
|
- 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
|
||||||
|
|||||||
+6
-2
@@ -3,7 +3,7 @@ id: T-694
|
|||||||
title: 商品套图拖放源项二次删除修复
|
title: 商品套图拖放源项二次删除修复
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-693]
|
deps: [T-693]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-23
|
created: 2026-07-23
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,4 +59,8 @@ T-693 的测试直接调用 `_begin_internal_drag()`、`_move_dragged_item_to()`
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 待实现。
|
- 2026-07-23 完成。
|
||||||
|
- `ProductOriginalList.startDrag()` 不再调用 `QAbstractItemView.startDrag(MoveAction)`,改为创建 source 为当前列表的 `QDrag`,使用私有 MIME 类型承载单张原图 ID,并复用缩略图作为拖拽视觉反馈。自定义 `dropEvent()` 仍是唯一执行 `takeItem()` + `insertItem()` 的位置,因此不会再由 Qt 基类二次删除移动源项。
|
||||||
|
- 拖拽进入、移动和投放同时校验当前列表来源及私有 MIME;外部文件 URL 仍走原导入路径。`QDrag.exec()` 返回后才提交待保存顺序,取消或结束时数量、唯一性、ID 集合异常均不写库,并通过独立 `orderRestoreRequested` 信号让页面从 SQLite 恢复原图及显示中文重试提示。
|
||||||
|
- 测试改为模拟完整 `startDrag()` 路径并控制 `QDrag.exec()`:覆盖单次移动不触发源端删除、连续4次更换主图后5张图片和连续标签保持完整、异常少项不发送排序信号,以及页面从 SQLite 自动恢复。同步更新 `docs/04-architecture.md` 与 `docs/routes.md`。
|
||||||
|
- 验证通过:`py -3.10 -m unittest tests.test_product_suite_gui tests.test_image_studio tests.test_product_suite`(112项)、`py -3.10 -m unittest discover -s tests`(679项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`。离屏 PySide6 仍有字体目录和插件能力告警,但无测试失败。
|
||||||
|
|||||||
+138
-17
@@ -2973,6 +2973,74 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
original_list.set_reordering_enabled(False)
|
original_list.set_reordering_enabled(False)
|
||||||
self.assertFalse(original_list._begin_internal_drag(original_list.item(0)))
|
self.assertFalse(original_list._begin_internal_drag(original_list.item(0)))
|
||||||
|
|
||||||
|
def test_original_list_start_drag_owns_move_without_qt_source_cleanup(self):
|
||||||
|
original_list = ProductOriginalList()
|
||||||
|
self.addCleanup(original_list.close)
|
||||||
|
original_list.setFixedSize(360, 120)
|
||||||
|
for asset_id in (11, 12, 13):
|
||||||
|
item = QListWidgetItem("原图%d" % asset_id)
|
||||||
|
item.setData(Qt.UserRole, asset_id)
|
||||||
|
item.setIcon(QIcon(QPixmap(24, 18)))
|
||||||
|
original_list.addItem(item)
|
||||||
|
original_list.show()
|
||||||
|
self.app.processEvents()
|
||||||
|
original_list.setCurrentItem(original_list.item(1))
|
||||||
|
changes = []
|
||||||
|
restores = []
|
||||||
|
original_list.orderChanged.connect(changes.append)
|
||||||
|
original_list.orderRestoreRequested.connect(lambda: restores.append(True))
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.product_suite.QDrag") as drag_type:
|
||||||
|
drag = drag_type.return_value
|
||||||
|
|
||||||
|
def execute_drag(*args):
|
||||||
|
first_rect = original_list.visualItemRect(original_list.item(0))
|
||||||
|
self.assertTrue(original_list._move_dragged_item_to(first_rect.center()))
|
||||||
|
self.assertEqual([], changes)
|
||||||
|
return Qt.MoveAction
|
||||||
|
|
||||||
|
drag.exec.side_effect = execute_drag
|
||||||
|
original_list.startDrag(Qt.MoveAction)
|
||||||
|
|
||||||
|
drag_type.assert_called_once_with(original_list)
|
||||||
|
mime_data = drag.setMimeData.call_args.args[0]
|
||||||
|
self.assertTrue(mime_data.hasFormat(original_list.INTERNAL_MIME_TYPE))
|
||||||
|
self.assertEqual([12, 11, 13], original_list.asset_ids())
|
||||||
|
self.assertEqual(3, original_list.count())
|
||||||
|
self.assertEqual([[12, 11, 13]], changes)
|
||||||
|
self.assertEqual([], restores)
|
||||||
|
|
||||||
|
def test_original_list_start_drag_requests_restore_after_unexpected_item_loss(self):
|
||||||
|
original_list = ProductOriginalList()
|
||||||
|
self.addCleanup(original_list.close)
|
||||||
|
original_list.setFixedSize(360, 120)
|
||||||
|
for asset_id in (11, 12, 13):
|
||||||
|
item = QListWidgetItem("原图%d" % asset_id)
|
||||||
|
item.setData(Qt.UserRole, asset_id)
|
||||||
|
original_list.addItem(item)
|
||||||
|
original_list.show()
|
||||||
|
self.app.processEvents()
|
||||||
|
original_list.setCurrentItem(original_list.item(1))
|
||||||
|
changes = []
|
||||||
|
restores = []
|
||||||
|
original_list.orderChanged.connect(changes.append)
|
||||||
|
original_list.orderRestoreRequested.connect(lambda: restores.append(True))
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.product_suite.QDrag") as drag_type:
|
||||||
|
drag = drag_type.return_value
|
||||||
|
|
||||||
|
def execute_drag(*args):
|
||||||
|
first_rect = original_list.visualItemRect(original_list.item(0))
|
||||||
|
self.assertTrue(original_list._move_dragged_item_to(first_rect.center()))
|
||||||
|
original_list.takeItem(original_list.count() - 1)
|
||||||
|
return Qt.MoveAction
|
||||||
|
|
||||||
|
drag.exec.side_effect = execute_drag
|
||||||
|
original_list.startDrag(Qt.MoveAction)
|
||||||
|
|
||||||
|
self.assertEqual([], changes)
|
||||||
|
self.assertEqual([True], restores)
|
||||||
|
|
||||||
def test_original_list_repeated_primary_moves_keep_every_asset_and_label(self):
|
def test_original_list_repeated_primary_moves_keep_every_asset_and_label(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
config = self._config(temp_dir)
|
config = self._config(temp_dir)
|
||||||
@@ -2992,24 +3060,33 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
expected_labels = ["主图", "参考1", "参考2", "参考3", "参考4"]
|
expected_labels = ["主图", "参考1", "参考2", "参考3", "参考4"]
|
||||||
for asset in assets[1:]:
|
for asset in assets[1:]:
|
||||||
row = tab.original_list.asset_ids().index(asset.id)
|
row = tab.original_list.asset_ids().index(asset.id)
|
||||||
self.assertTrue(
|
tab.original_list.setCurrentItem(tab.original_list.item(row))
|
||||||
tab.original_list._begin_internal_drag(tab.original_list.item(row))
|
with mock.patch("app.gui.tabs.product_suite.QDrag") as drag_type:
|
||||||
)
|
drag = drag_type.return_value
|
||||||
first_rect = tab.original_list.visualItemRect(tab.original_list.item(0))
|
|
||||||
self.assertTrue(
|
|
||||||
tab.original_list._move_dragged_item_to(first_rect.center())
|
|
||||||
)
|
|
||||||
database_ids_before_finish = [
|
|
||||||
stored.id
|
|
||||||
for stored in image_studio.list_assets(
|
|
||||||
project.id,
|
|
||||||
kind=image_studio.ASSET_KIND_ORIGINAL,
|
|
||||||
path=config["db_path"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.assertNotEqual(tab.original_list.asset_ids(), database_ids_before_finish)
|
|
||||||
|
|
||||||
self.assertTrue(tab.original_list._finish_internal_drag())
|
def execute_drag(*args):
|
||||||
|
first_rect = tab.original_list.visualItemRect(
|
||||||
|
tab.original_list.item(0)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
tab.original_list._move_dragged_item_to(first_rect.center())
|
||||||
|
)
|
||||||
|
database_ids_before_finish = [
|
||||||
|
stored.id
|
||||||
|
for stored in image_studio.list_assets(
|
||||||
|
project.id,
|
||||||
|
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
path=config["db_path"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.assertNotEqual(
|
||||||
|
tab.original_list.asset_ids(),
|
||||||
|
database_ids_before_finish,
|
||||||
|
)
|
||||||
|
return Qt.MoveAction
|
||||||
|
|
||||||
|
drag.exec.side_effect = execute_drag
|
||||||
|
tab.original_list.startDrag(Qt.MoveAction)
|
||||||
visible_ids = tab.original_list.asset_ids()
|
visible_ids = tab.original_list.asset_ids()
|
||||||
database_ids = [
|
database_ids = [
|
||||||
stored.id
|
stored.id
|
||||||
@@ -3031,6 +3108,50 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_original_list_restores_database_order_after_drag_item_loss(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
project, assets = self._create_project_with_assets(temp_dir, config, 3)
|
||||||
|
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 = "51100639510"
|
||||||
|
state.project_id = project.id
|
||||||
|
tab._load_state(state)
|
||||||
|
tab.resize(1180, 760)
|
||||||
|
tab.show()
|
||||||
|
self.app.processEvents()
|
||||||
|
statuses = []
|
||||||
|
tab._status = lambda message, level=None: statuses.append((message, level))
|
||||||
|
tab.original_list.setCurrentItem(tab.original_list.item(1))
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.product_suite.QDrag") as drag_type:
|
||||||
|
drag = drag_type.return_value
|
||||||
|
|
||||||
|
def execute_drag(*args):
|
||||||
|
first_rect = tab.original_list.visualItemRect(
|
||||||
|
tab.original_list.item(0)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
tab.original_list._move_dragged_item_to(first_rect.center())
|
||||||
|
)
|
||||||
|
tab.original_list.takeItem(tab.original_list.count() - 1)
|
||||||
|
return Qt.MoveAction
|
||||||
|
|
||||||
|
drag.exec.side_effect = execute_drag
|
||||||
|
tab.original_list.startDrag(Qt.MoveAction)
|
||||||
|
|
||||||
|
expected_ids = [asset.id for asset in assets]
|
||||||
|
self.assertEqual(expected_ids, tab.original_list.asset_ids())
|
||||||
|
self.assertEqual(3, tab.original_list.count())
|
||||||
|
self.assertEqual(
|
||||||
|
("原图排序未完成,已恢复原顺序,请重试", "warning"),
|
||||||
|
statuses[-1],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_temporary_draft_allows_local_work_but_blocks_shopee_pull_and_recovers(self):
|
def test_temporary_draft_allows_local_work_but_blocks_shopee_pull_and_recovers(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
config = self._config(temp_dir)
|
config = self._config(temp_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user