fix: support server-side zip download for selected canvas images
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-02-19 20:35:55 +08:00
parent 06c0b63a27
commit 31b6e549b4
2 changed files with 217 additions and 79 deletions

View File

@ -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<string, unknown>;
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<string>();
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 });
}
}

View File

@ -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<Blob> {
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<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
@ -95,6 +56,7 @@ export function CanvasWorkspace() {
const [marqueeStart, setMarqueeStart] = useState<Position | null>(null);
const [marqueeEnd, setMarqueeEnd] = useState<Position | null>(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<Position>({ 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="下载为文件夹压缩包"
>
<Download className="h-3.5 w-3.5" />
下载文件夹({selectedImageNodes.length})
{isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`}
</Button>
</div>
)}