|
|
|
@@ -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 (
|
|
|
|
|