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
+4
View File
@@ -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/");
},
+8
View File
@@ -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; }
+78 -98
View File
@@ -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 (
+8
View File
@@ -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;