From c7de64b3f6f74c7204b91225db5d6ffaa742c542 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 18:35:11 +0800 Subject: [PATCH] perf: optimize canvas image loading --- app/(chat)/canvas/page.tsx | 4 +- app/(chat)/video-canvas/page.tsx | 7 +- app/api/canvas/route.ts | 49 +- .../[id]/CanvasEditorLegacyPageClient.tsx | 448 +++++++++++++----- components/canvas/CanvasFileList.tsx | 93 ++-- components/canvas/CanvasFileListPanel.tsx | 93 ++-- components/canvas/CanvasWorkspace.tsx | 5 +- components/canvas/nodes/ImageNode.tsx | 74 ++- types/canvas/index.ts | 2 + 9 files changed, 555 insertions(+), 220 deletions(-) diff --git a/app/(chat)/canvas/page.tsx b/app/(chat)/canvas/page.tsx index 8bbb3e931..511f5d2b9 100644 --- a/app/(chat)/canvas/page.tsx +++ b/app/(chat)/canvas/page.tsx @@ -25,7 +25,9 @@ export default function CanvasRedirectPage() { } try { - const response = await fetch("/api/canvas", { cache: "no-store" }); + const response = await fetch("/api/canvas?limit=1&page=1", { + cache: "no-store", + }); if (response.ok) { const result = await response.json(); const items = result?.data || []; diff --git a/app/(chat)/video-canvas/page.tsx b/app/(chat)/video-canvas/page.tsx index 1e3f80477..e51bb7a22 100644 --- a/app/(chat)/video-canvas/page.tsx +++ b/app/(chat)/video-canvas/page.tsx @@ -19,9 +19,10 @@ export default function VideoCanvasRedirectPage() { } try { - const response = await fetch("/api/canvas?workspaceType=video", { - cache: "no-store", - }); + const response = await fetch( + "/api/canvas?workspaceType=video&limit=1&page=1", + { cache: "no-store" } + ); if (response.ok) { const result = await response.json(); const items = result?.data || []; diff --git a/app/api/canvas/route.ts b/app/api/canvas/route.ts index b1dce5359..621e14182 100644 --- a/app/api/canvas/route.ts +++ b/app/api/canvas/route.ts @@ -3,7 +3,7 @@ import { auth } from '@/app/(auth)/auth'; import { db } from '@/lib/db'; import { canvasProject } from '@/lib/db/schema'; import { syncCanvasNodesForProject } from '@/lib/db/queries'; -import { and, eq, desc } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; function resolveWorkspaceType(input: unknown) { return input === "agent" || input === "video" ? input : "canvas"; @@ -13,6 +13,14 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType limit; + + return NextResponse.json({ + success: true, + data: hasMore ? projects.slice(0, limit) : projects, + pagination: { + page, + limit, + hasMore, + }, + }); } catch (error) { console.error('获取画布列表失败:', error); return NextResponse.json({ success: false, error: '获取画布列表失败' }, { status: 500 }); diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 4940de79b..fecd8da0c 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -62,35 +62,15 @@ import { type ChangeEvent, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import useSWR from "swr"; -import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; -import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; -import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew"; // 深色主题组件 V2 (新版布局) -import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2"; -import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew"; -import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet"; -import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker"; -import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; -import { - AgentWorkflowBrandStorePanel, - AgentWorkflowPanelDarkV2, -} from "@/components/agents/AgentWorkflowPanelDarkV2"; -import { - type AgentWorkflowInputs, - AgentWorkflowPanelNew, -} from "@/components/agents/AgentWorkflowPanelNew"; -import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; -import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; +import { type AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanelNew"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; -import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay"; -import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -250,19 +230,163 @@ import type { WorkflowNodeData, } from "@/types/canvas"; +function CanvasLazyPanelLoader({ + label, + className, +}: { + label: string; + className?: string; +}) { + return ( +
+ + {label} +
+ ); +} + const CanvasWorkspace = dynamic( - () => import("@/components/canvas").then((module) => module.CanvasWorkspace), + () => + import("@/components/canvas/CanvasWorkspace").then( + (module) => module.CanvasWorkspace + ), + { + loading: () => , + ssr: false, + } +); + +const CanvasChatPanel = dynamic( + () => + import("@/components/canvas/CanvasChatPanel").then( + (module) => module.CanvasChatPanel + ), + { + loading: () => , + ssr: false, + } +); + +const CanvasFileListPanel = dynamic( + () => + import("@/components/canvas/CanvasFileListPanel").then( + (module) => module.CanvasFileListPanel + ), + { + loading: () => , + ssr: false, + } +); + +const SceneFusionDialog = dynamic( + () => + import("@/components/scene-fusion-dialog").then( + (module) => module.SceneFusionDialog + ), + { + loading: () => null, + ssr: false, + } +); + +const AgentCanvasTopNavDark = dynamic( + () => + import("@/components/agents/AgentCanvasTopNavDark").then( + (module) => module.AgentCanvasTopNavDark + ), + { + loading: () =>
, + ssr: false, + } +); + +const AgentGenerationWorkspaceDarkV2 = dynamic( + () => + import("@/components/agents/AgentGenerationWorkspaceDarkV2").then( + (module) => module.AgentGenerationWorkspaceDarkV2 + ), { loading: () => ( -
- - 正在加载画布... -
+ ), ssr: false, } ); +const AgentPresetPanelDarkV2 = dynamic( + () => + import("@/components/agents/AgentPresetPanelDarkV2").then( + (module) => module.AgentPresetPanelDarkV2 + ), + { + loading: () => , + ssr: false, + } +); + +const AgentProjectListSheet = dynamic( + () => + import("@/components/agents/AgentProjectListSheet").then( + (module) => module.AgentProjectListSheet + ), + { + loading: () => null, + ssr: false, + } +); + +const AgentWorkflowBrandModelPicker = dynamic( + () => + import("@/components/agents/AgentWorkflowBrandModelPicker").then( + (module) => module.AgentWorkflowBrandModelPicker + ), + { + loading: () => , + ssr: false, + } +); + +const AgentWorkflowMaterialSelectionPanel = dynamic( + () => + import("@/components/agents/AgentWorkflowMaterialSelectionPanel").then( + (module) => module.AgentWorkflowMaterialSelectionPanel + ), + { + loading: () => , + ssr: false, + } +); + +const AgentWorkflowBrandStorePanel = dynamic( + () => + import("@/components/agents/AgentWorkflowPanelDarkV2").then( + (module) => module.AgentWorkflowBrandStorePanel + ), + { + loading: () => , + ssr: false, + } +); + +const AgentWorkflowPanelDarkV2 = dynamic( + () => + import("@/components/agents/AgentWorkflowPanelDarkV2").then( + (module) => module.AgentWorkflowPanelDarkV2 + ), + { + loading: () => , + ssr: false, + } +); + interface PageProps { params: Promise<{ id: string }>; } @@ -502,7 +626,9 @@ function normalizeRealtimeCanvasModeDisplayConfig( : {}; const normalizeUrl = (input: unknown) => String(input || "").trim(); const normalizeLabel = (input: unknown, fallback: string) => { - const label = String(input || "").trim().slice(0, 32); + const label = String(input || "") + .trim() + .slice(0, 32); return label || fallback; }; @@ -1144,7 +1270,10 @@ function VideoCanvasMarkerPanel({ > @@ -1421,7 +1550,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const routeParams = useParams(); const searchParams = useSearchParams(); const routeId = routeParams?.id; - const id = Array.isArray(routeId) ? routeId[0] || "" : typeof routeId === "string" ? routeId : ""; + const id = Array.isArray(routeId) + ? routeId[0] || "" + : typeof routeId === "string" + ? routeId + : ""; const router = useRouter(); const mode = searchParams.get("mode"); const requestedWorkspaceType = searchParams.get("workspaceType"); @@ -1488,6 +1621,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); + const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] = + useState(false); const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = useState(false); const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState< @@ -1783,9 +1918,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { [selectedVideoWorkflowType] ); const selectedVideoCanvasMarkers = useMemo(() => { - const metadata = (selectedVideoWorkflowNode?.data as - | { metadata?: unknown } - | undefined)?.metadata; + const metadata = ( + selectedVideoWorkflowNode?.data as { metadata?: unknown } | undefined + )?.metadata; return normalizeVideoCanvasMarkers( isPlainObject(metadata) ? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] @@ -1846,11 +1981,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode ? selectedVideoWorkflowNode : null; - const videoModuleSidebarWidth = isVideoMode && isVideoModuleSidebarEnabled - ? isVideoModuleSidebarCollapsed - ? 58 - : 236 - : 0; + const videoModuleSidebarWidth = + isVideoMode && isVideoModuleSidebarEnabled + ? isVideoModuleSidebarCollapsed + ? 58 + : 236 + : 0; const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 24 : 16; @@ -2613,8 +2749,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }; }, [checkCanvasAuthSession]); + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setAreSecondaryPanelsReady(true); + }, 800); + return () => window.clearTimeout(timeoutId); + }, []); + // 加载画布数据 - useLayoutEffect(() => { + useEffect(() => { console.log("[Canvas] Loading project:", id); const loadProject = async () => { @@ -5935,15 +6078,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { definition.size.height / 2, } : definition - ? { - x: - videoCanvasInsertMenu.canvasPosition.x - - Math.min(Math.round(definition.size.width * 0.18), 84), - y: - videoCanvasInsertMenu.canvasPosition.y - - Math.min(Math.round(definition.size.height * 0.12), 42), - } - : videoCanvasInsertMenu.canvasPosition; + ? { + x: + videoCanvasInsertMenu.canvasPosition.x - + Math.min(Math.round(definition.size.width * 0.18), 84), + y: + videoCanvasInsertMenu.canvasPosition.y - + Math.min(Math.round(definition.size.height * 0.12), 42), + } + : videoCanvasInsertMenu.canvasPosition; handleAddWorkflowNode(templateType, { position: anchoredPosition, @@ -8276,7 +8419,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { success?: boolean; data?: AgentPreset; }>( - effectiveSelectedAgentPresetId ? `/api/agents/${effectiveSelectedAgentPresetId}` : null, + effectiveSelectedAgentPresetId + ? `/api/agents/${effectiveSelectedAgentPresetId}` + : null, fetcher ); const selectedAgentPresetData = useMemo( @@ -8351,17 +8496,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ? String(agentProjectTitle || "").trim() || "未命名生图项目" : "未选择项目"; - if (isLoading) { - return ( -
-
- - 加载中... -
-
- ); - } - const brandEntry = (
- + {isAgentProjectSheetOpen ? ( + + ) : null}
) : null; @@ -8555,6 +8691,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ); + const projectLoadingBadge = isLoading ? ( +
+ + 正在同步项目数据 +
+ ) : null; const agentProjectSelectionScreen = isAgentMode && !hasActiveAgentProject ? ( @@ -8623,6 +8765,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { <> {agentProjectSelectionScreen} {loginDialogElement} + {projectLoadingBadge} ); } @@ -8728,6 +8871,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { workspaceState={agentWorkspaceState} /> {loginDialogElement} + {projectLoadingBadge} ); } @@ -8916,7 +9060,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { - {getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label} (V) + {getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label}{" "} + (V) @@ -9639,13 +9795,19 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { )} - {getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas").label} + { + getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas") + .label + } ) : null} @@ -9665,12 +9827,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { - {getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles").label} + { + getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles") + .label + } ) : null} @@ -9695,12 +9863,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { active={isVideoCanvasMarkerPanelOpen} className="h-4 w-4" fallback={Tag} - item={getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")} + item={getCanvasToolbarItem( + canvasToolbarConfig, + "markerPanel" + )} /> - {getCanvasToolbarItem(canvasToolbarConfig, "markerPanel").label} + { + getCanvasToolbarItem(canvasToolbarConfig, "markerPanel") + .label + } ) : null} @@ -9726,12 +9900,21 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { active={tool === "draw"} className="h-4 w-4" fallback={GitBranch} - item={getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection")} + item={getCanvasToolbarItem( + canvasToolbarConfig, + "workflowConnection" + )} /> - {getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection").label} (L) + { + getCanvasToolbarItem( + canvasToolbarConfig, + "workflowConnection" + ).label + }{" "} + (L) ) : null} @@ -9757,7 +9940,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { active={isAgentMode} className="h-4 w-4" fallback={Wand2} - item={getCanvasToolbarItem(canvasToolbarConfig, "styleMode")} + item={getCanvasToolbarItem( + canvasToolbarConfig, + "styleMode" + )} /> @@ -9783,12 +9969,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { - {getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing").label} + { + getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing") + .label + } ) : null} @@ -9869,12 +10061,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { - {getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput").label} + { + getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput") + .label + } ) : null} @@ -9886,14 +10084,20 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS} size="icon" title={ - getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary").label + getCanvasToolbarItem( + canvasToolbarConfig, + "workflowNodeLibrary" + ).label } variant="ghost" > @@ -9977,11 +10181,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { isVideoMode ? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS : "h-9 w-9 rounded-xl" - )} - onClick={() => router.push(`/canvas/${id}/realtime`)} - size="icon" - variant="ghost" - > + )} + onClick={() => router.push(`/canvas/${id}/realtime`)} + size="icon" + variant="ghost" + > - router.push(`/canvas/${id}/realtime?mode=enhance-pro`) - } - size="icon" - variant="ghost" - > + )} + onClick={() => + router.push(`/canvas/${id}/realtime?mode=enhance-pro`) + } + size="icon" + variant="ghost" + > - {getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas").label} + { + getCanvasToolbarItem( + canvasToolbarConfig, + "exportVideoCanvas" + ).label + } ) : null} @@ -10133,13 +10344,21 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { )} - {getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas").label} + { + getCanvasToolbarItem( + canvasToolbarConfig, + "importVideoCanvas" + ).label + } ) : null} @@ -10268,7 +10487,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { {Math.round(viewport.zoom * 100)}% @@ -10291,9 +10512,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
- {/* 7. 物品拾取模式覆盖层 */} - - {isBatchProcessingEnabled ? ( + {isBatchProcessingEnabled && sceneFusionDialogOpen ? ( ) : null} {loginDialogElement} + {projectLoadingBadge} ); diff --git a/components/canvas/CanvasFileList.tsx b/components/canvas/CanvasFileList.tsx index 1201159d9..5afe20a18 100644 --- a/components/canvas/CanvasFileList.tsx +++ b/components/canvas/CanvasFileList.tsx @@ -17,8 +17,9 @@ import { import { canvasService } from "@/services/canvas"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { buildImageProxyUrl } from "@/lib/image-delivery"; -import type { CanvasProject } from "@/types/canvas"; +import type { CanvasProjectListItem } from "@/types/canvas"; + +const PROJECT_LIST_PAGE_SIZE = 24; interface CanvasFileListProps { open: boolean; @@ -28,26 +29,52 @@ interface CanvasFileListProps { export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileListProps) { const router = useRouter(); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); // 加载项目列表 - const loadProjects = async () => { - setIsLoading(true); - const result = await canvasService.getProjects(); + const loadProjects = async (nextPage = 1, append = false) => { + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } + const result = await canvasService.getProjects("canvas", { + page: nextPage, + limit: PROJECT_LIST_PAGE_SIZE, + }); if (result.success && result.data) { // 按更新时间降序排序 const sorted = result.data.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); - setProjects(sorted); + setProjects((prev) => { + if (!append) return sorted; + const seen = new Set(prev.map((project) => project.id)); + return [ + ...prev, + ...sorted.filter((project) => !seen.has(project.id)), + ]; + }); + setPage(nextPage); + setHasMore(Boolean(result.pagination?.hasMore)); + } + if (append) { + setIsLoadingMore(false); + } else { + setIsLoading(false); } - setIsLoading(false); }; useEffect(() => { if (open) { - loadProjects(); + setProjects([]); + setPage(1); + setHasMore(false); + loadProjects(1, false); } }, [open]); @@ -136,30 +163,9 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList : "bg-card border-transparent" )} > - {/* 缩略图 */} -
- {project.thumbnail ? ( - {project.title} { - if (event.currentTarget.src !== project.thumbnail) { - event.currentTarget.src = project.thumbnail || ""; - } - }} - /> - ) : ( -
- -
- )} + {/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */} +
+
{/* 信息 */} @@ -190,9 +196,24 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
))} -
- 到底了 -
+ {hasMore ? ( + + ) : ( +
+ 到底了 +
+ )} )} diff --git a/components/canvas/CanvasFileListPanel.tsx b/components/canvas/CanvasFileListPanel.tsx index 471115ffa..db12e488d 100644 --- a/components/canvas/CanvasFileListPanel.tsx +++ b/components/canvas/CanvasFileListPanel.tsx @@ -10,8 +10,9 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { canvasService } from "@/services/canvas"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { buildImageProxyUrl } from "@/lib/image-delivery"; -import type { CanvasProject, CanvasWorkspaceType } from "@/types/canvas"; +import type { CanvasProjectListItem, CanvasWorkspaceType } from "@/types/canvas"; + +const PROJECT_LIST_PAGE_SIZE = 24; interface CanvasFileListPanelProps { currentId: string; @@ -32,25 +33,51 @@ export function CanvasFileListPanel({ }: CanvasFileListPanelProps) { const router = useRouter(); const isVideoWorkspace = workspaceType === "video"; - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); // 加载项目列表 - const loadProjects = async () => { - setIsLoading(true); - const result = await canvasService.getProjects(workspaceType); + const loadProjects = async (nextPage = 1, append = false) => { + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } + const result = await canvasService.getProjects(workspaceType, { + page: nextPage, + limit: PROJECT_LIST_PAGE_SIZE, + }); if (result.success && result.data) { // 按更新时间降序排序 const sorted = result.data.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); - setProjects(sorted); + setProjects((prev) => { + if (!append) return sorted; + const seen = new Set(prev.map((project) => project.id)); + return [ + ...prev, + ...sorted.filter((project) => !seen.has(project.id)), + ]; + }); + setPage(nextPage); + setHasMore(Boolean(result.pagination?.hasMore)); + } + if (append) { + setIsLoadingMore(false); + } else { + setIsLoading(false); } - setIsLoading(false); }; useEffect(() => { - loadProjects(); + setProjects([]); + setPage(1); + setHasMore(false); + loadProjects(1, false); }, [workspaceType]); // 创建新画布 @@ -163,37 +190,16 @@ export function CanvasFileListPanel({ : "bg-card" )} > - {/* 缩略图 */} + {/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
- {project.thumbnail ? ( - {project.title} { - if (event.currentTarget.src !== project.thumbnail) { - event.currentTarget.src = project.thumbnail || ""; - } - }} - /> - ) : ( -
- -
- )} +
{/* 信息 */} @@ -229,9 +235,24 @@ export function CanvasFileListPanel({ ))} -
- 到底了 -
+ {hasMore ? ( + + ) : ( +
+ 到底了 +
+ )} )} diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 8c024c04e..6f64dde6e 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -348,11 +348,8 @@ export function CanvasWorkspace({ }; }, [isFileDrag]); - // 性能优化:只渲染视口内的节点(虚拟化) + // 性能优化:始终只渲染视口附近节点,避免打开大画布时一次性拉取所有云端图片。 const visibleNodes = useMemo(() => { - // 如果节点数量少于 50,直接渲染所有节点 - if (nodes.length < 50) return nodes; - return nodes.filter(node => isNodeInViewport(node, viewport, containerSize) || selectedNodeIds.includes(node.id) // 选中的节点总是渲染 diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 899ac6f0e..d8d4a1516 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -538,6 +538,7 @@ export function ImageNode({ const [textEditPanelOpen, setTextEditPanelOpen] = useState(false); const [notePanelOpen, setNotePanelOpen] = useState(false); const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false); + const [hasEnteredViewport, setHasEnteredViewport] = useState(false); const [textEditItems, setTextEditItems] = useState(() => normalizeTextEditItems(data.textEditItems) ); @@ -798,6 +799,16 @@ export function ImageNode({ }), [compareSource, displayImageWidth] ); + const shouldRequestDisplayImage = + hasEnteredViewport || + isSelected || + isCompareMode || + boxCutoutMode || + isThisNodePicking || + fullscreenPreviewOpen || + annotatorOpen || + resizeAnnotatorOpen || + textEditPanelOpen; const [loadedResolution, setLoadedResolution] = useState<{ width: number; height: number; @@ -1055,29 +1066,41 @@ export function ImageNode({ useEffect(() => { setLoadedResolution(null); - if (!isSelected || !data.src || metadataResolution) return; + }, [data.src]); - let canceled = false; - const img = new Image(); - img.onload = () => { - if (canceled) return; - const width = Math.round(img.naturalWidth || 0); - const height = Math.round(img.naturalHeight || 0); - if (width > 0 && height > 0) { - setLoadedResolution({ width, height }); - } - }; - img.onerror = () => { - if (!canceled) { - setLoadedResolution(null); - } - }; - img.src = data.src; + useEffect(() => { + setHasEnteredViewport(false); + }, [data.src]); - return () => { - canceled = true; - }; - }, [data.src, isSelected, metadataResolution]); + useEffect(() => { + if (!data.src || hasEnteredViewport || typeof window === "undefined") { + return; + } + + const element = containerRef.current; + if (!element || typeof IntersectionObserver === "undefined") { + setHasEnteredViewport(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setHasEnteredViewport(true); + observer.disconnect(); + } + }, + { + root: null, + // 轻微提前加载,保证拖动画布时体验顺滑,但不请求远离视口的图片。 + rootMargin: "320px", + threshold: 0.01, + } + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [data.src, hasEnteredViewport]); useEffect(() => { if (typeof window === "undefined") return; @@ -4033,7 +4056,14 @@ export function ImageNode({ {/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? (
- {isCompareMode && hasCompareSource ? ( + {!shouldRequestDisplayImage ? ( +
+
+ + 图片即将进入视口时加载 +
+
+ ) : isCompareMode && hasCompareSource ? (
; + // 附件类型 export interface Attachment { type: 'image' | 'file';