Files
ttstd-infinite-canvas/web/src/pages/canvas/index.tsx
T

545 lines
35 KiB
TypeScript

import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Input, Modal } from "antd";
import { Select } from "@/components/ui/base/select";
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2 } from "lucide-react";
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer";
import type { CanvasExportFile } from "@/types/canvas-export";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow, scheduleRemoteUserDataSync } from "@/services/user-data-sync";
import { listRemoteCanvasProjectsPage, type CanvasLibrarySummary } from "@/services/api/user-data";
import { useUserStore } from "@/stores/use-user-store";
import { listProjects } from "@/services/api/projects";
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasSyncDraftMenu } from "./canvas-sync-status";
import { useAppearanceStore } from "@/stores/use-appearance-store";
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog })));
export default function CanvasPage() {
const { message } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
const autoOpenRef = useRef(false);
const [keyword, setKeyword] = useState("");
const [sort, setSort] = useState<"updated" | "name" | "nodes">("updated");
const [projectFilter, setProjectFilter] = useState("all");
const loadMoreRef = useRef<HTMLDivElement>(null);
const [loadedProjectCount, setLoadedProjectCount] = useState(50);
const [openingProjectId, setOpeningProjectId] = useState("");
const openingProjectIdRef = useRef("");
const hydrated = useCanvasStore((state) => state.hydrated);
const localProjects = useCanvasStore((state) => state.projects);
const userId = useUserStore((state) => state.user?.id);
const sessionHydrated = useUserStore((state) => state.hydrated);
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 250);
return () => window.clearTimeout(timer);
}, [keyword]);
const libraryQuery = useInfiniteQuery({
queryKey: ["canvas-library", userId, projectFilter, sort, debouncedKeyword],
queryFn: ({ pageParam, signal }) => listRemoteCanvasProjectsPage({ page: pageParam, pageSize: 40, projectId: projectFilter, sort, query: debouncedKeyword, signal }),
initialPageParam: 1,
getNextPageParam: (last) => last.hasMore ? last.page + 1 : undefined,
enabled: Boolean(userId) && sessionHydrated,
});
const projects = useMemo<CanvasLibrarySummary[]>(() => userId
? libraryQuery.data?.pages.flatMap((page) => page.projects) || []
: localProjects.map((project) => ({ ...project, nodeCount: project.nodes.length, previewNodes: project.nodes.slice(0, 4) })), [libraryQuery.data, localProjects, userId]);
const totalProjects = userId ? libraryQuery.data?.pages[0]?.total || 0 : projects.length;
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const deleteDialogOpen = useCanvasUiStore((state) => state.deleteProjectIds.length > 0);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const updateProject = useCanvasStore((state) => state.updateProject);
const [historyOpen, setHistoryOpen] = useState(false);
const [associationOpen, setAssociationOpen] = useState(false);
const [associationProjectId, setAssociationProjectId] = useState("");
const projectQuery = useQuery({ queryKey: ["projects"], queryFn: () => listProjects() });
const mode = searchParams.get("mode");
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const handoffMode = mode === "handoff";
const forwardedQuery = agentMode || handoffMode || searchParams.get("agent") === "1" ? `?${searchParams.toString()}` : "";
const preloadProject = useCallback(() => {
void loadCanvasProjectPage();
}, []);
const enterProject = useCallback(
(id: string) => {
if (openingProjectIdRef.current) return;
openingProjectIdRef.current = id;
setOpeningProjectId(id);
preloadProject();
window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`));
},
[forwardedQuery, navigate, preloadProject],
);
const createAndEnter = () => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
enterProject(id);
});
};
const filteredProjects = useMemo(() => {
if (userId) return projects;
const query = keyword.trim().toLowerCase();
const scoped = projects.filter((project) => projectFilter === "all" || (projectFilter === "independent" ? !project.projectId : project.projectId === projectFilter));
const values = query ? scoped.filter((project) => project.title.toLowerCase().includes(query)) : [...scoped];
values.sort((a, b) => (sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodeCount - a.nodeCount : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
return values;
}, [keyword, projectFilter, projects, sort, userId]);
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
const visibleProjects = userId ? filteredProjects : filteredProjects.slice(0, loadedProjectCount);
const hasMore = userId ? libraryQuery.hasNextPage : visibleProjects.length < filteredProjects.length;
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
const sortLabel = sort === "name" ? "按名称" : sort === "nodes" ? "按节点" : "最近更新";
const projectFilterItems = useMemo(() => [{ key: "all", label: "全部画布" }, { key: "independent", label: "自由画布" }, ...(projectQuery.data?.projects || []).map(({ project }) => ({ key: project.id, label: project.name }))], [projectQuery.data]);
const sortItems = [
{ key: "updated", label: "最近更新", icon: <Clock3 className="size-3.5" /> },
{ key: "name", label: "按名称", icon: <ArrowDownAZ className="size-3.5" /> },
{ key: "nodes", label: "按节点数量", icon: <ListFilter className="size-3.5" /> },
];
useEffect(() => {
setLoadedProjectCount(50);
}, [keyword, projectFilter, sort]);
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
if (userId) {
if (!libraryQuery.isFetchingNextPage && !libraryQuery.isFetchNextPageError) void libraryQuery.fetchNextPage();
} else setLoadedProjectCount((count) => Math.min(count + 50, filteredProjects.length));
},
{ rootMargin: "600px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [filteredProjects.length, visibleProjects.length, hasMore, userId, libraryQuery.fetchNextPage, libraryQuery.isFetchingNextPage, libraryQuery.isFetchNextPageError]);
const associateSelected = async (nextProjectId = associationProjectId) => {
const projectId = nextProjectId || undefined;
try {
for (const id of selectedIds) await loadCanvasProjectForEditing(id);
selectedIds.forEach((id) => updateProject(id, { projectId }));
await saveRemoteUserDataNow();
message.success(projectId ? "已加入项目" : "已移出项目,画布仍保留");
setAssociationOpen(false);
} catch (error) {
message.error(error instanceof Error ? `画布关系保存失败:${error.message}` : "画布关系保存失败");
}
};
const exportSelected = async () => {
try {
const selected = [];
for (const id of selectedIds) {
const project = await loadCanvasProjectForEditing(id);
if (!project) throw new Error("画布不存在,无法导出");
selected.push(project);
}
await exportCanvasProjects(selected, `${brandName}画布-${selected.length}个画布`);
} catch (error) { message.error(error instanceof Error ? error.message : "导出失败"); }
};
const importCanvas = async (file?: File) => {
if (!file) return;
const hideLoading = message.loading({ content: "正在解压并准备导入画布...", duration: 0 });
try {
const zip = await readZip(file);
const projectFile = zip.get("projects.json");
if (!projectFile) throw new Error("缺少 projects.json 元数据文件");
const data = JSON.parse(await projectFile.text()) as CanvasExportFile;
if (!Array.isArray(data.projects)) throw new Error("projects.json 中缺少画布列表");
for (const item of data.projects) {
if (!Array.isArray(item.files)) throw new Error(`画布「${item.project?.title || "未命名画布"}」的媒体清单无效`);
const missing = item.files.find((entry) => !zip.get(entry.path));
if (missing) throw new Error(`压缩包缺少媒体文件:${missing.path}`);
}
hideLoading();
const remoteSyncEnabled = hasRemoteUserDataSyncSession();
let remoteSyncWarning: unknown;
for (const item of data.projects) {
const totalFiles = item.files.length;
const importedProjectId = importProject({
...item.project,
title: item.project.title || "导入画布",
nodes: item.project.nodes || [],
});
if (totalFiles > 0) {
useSyncProgressStore.getState().setProjectProgress(importedProjectId, {
projectId: importedProjectId,
total: totalFiles,
completed: 0,
phase: "uploading",
message: "正在上传媒体至云端",
});
}
try {
const storageKeyMap = new Map<string, { storageKey: string; url: string }>();
const concurrency = 4;
let fileIndex = 0;
const workers = new Array(Math.min(item.files.length, concurrency)).fill(null).map(async () => {
while (fileIndex < item.files.length) {
const current = fileIndex++;
const fileItem = item.files[current];
const blob = zip.get(fileItem.path)!;
const mime = fileItem.mimeType || blob.type || "image/png";
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, mime);
const kind: "image" | "video" | "audio" | "file" = mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : mime.startsWith("audio/") ? "audio" : "file";
try {
const resource = await uploadResourceFile(typedBlob, kind, { fileName: fileItem.path.split("/").pop() });
const newStorageKey = resourceStorageKey(resource.id);
const newUrl = resourceFileUrl(resource.id);
await primeResourceBlobCache(newStorageKey, typedBlob).catch(() => "");
storageKeyMap.set(fileItem.storageKey, { storageKey: newStorageKey, url: newUrl });
} catch (uploadErr) {
console.warn("上传资源到后端失败,降级保存本地", uploadErr);
const localUrl = await (fileItem.storageKey.startsWith("image:") ? setImageBlob(fileItem.storageKey, typedBlob) : setMediaBlob(fileItem.storageKey, typedBlob));
if (localUrl) {
storageKeyMap.set(fileItem.storageKey, { storageKey: fileItem.storageKey, url: localUrl });
}
} finally {
useSyncProgressStore.getState().incrementProjectCompleted(importedProjectId);
}
}
});
await Promise.all(workers);
const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"]));
const remapNodeMedia = (node: CanvasNodeData): CanvasNodeData => {
const oldKey = node.metadata?.storageKey;
const mapped = oldKey ? storageKeyMap.get(oldKey) : undefined;
const isDeadBlob = (val?: string) => typeof val === "string" && val.startsWith("blob:");
const nextStorageKey = mapped ? mapped.storageKey : oldKey && !isDeadBlob(oldKey) ? oldKey : undefined;
const content = mapped ? mapped.url : isDeadBlob(node.metadata?.content) ? "" : node.metadata?.content;
const previewContent = mapped ? mapped.url : isDeadBlob(node.metadata?.previewContent) ? "" : node.metadata?.previewContent;
return {
...node,
metadata: {
...node.metadata,
...(nextStorageKey !== undefined ? { storageKey: nextStorageKey } : {}),
...(content !== undefined ? { content } : {}),
...(previewContent !== undefined ? { previewContent } : {}),
drawingEngine: node.type === "drawing" && node.metadata?.drawingId ? drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" : node.metadata?.drawingEngine,
},
};
};
let remappedNodes = (item.project.nodes || []).map(remapNodeMedia);
let remappedTimeline = item.project.timeline
? {
...item.project.timeline,
clips: item.project.timeline.clips.map((clip) => {
const directMedia = clip.directMedia;
if (!directMedia?.storageKey) return clip;
const mapped = storageKeyMap.get(directMedia.storageKey);
return mapped
? {
...clip,
directMedia: { ...directMedia, storageKey: mapped.storageKey, url: mapped.url, dataUrl: directMedia.dataUrl ? mapped.url : directMedia.dataUrl, content: directMedia.content ? mapped.url : directMedia.content },
}
: clip;
}),
}
: undefined;
updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline });
const assetIdByStorageKey = new Map<string, string>();
for (let index = 0; index < remappedNodes.length; index += 1) {
const node = remappedNodes[index];
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
if (!isMedia || !node.metadata?.content) continue;
const storageKey = node.metadata.storageKey || "";
let assetId = storageKey ? assetIdByStorageKey.get(storageKey) : undefined;
if (!assetId) {
const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" });
assetId = result.assetId;
if (storageKey) assetIdByStorageKey.set(storageKey, assetId);
}
remappedNodes[index] = { ...node, metadata: { ...node.metadata, assetId } };
}
if (remappedTimeline) {
const clips: typeof remappedTimeline.clips = [];
for (const clip of remappedTimeline.clips) {
const media = clip.directMedia;
const content = media?.url || media?.dataUrl || media?.content || "";
if (!media || media.assetId || !media.storageKey || !content || media.kind === "text") {
clips.push(clip);
continue;
}
let assetId = assetIdByStorageKey.get(media.storageKey);
if (!assetId) {
const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image;
const node: CanvasNodeData = {
id: media.id,
type,
title: media.title,
position: { x: 0, y: 0 },
width: media.width || 320,
height: media.height || (type === CanvasNodeType.Audio ? 120 : 240),
metadata: { content, storageKey: media.storageKey, naturalWidth: media.width, naturalHeight: media.height, durationMs: media.durationMs, bytes: media.bytes, mimeType: media.mimeType },
};
const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" });
assetId = result.assetId;
assetIdByStorageKey.set(media.storageKey, assetId);
}
clips.push({ ...clip, directMedia: { ...media, assetId } });
}
remappedTimeline = { ...remappedTimeline, clips };
}
updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline });
await Promise.all(
(item.drawingDocuments || []).map((document) => {
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
const renderFile = document.generationRender?.path ? zip.get(document.generationRender.path) : undefined;
const renderBlob = renderFile && !renderFile.type ? renderFile.slice(0, renderFile.size, document.generationRender?.mimeType || "image/png") : renderFile;
const render =
renderBlob && document.generationRender
? ({
blob: renderBlob,
pageId: document.generationRender.pageId,
width: document.generationRender.width,
height: document.generationRender.height,
mimeType: document.generationRender.mimeType,
background: document.generationRender.background,
} satisfies CanvasDrawingRenderDraft)
: undefined;
const engine = document.engine || "tldraw";
return saveCanvasDrawing(
importedProjectId,
document.drawingId,
engine,
document.snapshot,
{
version: 2,
engine,
snapshot: document.snapshot,
revision: Math.max(0, document.revision - 1),
updatedAt: document.updatedAt,
shapeCount: document.shapeCount,
pageCount: document.pageCount,
},
preview,
render,
);
}),
);
useSyncProgressStore.getState().setProjectProgress(importedProjectId, {
phase: "saving",
message: remoteSyncEnabled ? "正在保存画布结构" : "正在保存本地画布",
});
await flushCanvasStorePersistence();
if (remoteSyncEnabled) {
try {
await saveRemoteUserDataNow(importedProjectId);
} catch (syncError) {
remoteSyncWarning ||= syncError;
scheduleRemoteUserDataSync();
console.warn("导入画布云端同步失败,等待自动重试", syncError);
}
}
} catch (error) {
useSyncProgressStore.getState().setProjectProgress(importedProjectId, {
phase: "error",
message: error instanceof Error ? error.message : "画布导入未完成",
});
throw error;
} finally {
if (!remoteSyncEnabled) useSyncProgressStore.getState().setProjectProgress(importedProjectId, null);
}
}
await flushCanvasStorePersistence();
if (remoteSyncWarning) {
message.warning(`已导入 ${data.projects.length} 个画布,云端同步未完成,将自动重试`);
} else {
message.success(remoteSyncEnabled ? `已导入 ${data.projects.length} 个画布并完成云端同步` : `已导入 ${data.projects.length} 个画布并保存到本地`);
}
} catch (error) {
hideLoading();
console.error("导入画布失败", error);
message.error(error instanceof Error ? `导入失败:${error.message}` : "导入失败,请选择有效的画布压缩包");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
useEffect(() => {
if (!hydrated || !sessionHydrated || (userId && !libraryQuery.isSuccess) || autoOpenRef.current || (mode !== "new" && mode !== "recent" && mode !== "handoff")) return;
autoOpenRef.current = true;
if (mode === "recent" && projects[0]?.id) {
enterProject(projects[0].id);
return;
}
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
enterProject(id);
});
}, [hydrated, message, mode, projects, sessionHydrated, userId, libraryQuery.isSuccess]);
if (hydrated && !libraryQuery.isError && (mode === "new" || mode === "recent" || mode === "handoff")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
return (
<WorkspacePage className="studio-collection-page">
<div className="studio-band">
<PageHeader
title="我的画布"
description="把镜头、素材和想法留在同一张画布里。"
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={
<div className="collection-header-actions">
<CanvasSyncDraftMenu />
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
新建画布
</Button>
{projects.length ? (
<Dropdown
menu={{
classNames: { root: "canvas-library-actions-menu", item: "canvas-library-actions-menu-item" },
items: [{ key: "delete-loaded", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除当前已加载画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }],
}}
openClassName="is-open"
placement="bottomRight"
trigger={["click"]}
>
<Button type="text" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal />} />
</Dropdown>
) : null}
<Button disabled={!hydrated} icon={<FileUp />} onClick={() => inputRef.current?.click()}>
导入
</Button>
</div>
}
/>
<CollectionToolbar
label="画布浏览工具"
active={Boolean(keyword || projectFilter !== "all" || sort !== "updated")}
onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); }}
trailing={<Button type="text" icon={<History />} onClick={() => setHistoryOpen(true)}>创作历史</Button>}
>
<Input prefix={<Search />} value={keyword} allowClear placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => setKeyword(event.target.value)} />
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: projectFilterItems, selectedKeys: [projectFilter], onClick: ({ key }) => setProjectFilter(String(key)) }}>
<Button icon={<SlidersHorizontal />} aria-label="按所属项目筛选">{projectFilterLabel}</Button>
</Dropdown>
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: sortItems, selectedKeys: [sort], onClick: ({ key }) => setSort(key as typeof sort) }}>
<Button icon={sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />} aria-label="画布排序">{sortLabel}</Button>
</Dropdown>
</CollectionToolbar>
</div>
<div className="collection-content">
{selectedIds.length ? (
<div className="collection-selection-bar">
<strong className="mr-auto font-medium">已选 {selectedIds.length} 个画布</strong>
<Button
size="small"
disabled={!hydrated || projectQuery.isLoading}
onClick={() => {
setAssociationProjectId(selectedProjects[0]?.projectId || "");
setAssociationOpen(true);
}}
>
加入项目
</Button>
{selectedProjects.some((project) => project.projectId) ? (
<Button
size="small"
disabled={!hydrated}
onClick={() => {
setAssociationProjectId("");
void associateSelected("");
}}
>
移出项目
</Button>
) : null}
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportSelected()}>
导出
</Button>
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
删除
</Button>
</div>
) : null}
{userId && libraryQuery.isError ? (
<div role="alert">画布列表读取失败<Button onClick={() => void libraryQuery.refetch()}>重试</Button></div>
) : !hydrated || (userId && libraryQuery.isPending) ? (
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
) : visibleProjects.length ? (
<CollectionGrid className="canvas-collection-grid">
{visibleProjects.map((project) => (
<CanvasFolderCard
key={project.id}
project={project}
projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined}
onClick={() => enterProject(project.id)}
onPrefetch={preloadProject}
opening={openingProjectId === project.id}
/>
))}
</CollectionGrid>
) : (
<WorkspaceState icon="canvas" title={keyword || projectFilter !== "all" ? "没有匹配的画布" : "让第一个想法落在画布上"} description={keyword || projectFilter !== "all" ? "换一个画布名称或重置筛选条件。" : "图片、分镜和灵感,都可以在这里自由组织。"} action={!keyword && projectFilter === "all" ? <Button type="primary" icon={<Plus />} disabled={!hydrated} onClick={createAndEnter}>新建画布</Button> : undefined} />
)}
{hydrated && visibleProjects.length ? (
<div ref={loadMoreRef} className="library-load-more" aria-live="polite">
{libraryQuery.isFetchNextPageError ? <Button onClick={() => void libraryQuery.fetchNextPage()}>加载失败,重试</Button> : hasMore ? "继续下滑加载更多" : `已加载全部 ${filteredProjects.length} 个画布`}
</div>
) : null}
</div>
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<Modal
title="加入项目"
open={associationOpen}
okText="保存关联"
cancelText="取消"
okButtonProps={{ disabled: !associationProjectId, loading: projectQuery.isFetching }}
onCancel={() => setAssociationOpen(false)}
onOk={() => void associateSelected()}
>
<p className="mb-3 text-sm text-foreground/60">选中的画布会保留原有节点和本地媒体,只增加项目关联。</p>
<Select
className="w-full"
value={associationProjectId || undefined}
placeholder="选择项目"
options={(projectQuery.data?.projects || []).map((item) => ({ label: item.project.name, value: item.project.id }))}
onChange={setAssociationProjectId}
/>
</Modal>
<CanvasHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
{deleteDialogOpen ? <Suspense fallback={null}><CanvasDeleteProjectsDialog /></Suspense> : null}
</WorkspacePage>
);
}