perf: optimize canvas image loading
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-24 18:35:11 +08:00
parent 314884dc77
commit c7de64b3f6
9 changed files with 555 additions and 220 deletions

View File

@ -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 || [];

View File

@ -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 || [];

View File

@ -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<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
}
function parsePositiveInteger(input: string | null, fallback: number) {
const value = Number(input);
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.floor(value);
}
// 获取画布列表
export async function GET(request: NextRequest) {
try {
@ -24,9 +32,30 @@ export async function GET(request: NextRequest) {
const workspaceType = resolveWorkspaceType(
request.nextUrl.searchParams.get("workspaceType")
);
const limit = Math.min(
Math.max(
parsePositiveInteger(request.nextUrl.searchParams.get("limit"), 24),
1
),
50
);
const page = Math.max(
parsePositiveInteger(request.nextUrl.searchParams.get("page"), 1),
1
);
const offset = (page - 1) * limit;
const projects = await db
.select()
.select({
id: canvasProject.id,
userId: canvasProject.userId,
title: canvasProject.title,
description: canvasProject.description,
thumbnail: canvasProject.thumbnail,
workspaceType: canvasProject.workspaceType,
createdAt: canvasProject.createdAt,
updatedAt: canvasProject.updatedAt,
})
.from(canvasProject)
.where(
and(
@ -34,9 +63,21 @@ export async function GET(request: NextRequest) {
eq(canvasProject.workspaceType, workspaceType)
)
)
.orderBy(desc(canvasProject.updatedAt));
.orderBy(desc(canvasProject.updatedAt))
.limit(limit + 1)
.offset(offset);
return NextResponse.json({ success: true, data: projects });
const hasMore = projects.length > 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 });

View File

@ -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 (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-background/80 text-muted-foreground",
className
)}
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{label}
</div>
);
}
const CanvasWorkspace = dynamic(
() => import("@/components/canvas").then((module) => module.CanvasWorkspace),
() =>
import("@/components/canvas/CanvasWorkspace").then(
(module) => module.CanvasWorkspace
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载画布..." />,
ssr: false,
}
);
const CanvasChatPanel = dynamic(
() =>
import("@/components/canvas/CanvasChatPanel").then(
(module) => module.CanvasChatPanel
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载对话面板..." />,
ssr: false,
}
);
const CanvasFileListPanel = dynamic(
() =>
import("@/components/canvas/CanvasFileListPanel").then(
(module) => module.CanvasFileListPanel
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载项目列表..." />,
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: () => <div className="h-20 bg-[#050607]" />,
ssr: false,
}
);
const AgentGenerationWorkspaceDarkV2 = dynamic(
() =>
import("@/components/agents/AgentGenerationWorkspaceDarkV2").then(
(module) => module.AgentGenerationWorkspaceDarkV2
),
{
loading: () => (
<div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载画布...
</div>
<CanvasLazyPanelLoader
className="min-h-dvh bg-[#050607] text-white/65"
label="正在加载风格化工作台..."
/>
),
ssr: false,
}
);
const AgentPresetPanelDarkV2 = dynamic(
() =>
import("@/components/agents/AgentPresetPanelDarkV2").then(
(module) => module.AgentPresetPanelDarkV2
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载风格化模板..." />,
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: () => <CanvasLazyPanelLoader label="正在加载品牌模特..." />,
ssr: false,
}
);
const AgentWorkflowMaterialSelectionPanel = dynamic(
() =>
import("@/components/agents/AgentWorkflowMaterialSelectionPanel").then(
(module) => module.AgentWorkflowMaterialSelectionPanel
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载素材选择..." />,
ssr: false,
}
);
const AgentWorkflowBrandStorePanel = dynamic(
() =>
import("@/components/agents/AgentWorkflowPanelDarkV2").then(
(module) => module.AgentWorkflowBrandStorePanel
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载品牌配置..." />,
ssr: false,
}
);
const AgentWorkflowPanelDarkV2 = dynamic(
() =>
import("@/components/agents/AgentWorkflowPanelDarkV2").then(
(module) => module.AgentWorkflowPanelDarkV2
),
{
loading: () => <CanvasLazyPanelLoader label="正在加载风格化配置..." />,
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({
>
<span
className="flex h-7 w-7 items-center justify-center rounded-full"
style={{ backgroundColor: entry.soft, color: entry.color }}
style={{
backgroundColor: entry.soft,
color: entry.color,
}}
>
<Icon className="h-3.5 w-3.5" />
</span>
@ -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<string | null>(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 (
<div className="flex h-full items-center justify-center bg-muted/30">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-muted-foreground text-sm">加载中...</span>
</div>
</div>
);
}
const brandEntry = (
<div
className="absolute top-4 left-4 z-20 rounded-full px-1 py-1 shadow-none"
@ -8519,15 +8653,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AgentProjectListSheet
currentProjectId={agentProjectId || null}
onCreateProject={handleCreateAgentProject}
onDeleteProject={handleDeleteAgentProject}
onOpenChange={setIsAgentProjectSheetOpen}
onRenameProject={handleRenameAgentProject}
onSelectProject={handleSelectAgentProject}
open={isAgentProjectSheetOpen}
/>
{isAgentProjectSheetOpen ? (
<AgentProjectListSheet
currentProjectId={agentProjectId || null}
onCreateProject={handleCreateAgentProject}
onDeleteProject={handleDeleteAgentProject}
onOpenChange={setIsAgentProjectSheetOpen}
onRenameProject={handleRenameAgentProject}
onSelectProject={handleSelectAgentProject}
open={isAgentProjectSheetOpen}
/>
) : null}
</div>
) : null;
@ -8555,6 +8691,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</DialogContent>
</Dialog>
);
const projectLoadingBadge = isLoading ? (
<div className="pointer-events-none fixed top-4 right-4 z-[90] inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/90 px-3.5 py-2 text-muted-foreground text-xs shadow-lg backdrop-blur">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在同步项目数据
</div>
) : 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) {
<Button
className={cn(
"h-8 w-8 rounded-[12px] text-[#7a766f] hover:bg-[#f6f5f4] hover:text-[#16181d] dark:hover:bg-zinc-900",
isVideoModuleSidebarCollapsed && "absolute top-2.5 right-2.5"
isVideoModuleSidebarCollapsed &&
"absolute top-2.5 right-2.5"
)}
onClick={() =>
setIsVideoModuleSidebarCollapsed((prev) => !prev)
@ -9154,22 +9299,27 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const position = definition
? {
x: selectedWorkflowQuickConnectAnchor.openLeft
? selectedWorkflowQuickConnectSourceNode.position.x -
? selectedWorkflowQuickConnectSourceNode.position
.x -
definition.size.width -
180
: selectedWorkflowQuickConnectSourceNode.position.x +
selectedWorkflowQuickConnectSourceNode.size.width +
: selectedWorkflowQuickConnectSourceNode.position
.x +
selectedWorkflowQuickConnectSourceNode.size
.width +
180,
y:
selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2 -
selectedWorkflowQuickConnectSourceNode.size.height /
2 -
definition.size.height / 2 +
y / viewport.zoom,
}
: null;
handleAddWorkflowNode(item.type, {
connectFromSelection: true,
connectFromNodeId: selectedWorkflowQuickConnectSourceNode.id,
connectFromNodeId:
selectedWorkflowQuickConnectSourceNode.id,
...(position ? { position } : {}),
});
}}
@ -9475,7 +9625,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
}}
>
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
{panelMode === "chat" ? (
{!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
@ -9585,12 +9737,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
active={tool === "select"}
className="h-4 w-4"
fallback={MousePointer}
item={getCanvasToolbarItem(canvasToolbarConfig, "selectTool")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"selectTool"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label} (V)
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label}{" "}
(V)
</TooltipContent>
</Tooltip>
@ -9639,13 +9795,19 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Save}
item={getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"saveCanvas"
)}
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas").label}
{
getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas")
.label
}
</TooltipContent>
</Tooltip>
) : null}
@ -9665,12 +9827,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={FolderOpen}
item={getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"cloudFiles"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles").label}
{
getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")
.label
}
</TooltipContent>
</Tooltip>
) : 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"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "markerPanel").label}
{
getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")
.label
}
</TooltipContent>
</Tooltip>
) : 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"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection").label} (L)
{
getCanvasToolbarItem(
canvasToolbarConfig,
"workflowConnection"
).label
}{" "}
(L)
</TooltipContent>
</Tooltip>
) : 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"
)}
/>
</Button>
</TooltipTrigger>
@ -9783,12 +9969,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers}
item={getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"batchProcessing"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing").label}
{
getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")
.label
}
</TooltipContent>
</Tooltip>
) : null}
@ -9869,12 +10061,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Video}
item={getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"addVideoInput"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput").label}
{
getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")
.label
}
</TooltipContent>
</Tooltip>
) : 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"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Grid3X3}
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"workflowNodeLibrary"
)}
/>
</Button>
</DropdownMenuTrigger>
@ -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"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={PenTool}
@ -10022,13 +10226,13 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
isVideoMode
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
: "h-9 w-9 rounded-xl"
)}
onClick={() =>
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"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
@ -10062,8 +10266,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
{
iconUrl:
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
label:
realtimeCanvasModeDisplayConfig.enhanceLabel,
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
}
).label
}后上传图片`}
@ -10107,12 +10310,20 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"exportVideoCanvas"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas").label}
{
getCanvasToolbarItem(
canvasToolbarConfig,
"exportVideoCanvas"
).label
}
</TooltipContent>
</Tooltip>
) : null}
@ -10133,13 +10344,21 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Upload}
item={getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas")}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"importVideoCanvas"
)}
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas").label}
{
getCanvasToolbarItem(
canvasToolbarConfig,
"importVideoCanvas"
).label
}
</TooltipContent>
</Tooltip>
) : null}
@ -10268,7 +10487,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</Button>
<span
className="w-10 text-center font-medium text-[10px] text-muted-foreground tabular-nums"
title={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
title={
getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label
}
>
{Math.round(viewport.zoom * 100)}%
</span>
@ -10291,9 +10512,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</div>
</div>
{/* 7. 物品拾取模式覆盖层 */}
<ObjectPickerOverlay />
{isBatchProcessingEnabled ? (
{isBatchProcessingEnabled && sceneFusionDialogOpen ? (
<SceneFusionDialog
onBatchAddToCanvas={handleBatchAddToCanvas}
onOpenChange={setSceneFusionDialogOpen}
@ -10301,6 +10520,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
/>
) : null}
{loginDialogElement}
{projectLoadingBadge}
</div>
</TooltipProvider>
);

View File

@ -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<CanvasProject[]>([]);
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
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"
)}
>
{/* 缩略图 */}
<div className="h-12 w-12 rounded-lg bg-muted/50 overflow-hidden shrink-0 border">
{project.thumbnail ? (
<img
src={buildImageProxyUrl(project.thumbnail, {
width: 144,
quality: 82,
format: "webp",
})}
alt={project.title}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== project.thumbnail) {
event.currentTarget.src = project.thumbnail || "";
}
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<FileImage className="h-5 w-5 text-muted-foreground/40" />
</div>
)}
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
<FileImage className="h-5 w-5 text-muted-foreground/40" />
</div>
{/* 信息 */}
@ -190,9 +196,24 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
</div>
))}
<div className="text-center py-4 text-xs text-muted-foreground/50">
到底了
</div>
{hasMore ? (
<Button
className="mt-3 w-full"
disabled={isLoadingMore}
onClick={() => loadProjects(page + 1, true)}
size="sm"
variant="outline"
>
{isLoadingMore ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
加载更多
</Button>
) : (
<div className="text-center py-4 text-xs text-muted-foreground/50">
到底了
</div>
)}
</div>
)}
</ScrollArea>

View File

@ -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<CanvasProject[]>([]);
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
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"
)}
>
{/* 缩略图 */}
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
<div
className={cn(
"h-14 w-14 shrink-0 overflow-hidden border",
"flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden border",
isVideoWorkspace
? "rounded-xl border-border/60 bg-background/80"
: "rounded-lg bg-muted/50"
)}
>
{project.thumbnail ? (
<img
src={buildImageProxyUrl(project.thumbnail, {
width: 160,
quality: 82,
format: "webp",
})}
alt={project.title}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== project.thumbnail) {
event.currentTarget.src = project.thumbnail || "";
}
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<FileImage className="h-6 w-6 text-muted-foreground/40" />
</div>
)}
<FileImage className="h-6 w-6 text-muted-foreground/40" />
</div>
{/* 信息 */}
@ -229,9 +235,24 @@ export function CanvasFileListPanel({
</div>
))}
<div className="text-center py-6 text-xs text-muted-foreground/50">
到底了
</div>
{hasMore ? (
<Button
className="mt-3 w-full"
disabled={isLoadingMore}
onClick={() => loadProjects(page + 1, true)}
size="sm"
variant="outline"
>
{isLoadingMore ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
加载更多
</Button>
) : (
<div className="text-center py-6 text-xs text-muted-foreground/50">
到底了
</div>
)}
</div>
)}
</div>

View File

@ -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) // 选中的节点总是渲染

View File

@ -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<ImageTextEditItem[]>(() =>
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 ? (
<div className="relative h-full w-full">
{isCompareMode && hasCompareSource ? (
{!shouldRequestDisplayImage ? (
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>图片即将进入视口时加载</span>
</div>
</div>
) : isCompareMode && hasCompareSource ? (
<div
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
data-canvas-no-drag="true"

View File

@ -254,6 +254,8 @@ export interface CanvasProject {
updatedAt: Date;
}
export type CanvasProjectListItem = Omit<CanvasProject, "canvasData">;
// 附件类型
export interface Attachment {
type: 'image' | 'file';