perf(core): 全站资产改服务端懒加载,撤掉 bootstrap 全量 allAssets

不再在启动时一次性拉全部资产(数据多时随之变慢/串多页),各页按需服务端分页/过滤:
- 生图(ImageWorkbench 模特库按 category=person、AssetFactory 按 ai 生成图)页面内懒加载。
- 商品库/项目向导封面图改用后端内嵌 cover_preview_url / images[].preview_url,不再反查全局 assets。
- 商品详情 AI 素材按 ?product 懒加载(上传/生成/采用三视图后刷新)。
- 工作台资产总数走轻量 /assets/summary/。
- App bootstrap 删除 api.allAssets() 与全局 assets state;loadData 仅留必要全局数据。
- 后端 ?product 过滤并集补上 ProductImage 关联资产,等价前端原 belongsToProduct。
- pipeline 用项目详情已内嵌的 *_url 显示,调用处传空 assets(不再依赖全局全量)。

闸验证:9 页功能正常、无分页瀑布、无接口报错;首屏不再含资产全量拉取。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 18:25:05 +08:00
co-authored by Claude Opus 4.8
parent 59ab3c0404
commit 2f6d3f5823
7 changed files with 92 additions and 71 deletions
+8 -2
View File
@@ -79,8 +79,14 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
if p.get("source"):
qs = qs.filter(source=p["source"])
if p.get("product"):
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product
qs = qs.filter(Q(metadata__product_id=p["product"]) | Q(origin_task__project__product_id=p["product"]))
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
pid = p["product"]
qs = qs.filter(
Q(metadata__product_id=pid)
| Q(origin_task__project__product_id=pid)
| Q(product_images__product_id=pid)
).distinct()
if p.get("q"):
qs = qs.filter(Q(name__icontains=p["q"]) | Q(description__icontains=p["q"]))
for key, val in p.items():
+10 -16
View File
@@ -102,7 +102,6 @@ export function App() {
const [team, setTeam] = useState<Team | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
@@ -139,11 +138,11 @@ export function App() {
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
// 资产不再全局取全:各页按需服务端分页懒加载(library/生图/商品详情/dashboard 自取)。
const [productData, projectData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
await Promise.all([
api.products(),
api.projects(),
api.allAssets(),
api.billingSummary().catch(() => null),
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
api.billingTrend().catch(() => null),
@@ -154,7 +153,6 @@ export function App() {
]);
setProducts(productData.results);
setProjects(projectData.results);
setAssets(assetData);
setTeamMembers(memberData);
setModelConfigs(modelData?.results || []);
setAiTasks(taskData?.results || []);
@@ -564,13 +562,12 @@ export function App() {
function renderPage() {
switch (page) {
case "dashboard":
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} userName={currentUser.username} navigate={navigate} />;
return <Dashboard products={products} projects={projects} billing={billing} userName={currentUser.username} navigate={navigate} />;
case "products":
return (
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
@@ -585,7 +582,6 @@ export function App() {
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
@@ -595,12 +591,11 @@ export function App() {
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} assets={assets} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
assets={assets}
initialTab={route.hash === "videos" ? "videos" : "assets"}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
@@ -626,7 +621,6 @@ export function App() {
<ProjectWizardPage
products={products}
projects={projects}
assets={assets}
preselectProductId={activeProductId}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
@@ -699,13 +693,13 @@ export function App() {
/>
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
@@ -715,7 +709,7 @@ export function App() {
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
return <Dashboard products={products} projects={projects} billing={billing} navigate={navigate} />;
}
}
@@ -740,7 +734,7 @@ export function App() {
team={currentTeam}
products={products}
projects={projects}
assets={assets}
assets={[]}
billing={billing}
notice={notice}
unreadCount={unreadCount}
+21 -6
View File
@@ -22,6 +22,7 @@ import {
X
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import { api } from "../api";
import { MediaLightbox, SuccessModal } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
@@ -80,8 +81,17 @@ async function downloadImage(url: string, filename: string) {
}
}
export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate: (page: Page) => void; aiTasks: AITask[]; assets?: Asset[] }) {
// 任务 → 生成结果图:按 asset.origin_task 关联,取首张有预览 URL 的图片文件(脚本/视频任务无图则留占位)
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
// 任务 → 生成结果图:按 asset.origin_task 关联取缩略图。服务端懒加载最近的 AI 生成图(不再吃全局 assets 全量),
// 覆盖近 200 张生成图的任务卡缩略图;更早的任务卡留占位。
const [assets, setAssets] = useState<Asset[]>([]);
useEffect(() => {
let alive = true;
api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 })
.then((res) => { if (alive) setAssets(res.results); })
.catch(() => { if (alive) setAssets([]); });
return () => { alive = false; };
}, []);
const taskImage = useMemo(() => {
const map: Record<string, string> = {};
for (const asset of assets) {
@@ -471,7 +481,6 @@ type GenBatch = {
export function ImageWorkbenchPage({
mode,
products,
assets,
modelConfigs,
onBack,
navigate,
@@ -481,7 +490,6 @@ export function ImageWorkbenchPage({
}: {
mode: WorkMode;
products: Product[];
assets: Asset[];
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
@@ -523,9 +531,16 @@ export function ImageWorkbenchPage({
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
/* 模特卡数据来源:assets 中 category==='person' 的真资产(显真图 preview_url + name);
/* 模特卡数据来源:服务端按 category=person 懒加载(不再吃全局 assets 全量数组);
无则回退到基线占位模特卡 Ava/Luna/Mia/Zoe */
const personAssets = useMemo(() => assets.filter((item) => item.category === "person"), [assets]);
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
useEffect(() => {
let alive = true;
api.assetsPage({ category: "person", pageSize: 200 })
.then((res) => { if (alive) setPersonAssets(res.results); })
.catch(() => { if (alive) setPersonAssets([]); });
return () => { alive = false; };
}, []);
useEffect(() => {
if (product) setPrompt(meta.promptTemplate(product.title));
+14 -4
View File
@@ -1,17 +1,27 @@
import type { Asset, BillingSummary, Product, Project } from "../types";
import { useEffect, useState } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { Page } from "./route-config";
import { api } from "../api";
import { money, stageMeta, statusPill } from "./stage-config";
import { Progress } from "../components/pipeline-stage";
import { IconKitSvg } from "../components/IconKitSvg";
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
export function Dashboard({ products, projects, billing, userName, navigate }: {
products: Product[];
projects: Project[];
assets: Asset[];
billing: BillingSummary | null;
userName?: string;
navigate: (page: Page) => void;
}) {
// 资产总数:走轻量 summary 接口(各 tab 计数之和),不再吃全局 assets 全量数组
const [assetCount, setAssetCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.assetSummary()
.then((c) => { if (alive) setAssetCount(Object.values(c).reduce((a, b) => a + b, 0)); })
.catch(() => {});
return () => { alive = false; };
}, []);
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
@@ -34,7 +44,7 @@ export function Dashboard({ products, projects, assets, billing, userName, navig
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub"> {money(billing?.account.reserved_balance)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assets.length} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projects.length} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projects.length} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
</>
);
}
+32 -28
View File
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { ArrowLeft, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays";
import { Pager } from "../components/pager";
import { api } from "../api";
const PROD_PAGE_SIZE = 10;
import type { Asset, Product, Project } from "../types";
@@ -38,19 +39,15 @@ function productCover(name: string): string {
}
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
// 卡片真图:优先 cover_asset,其次首张商品图的 asset,反查团队 assets 的真 preview_url
function resolveCoverUrl(product: Product, assetById: Map<string, Asset>): string {
// 卡片真图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets。
function resolveCoverUrl(product: Product): string {
const firstImage = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
const id = product.cover_asset || firstImage?.asset;
if (!id) return "";
const asset = assetById.get(id);
return asset?.files?.find((file) => file.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || "";
return product.cover_preview_url || firstImage?.preview_url || "";
}
export function ProductsPage({ products, projects = [], assets = [], navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: {
export function ProductsPage({ products, projects = [], navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: {
products: Product[];
projects?: Project[];
assets?: Asset[];
navigate: (page: Page) => void;
openProduct: (productId: string, tab?: "assets" | "videos") => void;
onCreate: (payload: ProductPayload) => Promise<Product | null | undefined> | void;
@@ -152,8 +149,6 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o
return matchQuery && matchCat && matchDate;
});
// 分页:每页 10 个,搜索/筛选变化回第 1 页
// 卡片真图:把团队 assets 做 id→asset 映射,卡片用 cover_asset / 首图 asset 反查真 preview_url
const assetById = useMemo(() => new Map(assets.map((asset) => [asset.id, asset])), [assets]);
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
@@ -276,7 +271,7 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o
<ProductCard
key={product.id}
product={product}
coverUrl={resolveCoverUrl(product, assetById)}
coverUrl={resolveCoverUrl(product)}
videoCount={projects.filter((p) => p.product === product.id).length}
editMode={editMode}
selected={selected.has(product.id)}
@@ -659,10 +654,9 @@ function pdProjStatusLabel(project: Project) {
}
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
export function ProductDetailPage({ product, projects, assets, initialTab = "assets", navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages, onAdoptTriView }: {
export function ProductDetailPage({ product, projects, initialTab = "assets", navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages, onAdoptTriView }: {
product: Product;
projects: Project[];
assets: Asset[];
initialTab?: "assets" | "videos";
navigate: NavigateFn;
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
@@ -695,6 +689,18 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
// 图片预览灯箱
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// ── 该商品的 AI 素材:服务端按 ?product 懒加载(不再吃全局 assets 全量数组)──
const [productAssets, setProductAssets] = useState<Asset[]>([]);
const [assetReload, setAssetReload] = useState(0);
useEffect(() => {
if (!product?.id) return;
let alive = true;
api.assetsPage({ product: product.id, pageSize: 200 })
.then((res) => { if (alive) setProductAssets(res.results); })
.catch(() => { if (alive) setProductAssets([]); });
return () => { alive = false; };
}, [product?.id, assetReload]);
useEffect(() => {
if (!openFilter) return;
const close = (event: MouseEvent) => {
@@ -714,6 +720,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
fd.append("file", file);
fd.append("name", `${product.title || "商品"}-图${(product.images?.length || 0) + 1}`);
await onUploadImage(fd);
setAssetReload((n) => n + 1); // 上传后刷新该商品素材
} finally {
setUploading(false);
}
@@ -739,6 +746,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
// 新版本置顶进历史并切到该版本查看(采用需手动点「采用」)
setTriVersions((list) => [version, ...list]);
setTriViewingId(version.id);
setAssetReload((n) => n + 1); // 生成后刷新该商品素材
}
} finally {
setTriGenerating(false);
@@ -748,26 +756,22 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
function adoptTriView() {
if (!triViewing) return;
setTriAdoptedId(triViewing.id);
if (triViewing.asset && onAdoptTriView) void onAdoptTriView(triViewing.asset);
if (triViewing.asset && onAdoptTriView) void Promise.resolve(onAdoptTriView(triViewing.asset)).then(() => setAssetReload((n) => n + 1));
}
// 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重)
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
// 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const imageIds = imageRefs.map((ref) => ref.asset);
if (product.cover_asset && !imageIds.includes(product.cover_asset)) imageIds.unshift(product.cover_asset);
const productImages = imageIds
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
const productImages: Array<{ id: string; url: string }> = [];
if (product.cover_asset && !imageRefs.some((ref) => ref.asset === product.cover_asset)) {
productImages.push({ id: product.cover_asset, url: product.cover_preview_url || "" });
}
for (const ref of imageRefs) productImages.push({ id: ref.asset, url: ref.preview_url || "" });
// AI 生成素材 · 只取「该商品」的 AI 素材,而非全团队资产。归属由后端解析的 asset.product 给出
// (独立生图记 metadata.product_id;项目内生成的图回溯 origin_task→project→product,历史图也能归位);
// 再纳入该商品上传的商品图(product.images 的 asset)。
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
const productImageIds = new Set(imageIds);
const belongsToProduct = (asset: Asset) =>
asset.product === product.id || productImageIds.has(asset.id);
const allImageAssets = assets.filter(
(asset) => belongsToProduct(asset) && (AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image")
const allImageAssets = productAssets.filter(
(asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image"
);
// 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
+5 -14
View File
@@ -28,11 +28,9 @@ type WizProductPayload = {
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
export function ProjectWizardPage({ products, projects = [], assets = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
products: Product[];
projects?: Project[];
// 团队 assets:商品的 cover_asset/images[].asset 是资产 id,需在此反查真 preview_url
assets?: Asset[];
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
preselectProductId?: string;
onBack: () => void;
@@ -202,18 +200,11 @@ export function ProjectWizardPage({ products, projects = [], assets = [], presel
}
}
// cover_asset / images[].asset 是资产 id,在团队 assets 里反查真 preview_url(对齐 products.tsx)
const assetById = useMemo(() => {
const map = new Map<string, Asset>();
assets.forEach((a) => map.set(a.id, a));
return map;
}, [assets]);
// 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
const firstImage = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
const id = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || firstImage?.asset;
if (!id) return "";
const asset = assetById.get(id);
return asset?.files?.find((f) => f.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || "";
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
return p.cover_preview_url || primary?.preview_url || "";
};
return (
+2 -1
View File
@@ -43,7 +43,8 @@ export type Product = {
description: string;
status: string;
cover_asset?: string | null;
images?: Array<{ id: string; asset: string; sort_order: number; is_primary: boolean }>;
cover_preview_url?: string;
images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>;
selling_points: Array<{ id: string; title: string; detail: string; sort_order: number }>;
created_at: string;
updated_at: string;