diff --git a/app/api/canvas/download-zip-task/route.ts b/app/api/canvas/download-zip-task/route.ts new file mode 100644 index 000000000..44b130417 --- /dev/null +++ b/app/api/canvas/download-zip-task/route.ts @@ -0,0 +1,367 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import JSZip from "jszip"; +import { auth } from "@/app/(auth)/auth"; +import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type DownloadFileItem = { + url: string; + name?: string; +}; + +type ZipTaskStatus = "pending" | "running" | "completed" | "failed"; + +type ZipTask = { + id: string; + userId: string; + folderName: string; + files: DownloadFileItem[]; + totalCount: number; + processedCount: number; + successCount: number; + failedCount: number; + compressPercent: number; + status: ZipTaskStatus; + stage: string; + error?: string; + zipBuffer?: Buffer; + contentDisposition?: string; + createdAt: number; + updatedAt: number; + expiresAt: number; +}; + +const MAX_CONCURRENCY = 8; +const MAX_DOWNLOAD_FILES = 200; +const TASK_TTL_MS = 30 * 60 * 1000; + +const globalTaskStore = globalThis as typeof globalThis & { + __canvasDownloadZipTasks?: Map; +}; + +function getTaskStore() { + if (!globalTaskStore.__canvasDownloadZipTasks) { + globalTaskStore.__canvasDownloadZipTasks = new Map(); + } + return globalTaskStore.__canvasDownloadZipTasks; +} + +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 buildContentDisposition(folderName: string): string { + const encodedName = encodeURIComponent(`${folderName}.zip`); + const asciiName = + folderName + .replace(/[^\x20-\x7E]+/g, "") + .replace(/"/g, "") + .trim() || "canvas-images"; + return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`; +} + +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(); + if (forwardedHost && forwardedProto) { + return `${forwardedProto}://${forwardedHost}`; + } + return new URL(request.url).origin; +} + +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(); + const nextItem: DownloadFileItem = { url: resolvedUrl }; + if (name) { + nextItem.name = name; + } + return nextItem; + }) + .filter((item): item is DownloadFileItem => item !== null) + .slice(0, MAX_DOWNLOAD_FILES); +} + +function cleanupExpiredTasks() { + const now = Date.now(); + const store = getTaskStore(); + for (const [taskId, task] of store.entries()) { + if (task.expiresAt <= now) { + store.delete(taskId); + } + } +} + +function getTaskProgress(task: ZipTask) { + if (task.status === "completed") { + return 100; + } + if (task.status === "failed") { + return Math.max( + 0, + Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85)) + ); + } + if (task.processedCount >= task.totalCount && task.totalCount > 0) { + return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14)); + } + return Math.max( + 4, + Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85)) + ); +} + +function serializeTask(task: ZipTask) { + const downloadUrl = + task.status === "completed" + ? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1` + : ""; + return { + id: task.id, + folderName: task.folderName, + status: task.status, + stage: task.stage, + totalCount: task.totalCount, + processedCount: task.processedCount, + successCount: task.successCount, + failedCount: task.failedCount, + progress: getTaskProgress(task), + error: task.error || "", + downloadUrl, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + expiresAt: task.expiresAt, + }; +} + +async function processZipTask(params: { + task: ZipTask; + requestOrigin: string; + cookie: string; +}) { + const { task, requestOrigin, cookie } = params; + const store = getTaskStore(); + try { + task.status = "running"; + task.stage = "正在读取源图"; + task.updatedAt = Date.now(); + + const zip = new JSZip(); + const folder = zip.folder(task.folderName) || zip; + const results: Array< + | Awaited> + | null + > = new Array(task.files.length).fill(null); + let cursor = 0; + const workerCount = Math.min(MAX_CONCURRENCY, task.files.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= task.files.length) break; + + const file = task.files[index]!; + results[index] = await fetchCanvasDownloadAsset({ + file, + index, + requestOrigin, + cookie, + }); + + const result = results[index]; + task.processedCount += 1; + if (result?.ok) { + task.successCount += 1; + } else { + task.failedCount += 1; + } + task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`; + task.updatedAt = Date.now(); + } + }) + ); + + const failures: Array<{ index: number; url: string; name?: string; error: string }> = []; + for (const [index, item] of results.entries()) { + if (!item || !item.ok) { + failures.push({ + index: index + 1, + url: item?.ok === false ? item.url : task.files[index]?.url || "", + name: item?.ok === false ? item.name : task.files[index]?.name, + error: item?.ok === false ? item.error : "未知错误", + }); + continue; + } + folder.file(item.filename, item.bytes); + } + + if (failures.length > 0) { + folder.file( + "_download-failures.txt", + [ + "以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。", + "", + ...failures.map((item) => { + const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`; + return `${label}\nURL: ${item.url}\n原因: ${item.error}`; + }), + ].join("\n\n") + ); + console.warn("[CanvasDownloadZipTask] Partial download failures:", failures); + } + + task.stage = "正在生成 ZIP 文件"; + task.updatedAt = Date.now(); + task.zipBuffer = await zip.generateAsync( + { + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }, + (metadata) => { + task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0)); + task.updatedAt = Date.now(); + } + ); + task.contentDisposition = buildContentDisposition(task.folderName); + task.status = "completed"; + task.stage = "打包完成"; + task.compressPercent = 100; + task.updatedAt = Date.now(); + task.expiresAt = Date.now() + TASK_TTL_MS; + } catch (error) { + console.error("[CanvasDownloadZipTask] Failed:", error); + task.status = "failed"; + task.stage = "打包失败"; + task.error = error instanceof Error ? error.message : "打包下载失败"; + task.updatedAt = Date.now(); + task.expiresAt = Date.now() + TASK_TTL_MS; + } finally { + store.set(task.id, task); + } +} + +export async function POST(request: NextRequest) { + try { + cleanupExpiredTasks(); + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const requestOrigin = getRequestOrigin(request); + const files = toDownloadItems(body.files, requestOrigin); + const folderName = sanitizeFolderName(String(body.folderName || "")); + if (files.length === 0) { + return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 }); + } + + const task: ZipTask = { + id: randomUUID(), + userId: session.user.id, + folderName, + files, + totalCount: files.length, + processedCount: 0, + successCount: 0, + failedCount: 0, + compressPercent: 0, + status: "pending", + stage: "等待服务端开始打包", + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: Date.now() + TASK_TTL_MS, + }; + getTaskStore().set(task.id, task); + + void processZipTask({ + task, + requestOrigin, + cookie: request.headers.get("cookie") || "", + }); + + return NextResponse.json({ + success: true, + task: serializeTask(task), + }); + } catch (error) { + console.error("[CanvasDownloadZipTask] Create failed:", error); + return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + try { + cleanupExpiredTasks(); + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim(); + if (!taskId) { + return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 }); + } + + const task = getTaskStore().get(taskId); + if (!task || task.userId !== session.user.id) { + return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 }); + } + + if (request.nextUrl.searchParams.get("download") === "1") { + if (task.status !== "completed" || !task.zipBuffer) { + return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 }); + } + return new NextResponse(task.zipBuffer, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName), + "Content-Length": String(task.zipBuffer.length), + "Cache-Control": "no-store", + "X-Download-Success-Count": String(task.successCount), + "X-Download-Failed-Count": String(task.failedCount), + }, + }); + } + + return NextResponse.json({ + success: true, + task: serializeTask(task), + }); + } catch (error) { + console.error("[CanvasDownloadZipTask] Query failed:", error); + return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 }); + } +} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index a14d89b37..6a727a249 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -176,6 +176,46 @@ interface AgentWorkflowModelOption { interface PickedAttachment extends Attachment {} +type BatchDownloadScope = "scene" | "detail" | "sku"; +type BatchDownloadTaskStatus = + | "idle" + | "starting" + | "pending" + | "running" + | "completed" + | "failed"; + +type BatchDownloadTaskSnapshot = { + id: string; + folderName: string; + status: Exclude; + stage: string; + totalCount: number; + processedCount: number; + successCount: number; + failedCount: number; + progress: number; + error: string; + downloadUrl: string; + expiresAt: number; +}; + +type BatchDownloadDialogState = { + scope: BatchDownloadScope; + zipName: string; + status: BatchDownloadTaskStatus; + taskId: string; + totalCount: number; + processedCount: number; + successCount: number; + failedCount: number; + progress: number; + stage: string; + error: string; + downloadUrl: string; + expiresAt: number; +}; + const MAX_CHAT_TEXT_PART_LENGTH = 2000; const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; @@ -236,6 +276,38 @@ function normalizeChatMediaType(value?: string | null): string { return "image/jpeg"; } +function normalizeBatchDownloadTaskSnapshot( + value: unknown +): BatchDownloadTaskSnapshot | null { + const record = (value || {}) as Record; + const id = String(record.id || "").trim(); + const status = String(record.status || "").trim(); + if ( + !id || + (status !== "pending" && + status !== "running" && + status !== "completed" && + status !== "failed") + ) { + return null; + } + + return { + id, + folderName: String(record.folderName || "").trim(), + status, + stage: String(record.stage || "").trim(), + totalCount: Math.max(0, Number(record.totalCount || 0)), + processedCount: Math.max(0, Number(record.processedCount || 0)), + successCount: Math.max(0, Number(record.successCount || 0)), + failedCount: Math.max(0, Number(record.failedCount || 0)), + progress: Math.max(0, Math.min(100, Number(record.progress || 0))), + error: String(record.error || "").trim(), + downloadUrl: String(record.downloadUrl || "").trim(), + expiresAt: Math.max(0, Number(record.expiresAt || 0)), + }; +} + function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode { return modeType === "sku_replacement" ? "sku" : "main"; } @@ -1343,7 +1415,10 @@ export function AgentGenerationWorkspaceDarkV2({ const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState("main"); const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] = useState("scene_generation"); - const [batchDownloadState, setBatchDownloadState] = useState(null); + const [batchDownloadState, setBatchDownloadState] = + useState(null); + const [batchDownloadDialog, setBatchDownloadDialog] = + useState(null); const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{ isLoading: boolean; lastQuery: string | null; @@ -3481,7 +3556,7 @@ export function AgentGenerationWorkspaceDarkV2({ const handleBatchDownloadImages = useCallback(async (params: { imageUrls: string[]; zipName: string; - scope: "scene" | "detail" | "sku"; + scope: BatchDownloadScope; }) => { const normalizedUrls = (params.imageUrls || []) .map((item) => String(item || "").trim()) @@ -3492,6 +3567,21 @@ export function AgentGenerationWorkspaceDarkV2({ } setBatchDownloadState(params.scope); + setBatchDownloadDialog({ + scope: params.scope, + zipName: params.zipName, + status: "starting", + taskId: "", + totalCount: normalizedUrls.length, + processedCount: 0, + successCount: 0, + failedCount: 0, + progress: 4, + stage: "正在提交服务端打包任务", + error: "", + downloadUrl: "", + expiresAt: 0, + }); try { const batchFileBaseName = params.scope === "sku" @@ -3499,35 +3589,182 @@ export function AgentGenerationWorkspaceDarkV2({ : params.scope === "scene" ? "scene-result" : "detail-result"; - const form = document.createElement("form"); - form.method = "POST"; - form.action = "/api/canvas/download-zip"; - form.style.display = "none"; - const payloadInput = document.createElement("input"); - payloadInput.type = "hidden"; - payloadInput.name = "payload"; - payloadInput.value = JSON.stringify({ - folderName: params.zipName, - files: normalizedUrls.map((imageUrl) => ({ - url: imageUrl, - name: `${params.zipName}-${batchFileBaseName}`, - })), + const response = await fetch("/api/canvas/download-zip-task", { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ + folderName: params.zipName, + files: normalizedUrls.map((imageUrl) => ({ + url: imageUrl, + name: `${params.zipName}-${batchFileBaseName}`, + })), + }), }); - form.appendChild(payloadInput); + const payload = (await response.json().catch(() => null)) as + | { success?: boolean; task?: unknown; error?: string } + | null; + const task = normalizeBatchDownloadTaskSnapshot(payload?.task); + if (!response.ok || payload?.success !== true || !task) { + throw new Error(payload?.error || "创建打包任务失败"); + } - document.body.appendChild(form); - form.submit(); - form.remove(); - toast.success("已开始服务端打包下载,请留意浏览器下载栏"); + setBatchDownloadDialog((current) => ({ + scope: current?.scope || params.scope, + zipName: current?.zipName || params.zipName, + status: task.status, + taskId: task.id, + totalCount: task.totalCount, + processedCount: task.processedCount, + successCount: task.successCount, + failedCount: task.failedCount, + progress: task.progress, + stage: task.stage || "服务端正在打包", + error: task.error, + downloadUrl: task.downloadUrl, + expiresAt: task.expiresAt, + })); } catch (error) { - toast.error(error instanceof Error ? error.message : "批量下载失败"); - } finally { - window.setTimeout(() => { - setBatchDownloadState(null); - }, 1200); + const message = error instanceof Error ? error.message : "批量下载失败"; + setBatchDownloadState(null); + setBatchDownloadDialog((current) => ({ + scope: current?.scope || params.scope, + zipName: current?.zipName || params.zipName, + status: "failed", + taskId: current?.taskId || "", + totalCount: current?.totalCount || normalizedUrls.length, + processedCount: current?.processedCount || 0, + successCount: current?.successCount || 0, + failedCount: current?.failedCount || 0, + progress: current?.progress || 0, + stage: "打包任务创建失败", + error: message, + downloadUrl: "", + expiresAt: 0, + })); + toast.error(message); } }, []); + + useEffect(() => { + const taskId = batchDownloadDialog?.taskId; + const status = batchDownloadDialog?.status; + if (!taskId || (status !== "pending" && status !== "running")) { + return; + } + + let cancelled = false; + let timeoutId: number | null = null; + const pollTask = async () => { + try { + const response = await fetch( + `/api/canvas/download-zip-task?taskId=${encodeURIComponent(taskId)}`, + { cache: "no-store" } + ); + const payload = (await response.json().catch(() => null)) as + | { success?: boolean; task?: unknown; error?: string } + | null; + const task = normalizeBatchDownloadTaskSnapshot(payload?.task); + if (!response.ok || payload?.success !== true || !task) { + throw new Error(payload?.error || "查询打包进度失败"); + } + if (cancelled) { + return; + } + + setBatchDownloadDialog((current) => { + if (!current || current.taskId !== taskId) { + return current; + } + return { + ...current, + status: task.status, + totalCount: task.totalCount, + processedCount: task.processedCount, + successCount: task.successCount, + failedCount: task.failedCount, + progress: task.progress, + stage: task.stage || current.stage, + error: task.error, + downloadUrl: task.downloadUrl, + expiresAt: task.expiresAt, + }; + }); + + if (task.status === "completed") { + setBatchDownloadState(null); + toast.success("批量导出已打包完成,请在弹窗中下载"); + return; + } + if (task.status === "failed") { + setBatchDownloadState(null); + toast.error(task.error || "批量导出打包失败"); + return; + } + + timeoutId = window.setTimeout(pollTask, 1000); + } catch (error) { + if (cancelled) { + return; + } + const message = + error instanceof Error ? error.message : "查询打包进度失败"; + setBatchDownloadState(null); + setBatchDownloadDialog((current) => + current && current.taskId === taskId + ? { + ...current, + status: "failed", + stage: "查询打包进度失败", + error: message, + } + : current + ); + toast.error(message); + } + }; + + timeoutId = window.setTimeout(pollTask, 600); + + return () => { + cancelled = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + }; + }, [batchDownloadDialog?.status, batchDownloadDialog?.taskId]); + + const handleDownloadPreparedBatchZip = useCallback(() => { + if (!batchDownloadDialog?.downloadUrl) { + toast.error("下载文件还没有准备好"); + return; + } + + const link = document.createElement("a"); + link.href = batchDownloadDialog.downloadUrl; + link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + }, [batchDownloadDialog?.downloadUrl, batchDownloadDialog?.zipName]); + + const handleBatchDownloadDialogOpenChange = useCallback((open: boolean) => { + if (open) { + return; + } + setBatchDownloadDialog((current) => { + if ( + current?.status === "starting" || + current?.status === "pending" || + current?.status === "running" + ) { + toast.info("批量导出仍在处理中,完成后即可下载"); + return current; + } + return null; + }); + }, []); const handleSendResultToCanvasTextEdit = useCallback((params: { imageUrl: string; title: string; @@ -5567,6 +5804,23 @@ export function AgentGenerationWorkspaceDarkV2({ }; }, []); + const batchDownloadProgress = Math.round( + Math.max(0, Math.min(100, batchDownloadDialog?.progress || 0)) + ); + const isBatchDownloadRunning = + batchDownloadDialog?.status === "starting" || + batchDownloadDialog?.status === "pending" || + batchDownloadDialog?.status === "running"; + const isBatchDownloadCompleted = + batchDownloadDialog?.status === "completed"; + const isBatchDownloadFailed = batchDownloadDialog?.status === "failed"; + const batchDownloadScopeLabel = + batchDownloadDialog?.scope === "sku" + ? "SKU 替换结果" + : batchDownloadDialog?.scope === "scene" + ? "场景生图结果" + : "详情替换结果"; + return (
+ + + + + {isBatchDownloadCompleted ? ( + + ) : isBatchDownloadFailed ? ( + + ) : ( + + )} + 批量导出结果 + + + {isBatchDownloadCompleted + ? "服务端已完成打包,点击下方按钮下载 ZIP 文件。" + : isBatchDownloadFailed + ? "本次打包没有完成,可关闭弹窗后重新尝试。" + : `正在服务端打包 ${batchDownloadScopeLabel},页面可保持打开等待完成。`} + + + +
+
+
+
+
+ {batchDownloadDialog?.zipName || "批量导出"} +
+
+ {batchDownloadDialog?.stage || "准备打包"} +
+
+
+ {isBatchDownloadCompleted + ? "已完成" + : isBatchDownloadFailed + ? "失败" + : "处理中"} +
+
+ +
+
+ + 已处理 {batchDownloadDialog?.processedCount || 0}/ + {batchDownloadDialog?.totalCount || 0} + + + {batchDownloadProgress}% + +
+
+
+
+
+ +
+
+
总数
+
+ {batchDownloadDialog?.totalCount || 0} +
+
+
+
成功
+
+ {batchDownloadDialog?.successCount || 0} +
+
+
+
失败
+
+ {batchDownloadDialog?.failedCount || 0} +
+
+
+
+ + {isBatchDownloadCompleted && + (batchDownloadDialog?.failedCount || 0) > 0 ? ( +
+ 有 {batchDownloadDialog?.failedCount || 0} 张图片暂时无法下载,ZIP + 内已附带 _download-failures.txt,可用于排查源图链接。 +
+ ) : null} + + {isBatchDownloadFailed ? ( +
+ {batchDownloadDialog?.error || "打包下载失败,请稍后重试。"} +
+ ) : null} +
+ +
+ + +
+ +
+ {