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:
@@ -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) # 场景 + 视频素材
|
||||
|
||||
@@ -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)商品。"""
|
||||
|
||||
Reference in New Issue
Block a user