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)商品。"""
|
||||
|
||||
@@ -233,6 +233,10 @@ export const api = {
|
||||
// 软删 = 进垃圾桶(后端 status→archived,可恢复)
|
||||
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
// 商品详情结构化素材(角色/商品图/图片成品/项目包)
|
||||
productMaterials(id: string) {
|
||||
return request<import("./types").ProductMaterials>(`/api/products/${id}/materials/`);
|
||||
},
|
||||
productsTrash() {
|
||||
return request<Paginated<Product>>("/api/products/trash/");
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<Asset[]>([]);
|
||||
// 图片预览灯箱
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
// 素材成组(A):点组卡 → 弹窗看这组所有素材
|
||||
const [openGroup, setOpenGroup] = useState<PdGroup | null>(null);
|
||||
// 结构化素材(角色逐个 / 商品图平铺 / 图片成品平铺 / 项目包);通用明细弹窗(角色看形象图+三视图、项目包看该项目素材)
|
||||
const [materials, setMaterials] = useState<ProductMaterials | null>(null);
|
||||
const [openDetail, setOpenDetail] = useState<{ title: string; sub: string; assets: Asset[] } | null>(null);
|
||||
// 审核盾:提交后本地覆盖该资产状态 + 提交中标记
|
||||
const [reviewOverrides, setReviewOverrides] = useState<Record<string, string>>({});
|
||||
const [reviewSubmitting, setReviewSubmitting] = useState<string | null>(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
|
||||
<div className={`tab-pane${tab === "assets" ? " active" : ""}`} data-pane="assets">
|
||||
|
||||
<div className="pd-toolbar">
|
||||
<div className="total">该商品 AI 素材 <span className="ct">({assetCount})</span></div>
|
||||
<div className={`chip-wrap${openFilter === "type" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="type">
|
||||
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "type" ? "" : "type"))}>
|
||||
{typeFilter ? (pdAssetTypeLabel({ category: typeFilter, asset_type: "" } as Asset) || typeFilter) : "全部类型"}
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setAssetLimit(12); setOpenFilter(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
|
||||
</div>
|
||||
{typeOptions.length > 0 && <div className="mi-sep" />}
|
||||
{typeOptions.map((cat) => (
|
||||
<div className={`mi${typeFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setTypeFilter(cat); setAssetLimit(12); setOpenFilter(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{pdAssetTypeLabel({ category: cat, asset_type: "" } as Asset) || cat}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 状态筛选 chip(通过/不通过/归档) */}
|
||||
<div className={`chip-wrap${openFilter === "status" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="status">
|
||||
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "status" ? "" : "status"))}>
|
||||
{PD_STATUS_FILTER_OPTS.find((o) => o.value === statusFilter)?.label || "全部状态"}
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{PD_STATUS_FILTER_OPTS.map((opt) => (
|
||||
<div className={`mi${statusFilter === opt.value ? " selected" : ""}`} key={opt.value || "all"} role="button" tabIndex={0} onClick={() => { setStatusFilter(opt.value); setAssetLimit(12); setOpenFilter(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
{/* 视图切换:网格 / 列表(黑激活) */}
|
||||
<div className="view-tog" role="group" aria-label="素材视图切换">
|
||||
<button type="button" className={assetView === "grid" ? "active" : ""} aria-pressed={assetView === "grid"} title="网格视图" onClick={() => setAssetView("grid")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
|
||||
</button>
|
||||
<button type="button" className={assetView === "list" ? "active" : ""} aria-pressed={assetView === "list"} title="列表视图" onClick={() => setAssetView("list")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={`chip-wrap${openFilter === "sort" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="sort">
|
||||
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}>
|
||||
{assetSortDesc ? "最新生成" : "最早生成"}
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-right">
|
||||
<div className={`mi${assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(true); setOpenFilter(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最新生成
|
||||
</div>
|
||||
<div className={`mi${!assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(false); setOpenFilter(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最早生成
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="total">该商品 AI 素材 <span className="ct">({matTotal})</span></div>
|
||||
</div>
|
||||
|
||||
{assetsLoading && assetCount === 0 ? (
|
||||
{assetsLoading && !materials ? (
|
||||
<SkeletonGrid count={6} />
|
||||
) : assetCount === 0 && hasActiveAssetFilter ? (
|
||||
// 有筛选但无结果 → 空筛选提示 + 重置入口(忠实移植 .empty-filter)
|
||||
<div className="empty-filter">
|
||||
// 当前筛选下没有匹配的素材
|
||||
<span className="reset" role="button" tabIndex={0} onClick={resetAssetFilters} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); resetAssetFilters(); } }}>重置筛选</span>
|
||||
</div>
|
||||
) : assetCount === 0 ? (
|
||||
) : matTotal === 0 ? (
|
||||
<div className="empty-state show pd-empty-assets">
|
||||
<div className="ic-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||
@@ -1122,26 +1064,64 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||
</div>
|
||||
) : (
|
||||
// 成组(A):该商品素材按类型成组,点组卡 → 弹窗看这组所有素材
|
||||
<div className="asset-grid pd-group-grid">
|
||||
{pdGroups.map((g) => {
|
||||
const first = g.assets[0];
|
||||
<div className="asset-grid pd-mat-grid">
|
||||
{/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */}
|
||||
{materials!.roles.map((role) => {
|
||||
const cover = pdAssetPreview(role.portrait);
|
||||
return (
|
||||
<div className="asset-card pd-role-card" key={`role-${role.id}`} role="button" tabIndex={0} title={role.name}
|
||||
onClick={() => 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[] }); } }}>
|
||||
<div className="thumb placeholder">
|
||||
{cover ? <img src={cover} alt={role.name} loading="lazy" /> : <span className="ph-frame">角色</span>}
|
||||
<span className="type-pill">角色</span>
|
||||
{role.triview && <span className="pd-role-tri mono">含三视图</span>}
|
||||
</div>
|
||||
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 商品图:平铺 */}
|
||||
{materials!.product_images.map((a) => {
|
||||
const u = pdAssetPreview(a);
|
||||
return (
|
||||
<div className="asset-card" key={a.id}>
|
||||
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
|
||||
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">商品图</span>}
|
||||
<span className="type-pill">商品图</span>
|
||||
</div>
|
||||
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */}
|
||||
{materials!.image_products.map((a) => {
|
||||
const u = pdAssetPreview(a);
|
||||
return (
|
||||
<div className="asset-card" key={a.id}>
|
||||
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
|
||||
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">{pdAssetTypeLabel(a)}</span>}
|
||||
<span className="type-pill">{pdAssetTypeLabel(a)}</span>
|
||||
</div>
|
||||
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */}
|
||||
{materials!.project_packs.map((pack) => {
|
||||
const first = pack.items[0];
|
||||
const cover = pdAssetPreview(first);
|
||||
const isVid = first?.asset_type === "video";
|
||||
return (
|
||||
<div className="asset-card pd-group-card" key={g.key} role="button" tabIndex={0} title={`${g.label} · ${g.assets.length} 个`} onClick={() => setOpenGroup(g)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpenGroup(g); } }}>
|
||||
<div className="asset-card pd-pack-card" key={`pack-${pack.project_id || pack.project_name}`} role="button" tabIndex={0} title={pack.project_name}
|
||||
onClick={() => 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 }); } }}>
|
||||
<div className="thumb placeholder">
|
||||
{isVid && cover ? (
|
||||
<video src={cover} muted playsInline preload="metadata" />
|
||||
) : cover ? (
|
||||
<img src={cover} alt={g.label} loading="lazy" />
|
||||
) : (
|
||||
<span className="ph-frame">{g.label}</span>
|
||||
)}
|
||||
<span className="type-pill">{g.label}</span>
|
||||
<span className="pd-group-count mono">{g.assets.length}</span>
|
||||
{isVid && cover ? <video src={cover} muted playsInline preload="metadata" /> : cover ? <img src={cover} alt={pack.project_name} loading="lazy" /> : <span className="ph-frame">视频项目</span>}
|
||||
<span className="type-pill">视频项目</span>
|
||||
<span className="pd-group-count mono">{pack.items.length}</span>
|
||||
</div>
|
||||
<div className="meta"><span className="date">{g.label} · {g.assets.length} 个</span></div>
|
||||
<div className="meta"><span className="date">{pack.project_name}</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1174,20 +1154,20 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
{/* 素材组弹窗:看这一组的所有素材(点缩图放大;送审类显审核态)*/}
|
||||
{openGroup && createPortal(
|
||||
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label={`${openGroup.label}素材组`} onClick={(e) => { if (e.target === e.currentTarget) setOpenGroup(null); }}>
|
||||
{openDetail && createPortal(
|
||||
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label={openDetail.title} onClick={(e) => { if (e.target === e.currentTarget) setOpenDetail(null); }}>
|
||||
<div className="pack-modal">
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openGroup.label}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 该商品 · {openGroup.assets.length} 个</span>
|
||||
<h2>{openDetail.title}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openDetail.sub}</span>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenGroup(null)}>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenDetail(null)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pd-group-modal-grid">
|
||||
{openGroup.assets.map((a) => {
|
||||
{openDetail.assets.map((a) => {
|
||||
const u = pdAssetPreview(a);
|
||||
const isV = a.asset_type === "video";
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user