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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user