feat(product-detail): AI素材成组(A)— 按类型归组,点组卡弹窗看整组
商品详情「AI 生成素材」从一堆散卡(单拎着)改为按类型成组:角色(形象图+三视图)/ 商品图 / 模特上身图 / 平台套图 / 自由创作 / 场景 / 分镜图 / 视频素材 各一组卡(带数量角标), 点组卡 → 弹窗看这组所有素材(缩图可放大;送审类才显审核态)。去掉「加载更多」(组少不分页)。 验收:tsc+build 绿;无头核对(2 组卡 角色/商品图、点开弹窗显组内素材 + 审核态、0 console error) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -95,3 +95,12 @@ body.edit-mode .bulk-bar { display: inline-flex; }
|
||||
.bulk-bar .clear-sel:hover { color: var(--accent-white); }
|
||||
|
||||
.btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
|
||||
|
||||
/* ── 商品详情·素材成组(A) ── */
|
||||
.pd-group-card .thumb { position: relative; }
|
||||
.pd-group-card .pd-group-count { position: absolute; right: 8px; top: 8px; padding: 1px 7px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 11px; letter-spacing: .02em; }
|
||||
.pd-group-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 18px 20px; overflow-y: auto; }
|
||||
.pd-group-thumb { aspect-ratio: 3 / 4; border-radius: var(--r-sm); position: relative; overflow: hidden; }
|
||||
.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); }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { ArrowLeft, Trash2, X } from "lucide-react";
|
||||
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
@@ -562,6 +563,20 @@ const PD_ASSET_TYPE_LABEL: Record<string, string> = {
|
||||
final_video: "最终成片",
|
||||
background: "背景图"
|
||||
};
|
||||
// 商品素材按类型归组(A:成品 + 中间产物都留,分组展示;角色 = 形象图 + 三视图)
|
||||
const PD_GROUPS: Array<{ key: string; label: string; cats: string[] }> = [
|
||||
{ key: "role", label: "角色", cats: ["person", "model_portrait", "tri_view"] },
|
||||
{ key: "product", label: "商品图", cats: ["product_image"] },
|
||||
{ key: "tryon", label: "模特上身图", cats: ["model_tryon"] },
|
||||
{ key: "kit", label: "平台套图", cats: ["platform_kit"] },
|
||||
{ key: "free", label: "自由创作", cats: ["free_create"] },
|
||||
{ key: "scene", label: "场景", cats: ["scene"] },
|
||||
{ key: "storyboard", label: "分镜图", cats: ["storyboard"] },
|
||||
{ key: "clip", label: "视频素材", cats: ["video_clip"] }
|
||||
];
|
||||
const PD_GROUPED_CATS = new Set(PD_GROUPS.flatMap((g) => g.cats));
|
||||
type PdGroup = { key: string; label: string; assets: Asset[] };
|
||||
|
||||
function pdAssetTypeLabel(asset: Asset): string {
|
||||
return PD_ASSET_TYPE_LABEL[asset.category] || PD_ASSET_TYPE_LABEL[asset.asset_type] || asset.category || "素材";
|
||||
}
|
||||
@@ -618,6 +633,8 @@ 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);
|
||||
|
||||
// ── 该商品的 AI 素材:服务端按 ?product 懒加载(不再吃全局 assets 全量数组)──
|
||||
const [productAssets, setProductAssets] = useState<Asset[]>([]);
|
||||
@@ -735,6 +752,11 @@ 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 hasActiveAssetFilter = Boolean(typeFilter || statusFilter);
|
||||
const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); };
|
||||
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
|
||||
@@ -1087,34 +1109,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`asset-grid${assetView === "list" ? " list-view" : ""}`}>
|
||||
{imageAssets.map((asset) => {
|
||||
const url = pdAssetPreview(asset);
|
||||
const status = statusOf(asset);
|
||||
// 成组(A):该商品素材按类型成组,点组卡 → 弹窗看这组所有素材
|
||||
<div className="asset-grid pd-group-grid">
|
||||
{pdGroups.map((g) => {
|
||||
const first = g.assets[0];
|
||||
const cover = pdAssetPreview(first);
|
||||
const isVid = first?.asset_type === "video";
|
||||
return (
|
||||
<div className="asset-card" key={asset.id}>
|
||||
<div className="thumb placeholder" role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} style={url ? { cursor: "zoom-in" } : undefined} onClick={url ? () => setPreview({ src: url, name: asset.name }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, name: asset.name }); } } : undefined}>
|
||||
{url ? <img src={url} alt={asset.name} loading="lazy" /> : null}
|
||||
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
|
||||
{url ? null : <span className="ph-frame">3:4</span>}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{/* 审核状态(只读):仅视频流程人脸三类(角色/三视图/分镜)才真送火山审核 → 才显审核态;
|
||||
其余素材无需审核,不显(此前一律标「待审核」是误导)。 */}
|
||||
{PD_REVIEW_CATS.has(asset.category) && (
|
||||
<span className={`pill ${status}`} data-status={status} title="火山人像审核状态(仅含人脸的视频素材送审)">{PD_ASSET_STATUS_LABEL[status]}</span>
|
||||
<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="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="date">{(asset.created_at || "").slice(0, 10)}</span>
|
||||
<span className="type-pill">{g.label}</span>
|
||||
<span className="pd-group-count mono">{g.assets.length}</span>
|
||||
</div>
|
||||
<div className="meta"><span className="date">{g.label} · {g.assets.length} 个</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageAssets.length < filteredAssets.length && (
|
||||
<div className="pd-more"><button type="button" onClick={() => setAssetLimit((n) => n + 12)}>加载更多({filteredAssets.length - imageAssets.length})</button></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 视频项目 ===== */}
|
||||
@@ -1141,6 +1160,42 @@ 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); }}>
|
||||
<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>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenGroup(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) => {
|
||||
const u = pdAssetPreview(a);
|
||||
const isV = a.asset_type === "video";
|
||||
const st = statusOf(a);
|
||||
return (
|
||||
<div className="pd-group-item" key={a.id}>
|
||||
<div className="pd-group-thumb placeholder" role={u && !isV ? "button" : undefined} style={u && !isV ? { cursor: "zoom-in" } : undefined} onClick={u && !isV ? () => setPreview({ src: u, name: a.name }) : undefined}>
|
||||
{isV && u ? <video src={u} controls muted playsInline preload="metadata" /> : u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
</div>
|
||||
<div className="pd-group-item-meta">
|
||||
{PD_REVIEW_CATS.has(a.category) && <span className={`pill ${st}`} data-status={st}>{PD_ASSET_STATUS_LABEL[st]}</span>}
|
||||
<span className="date mono">{(a.created_at || "").slice(0, 10)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user