diff --git a/src/app/api/canvas/projects/[id]/export/route.ts b/src/app/api/canvas/projects/[id]/export/route.ts new file mode 100644 index 0000000..0e3791d --- /dev/null +++ b/src/app/api/canvas/projects/[id]/export/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getCanvasProjectById } from "@/lib/server/repositories/canvas-project-repository"; +import { createCanvasProjectExport } from "@/lib/server/services/canvas-project-transfer"; + +export const runtime = "nodejs"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params; + const project = getCanvasProjectById(id); + + if (!project) { + return NextResponse.json( + { error: "Canvas project not found" }, + { status: 404 }, + ); + } + + try { + return NextResponse.json({ + item: createCanvasProjectExport({ + title: project.title, + data: project.data, + }), + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "导出画布失败" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/canvas/projects/[id]/route.ts b/src/app/api/canvas/projects/[id]/route.ts index f4c2af2..542f23e 100644 --- a/src/app/api/canvas/projects/[id]/route.ts +++ b/src/app/api/canvas/projects/[id]/route.ts @@ -4,7 +4,10 @@ import { getCanvasProjectById, updateCanvasProject, } from "@/lib/server/repositories/canvas-project-repository"; -import { isCanvasProjectData } from "@/lib/canvas/types"; +import { + type CanvasProjectData, + normalizeCanvasProjectData, +} from "@/lib/canvas/types"; export const runtime = "nodejs"; @@ -33,19 +36,26 @@ export async function PATCH( const payload = await request.json().catch(() => ({})); const patch: { title?: string; - data?: unknown; + data?: CanvasProjectData; } = {}; if (typeof payload.title === "string") { patch.title = payload.title; } - if (isCanvasProjectData(payload.data)) { - patch.data = payload.data; + if ("data" in payload) { + const data = normalizeCanvasProjectData(payload.data); + if (!data) { + return NextResponse.json( + { error: "画布 JSON 格式无效" }, + { status: 400 }, + ); + } + patch.data = data; } const project = updateCanvasProject(id, { title: patch.title, - data: isCanvasProjectData(patch.data) ? patch.data : undefined, + data: patch.data, }); if (!project) { diff --git a/src/app/api/canvas/projects/import/route.ts b/src/app/api/canvas/projects/import/route.ts new file mode 100644 index 0000000..2b63d0a --- /dev/null +++ b/src/app/api/canvas/projects/import/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { importCanvasProjectExport } from "@/lib/server/services/canvas-project-transfer"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const payload = await request.json().catch(() => null); + + try { + const project = importCanvasProjectExport(payload); + return NextResponse.json({ item: project }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "导入画布失败" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/canvas/projects/route.ts b/src/app/api/canvas/projects/route.ts index 93c0685..c282433 100644 --- a/src/app/api/canvas/projects/route.ts +++ b/src/app/api/canvas/projects/route.ts @@ -3,7 +3,10 @@ import { createCanvasProject, listCanvasProjects, } from "@/lib/server/repositories/canvas-project-repository"; -import { defaultCanvasProjectData, isCanvasProjectData } from "@/lib/canvas/types"; +import { + defaultCanvasProjectData, + normalizeCanvasProjectData, +} from "@/lib/canvas/types"; export const runtime = "nodejs"; @@ -17,9 +20,7 @@ export async function POST(request: Request) { typeof payload.title === "string" && payload.title.trim() ? payload.title : "未命名画布"; - const data = isCanvasProjectData(payload.data) - ? payload.data - : defaultCanvasProjectData; + const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData; const project = createCanvasProject({ title, data }); return NextResponse.json({ item: project }, { status: 201 }); diff --git a/src/components/canvas/canvas-editor-client.tsx b/src/components/canvas/canvas-editor-client.tsx index 8674a37..858f8cb 100644 --- a/src/components/canvas/canvas-editor-client.tsx +++ b/src/components/canvas/canvas-editor-client.tsx @@ -98,6 +98,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const [showLibrary, setShowLibrary] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); + const [importJson, setImportJson] = useState(""); + const [isImporting, setIsImporting] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, @@ -471,6 +474,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { ...current.data.connections, { id: crypto.randomUUID(), + type: inferConnectionType(current.data.nodes, fromNodeId, toNodeId), fromNodeId, toNodeId, }, @@ -494,6 +498,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const node = createCanvasNode(type, position); const connection: CanvasConnection = { id: crypto.randomUUID(), + type: inferConnectionType(nodes, activeConnection.fromNodeId, node.id), fromNodeId: activeConnection.fromNodeId, toNodeId: node.id, }; @@ -543,6 +548,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { ...current.data.connections, { id: crypto.randomUUID(), + type: inferConnectionType( + current.data.nodes, + materialConnection.fromNodeId, + nextNode.id, + ), fromNodeId: materialConnection.fromNodeId, toNodeId: nextNode.id, }, @@ -585,6 +595,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { ...getImageNodeDimensions(naturalSize.width, naturalSize.height), metadata: { ...baseNode.metadata, + filePath: payload.item.filePath, naturalWidth: naturalSize.width, naturalHeight: naturalSize.height, }, @@ -599,6 +610,45 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { uploadInputRef.current?.click(); } + async function copyCanvasJson() { + setError(null); + try { + const response = await fetch(`/api/canvas/projects/${projectId}/export`, { + cache: "no-store", + }); + const payload = await response.json(); + if (!response.ok || !payload.item) { + throw new Error(payload.error || "复制 JSON 失败"); + } + await navigator.clipboard.writeText(JSON.stringify(payload.item, null, 2)); + } catch (err) { + setError(err instanceof Error ? err.message : "复制 JSON 失败"); + } + } + + async function importCanvasJson() { + if (!importJson.trim() || isImporting) return; + setError(null); + setIsImporting(true); + try { + const parsed = JSON.parse(importJson); + const response = await fetch("/api/canvas/projects/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed), + }); + const payload = await response.json(); + if (!response.ok || !payload.item?.id) { + throw new Error(payload.error || "导入画布失败"); + } + router.push(`/canvas/${payload.item.id}`); + } catch (err) { + setError(err instanceof Error ? err.message : "导入画布失败"); + } finally { + setIsImporting(false); + } + } + async function replaceNodeImage(nodeId: string, file: File) { setError(null); try { @@ -856,6 +906,8 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })} onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)} + onCopyJson={() => void copyCanvasJson()} + onImportJson={() => setShowImportDialog(true)} onUndo={undoCanvasChange} onUploadMaterial={openUploadMaterial} onZoomIn={() => updateViewport({ ...viewport, k: clampScale(viewport.k * 1.18) })} @@ -987,6 +1039,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onReplaceNodeImage={(nodeId, file) => void replaceNodeImage(nodeId, file)} onUploadImage={(file) => void uploadImage(file)} /> + { + if (isImporting) return; + setShowImportDialog(false); + }} + onImport={() => void importCanvasJson()} + /> ); } @@ -1097,6 +1160,67 @@ function SelectionRect({ selectionBox }: { selectionBox: SelectionBox }) { ); } +function CanvasJsonImportDialog({ + open, + value, + isImporting, + onChange, + onClose, + onImport, +}: { + open: boolean; + value: string; + isImporting: boolean; + onChange: (value: string) => void; + onClose: () => void; + onImport: () => void; +}) { + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

导入画布 JSON

+

+ 粘贴由复制 JSON 生成的完整画布数据,导入后会创建一个新画布。 +

+
+ +
+