From bbecafc82688ce65d02bb2cf06bde9f4ce86ef50 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 11 Nov 2026 14:59:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=8B=E8=BD=BD=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E5=9B=BE=E7=89=87=E6=A0=BC=E5=BC=8F=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/canvas/CanvasChatPanel.tsx | 1 - .../CanvasImageDownloadProgressDialog.tsx | 16 ++++++--- components/canvas/nodes/ImageNode.tsx | 1 - lib/client/canvas-image-download.ts | 33 ++++++++++--------- tests/routes/original-image-download.test.ts | 29 +++++++++++++--- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 5331ddf28..b88bc921b 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -4467,7 +4467,6 @@ export function CanvasChatPanel({ setDownloadProgress({ phase: "preparing", message: "正在准备原图...", - percent: 5, }); try { diff --git a/components/canvas/CanvasImageDownloadProgressDialog.tsx b/components/canvas/CanvasImageDownloadProgressDialog.tsx index 48f0e20d8..d7287a8d2 100644 --- a/components/canvas/CanvasImageDownloadProgressDialog.tsx +++ b/components/canvas/CanvasImageDownloadProgressDialog.tsx @@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({ : null; const loadedLabel = formatBytes(progress.loadedBytes); const totalLabel = formatBytes(progress.totalBytes); + const converting = progress.phase === "converting"; return createPortal(
) : ( - + )}
下载原图
-
{progress.message}
+
{progress.message}
{onCancel ? ( @@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
- {loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"} + {loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"} {percent !== null ? `${percent}%` : "..."}
-
+
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index cbd4e6253..1e77b3938 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -8236,7 +8236,6 @@ export function ImageNode({ setDownloadProgress({ phase: "preparing", message: "正在准备原图...", - percent: 5, }); try { diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 33f9251a5..da6aeaafa 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -2,7 +2,7 @@ import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-deliv import { inferOriginalImageUrl } from "@/lib/image-url"; import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; -export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; +export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving"; export type CanvasImageDownloadProgress = { phase: CanvasImageDownloadPhase; @@ -224,7 +224,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams): // The resolver names the attachment using the stored MIME type. const extension = delivery.filename.split(".").pop(); if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { - return downloadCanvasImageAsPng(params); + return downloadCanvasImageAsPng(params, "converting"); } const result = triggerCdnDownload(delivery, signal); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); @@ -239,7 +239,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams): // A local blob can still be downloaded while its upload is in progress. if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { void response.body?.cancel().catch(() => {}); - return downloadCanvasImageAsPng(params); + return downloadCanvasImageAsPng(params, "converting"); } const blob = await readResponseBlobWithProgress(response, onProgress); signal?.throwIfAborted(); @@ -267,14 +267,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) { async function readResponseBlobWithProgress( response: Response, - onProgress?: (progress: CanvasImageDownloadProgress) => void + onProgress?: (progress: CanvasImageDownloadProgress) => void, + message = "正在下载原图..." ) { const totalBytes = Number(response.headers.get("content-length") || 0); if (!response.body) { const blob = await response.blob(); onProgress?.({ phase: "downloading", - message: "正在下载原图...", + message, loadedBytes: blob.size, totalBytes: totalBytes || blob.size, percent: 100, @@ -294,7 +295,7 @@ async function readResponseBlobWithProgress( loadedBytes += value.byteLength; onProgress?.({ phase: "downloading", - message: "正在下载原图...", + message, loadedBytes, totalBytes: totalBytes || undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, @@ -312,7 +313,7 @@ export async function downloadCanvasImageAsPng({ onProgress, signal, url, -}: DownloadCanvasImageParams): Promise { +}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting" = "preparing"): Promise { const raw = String(url || "").trim(); if (!raw) { throw new Error("图片地址无效"); @@ -325,11 +326,9 @@ export async function downloadCanvasImageAsPng({ "canvas-image" ); - onProgress?.({ - phase: "preparing", - message: "正在准备原图...", - percent: 8, - }); + const isConverting = preparationPhase === "converting"; + const conversionMessage = "正在转换为兼容 PNG,完成后自动下载"; + onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." }); let response: Response | null = null; let errorPayload: DownloadErrorPayload | null = null; @@ -368,7 +367,10 @@ export async function downloadCanvasImageAsPng({ if (Date.now() >= preparationDeadline || preparationPolls >= 60) { throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); } - onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); + onProgress?.({ + phase: preparationPhase, + message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载", + }); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); await waitForRetry(delaySeconds * 1000, signal); continue; @@ -399,7 +401,6 @@ export async function downloadCanvasImageAsPng({ retryAfterSeconds > 0 ? `请求较多,${retryAfterSeconds} 秒后自动重试...` : "请求较多,正在自动重试...", - percent: 8, }); await waitForRetry(retryAfterSeconds * 1000, signal); } @@ -428,7 +429,9 @@ export async function downloadCanvasImageAsPng({ const filename = parseAttachmentFilename(response.headers.get("content-disposition")) || `${filenameBase}.png`; - const blob = await readResponseBlobWithProgress(response, onProgress); + const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图..."; + onProgress?.({ phase: "downloading", message: downloadMessage }); + const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage); onProgress?.({ phase: "saving", diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts index cb4e41c84..14be81c52 100644 --- a/tests/routes/original-image-download.test.ts +++ b/tests/routes/original-image-download.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { serverLoader } from "../helpers/canvas-download-server-loader"; +import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download"; const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" }; @@ -124,16 +125,18 @@ function browserMocks() { test("chat original downloads use a native CDN download without fetching the image through CORS", async () => { const dom = browserMocks(); const calls: string[] = []; + const progress: CanvasImageDownloadProgress[] = []; const client = serverLoader({ ...dom, fetch: async (url: string) => { calls.push(url); return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 }); } })("lib/client/canvas-image-download.ts"); - const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); + const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }); expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 }); expect(calls).toHaveLength(1); expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve"); expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg"); expect(dom.links).toHaveLength(0); + expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]); }); test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => { @@ -149,6 +152,7 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy test("one click prepares a compatible PNG when the CDN original is WebP", async () => { const dom = browserMocks(); const calls: string[] = []; + const progress: CanvasImageDownloadProgress[] = []; const url = "https://cdn.example/image.png"; const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => { calls.push(path); @@ -157,10 +161,19 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async } expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" }); + if (calls.length === 2) { + return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 }); + } return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 }); } })("lib/client/canvas-image-download.ts"); - expect(await client.downloadOriginalImage({ url, name: "商品" })).toEqual({ filename: "商品.png", bytes: 900 }); - expect(calls[1]).toBe("/api/canvas/download-image"); + expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) })) + .toEqual({ filename: "商品.png", bytes: 900 }); + expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]); + expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]); + for (const event of progress.filter(event => event.phase === "converting")) { + expect(event.message).toContain("正在转换为兼容 PNG"); + expect(event.percent).toBeUndefined(); + } expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]); expect(dom.links).toHaveLength(0); }); @@ -168,6 +181,7 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => { const dom = browserMocks(); const calls: string[] = []; + const progress: CanvasImageDownloadProgress[] = []; let cancelled = false; const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10); const client = serverLoader({ ...dom, fetch: async (path: string) => { @@ -177,11 +191,16 @@ test("one click uses the PNG preparation route without buffering an incompatible return new Response(new ReadableStream({ cancel() { cancelled = true; } }), { headers: { "content-type": "image/avif" } }); } - return new Response(bytes, { headers: { "content-type": "image/png" } }); + return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } }); } })("lib/client/canvas-image-download.ts"); - expect(await client.downloadOriginalImage({ url: "https://external.example/image", name: "商品" })).toEqual({ filename: "商品.png", bytes: bytes.length }); + expect(await client.downloadOriginalImage({ + url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event), + })).toEqual({ filename: "商品.png", bytes: bytes.length }); expect(cancelled).toBe(true); expect(calls[2]).toBe("/api/canvas/download-image"); + expect(progress).toContainEqual({ + phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99, + }); expect(dom.links).toHaveLength(1); URL.revokeObjectURL(dom.links[0].href); });