diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index c39ec45..c0da726 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -98,3 +98,37 @@ class ProductMaterialsTests(TestCase): self.assertEqual(len(data["project_packs"]), 1) self.assertEqual(data["project_packs"][0]["project_name"], "项目甲") self.assertEqual(len(data["project_packs"][0]["items"]), 2) # 场景 + 视频素材 + + +class ProductImageDeleteGuardTests(TestCase): + """商品至少保留一张图:删到最后一张则拒绝(要清空只能删整个商品)。""" + + def setUp(self): + from apps.assets.models import Asset, AssetFile + from .models import ProductImage + + self.user = User.objects.create_user(username="idg", password="x") + self.team = Team.objects.create(name="IDG", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.client = APIClient() + self.client.force_authenticate(self.user) + self.product = Product.objects.create(team=self.team, created_by=self.user, title="P") + + def mk_img(i): + a = Asset.objects.create(team=self.team, created_by=self.user, name=f"img{i}", asset_type="image", category="product_image") + AssetFile.objects.create(asset=a, object_key=f"k{i}", bucket="b", content_type="image/png", size_bytes=1, is_primary=True) + return ProductImage.objects.create(product=self.product, asset=a, sort_order=i, is_primary=(i == 0)) + + self.img0 = mk_img(0) + self.img1 = mk_img(1) + + def test_delete_when_multiple_ok(self): + res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/") + self.assertEqual(res.status_code, 200) + self.assertEqual(self.product.images.count(), 1) + + def test_delete_last_image_rejected(self): + self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/") # 删到只剩一张 + res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/") # 再删最后一张 + self.assertEqual(res.status_code, 400) + self.assertEqual(self.product.images.count(), 1) # 没删掉,仍保留一张 diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 73e5817..8c34563 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -192,10 +192,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["delete"], url_path=r"images/(?P[^/.]+)") def delete_image(self, request, pk=None, image_id=None): - """移除商品图(删 ProductImage 关联,保留底层 Asset)。""" + """移除商品图(删 ProductImage 关联,保留底层 Asset)。 + 商品至少保留一张图(与「创建必须带图」一致):删到最后一张则拒绝 —— 要清空只能删整个商品。""" product = self.get_object() - deleted, _ = ProductImage.objects.filter(product=product, id=image_id).delete() - if not deleted: + if not ProductImage.objects.filter(product=product, id=image_id).exists(): return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND) + if product.images.count() <= 1: + return Response( + {"detail": "商品至少保留一张图片,如需移除请删除整个商品(或先上传新图再删旧图)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + ProductImage.objects.filter(product=product, id=image_id).delete() product.refresh_from_db() return Response(ProductSerializer(product).data, status=status.HTTP_200_OK) diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index 137aa10..cf97bb6 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -336,8 +336,9 @@ .img-upload:hover { border-color: var(--heat); color: var(--heat); } .img-upload svg { width: 18px; height: 18px; } .ov-card.editing .img-upload { display: grid; } - .ov-card.editing .ov-images-sub .thumb { cursor: pointer; } - .ov-card.editing .ov-images-sub .thumb::after { + /* 编辑态:可删的图(有 ProductImage 关联)右上角显示删除 X;整块点击即删 */ + .ov-card.editing .ov-images-sub .thumb.is-del { cursor: pointer; } + .ov-card.editing .ov-images-sub .thumb.is-del::after { content: '×'; position: absolute; top: 4px; right: 4px; @@ -348,7 +349,9 @@ display: grid; place-items: center; font-size: 13px; line-height: 1; + pointer-events: none; } + .ov-card.editing .ov-images-sub .thumb.is-del:hover::after { background: var(--heat); } .ov-images-sub .thumb { position: relative; } .pd-overview .ov-h { display: flex; align-items: baseline; gap: 8px; diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index cbe4c96..e226d6b 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -738,11 +738,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na // 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order); - const productImages: Array<{ id: string; url: string }> = []; + // imageId = ProductImage.id(删除接口要的就是它,不是 asset id);封面若无对应 ProductImage + // (如三视图采用为封面)则只展示、不可删(删除走 ProductImage 关联)。 + const productImages: Array<{ key: string; url: string; imageId?: string }> = []; if (product.cover_asset && !imageRefs.some((ref) => ref.asset === product.cover_asset)) { - productImages.push({ id: product.cover_asset, url: product.cover_preview_url || "" }); + productImages.push({ key: product.cover_asset, url: product.cover_preview_url || "" }); } - for (const ref of imageRefs) productImages.push({ id: ref.asset, url: ref.preview_url || "" }); + for (const ref of imageRefs) productImages.push({ key: ref.id, url: ref.preview_url || "", imageId: ref.id }); + // 可删的图(有 ProductImage 关联)数量:只剩一张时锁住最后一张,不允许删到没图 + const deletableImageCount = productImages.filter((im) => im.imageId).length; // AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product / // ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。 @@ -981,17 +985,24 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na ({productImages.length})
- {productImages.map((image) => ( - image.url ? ( -
setPreview({ src: image.url, name: realName })} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: image.url, name: realName }); } }}> - {realName} + {productImages.map((image) => { + // 商品至少保留一张图:只剩一张可删图时,那张不显示叉子、不可删(要移除请删整个商品) + const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1; + const lastLocked = editing && Boolean(image.imageId) && deletableImageCount <= 1; + const onThumb = () => { + if (canDelete) void onDeleteImage?.(image.imageId as string); + else if (image.url) setPreview({ src: image.url, name: realName }); + }; + return ( +
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onThumb(); } }}> + {image.url ? {realName} : 1:1}
- ) : ( -
- 1:1 -
- ) - ))} + ); + })}
imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> {uploading ? ( 上传中…