From b8b3b8f1378be269bfbbff329cccf497a63af660 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 17:35:54 +0800 Subject: [PATCH] perf: memoize derived render data --- src/App.tsx | 27 ++++++++++ src/components/AppShell.tsx | 56 ++++++++++---------- src/features/canvas/CanvasPage.tsx | 43 +++++++++++---- src/features/ecommerce/EcommercePage.tsx | 66 ++++++++++++++++++------ src/styles/index.css | 1 - src/utils/generationNotifier.ts | 6 +-- 6 files changed, 143 insertions(+), 56 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5ee6fc3..992f62f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -127,6 +127,27 @@ const VIEW_KEYS = new Set([ ]); const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); +const LEGACY_PAGE_STYLE_VIEWS = new Set([ + "login", + "workbench", + "canvas", + "community", + "communityReview", + "communityCaseAdd", + "assets", + "ecommerce", + "ecommerceHub", + "digitalHuman", + "characterMix", + "more", +]); + +let legacyPageStylesPromise: Promise | null = null; + +function loadLegacyPageStyles(): Promise { + legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css"); + return legacyPageStylesPromise; +} function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -357,6 +378,12 @@ function App() { if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); }, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) { + void loadLegacyPageStyles(); + } + }, [activeView, ecommerceEverMounted]); + // Dismiss boot splash after first render useEffect(() => { const splash = document.getElementById("app-boot-splash"); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 662c17f..7d9fa3d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -41,6 +41,32 @@ interface AppShellProps { } const BRAND_LOGO_URL = ossAssets.brand.logo; +const TOOL_SURFACE_VIEW_SET = new Set([ + "workbench", + "canvas", + "more", + "scriptTokens", + "tokenUsage", + "ecommerceTemplates", + "sizeTemplate", + "imageWorkbench", + "resolutionUpscale", + "digitalHuman", + "dialogGenerator", + "avatarConsole", + "characterMix", +] as WebViewKey[]); +const PRIMARY_NAV_ORDER: WebViewKey[] = [ + "workbench", + "ecommerce", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "community", + "assets", + "more", +]; function formatBalance(cents: number): string { const value = Math.max(0, cents) / 100; @@ -76,37 +102,11 @@ function AppShell({ const isAuthView = activeView === "login"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home"; - const toolSurfaceViews = [ - "workbench", - "canvas", - "more", - "scriptTokens", - "tokenUsage", - "ecommerceTemplates", - "sizeTemplate", - "imageWorkbench", - "resolutionUpscale", - "digitalHuman", - "dialogGenerator", - "avatarConsole", - "characterMix", - ] as WebViewKey[]; - const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView); + const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView); const visibleNavItems = useMemo( () => { - const orderedKeys: WebViewKey[] = [ - "workbench", - "ecommerce", - "sizeTemplate", - "canvas", - "scriptTokens", - "tokenUsage", - "community", - "assets", - "more", - ]; - return orderedKeys + return PRIMARY_NAV_ORDER .map((key) => navItems.find((item) => item.key === key)) .filter((item): item is WebNavItem => Boolean(item)); }, diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 3c30783..1dd22c8 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -578,7 +578,17 @@ function CanvasPage({ // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition) // — see useEffect below near runCanvasAutoSave - const canvasAssets = serverAssets.filter((asset) => asset.imageUrl); + const canvasAssets = useMemo( + () => serverAssets.filter((asset) => asset.imageUrl), + [serverAssets], + ); + const assetCountsByCategory = useMemo(() => { + const counts = new Map(); + for (const asset of serverAssets) { + counts.set(asset.type, (counts.get(asset.type) ?? 0) + 1); + } + return counts; + }, [serverAssets]); const shouldShowEmptyProjectState = projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; const isWaitingForProjects = isAuthenticated && !projectsLoaded; @@ -2640,10 +2650,13 @@ function CanvasPage({ setConnectorDrag(null); }; - const collapsedPackageNodeKeys = new Set( - nodePackages.flatMap((nodePackage) => - nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] - ) + const collapsedPackageNodeKeys = useMemo( + () => new Set( + nodePackages.flatMap((nodePackage) => + nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] + ) + ), + [nodePackages], ); const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) => collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })); @@ -2684,6 +2697,18 @@ function CanvasPage({ return positionedLink ? [positionedLink] : []; }), ].filter((link) => !isLinkCollapsedInPackage(link)); + const visibleTextNodes = useMemo( + () => textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)), + [collapsedPackageNodeKeys, textNodes], + ); + const visibleImageNodes = useMemo( + () => imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)), + [collapsedPackageNodeKeys, imageNodes], + ); + const visibleVideoNodes = useMemo( + () => videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)), + [collapsedPackageNodeKeys, videoNodes], + ); const pendingLinkPreview = pendingLinkPort && pendingLinkPreviewPoint ? (() => { @@ -4002,7 +4027,7 @@ function CanvasPage({ ) : null} ) : null} - {textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => { + {visibleTextNodes.map((textNode) => { const textNodeSelected = isSelectedNode("text", textNode.id); const textNodeActive = isActiveSelectedNode("text", textNode.id); const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id; @@ -4270,7 +4295,7 @@ function CanvasPage({ ); })} - {imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => { + {visibleImageNodes.map((imageNode) => { const imageNodeSelected = isSelectedNode("image", imageNode.id); const imageNodeActive = isActiveSelectedNode("image", imageNode.id); const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id; @@ -4774,7 +4799,7 @@ function CanvasPage({ ); })} - {videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => { + {visibleVideoNodes.map((videoNode) => { const videoNodeSelected = isSelectedNode("video", videoNode.id); const videoNodeActive = isActiveSelectedNode("video", videoNode.id); const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id; @@ -5485,7 +5510,7 @@ function CanvasPage({ onClick={() => setSelectedExistingCategory(category.key)} > {category.label} - {serverAssets.filter((asset) => asset.type === category.key).length} 个素材 + {assetCountsByCategory.get(category.key) ?? 0} 个素材 ))} diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index dd5704e..d47b853 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -901,25 +901,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedDetailModules, setSelectedDetailModules] = useState(defaultDetailModuleIds); const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(null); - const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput); - const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null; - const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput); - const cloneRatioOptions = hotUploadedRatioOption - ? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption]) - : baseCloneRatioOptions; - const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket); - const cloneLanguageOptions = getPlatformLanguageOptions(platform, market); - const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket); + const productSetRatioOptions = useMemo( + () => getPlatformRatioOptions(productSetPlatform, productSetOutput), + [productSetOutput, productSetPlatform], + ); + const hotUploadedRatioOption = useMemo( + () => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null, + [cloneOutput, cloneReferenceImages], + ); + const baseCloneRatioOptions = useMemo( + () => getPlatformRatioOptions(platform, cloneOutput), + [cloneOutput, platform], + ); + const cloneRatioOptions = useMemo( + () => hotUploadedRatioOption + ? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption]) + : baseCloneRatioOptions, + [baseCloneRatioOptions, hotUploadedRatioOption], + ); + const productSetLanguageOptions = useMemo( + () => getPlatformLanguageOptions(productSetPlatform, productSetMarket), + [productSetMarket, productSetPlatform], + ); + const cloneLanguageOptions = useMemo( + () => getPlatformLanguageOptions(platform, market), + [market, platform], + ); + const detailLanguageOptions = useMemo( + () => getPlatformLanguageOptions(detailPlatform, detailMarket), + [detailMarket, detailPlatform], + ); const ecommerceMentionImages: MentionImageOption[] = [ ...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })), ...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })), ]; + const ecommerceVideoImageDataUrls = useMemo( + () => productImages.map((img) => img.src), + [productImages], + ); + const ecommerceVideoImageFiles = useMemo( + () => productImages.map((img) => img.file), + [productImages], + ); + const selectedProductSetOutput = productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const productSetPreviewReady = productSetStatus === "done"; - const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0); + const cloneSetTotal = useMemo( + () => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0), + [cloneSetCounts], + ); const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; const canGenerate = (cloneOutput === "video-outfit" ? Boolean(videoOutfitVideoFile && videoOutfitRefFile) @@ -928,9 +961,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const cloneVideoDurationProgress = ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; - const cloneVideoDurationStyle: CSSProperties = { - "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, - } as CSSProperties; + const cloneVideoDurationStyle: CSSProperties = useMemo( + () => ({ + "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, + }) as CSSProperties, + [cloneVideoDurationProgress], + ); const trackEcommerceTask = (taskId: string) => { activeEcommerceTaskIdsRef.current.add(taskId); @@ -2647,8 +2683,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
).isAuthenticated)} - productImageDataUrls={productImages.map((img) => img.src)} - productImageFiles={productImages.map((img) => img.file)} + productImageDataUrls={ecommerceVideoImageDataUrls} + productImageFiles={ecommerceVideoImageFiles} requirement={requirement} platform={platform} aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"} diff --git a/src/styles/index.css b/src/styles/index.css index e9c2fba..d58bd11 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -12,7 +12,6 @@ @import "./pages/more-tools.css"; @import "./pages/studio-layout.css"; @import "./pages/size-template.css"; -@import "./pages/legacy-pages.css"; @import "./components/recharge-modal.css"; @import "./components/dropzone.css"; @import "./components/skeleton.css"; diff --git a/src/utils/generationNotifier.ts b/src/utils/generationNotifier.ts index d8b459b..cfa5ab1 100644 --- a/src/utils/generationNotifier.ts +++ b/src/utils/generationNotifier.ts @@ -3,6 +3,8 @@ * Falls back gracefully when Notification API is unavailable. */ +import { toast } from "../components/toast/toastStore"; + let permissionGranted = false; async function requestPermission(): Promise { @@ -35,9 +37,7 @@ export function notifyTaskCompleted(label: string, mode: "image" | "video" = "im // Use the existing toast system for in-app notifications function dispatchGenToast(msg: string) { - try { - import("../components/toast/toastStore").then((m) => m.toast(msg, "success")); - } catch { /* toast system not loaded */ } + toast(msg, "success"); } /** Call once on app init to pre-warm permission. */