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"):
|
if p.get("source"):
|
||||||
qs = qs.filter(source=p["source"])
|
qs = qs.filter(source=p["source"])
|
||||||
if p.get("product"):
|
if p.get("product"):
|
||||||
# 资产归属商品:独立生图写在 metadata.product_id;项目内生成回溯 origin_task→project→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"]))
|
# 上传的商品图经 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"):
|
if p.get("q"):
|
||||||
qs = qs.filter(Q(name__icontains=p["q"]) | Q(description__icontains=p["q"]))
|
qs = qs.filter(Q(name__icontains=p["q"]) | Q(description__icontains=p["q"]))
|
||||||
for key, val in p.items():
|
for key, val in p.items():
|
||||||
|
|||||||
+10
-16
@@ -102,7 +102,6 @@ export function App() {
|
|||||||
const [team, setTeam] = useState<Team | null>(null);
|
const [team, setTeam] = useState<Team | null>(null);
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
|
||||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||||
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
||||||
@@ -139,11 +138,11 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
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([
|
await Promise.all([
|
||||||
api.products(),
|
api.products(),
|
||||||
api.projects(),
|
api.projects(),
|
||||||
api.allAssets(),
|
|
||||||
api.billingSummary().catch(() => null),
|
api.billingSummary().catch(() => null),
|
||||||
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
|
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
|
||||||
api.billingTrend().catch(() => null),
|
api.billingTrend().catch(() => null),
|
||||||
@@ -154,7 +153,6 @@ export function App() {
|
|||||||
]);
|
]);
|
||||||
setProducts(productData.results);
|
setProducts(productData.results);
|
||||||
setProjects(projectData.results);
|
setProjects(projectData.results);
|
||||||
setAssets(assetData);
|
|
||||||
setTeamMembers(memberData);
|
setTeamMembers(memberData);
|
||||||
setModelConfigs(modelData?.results || []);
|
setModelConfigs(modelData?.results || []);
|
||||||
setAiTasks(taskData?.results || []);
|
setAiTasks(taskData?.results || []);
|
||||||
@@ -564,13 +562,12 @@ export function App() {
|
|||||||
function renderPage() {
|
function renderPage() {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case "dashboard":
|
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":
|
case "products":
|
||||||
return (
|
return (
|
||||||
<ProductsPage
|
<ProductsPage
|
||||||
products={products}
|
products={products}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
assets={assets}
|
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
|
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
|
||||||
onCreate={(payload) => action(() => api.createProduct(payload), "")}
|
onCreate={(payload) => action(() => api.createProduct(payload), "")}
|
||||||
@@ -585,7 +582,6 @@ export function App() {
|
|||||||
<ProductsPage
|
<ProductsPage
|
||||||
products={products}
|
products={products}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
assets={assets}
|
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
|
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
|
||||||
onCreate={(payload) => action(() => api.createProduct(payload), "")}
|
onCreate={(payload) => action(() => api.createProduct(payload), "")}
|
||||||
@@ -595,12 +591,11 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "productDetail":
|
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 (
|
return (
|
||||||
<ProductDetailPage
|
<ProductDetailPage
|
||||||
product={activeProduct}
|
product={activeProduct}
|
||||||
projects={projects.filter((project) => project.product === activeProduct.id)}
|
projects={projects.filter((project) => project.product === activeProduct.id)}
|
||||||
assets={assets}
|
|
||||||
initialTab={route.hash === "videos" ? "videos" : "assets"}
|
initialTab={route.hash === "videos" ? "videos" : "assets"}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
|
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
|
||||||
@@ -626,7 +621,6 @@ export function App() {
|
|||||||
<ProjectWizardPage
|
<ProjectWizardPage
|
||||||
products={products}
|
products={products}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
assets={assets}
|
|
||||||
preselectProductId={activeProductId}
|
preselectProductId={activeProductId}
|
||||||
onBack={() => navigate("projects")}
|
onBack={() => navigate("projects")}
|
||||||
onCreate={async (payload) => {
|
onCreate={async (payload) => {
|
||||||
@@ -699,13 +693,13 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "assetFactory":
|
case "assetFactory":
|
||||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
|
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
||||||
case "imageOptimize":
|
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":
|
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":
|
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":
|
case "modelPhotoDemoA":
|
||||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
|
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
|
||||||
case "modelPhotoDemoB":
|
case "modelPhotoDemoB":
|
||||||
@@ -715,7 +709,7 @@ export function App() {
|
|||||||
case "settingsNotify":
|
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} />;
|
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:
|
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}
|
team={currentTeam}
|
||||||
products={products}
|
products={products}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
assets={assets}
|
assets={[]}
|
||||||
billing={billing}
|
billing={billing}
|
||||||
notice={notice}
|
notice={notice}
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||||
|
import { api } from "../api";
|
||||||
import { MediaLightbox, SuccessModal } from "../components/overlays";
|
import { MediaLightbox, SuccessModal } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
import type { Page } from "./route-config";
|
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[] }) {
|
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
||||||
// 任务 → 生成结果图:按 asset.origin_task 关联,取首张有预览 URL 的图片文件(脚本/视频任务无图则留占位)
|
// 任务 → 生成结果图:按 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 taskImage = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
@@ -471,7 +481,6 @@ type GenBatch = {
|
|||||||
export function ImageWorkbenchPage({
|
export function ImageWorkbenchPage({
|
||||||
mode,
|
mode,
|
||||||
products,
|
products,
|
||||||
assets,
|
|
||||||
modelConfigs,
|
modelConfigs,
|
||||||
onBack,
|
onBack,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -481,7 +490,6 @@ export function ImageWorkbenchPage({
|
|||||||
}: {
|
}: {
|
||||||
mode: WorkMode;
|
mode: WorkMode;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
assets: Asset[];
|
|
||||||
modelConfigs: ModelConfig[];
|
modelConfigs: ModelConfig[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
navigate?: (page: Page) => void;
|
navigate?: (page: Page) => void;
|
||||||
@@ -523,9 +531,16 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||||
|
|
||||||
/* 模特卡数据来源:assets 中 category==='person' 的真资产(显真图 preview_url + name);
|
/* 模特卡数据来源:服务端按 category=person 懒加载(不再吃全局 assets 全量数组);
|
||||||
无则回退到基线占位模特卡 Ava/Luna/Mia/Zoe */
|
无则回退到基线占位模特卡 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(() => {
|
useEffect(() => {
|
||||||
if (product) setPrompt(meta.promptTemplate(product.title));
|
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 type { Page } from "./route-config";
|
||||||
|
import { api } from "../api";
|
||||||
import { money, stageMeta, statusPill } from "./stage-config";
|
import { money, stageMeta, statusPill } from "./stage-config";
|
||||||
import { Progress } from "../components/pipeline-stage";
|
import { Progress } from "../components/pipeline-stage";
|
||||||
import { IconKitSvg } from "../components/IconKitSvg";
|
import { IconKitSvg } from "../components/IconKitSvg";
|
||||||
|
|
||||||
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
|
export function Dashboard({ products, projects, billing, userName, navigate }: {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
assets: Asset[];
|
|
||||||
billing: BillingSummary | null;
|
billing: BillingSummary | null;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
navigate: (page: Page) => void;
|
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 completed = projects.filter((project) => project.status === "completed").length;
|
||||||
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
||||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
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>
|
</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="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 { ArrowLeft, Trash2, X } from "lucide-react";
|
||||||
import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
const PROD_PAGE_SIZE = 10;
|
const PROD_PAGE_SIZE = 10;
|
||||||
import type { Asset, Product, Project } from "../types";
|
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);
|
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||||
|
|
||||||
// 卡片真图:优先 cover_asset,其次首张商品图的 asset,反查团队 assets 的真 preview_url
|
// 卡片真图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets。
|
||||||
function resolveCoverUrl(product: Product, assetById: Map<string, Asset>): string {
|
function resolveCoverUrl(product: Product): string {
|
||||||
const firstImage = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
|
const firstImage = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
|
||||||
const id = product.cover_asset || firstImage?.asset;
|
return product.cover_preview_url || firstImage?.preview_url || "";
|
||||||
if (!id) return "";
|
|
||||||
const asset = assetById.get(id);
|
|
||||||
return asset?.files?.find((file) => file.is_primary)?.preview_url || asset?.files?.[0]?.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[];
|
products: Product[];
|
||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
assets?: Asset[];
|
|
||||||
navigate: (page: Page) => void;
|
navigate: (page: Page) => void;
|
||||||
openProduct: (productId: string, tab?: "assets" | "videos") => void;
|
openProduct: (productId: string, tab?: "assets" | "videos") => void;
|
||||||
onCreate: (payload: ProductPayload) => Promise<Product | null | undefined> | 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;
|
return matchQuery && matchCat && matchDate;
|
||||||
});
|
});
|
||||||
// 分页:每页 10 个,搜索/筛选变化回第 1 页
|
// 分页:每页 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);
|
const [page, setPage] = useState(1);
|
||||||
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
|
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
|
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
|
<ProductCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
coverUrl={resolveCoverUrl(product, assetById)}
|
coverUrl={resolveCoverUrl(product)}
|
||||||
videoCount={projects.filter((p) => p.product === product.id).length}
|
videoCount={projects.filter((p) => p.product === product.id).length}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
selected={selected.has(product.id)}
|
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"; }
|
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;
|
product: Product;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
assets: Asset[];
|
|
||||||
initialTab?: "assets" | "videos";
|
initialTab?: "assets" | "videos";
|
||||||
navigate: NavigateFn;
|
navigate: NavigateFn;
|
||||||
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!openFilter) return;
|
if (!openFilter) return;
|
||||||
const close = (event: MouseEvent) => {
|
const close = (event: MouseEvent) => {
|
||||||
@@ -714,6 +720,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
fd.append("name", `${product.title || "商品"}-图${(product.images?.length || 0) + 1}`);
|
fd.append("name", `${product.title || "商品"}-图${(product.images?.length || 0) + 1}`);
|
||||||
await onUploadImage(fd);
|
await onUploadImage(fd);
|
||||||
|
setAssetReload((n) => n + 1); // 上传后刷新该商品素材
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -739,6 +746,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
// 新版本置顶进历史并切到该版本查看(采用需手动点「采用」)
|
// 新版本置顶进历史并切到该版本查看(采用需手动点「采用」)
|
||||||
setTriVersions((list) => [version, ...list]);
|
setTriVersions((list) => [version, ...list]);
|
||||||
setTriViewingId(version.id);
|
setTriViewingId(version.id);
|
||||||
|
setAssetReload((n) => n + 1); // 生成后刷新该商品素材
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTriGenerating(false);
|
setTriGenerating(false);
|
||||||
@@ -748,26 +756,22 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
function adoptTriView() {
|
function adoptTriView() {
|
||||||
if (!triViewing) return;
|
if (!triViewing) return;
|
||||||
setTriAdoptedId(triViewing.id);
|
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(去重)
|
// 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets
|
||||||
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
|
|
||||||
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||||
const imageIds = imageRefs.map((ref) => ref.asset);
|
const productImages: Array<{ id: string; url: string }> = [];
|
||||||
if (product.cover_asset && !imageIds.includes(product.cover_asset)) imageIds.unshift(product.cover_asset);
|
if (product.cover_asset && !imageRefs.some((ref) => ref.asset === product.cover_asset)) {
|
||||||
const productImages = imageIds
|
productImages.push({ id: product.cover_asset, url: product.cover_preview_url || "" });
|
||||||
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
|
}
|
||||||
|
for (const ref of imageRefs) productImages.push({ id: ref.asset, url: ref.preview_url || "" });
|
||||||
|
|
||||||
// AI 生成素材 · 只取「该商品」的 AI 素材,而非全团队资产。归属由后端解析的 asset.product 给出
|
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
|
||||||
// (独立生图记 metadata.product_id;项目内生成的图回溯 origin_task→project→product,历史图也能归位);
|
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
|
||||||
// 再纳入该商品上传的商品图(product.images 的 asset)。
|
|
||||||
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
|
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
|
||||||
const productImageIds = new Set(imageIds);
|
const allImageAssets = productAssets.filter(
|
||||||
const belongsToProduct = (asset: Asset) =>
|
(asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image"
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
// 类型筛选选项(当前素材里真实存在的 category)
|
// 类型筛选选项(当前素材里真实存在的 category)
|
||||||
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
|
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 }>;
|
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[];
|
products: Product[];
|
||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
// 团队 assets:商品的 cover_asset/images[].asset 是资产 id,需在此反查真 preview_url
|
|
||||||
assets?: Asset[];
|
|
||||||
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
|
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
|
||||||
preselectProductId?: string;
|
preselectProductId?: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -202,18 +200,11 @@ export function ProjectWizardPage({ products, projects = [], assets = [], presel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cover_asset / images[].asset 是资产 id,在团队 assets 里反查真 preview_url(对齐 products.tsx)
|
// 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||||||
const assetById = useMemo(() => {
|
|
||||||
const map = new Map<string, Asset>();
|
|
||||||
assets.forEach((a) => map.set(a.id, a));
|
|
||||||
return map;
|
|
||||||
}, [assets]);
|
|
||||||
const productCoverUrl = (p: Product): string => {
|
const productCoverUrl = (p: Product): string => {
|
||||||
const firstImage = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
|
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||||
const id = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || firstImage?.asset;
|
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
|
||||||
if (!id) return "";
|
return p.cover_preview_url || primary?.preview_url || "";
|
||||||
const asset = assetById.get(id);
|
|
||||||
return asset?.files?.find((f) => f.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export type Product = {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
cover_asset?: string | null;
|
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 }>;
|
selling_points: Array<{ id: string; title: string; detail: string; sort_order: number }>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user