fix: support server-side zip download for selected canvas images
This commit is contained in:
parent
06c0b63a27
commit
31b6e549b4
162
app/api/canvas/download-zip/route.ts
Normal file
162
app/api/canvas/download-zip/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user