feat(product-detail): 素材区重做为结构化 — 角色逐个 / 商品图平铺 / 图片成品平铺 / 场景分镜视频按项目打包

按"东西的性质"分,不再一锅炖:
- 后端 /api/products/{id}/materials/:角色=立绘+配对三视图(triview_of)逐个、带「被几个项目引用」
  (BaseAssetGroup 去重计数);商品图平铺;图片成品(上身图/套图/创作)平铺;场景/分镜/视频素材按视频项目打包
- 前端商品详情素材区重做:角色卡(左上标签+左下「N 个项目引用」/「// 暂未引用」,点开看形象图+三视图)、
  商品图平铺、图片成品平铺带类型标签、项目包卡(点开看该项目场景+分镜+视频素材);通用明细弹窗
- 单测 ProductMaterialsTests(角色配对+引用数=1 / 商品图 / 图片成品 / 项目包2素材)

验收:products+assets 22 测绿;tsc+build 绿;无头核对(角色卡「1 个项目引用」+商品图平铺、0 console error)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 13:34:39 +08:00
parent 708b9ea572
commit 1d40e89418
7 changed files with 246 additions and 98 deletions
+47
View File
@@ -51,3 +51,50 @@ class ProductTrashTests(TestCase):
res = self.client.delete(f"/api/products/{self.product.id}/purge/")
self.assertEqual(res.status_code, 404)
self.assertTrue(Product.objects.filter(id=self.product.id).exists())
class ProductMaterialsTests(TestCase):
"""商品详情结构化素材:角色(立绘+三视图配对+引用数)/ 商品图 / 图片成品 / 项目包。"""
def setUp(self):
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.projects.models import BaseAssetGroup, Project, VideoSegment, VideoSegmentVersion
self.user = User.objects.create_user(username="pm", password="x")
self.team = Team.objects.create(name="PMT", 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")
pid = str(self.product.id)
def mk(cat, name, meta=None, task=None, atype="image"):
return Asset.objects.create(team=self.team, name=name, asset_type=atype, source="ai_generated", category=cat, metadata=meta or {}, origin_task=task)
# 角色:立绘 + 配套三视图
self.portrait = mk("person", "角色甲", {"product_id": pid})
mk("tri_view", "角色甲-三视图", {"product_id": pid, "triview_of": str(self.portrait.id)})
# 引用数:立绘进一个项目的人物组
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
BaseAssetGroup.objects.create(project=self.proj, kind=BaseAssetGroup.Kind.PERSON, adopted_asset=self.portrait)
# 商品图 + 图片成品
mk("product_image", "正面主图", {"product_id": pid})
mk("model_tryon", "上身图1", {"product_id": pid})
# 项目包:场景 + 视频素材(走 origin_task→project)
prov = ModelProvider.objects.create(name="volcengine", display_name="V", base_url="https://x")
mc = ModelConfig.objects.create(provider=prov, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE)
task = AITask.objects.create(team=self.team, project=self.proj, task_type=AITask.Type.SCENE_IMAGE, status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="pm-1")
mk("scene", "场景1", task=task)
mk("video_clip", "片段1", task=task, atype="video")
def test_materials_structure(self):
data = self.client.get(f"/api/products/{self.product.id}/materials/").json()
self.assertEqual(len(data["roles"]), 1)
self.assertIsNotNone(data["roles"][0]["triview"]) # 形象图+三视图配对
self.assertEqual(data["roles"][0]["ref_count"], 1) # 被 1 个项目引用
self.assertEqual(len(data["product_images"]), 1)
self.assertEqual(len(data["image_products"]), 1)
self.assertEqual(len(data["project_packs"]), 1)
self.assertEqual(data["project_packs"][0]["project_name"], "项目甲")
self.assertEqual(len(data["project_packs"][0]["items"]), 2) # 场景 + 视频素材
+81
View File
@@ -40,6 +40,87 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
instance.status = Product.Status.ARCHIVED
instance.save(update_fields=["status", "updated_at"])
@action(detail=True, methods=["get"], url_path="materials")
def materials(self, request, pk=None):
"""商品详情素材区(结构化):
- roles:角色 = 立绘 + 配对三视图(triview_of),逐个;带「被几个项目引用」
- product_images:商品图,平铺
- image_products:模特上身图/平台套图/自由创作,平铺(各带类型)
- project_packs:场景/分镜图/视频素材,按视频项目打包
"""
from collections import defaultdict
from django.db.models import Q
from apps.assets.serializers import AssetSerializer
from apps.projects.models import BaseAssetGroup
product = self.get_object()
team = product.team
assets = list(
Asset.objects.filter(team=team, is_deleted=False)
.filter(
Q(metadata__product_id=str(product.id))
| Q(origin_task__project__product_id=product.id)
| Q(product_images__product_id=product.id)
)
.select_related("origin_task__project")
.prefetch_related("files")
.distinct()
)
def ser(a):
return AssetSerializer(a).data
def ref_count(asset):
return BaseAssetGroup.objects.filter(Q(adopted_asset=asset) | Q(candidate_assets=asset)).values("project").distinct().count()
# 三视图:tri_view 类 / 老数据(metadata.view=three_view 或名字含三视图)
def is_triview(a):
return a.category == "tri_view" or (a.metadata or {}).get("view") == "three_view" or "三视图" in (a.name or "")
triviews = [a for a in assets if is_triview(a)]
tv_by_portrait = {}
for t in triviews:
pid = str((t.metadata or {}).get("triview_of") or "")
if pid:
tv_by_portrait[pid] = t
triview_ids = {str(t.id) for t in triviews}
paired_tv_ids = {str(t.id) for t in tv_by_portrait.values()}
roles = []
# 立绘 = person/model_portrait 且不是三视图 → 配对其三视图
for p in assets:
if p.category not in ("person", "model_portrait") or str(p.id) in triview_ids:
continue
tv = tv_by_portrait.get(str(p.id))
roles.append({"id": str(p.id), "name": (p.name or "角色").split("·")[0].split("-")[0].strip() or "角色", "ref_count": ref_count(p), "portrait": ser(p), "triview": ser(tv) if tv else None})
# 孤儿三视图(没配到立绘)单独成角色卡
for t in triviews:
if str(t.id) in paired_tv_ids:
continue
roles.append({"id": str(t.id), "name": (t.name or "角色").split("·")[0].split("-")[0].strip() or "角色", "ref_count": ref_count(t), "portrait": ser(t), "triview": None})
product_images = [ser(a) for a in assets if a.category == "product_image"]
image_products = [ser(a) for a in assets if a.category in ("model_tryon", "platform_kit", "free_create")]
packs = {}
for a in assets:
if a.category not in ("scene", "storyboard", "video_clip"):
continue
proj = a.origin_task.project if (a.origin_task_id and a.origin_task.project_id) else None
key = str(proj.id) if proj else "_none"
if key not in packs:
packs[key] = {"project_id": (str(proj.id) if proj else None), "project_name": (proj.name if proj else "未归属项目"), "items": []}
packs[key]["items"].append(ser(a))
return Response({
"roles": roles,
"product_images": product_images,
"image_products": image_products,
"project_packs": list(packs.values()),
})
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已删除(archived)商品。"""