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:
co-authored by
Claude Opus 4.8
parent
f02c8053a1
commit
f9c52a171c
@@ -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) # 没删掉,仍保留一张
|
||||
|
||||
@@ -192,10 +192,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path=r"images/(?P<image_id>[^/.]+)")
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
<span className="ct">({productImages.length})</span>
|
||||
</div>
|
||||
<div className="grid" id="ov-images-grid">
|
||||
{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 }); } }}>
|
||||
<img src={image.url} alt={realName} loading="lazy" />
|
||||
{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 (
|
||||
<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 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(); } }}>
|
||||
{uploading ? (
|
||||
<span className="ph-frame" style={{ fontSize: 12 }}>上传中…</span>
|
||||
|
||||
Reference in New Issue
Block a user