fix: add progress dialog for batch result exports
This commit is contained in:
parent
fb240616e0
commit
87f23a0ab6
367
app/api/canvas/download-zip-task/route.ts
Normal file
367
app/api/canvas/download-zip-task/route.ts
Normal file
@ -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<string, ZipTask>;
|
||||
};
|
||||
|
||||
function getTaskStore() {
|
||||
if (!globalTaskStore.__canvasDownloadZipTasks) {
|
||||
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
|
||||
}
|
||||
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<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();
|
||||
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<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||
| 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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@ -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<BatchDownloadTaskStatus, "idle" | "starting">;
|
||||
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<string, unknown>;
|
||||
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<AgentPsdTemplateMode>("main");
|
||||
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
|
||||
const [batchDownloadState, setBatchDownloadState] =
|
||||
useState<null | BatchDownloadScope>(null);
|
||||
const [batchDownloadDialog, setBatchDownloadDialog] =
|
||||
useState<BatchDownloadDialogState | null>(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 (
|
||||
<div
|
||||
ref={workspaceRootRef}
|
||||
@ -7706,6 +7960,143 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
onApplySuccess={handleApplyReplacementTextEditResult}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(batchDownloadDialog)}
|
||||
onOpenChange={handleBatchDownloadDialogOpenChange}
|
||||
>
|
||||
<DialogContent className="border-white/10 bg-[#101112] text-white shadow-[0_30px_90px_rgba(0,0,0,0.65)] sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
{isBatchDownloadCompleted ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-300" />
|
||||
) : isBatchDownloadFailed ? (
|
||||
<X className="h-5 w-5 text-red-300" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-300" />
|
||||
)}
|
||||
批量导出结果
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/52">
|
||||
{isBatchDownloadCompleted
|
||||
? "服务端已完成打包,点击下方按钮下载 ZIP 文件。"
|
||||
: isBatchDownloadFailed
|
||||
? "本次打包没有完成,可关闭弹窗后重新尝试。"
|
||||
: `正在服务端打包 ${batchDownloadScopeLabel},页面可保持打开等待完成。`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">
|
||||
{batchDownloadDialog?.zipName || "批量导出"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/45">
|
||||
{batchDownloadDialog?.stage || "准备打包"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs",
|
||||
isBatchDownloadCompleted
|
||||
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
|
||||
: isBatchDownloadFailed
|
||||
? "border-red-400/25 bg-red-500/10 text-red-100"
|
||||
: "border-sky-400/25 bg-sky-500/10 text-sky-100"
|
||||
)}
|
||||
>
|
||||
{isBatchDownloadCompleted
|
||||
? "已完成"
|
||||
: isBatchDownloadFailed
|
||||
? "失败"
|
||||
: "处理中"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs">
|
||||
<span className="text-white/45">
|
||||
已处理 {batchDownloadDialog?.processedCount || 0}/
|
||||
{batchDownloadDialog?.totalCount || 0}
|
||||
</span>
|
||||
<span className="font-medium text-white/72">
|
||||
{batchDownloadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
isBatchDownloadFailed
|
||||
? "bg-red-400"
|
||||
: "bg-gradient-to-r from-emerald-500 via-teal-300 to-sky-400"
|
||||
)}
|
||||
style={{ width: `${batchDownloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<div className="text-white/42">总数</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{batchDownloadDialog?.totalCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-2">
|
||||
<div className="text-emerald-100/55">成功</div>
|
||||
<div className="mt-1 font-semibold text-emerald-100">
|
||||
{batchDownloadDialog?.successCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-400/15 bg-red-500/10 px-3 py-2">
|
||||
<div className="text-red-100/55">失败</div>
|
||||
<div className="mt-1 font-semibold text-red-100">
|
||||
{batchDownloadDialog?.failedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBatchDownloadCompleted &&
|
||||
(batchDownloadDialog?.failedCount || 0) > 0 ? (
|
||||
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-amber-50/80 text-xs leading-relaxed">
|
||||
有 {batchDownloadDialog?.failedCount || 0} 张图片暂时无法下载,ZIP
|
||||
内已附带 _download-failures.txt,可用于排查源图链接。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBatchDownloadFailed ? (
|
||||
<div className="rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-red-50/80 text-xs leading-relaxed">
|
||||
{batchDownloadDialog?.error || "打包下载失败,请稍后重试。"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/60 hover:bg-white/10 hover:text-white"
|
||||
disabled={isBatchDownloadRunning}
|
||||
onClick={() => setBatchDownloadDialog(null)}
|
||||
>
|
||||
{isBatchDownloadRunning ? "处理中" : "关闭"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isBatchDownloadCompleted || !batchDownloadDialog?.downloadUrl}
|
||||
className="rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 disabled:opacity-45"
|
||||
onClick={handleDownloadPreparedBatchZip}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(replacementRerunTarget?.open)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user