diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index fa087ed..06aeac2 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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(): diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 8b7f4f3..99f9570 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -102,7 +102,6 @@ export function App() { const [team, setTeam] = useState(null); const [products, setProducts] = useState([]); const [projects, setProjects] = useState([]); - const [assets, setAssets] = useState([]); const [teamMembers, setTeamMembers] = useState([]); const [modelConfigs, setModelConfigs] = useState([]); const [aiTasks, setAiTasks] = useState([]); @@ -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 ; + return ; case "products": return ( navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} @@ -585,7 +582,6 @@ export function App() { 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 navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; + if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; return ( 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() { navigate("projects")} onCreate={async (payload) => { @@ -699,13 +693,13 @@ export function App() { /> ); case "assetFactory": - return ; + return ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": @@ -715,7 +709,7 @@ export function App() { case "settingsNotify": return setNotice({ type: "success", text })} onLogout={logout} />; default: - return ; + return ; } } @@ -740,7 +734,7 @@ export function App() { team={currentTeam} products={products} projects={projects} - assets={assets} + assets={[]} billing={billing} notice={notice} unreadCount={unreadCount} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 62f3af1..24d2f16 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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([]); + 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 = {}; 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([]); + 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)); diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index 13b8d3a..3e9b4f9 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -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(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
++
-

最近项目

{projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+

最近项目

{projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
); } diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index ff9cdd8..1406b32 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -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 { +// 卡片真图:用后端内嵌的 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 | 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 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) => Promise | 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([]); + 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))); diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index e3127b3..4e90d2c 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -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(); - 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 ( diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 643a9fb..56c249e 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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;