From 9108b7679d1a6f0fc7d3a12f21a2138cfbe7f8d7 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 21 Jun 2026 22:20:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(product-detail):=20AI=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=88=90=E7=BB=84(A)=E2=80=94=20=E6=8C=89=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=BD=92=E7=BB=84,=E7=82=B9=E7=BB=84=E5=8D=A1=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E7=9C=8B=E6=95=B4=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商品详情「AI 生成素材」从一堆散卡(单拎着)改为按类型成组:角色(形象图+三视图)/ 商品图 / 模特上身图 / 平台套图 / 自由创作 / 场景 / 分镜图 / 视频素材 各一组卡(带数量角标), 点组卡 → 弹窗看这组所有素材(缩图可放大;送审类才显审核态)。去掉「加载更多」(组少不分页)。 验收:tsc+build 绿;无头核对(2 组卡 角色/商品图、点开弹窗显组内素材 + 审核态、0 console error) Co-Authored-By: Claude Opus 4.8 --- core/frontend/src/products-page.css | 9 +++ core/frontend/src/routes/products.tsx | 95 +++++++++++++++++++++------ core/qa/visual-parity/_pd-groups.mjs | 21 ++++++ 3 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 core/qa/visual-parity/_pd-groups.mjs diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index 6d72092..4bc2407 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -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); } diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index a7cb6b5..a0f0dae 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -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 = { 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([]); // 图片预览灯箱 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); + // 素材成组(A):点组卡 → 弹窗看这组所有素材 + const [openGroup, setOpenGroup] = useState(null); // ── 该商品的 AI 素材:服务端按 ?product 懒加载(不再吃全局 assets 全量数组)── const [productAssets, setProductAssets] = useState([]); @@ -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 ) : ( -
- {imageAssets.map((asset) => { - const url = pdAssetPreview(asset); - const status = statusOf(asset); + // 成组(A):该商品素材按类型成组,点组卡 → 弹窗看这组所有素材 +
+ {pdGroups.map((g) => { + const first = g.assets[0]; + const cover = pdAssetPreview(first); + const isVid = first?.asset_type === "video"; return ( -
-
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 ? {asset.name} : null} - {pdAssetTypeLabel(asset)} - {url ? null : 3:4} -
-
- {/* 审核状态(只读):仅视频流程人脸三类(角色/三视图/分镜)才真送火山审核 → 才显审核态; - 其余素材无需审核,不显(此前一律标「待审核」是误导)。 */} - {PD_REVIEW_CATS.has(asset.category) && ( - {PD_ASSET_STATUS_LABEL[status]} +
setOpenGroup(g)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpenGroup(g); } }}> +
+ {isVid && cover ? ( +
+
{g.label} · {g.assets.length} 个
); })}
)} - - {imageAssets.length < filteredAssets.length && ( -
- )}
{/* ===== 视频项目 ===== */} @@ -1141,6 +1160,42 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na setPreview(null)} /> + {/* 素材组弹窗:看这一组的所有素材(点缩图放大;送审类显审核态)*/} + {openGroup && createPortal( +
{ if (e.target === e.currentTarget) setOpenGroup(null); }}> +
+
+
+

{openGroup.label}

+ // 该商品 · {openGroup.assets.length} 个 +
+ +
+
+ {openGroup.assets.map((a) => { + const u = pdAssetPreview(a); + const isV = a.asset_type === "video"; + const st = statusOf(a); + return ( +
+
setPreview({ src: u, name: a.name }) : undefined}> + {isV && u ?
+
+ {PD_REVIEW_CATS.has(a.category) && {PD_ASSET_STATUS_LABEL[st]}} + {(a.created_at || "").slice(0, 10)} +
+
+ ); + })} +
+
+
, + document.body + )} + ); } diff --git a/core/qa/visual-parity/_pd-groups.mjs b/core/qa/visual-parity/_pd-groups.mjs new file mode 100644 index 0000000..ab28f02 --- /dev/null +++ b/core/qa/visual-parity/_pd-groups.mjs @@ -0,0 +1,21 @@ +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.groupCards=await p.locator(".pd-group-card").count(); +r.groups=await p.locator(".pd-group-card .type-pill").allInnerTexts().catch(()=>[]); +await p.screenshot({path:path.join(OUT,"pd-grouped.png"),fullPage:true}); +if(r.groupCards>0){ + await p.locator(".pd-group-card").first().click(); await p.waitForTimeout(1200); + r.modalOpen=await p.locator(".pack-modal").count(); + r.modalItems=await p.locator(".pd-group-item").count(); + await p.screenshot({path:path.join(OUT,"pd-group-modal.png")}); +} +await b.close(); console.log(JSON.stringify(r,null,2));