feat(product): 商品图删除接通 + 至少保留一张

- 详情页编辑态的删除叉子原是纯 CSS ::after 装饰、没绑事件,且列表用的是 asset id 而非删除接口要的
  ProductImage id —— 接通:编辑态点图真调删除接口、用 ProductImage id,非编辑态仍点击放大
- 商品至少保留一张图(与「创建必须带图」一致):后端删到最后一张返回 400;前端只剩一张时那张不显示
  叉子 + 提示「商品至少保留一张图;要移除请删除整个商品」;封面无对应 ProductImage(如三视图采用为封面)
  则只展示、不可删
- 加 2 个后端测试(多张可删 200 / 删最后一张 400)

真实接口验:两张删一张 200、再删最后一张 400 并保留一张;后端测试通过,tsc/build 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 16:29:05 +08:00
co-authored by Claude Opus 4.8
parent f02c8053a1
commit f9c52a171c
4 changed files with 72 additions and 18 deletions
+34
View File
@@ -98,3 +98,37 @@ class ProductMaterialsTests(TestCase):
self.assertEqual(len(data["project_packs"]), 1) self.assertEqual(len(data["project_packs"]), 1)
self.assertEqual(data["project_packs"][0]["project_name"], "项目甲") self.assertEqual(data["project_packs"][0]["project_name"], "项目甲")
self.assertEqual(len(data["project_packs"][0]["items"]), 2) # 场景 + 视频素材 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) # 没删掉,仍保留一张
+9 -3
View File
@@ -192,10 +192,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["delete"], url_path=r"images/(?P<image_id>[^/.]+)") @action(detail=True, methods=["delete"], url_path=r"images/(?P<image_id>[^/.]+)")
def delete_image(self, request, pk=None, image_id=None): def delete_image(self, request, pk=None, image_id=None):
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。""" """移除商品图(删 ProductImage 关联,保留底层 Asset)。
商品至少保留一张图(与「创建必须带图」一致):删到最后一张则拒绝 —— 要清空只能删整个商品。"""
product = self.get_object() product = self.get_object()
deleted, _ = ProductImage.objects.filter(product=product, id=image_id).delete() if not ProductImage.objects.filter(product=product, id=image_id).exists():
if not deleted:
return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND) 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() product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK) return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
+5 -2
View File
@@ -336,8 +336,9 @@
.img-upload:hover { border-color: var(--heat); color: var(--heat); } .img-upload:hover { border-color: var(--heat); color: var(--heat); }
.img-upload svg { width: 18px; height: 18px; } .img-upload svg { width: 18px; height: 18px; }
.ov-card.editing .img-upload { display: grid; } .ov-card.editing .img-upload { display: grid; }
.ov-card.editing .ov-images-sub .thumb { cursor: pointer; } /* 编辑态:可删的图(有 ProductImage 关联)右上角显示删除 X;整块点击即删 */
.ov-card.editing .ov-images-sub .thumb::after { .ov-card.editing .ov-images-sub .thumb.is-del { cursor: pointer; }
.ov-card.editing .ov-images-sub .thumb.is-del::after {
content: '×'; content: '×';
position: absolute; position: absolute;
top: 4px; right: 4px; top: 4px; right: 4px;
@@ -348,7 +349,9 @@
display: grid; place-items: center; display: grid; place-items: center;
font-size: 13px; font-size: 13px;
line-height: 1; 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; } .ov-images-sub .thumb { position: relative; }
.pd-overview .ov-h { .pd-overview .ov-h {
display: flex; align-items: baseline; gap: 8px; display: flex; align-items: baseline; gap: 8px;
+24 -13
View File
@@ -738,11 +738,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
// 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets // 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order); 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)) { 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 / // AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。 // ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
@@ -981,17 +985,24 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<span className="ct">({productImages.length})</span> <span className="ct">({productImages.length})</span>
</div> </div>
<div className="grid" id="ov-images-grid"> <div className="grid" id="ov-images-grid">
{productImages.map((image) => ( {productImages.map((image) => {
image.url ? ( // 商品至少保留一张图:只剩一张可删图时,那张不显示叉子、不可删(要移除请删整个商品)
<div className="thumb placeholder" key={image.id} role="button" tabIndex={0} title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: image.url, name: realName })} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: image.url, name: realName }); } }}> const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1;
<img src={image.url} alt={realName} loading="lazy" /> 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 (
<div className={`thumb placeholder${canDelete ? " is-del" : ""}${lastLocked ? " is-locked" : ""}`} key={image.key} role="button" tabIndex={0}
title={canDelete ? "点击移除该图" : lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
style={{ cursor: canDelete ? "pointer" : image.url ? "zoom-in" : "default" }}
onClick={onThumb}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onThumb(); } }}>
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
</div> </div>
) : ( );
<div className="thumb placeholder" key={image.id}> })}
<span className="ph-frame">1:1</span>
</div>
)
))}
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> <div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
{uploading ? ( {uploading ? (
<span className="ph-frame" style={{ fontSize: 12 }}></span> <span className="ph-frame" style={{ fontSize: 12 }}></span>