From 31b6e549b4d07ad68e13323d5edaadee4de69894 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 20:35:55 +0800 Subject: [PATCH] fix: support server-side zip download for selected canvas images --- app/api/canvas/download-zip/route.ts | 162 ++++++++++++++++++++++++++ components/canvas/CanvasWorkspace.tsx | 134 +++++++++------------ 2 files changed, 217 insertions(+), 79 deletions(-) create mode 100644 app/api/canvas/download-zip/route.ts diff --git a/app/api/canvas/download-zip/route.ts b/app/api/canvas/download-zip/route.ts new file mode 100644 index 000000000..34018529b --- /dev/null +++ b/app/api/canvas/download-zip/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import JSZip from "jszip"; +import { auth } from "@/app/(auth)/auth"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +type DownloadFileItem = { + url: string; + name?: string; +}; + +function sanitizeFileName(value: string, fallback: string): string { + const cleaned = String(value || "") + .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(); + return cleaned || fallback; +} + +function inferExt(url: string, contentType: string): string { + const type = String(contentType || "").toLowerCase(); + if (type.includes("jpeg")) return "jpg"; + if (type.includes("png")) return "png"; + if (type.includes("webp")) return "webp"; + if (type.includes("gif")) return "gif"; + if (type.includes("bmp")) return "bmp"; + if (type.includes("tiff")) return "tiff"; + + const cleanUrl = String(url || "").split("?")[0] || ""; + const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/); + if (matched?.[1]) return matched[1].toLowerCase(); + return "png"; +} + +function sanitizeFolderName(value: string): string { + const cleaned = String(value || "") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(); + return cleaned || `canvas-images-${Date.now()}`; +} + +function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] { + if (!Array.isArray(input)) return []; + return input + .map((item) => { + const raw = (item || {}) as Record; + const rawUrl = String(raw.url || "").trim(); + if (!rawUrl) return null; + let resolvedUrl = ""; + try { + const parsed = new URL(rawUrl, origin); + if (!/^https?:$/i.test(parsed.protocol)) return null; + resolvedUrl = parsed.toString(); + } catch (_error) { + return null; + } + const name = String(raw.name || "").trim(); + return { + url: resolvedUrl, + name, + } as DownloadFileItem; + }) + .filter((item): item is DownloadFileItem => Boolean(item)) + .slice(0, 100); +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const requestOrigin = new URL(request.url).origin; + const files = toDownloadItems((body as any)?.files, requestOrigin); + const folderName = sanitizeFolderName(String((body as any)?.folderName || "")); + + if (files.length === 0) { + return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 }); + } + + const zip = new JSZip(); + const folder = zip.folder(folderName) || zip; + let successCount = 0; + let failedCount = 0; + const usedNames = new Set(); + const cookie = request.headers.get("cookie") || ""; + + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + try { + const targetOrigin = new URL(file.url).origin; + const headers = + targetOrigin === requestOrigin && cookie + ? ({ + cookie, + } as HeadersInit) + : undefined; + const response = await fetch(file.url, { + method: "GET", + cache: "no-store", + headers, + }); + if (!response.ok) { + failedCount += 1; + continue; + } + + const ext = inferExt(file.url, response.headers.get("content-type") || ""); + const baseName = sanitizeFileName(file.name || `canvas-image-${index + 1}`, `canvas-image-${index + 1}`); + let candidate = `${baseName}.${ext}`; + let suffix = 2; + while (usedNames.has(candidate)) { + candidate = `${baseName}-${suffix}.${ext}`; + suffix += 1; + } + usedNames.add(candidate); + + const bytes = Buffer.from(await response.arrayBuffer()); + folder.file(candidate, bytes); + successCount += 1; + } catch (_error) { + failedCount += 1; + } + } + + if (successCount === 0) { + return NextResponse.json( + { success: false, error: "图片下载失败,请检查图片链接是否可访问" }, + { status: 502 } + ); + } + + const zipBuffer = await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }); + + return new NextResponse(zipBuffer, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename=\"${folderName}.zip\"`, + "Cache-Control": "no-store", + "X-Download-Success-Count": String(successCount), + "X-Download-Failed-Count": String(failedCount), + }, + }); + } catch (error) { + console.error("[CanvasDownloadZip] Failed:", error); + return NextResponse.json({ success: false, error: "打包下载失败" }, { status: 500 }); + } +} diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 145a6d9a9..4ef390fb1 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -36,33 +36,6 @@ function isNodeInViewport( ); } -function sanitizeDownloadFileName(value: string, fallback: string): string { - const cleaned = String(value || "") - .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") - .replace(/[\\/:*?"<>|]+/g, "-") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - return cleaned || fallback; -} - -function inferFileExtension(url: string, contentType?: string): string { - const byType = String(contentType || "").toLowerCase(); - if (byType.includes("jpeg")) return "jpg"; - if (byType.includes("png")) return "png"; - if (byType.includes("webp")) return "webp"; - if (byType.includes("gif")) return "gif"; - if (byType.includes("bmp")) return "bmp"; - if (byType.includes("tiff")) return "tiff"; - - const cleanUrl = String(url || "").split("?")[0] || ""; - const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/); - if (matched?.[1]) { - return matched[1].toLowerCase(); - } - return "png"; -} - function downloadBlob(blob: Blob, filename: string): void { const link = document.createElement("a"); const objectUrl = URL.createObjectURL(blob); @@ -75,18 +48,6 @@ function downloadBlob(blob: Blob, filename: string): void { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500); } -async function fetchImageBlob(url: string): Promise { - const response = await fetch(url, { - method: "GET", - credentials: "omit", - mode: "cors", - }); - if (!response.ok) { - throw new Error(`http_${response.status}`); - } - return response.blob(); -} - export function CanvasWorkspace() { const containerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); @@ -95,6 +56,7 @@ export function CanvasWorkspace() { const [marqueeStart, setMarqueeStart] = useState(null); const [marqueeEnd, setMarqueeEnd] = useState(null); const [isFileDragActive, setIsFileDragActive] = useState(false); + const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); const dragPointerRef = useRef({ x: 0, y: 0 }); @@ -566,57 +528,70 @@ export function CanvasWorkspace() { toast.error("请先框选要下载的图片"); return; } + if (isDownloadSubmitting) { + return; + } if (selectedImageNodes.length > 50) { toast.warning("当前选择图片较多,正在打包,请稍候"); } - const { default: JSZip } = await import("jszip"); - const zip = new JSZip(); const now = new Date(); const folderName = `canvas-images-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; - const folder = zip.folder(folderName) || zip; - let successCount = 0; - let failedCount = 0; + const files = selectedImageNodes + .map((node, index) => { + const nodeData = node.data as any; + const imageUrl = String(nodeData?.src || "").trim(); + if (!imageUrl) return null; + const name = + String(nodeData?.title || "").trim() || + String(nodeData?.prompt || "").trim() || + `canvas-image-${index + 1}`; + return { url: imageUrl, name }; + }) + .filter((item): item is { url: string; name: string } => Boolean(item)); - for (let index = 0; index < selectedImageNodes.length; index += 1) { - const node = selectedImageNodes[index]!; - const nodeData = node.data as any; - const imageUrl = String(nodeData?.src || "").trim(); - if (!imageUrl) { - failedCount += 1; - continue; - } - - const titleSeed = - String(nodeData?.title || "").trim() || - String(nodeData?.prompt || "").trim() || - `canvas-image-${index + 1}`; - - try { - const blob = await fetchImageBlob(imageUrl); - const ext = inferFileExtension(imageUrl, blob.type); - const filename = `${sanitizeDownloadFileName(titleSeed, `canvas-image-${index + 1}`)}.${ext}`; - folder.file(filename, blob); - successCount += 1; - } catch (_error) { - failedCount += 1; - } - } - - if (successCount === 0) { - toast.error("打包失败,未获取到可下载图片"); + if (files.length === 0) { + toast.error("当前没有可下载的图片"); return; } - const zipBlob = await zip.generateAsync({ type: "blob" }); - downloadBlob(zipBlob, `${folderName}.zip`); + setIsDownloadSubmitting(true); + try { + const response = await fetch("/api/canvas/download-zip", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + folderName, + files, + }), + }); - if (failedCount > 0) { - toast.warning(`已打包 ${successCount} 张到文件夹,失败 ${failedCount} 张`); - return; + if (!response.ok) { + const data = await response.json().catch(() => ({})); + toast.error(data?.error || "打包失败,请稍后重试"); + return; + } + + const zipBlob = await response.blob(); + downloadBlob(zipBlob, `${folderName}.zip`); + + const successCount = Number(response.headers.get("X-Download-Success-Count") || "0"); + const failedCount = Number(response.headers.get("X-Download-Failed-Count") || "0"); + const totalCount = files.length; + + if (failedCount > 0) { + toast.warning(`已打包 ${successCount} 张,失败 ${failedCount} 张`); + } else { + toast.success(`已下载压缩包,包含 ${successCount || totalCount} 张图片`); + } + } catch (_error) { + toast.error("下载失败,请稍后重试"); + } finally { + setIsDownloadSubmitting(false); } - toast.success(`已下载压缩包,包含 ${successCount} 张图片`); - }, [selectedImageNodes]); + }, [isDownloadSubmitting, selectedImageNodes]); // 渲染节点 const renderNode = (node: CanvasNode) => { @@ -832,10 +807,11 @@ export function CanvasWorkspace() { size="sm" className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60" onClick={handleDownloadSelectedImages} + disabled={isDownloadSubmitting} title="下载为文件夹压缩包" > - 下载文件夹({selectedImageNodes.length}) + {isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`} )}