diff --git a/app/api/canvas/download-zip/route.ts b/app/api/canvas/download-zip/route.ts index b2d719269..01007e6f1 100644 --- a/app/api/canvas/download-zip/route.ts +++ b/app/api/canvas/download-zip/route.ts @@ -10,6 +10,9 @@ type DownloadFileItem = { name?: string; }; +const FETCH_TIMEOUT_MS = 15000; +const MAX_CONCURRENCY = 6; + function sanitizeFileName(value: string, fallback: string): string { const cleaned = String(value || "") .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") @@ -46,6 +49,19 @@ function sanitizeFolderName(value: string): string { return cleaned || `canvas-images-${Date.now()}`; } +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + function getRequestOrigin(request: NextRequest): string { const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); @@ -98,47 +114,58 @@ export async function POST(request: NextRequest) { const zip = new JSZip(); const folder = zip.folder(folderName) || zip; + const cookie = request.headers.get("cookie") || ""; + const results: Array<{ filename: string; bytes: Buffer } | null> = new Array(files.length).fill(null); + let cursor = 0; + const workerCount = Math.min(MAX_CONCURRENCY, files.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= files.length) break; + const file = files[index]!; + try { + const targetOrigin = new URL(file.url).origin; + const headers = + targetOrigin === requestOrigin && cookie + ? ({ + cookie, + } as HeadersInit) + : undefined; + const response = await fetchWithTimeout( + file.url, + { + method: "GET", + cache: "no-store", + headers, + }, + FETCH_TIMEOUT_MS + ); + if (!response.ok) continue; + + const ext = inferExt(file.url, response.headers.get("content-type") || ""); + const baseName = sanitizeFileName(file.name || `canvas-image-${index + 1}`, `canvas-image-${index + 1}`); + const filename = `${baseName}-${index + 1}.${ext}`; + const bytes = Buffer.from(await response.arrayBuffer()); + results[index] = { filename, bytes }; + } catch (_error) { + continue; + } + } + }) + ); + 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) { + for (const item of results) { + if (!item) { failedCount += 1; + continue; } + folder.file(item.filename, item.bytes); + successCount += 1; } if (successCount === 0) { diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index f0bd05fe1..c9ef93dc0 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -563,12 +563,16 @@ export function CanvasWorkspace() { } setIsDownloadSubmitting(true); + let timeoutId: number | null = null; try { + const controller = new AbortController(); + timeoutId = window.setTimeout(() => controller.abort(), 120000); const response = await fetch("/api/canvas/download-zip", { method: "POST", headers: { "Content-Type": "application/json", }, + signal: controller.signal, body: JSON.stringify({ folderName, files, @@ -593,9 +597,16 @@ export function CanvasWorkspace() { } else { toast.success(`已下载压缩包,包含 ${successCount || totalCount} 张图片`); } - } catch (_error) { - toast.error("下载失败,请稍后重试"); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + toast.error("打包超时,请减少图片数量后重试"); + } else { + toast.error("下载失败,请稍后重试"); + } } finally { + if (timeoutId) { + window.clearTimeout(timeoutId); + } setIsDownloadSubmitting(false); } }, [isDownloadSubmitting, selectedImageNodes]);