From 1d40e894184e269706654114b0160989007d88e9 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 22 Jun 2026 13:34:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(product-detail):=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=8C=BA=E9=87=8D=E5=81=9A=E4=B8=BA=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=20=E2=80=94=20=E8=A7=92=E8=89=B2=E9=80=90=E4=B8=AA=20/=20?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=9B=BE=E5=B9=B3=E9=93=BA=20/=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=88=90=E5=93=81=E5=B9=B3=E9=93=BA=20/=20=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E5=88=86=E9=95=9C=E8=A7=86=E9=A2=91=E6=8C=89=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按"东西的性质"分,不再一锅炖: - 后端 /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 --- core/backend/apps/products/tests.py | 47 +++++++ core/backend/apps/products/views.py | 81 +++++++++++ core/frontend/src/api.ts | 4 + core/frontend/src/products-page.css | 8 ++ core/frontend/src/routes/products.tsx | 176 +++++++++++------------- core/frontend/src/types.ts | 8 ++ core/qa/visual-parity/_pd-materials.mjs | 20 +++ 7 files changed, 246 insertions(+), 98 deletions(-) create mode 100644 core/qa/visual-parity/_pd-materials.mjs diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 388b224..c39ec45 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -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) # 场景 + 视频素材 diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 05e0f3e..73e5817 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -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)商品。""" diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index dada15b..8c55b88 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -233,6 +233,10 @@ export const api = { // 软删 = 进垃圾桶(后端 status→archived,可恢复) return request(`/api/products/${id}/`, { method: "DELETE" }); }, + // 商品详情结构化素材(角色/商品图/图片成品/项目包) + productMaterials(id: string) { + return request(`/api/products/${id}/materials/`); + }, productsTrash() { return request>("/api/products/trash/"); }, diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index 4bc2407..4eeb3d3 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -104,3 +104,11 @@ body.edit-mode .bulk-bar { display: inline-flex; } .pd-group-thumb img, .pd-group-thumb video { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } .pd-group-item-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; } .pd-group-item-meta .date { font-size: 11px; color: var(--black-alpha-48); } + +/* ── 商品详情·结构化素材(角色逐个 / 项目包)── */ +.pd-mat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; } +.pd-role-card .thumb, .pd-pack-card .thumb { position: relative; } +/* 角色卡左下「N 个项目引用」白块 */ +.pd-role-foot .mono { font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; } +/* 角色卡「含三视图」小标(右下角) */ +.pd-role-tri { position: absolute; right: 8px; bottom: 8px; padding: 1px 7px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 10.5px; letter-spacing: .02em; } diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index b5e5901..cbe4c96 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -10,7 +10,7 @@ import { SkeletonGrid } from "../components/loading"; import { api } from "../api"; const PROD_PAGE_SIZE = 10; -import type { Asset, Product, Project } from "../types"; +import type { Asset, Product, ProductMaterials, Project } from "../types"; import type { NavigateFn, Page } from "./route-config"; import "../product-create-page.css"; @@ -634,8 +634,9 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na const [adoptedAssets, setAdoptedAssets] = useState([]); // 图片预览灯箱 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); - // 素材成组(A):点组卡 → 弹窗看这组所有素材 - const [openGroup, setOpenGroup] = useState(null); + // 结构化素材(角色逐个 / 商品图平铺 / 图片成品平铺 / 项目包);通用明细弹窗(角色看形象图+三视图、项目包看该项目素材) + const [materials, setMaterials] = useState(null); + const [openDetail, setOpenDetail] = useState<{ title: string; sub: string; assets: Asset[] } | null>(null); // 审核盾:提交后本地覆盖该资产状态 + 提交中标记 const [reviewOverrides, setReviewOverrides] = useState>({}); const [reviewSubmitting, setReviewSubmitting] = useState(null); @@ -657,10 +658,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na if (!product?.id) return; let alive = true; setAssetsLoading(true); - api.assetsPage({ product: product.id, pageSize: 200 }) - .then((res) => { if (alive) setProductAssets(res.results); }) - .catch(() => { if (alive) setProductAssets([]); }) - .finally(() => { if (alive) setAssetsLoading(false); }); + Promise.all([ + api.assetsPage({ product: product.id, pageSize: 200 }).catch(() => null), + api.productMaterials(product.id).catch(() => null) + ]).then(([assetsRes, matRes]) => { + if (!alive) return; + setProductAssets(assetsRes?.results ?? []); + setMaterials(matRes); + }).finally(() => { if (alive) setAssetsLoading(false); }); return () => { alive = false; }; }, [product?.id, assetReload]); @@ -765,11 +770,10 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na }); const assetCount = filteredAssets.length; const imageAssets = filteredAssets.slice(0, assetLimit); - // 成组(A):按类型把该商品素材归组,只留非空组,顺序按 PD_GROUPS;角色=形象图+三视图 - const pdGroups: PdGroup[] = [ - ...PD_GROUPS.map((g) => ({ key: g.key, label: g.label, assets: filteredAssets.filter((a) => g.cats.includes(a.category)) })), - { key: "other", label: "其他", assets: filteredAssets.filter((a) => !PD_GROUPED_CATS.has(a.category)) } - ].filter((g) => g.assets.length > 0); + // 结构化素材总数(角色 + 商品图 + 图片成品 + 项目包内素材),用于头部计数 / 空态判断 + const matTotal = materials + ? materials.roles.length + materials.product_images.length + materials.image_products.length + materials.project_packs.reduce((s, p) => s + p.items.length, 0) + : 0; const hasActiveAssetFilter = Boolean(typeFilter || statusFilter); const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); }; // 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序) @@ -1045,74 +1049,12 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
-
该商品 AI 素材 ({assetCount})
-
- -
-
{ setTypeFilter(""); setAssetLimit(12); setOpenFilter(""); }}> - 全部类型 -
- {typeOptions.length > 0 &&
} - {typeOptions.map((cat) => ( -
{ setTypeFilter(cat); setAssetLimit(12); setOpenFilter(""); }}> - {pdAssetTypeLabel({ category: cat, asset_type: "" } as Asset) || cat} -
- ))} -
-
- {/* 状态筛选 chip(通过/不通过/归档) */} -
- -
- {PD_STATUS_FILTER_OPTS.map((opt) => ( -
{ setStatusFilter(opt.value); setAssetLimit(12); setOpenFilter(""); }}> - {opt.label} -
- ))} -
-
-
- {/* 视图切换:网格 / 列表(黑激活) */} -
- - -
-
- -
-
{ setAssetSortDesc(true); setOpenFilter(""); }}> - 最新生成 -
-
{ setAssetSortDesc(false); setOpenFilter(""); }}> - 最早生成 -
-
-
-
+
该商品 AI 素材 ({matTotal})
- {assetsLoading && assetCount === 0 ? ( + {assetsLoading && !materials ? ( - ) : assetCount === 0 && hasActiveAssetFilter ? ( - // 有筛选但无结果 → 空筛选提示 + 重置入口(忠实移植 .empty-filter) -
- // 当前筛选下没有匹配的素材 - { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); resetAssetFilters(); } }}>重置筛选 -
- ) : assetCount === 0 ? ( + ) : matTotal === 0 ? (
@@ -1122,26 +1064,64 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
) : ( - // 成组(A):该商品素材按类型成组,点组卡 → 弹窗看这组所有素材 -
- {pdGroups.map((g) => { - const first = g.assets[0]; +
+ {/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */} + {materials!.roles.map((role) => { + const cover = pdAssetPreview(role.portrait); + return ( +
setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}> +
+ {cover ? {role.name} : 角色} + 角色 + {role.triview && 含三视图} +
+
{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}
+
+ ); + })} + {/* 商品图:平铺 */} + {materials!.product_images.map((a) => { + const u = pdAssetPreview(a); + return ( +
+
setPreview({ src: u, name: a.name }) : undefined}> + {u ? {a.name} : 商品图} + 商品图 +
+
{(a.created_at || "").slice(0, 10)}
+
+ ); + })} + {/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */} + {materials!.image_products.map((a) => { + const u = pdAssetPreview(a); + return ( +
+
setPreview({ src: u, name: a.name }) : undefined}> + {u ? {a.name} : {pdAssetTypeLabel(a)}} + {pdAssetTypeLabel(a)} +
+
{(a.created_at || "").slice(0, 10)}
+
+ ); + })} + {/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */} + {materials!.project_packs.map((pack) => { + const first = pack.items[0]; const cover = pdAssetPreview(first); const isVid = first?.asset_type === "video"; return ( -
setOpenGroup(g)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpenGroup(g); } }}> +
setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
- {isVid && cover ? ( -
-
{g.label} · {g.assets.length} 个
+
{pack.project_name}
); })} @@ -1174,20 +1154,20 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na setPreview(null)} /> {/* 素材组弹窗:看这一组的所有素材(点缩图放大;送审类显审核态)*/} - {openGroup && createPortal( -
{ if (e.target === e.currentTarget) setOpenGroup(null); }}> + {openDetail && createPortal( +
{ if (e.target === e.currentTarget) setOpenDetail(null); }}>
-

{openGroup.label}

- // 该商品 · {openGroup.assets.length} 个 +

{openDetail.title}

+ // {openDetail.sub}
-
- {openGroup.assets.map((a) => { + {openDetail.assets.map((a) => { const u = pdAssetPreview(a); const isV = a.asset_type === "video"; return ( diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 5b8d2b8..7ad705d 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -256,6 +256,14 @@ export type VideoPack = { clips: Array<{ id: string; name: string; url: string }>; }; +// 商品详情结构化素材:角色逐个(形象图+三视图绑一起+引用数)/ 商品图平铺 / 图片成品平铺 / 场景分镜视频按项目打包 +export type ProductMaterials = { + roles: Array<{ id: string; name: string; ref_count: number; portrait: Asset; triview: Asset | null }>; + product_images: Asset[]; + image_products: Asset[]; + project_packs: Array<{ project_id: string | null; project_name: string; items: Asset[] }>; +}; + export type ProjectStage = { id: string; stage: string; diff --git a/core/qa/visual-parity/_pd-materials.mjs b/core/qa/visual-parity/_pd-materials.mjs new file mode 100644 index 0000000..9a245b7 --- /dev/null +++ b/core/qa/visual-parity/_pd-materials.mjs @@ -0,0 +1,20 @@ +import { chromium } from "playwright"; +import path from "node:path"; import fs from "node:fs"; +const PID="53d7a7c3-2f41-431b-82d3-3acc97900310"; +const OUT=path.resolve("../../../_qa_shots/pd"); fs.mkdirSync(OUT,{recursive:true}); +const b=await chromium.launch({headless:true}); const r={consoleErrors:[]}; +const p=await (await b.newContext({viewport:{width:1440,height:1000}})).newPage(); +p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())}); p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message)); +await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300); +await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026"); +await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000}); +await p.goto("http://localhost:5200/products/"+PID); await p.waitForTimeout(3000); +r.roleCards=await p.locator(".pd-role-card").count(); +r.roleFoot=await p.locator(".pd-role-card .pd-role-foot .mono").allInnerTexts().catch(()=>[]); +r.packCards=await p.locator(".pd-pack-card").count(); +r.flatCards=await p.locator(".pd-mat-grid .asset-card:not(.pd-role-card):not(.pd-pack-card)").count(); +r.typePills=await p.locator(".pd-mat-grid .type-pill").allInnerTexts().catch(()=>[]); +await p.screenshot({path:path.join(OUT,"pd-materials-v2.png"),fullPage:true}); +// 点角色卡看弹窗 +if(r.roleCards>0){await p.locator(".pd-role-card").first().click();await p.waitForTimeout(1000);r.roleModalItems=await p.locator(".pd-group-item").count();await p.screenshot({path:path.join(OUT,"pd-role-modal.png")});} +await b.close(); console.log(JSON.stringify(r,null,2));