From 6dd4b4a7244e8ae68eb85e0d0a9d3d800e036dd2 Mon Sep 17 00:00:00 2001 From: Linzm <1015157648@qq.com> Date: Tue, 3 Nov 2026 15:30:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=EF=BC=9A=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/jingdong.ts | 4 +- src/utils/fly-image.ts | 2 +- src/utils/jingdong-download.ts | 187 ++++++++++++++++++ src/utils/jingdong-error.ts | 2 +- .../jingdong/components/GenerationStage.vue | 118 ++++++++--- src/views/jingdong/components/ResultCard.vue | 15 ++ src/views/jingdong/index.vue | 46 +++-- 7 files changed, 325 insertions(+), 49 deletions(-) create mode 100644 src/utils/jingdong-download.ts diff --git a/src/config/jingdong.ts b/src/config/jingdong.ts index 8cfbace..c78b201 100644 --- a/src/config/jingdong.ts +++ b/src/config/jingdong.ts @@ -68,7 +68,9 @@ export const jingdongConfig = { generating: '正在生成效果图,请稍候…', generatingTip: '效果图生成中,完成后可继续操作', downloadOk: '已开始下载效果图', - downloadFail: '下载失败,请稍后重试', + downloadOpened: + '当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载', + downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存', effectPreparing: '效果图准备中,请稍后再试', generateFail: '这次没生成成功,请换个描述或照片再试', editFail: '这次调整没成功,请换个描述再试', diff --git a/src/utils/fly-image.ts b/src/utils/fly-image.ts index ca8f09f..628ffd0 100644 --- a/src/utils/fly-image.ts +++ b/src/utils/fly-image.ts @@ -43,7 +43,7 @@ export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration borderRadius: '12px', objectFit: 'contain', objectPosition: 'center', - background: '#e8eaed', + background: '#ffffff', zIndex: '10000', pointerEvents: 'none', boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)', diff --git a/src/utils/jingdong-download.ts b/src/utils/jingdong-download.ts new file mode 100644 index 0000000..13957f3 --- /dev/null +++ b/src/utils/jingdong-download.ts @@ -0,0 +1,187 @@ +/** 效果图下载:尽量兼容各端浏览器,失败时回落打开大图并提示 */ + +export type DownloadOutcome = 'saved' | 'opened' | 'failed' + +function filenameFromUrl(url: string, fallback: string) { + try { + const path = new URL(url, window.location.href).pathname + const name = decodeURIComponent(path.split('/').pop() || '') + if (/\.(jpe?g|png|webp|gif)$/i.test(name)) return name + } catch { + /* ignore */ + } + return fallback +} + +function extFromMime(type: string) { + const mime = String(type || '').toLowerCase() + if (mime.includes('png')) return 'png' + if (mime.includes('webp')) return 'webp' + if (mime.includes('gif')) return 'gif' + if (mime.includes('jpeg') || mime.includes('jpg')) return 'jpg' + return '' +} + +function ensureFilename(name: string, mime = '') { + const raw = String(name || '').trim() || `suwa-figure-${Date.now()}.png` + if (/\.(jpe?g|png|webp|gif)$/i.test(raw)) return raw + const ext = extFromMime(mime) || 'png' + return `${raw}.${ext}` +} + +function imageFetchTarget(url: string) { + if (url.startsWith('blob:') || url.startsWith('data:')) { + return { url, cache: 'default' as RequestCache } + } + try { + const parsed = new URL(url, window.location.href) + if (parsed.origin === window.location.origin) { + return { url, cache: 'default' as RequestCache } + } + // 部分 OSS 对带缓存的跨域 GET 不稳定,加时间戳并禁缓存 + const busted = /\.aliyuncs\.com$/i.test(parsed.hostname) + ? `${url}${url.includes('?') ? '&' : '?'}_=${Date.now()}` + : url + return { url: busted, cache: 'no-store' as RequestCache } + } catch { + return { url, cache: 'default' as RequestCache } + } +} + +function isWeChatBrowser() { + return /MicroMessenger/i.test(navigator.userAgent || '') +} + +function isInAppBrowser() { + const ua = navigator.userAgent || '' + return /MicroMessenger|QQ\//i.test(ua) || /; wv\)/i.test(ua) +} + +async function fetchImageBlob(url: string) { + const target = imageFetchTarget(url) + const response = await fetch(target.url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: target.cache, + }) + if (!response.ok) throw new Error(`图片读取失败(${response.status})`) + const blob = await response.blob() + if (!blob || blob.size <= 0) throw new Error('图片内容为空') + return blob +} + +function loadImage(url: string, crossOrigin?: string) { + return new Promise((resolve, reject) => { + const image = new Image() + if (crossOrigin) image.crossOrigin = crossOrigin + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('图片加载失败')) + image.src = url + }) +} + +async function canvasToBlob(url: string) { + const image = await loadImage(url, 'anonymous') + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, image.naturalWidth || image.width) + canvas.height = Math.max(1, image.naturalHeight || image.height) + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('当前浏览器不支持画布导出') + ctx.drawImage(image, 0, 0) + const blob = await new Promise((resolve) => { + canvas.toBlob((value) => resolve(value), 'image/png') + }) + if (!blob) throw new Error('画布导出失败') + return blob +} + +function triggerAnchorDownload(href: string, filename: string, openBlank = false) { + const link = document.createElement('a') + link.href = href + link.download = filename + link.rel = 'noopener' + if (openBlank) link.target = '_blank' + link.style.display = 'none' + document.body.appendChild(link) + link.click() + link.remove() +} + +function saveBlob(blob: Blob, filename: string) { + const name = ensureFilename(filename, blob.type) + const nav = navigator as Navigator & { + msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean + } + // 旧版 Edge / IE + if (typeof nav.msSaveOrOpenBlob === 'function') { + nav.msSaveOrOpenBlob(blob, name) + return + } + const objectUrl = URL.createObjectURL(blob) + try { + triggerAnchorDownload(objectUrl, name) + } finally { + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 2_000) + } +} + +function openImageFallback(url: string) { + try { + const opened = window.open(url, '_blank', 'noopener,noreferrer') + if (opened) return true + } catch { + /* ignore */ + } + try { + triggerAnchorDownload(url, ensureFilename(`suwa-figure-${Date.now()}.png`), true) + return true + } catch { + return false + } +} + +/** + * 尽量触发本地下载;失败则打开大图,由调用方提示长按保存 / 换浏览器。 + */ +export async function downloadImageCompat( + url: string, + filename = `suwa-figure-${Date.now()}.png`, +): Promise { + const href = String(url || '').trim() + if (!href) return 'failed' + + const name = ensureFilename(filenameFromUrl(href, filename)) + + // 1) fetch → blob → a[download] / msSaveOrOpenBlob + try { + const blob = await fetchImageBlob(href) + saveBlob(blob, name) + return 'saved' + } catch { + /* continue */ + } + + // 2) Image + canvas 导出(部分可匿名跨域的 CDN) + try { + const blob = await canvasToBlob(href) + saveBlob(blob, name) + return 'saved' + } catch { + /* continue */ + } + + // 3) 打开大图兜底(微信 / iOS 内置浏览器等常只能长按保存) + if (openImageFallback(href)) return 'opened' + return 'failed' +} + +export function downloadFallbackTip() { + if (isWeChatBrowser()) { + return '微信内暂不支持直接下载,请点右上角「···」用浏览器打开后保存,或长按图片保存' + } + if (isInAppBrowser()) { + return '当前浏览器无法直接下载,请用系统浏览器打开后保存,或长按图片保存' + } + return '当前浏览器无法直接下载,请换用 Safari / Chrome,或长按图片保存' +} diff --git a/src/utils/jingdong-error.ts b/src/utils/jingdong-error.ts index 127d82c..ff6db7b 100644 --- a/src/utils/jingdong-error.ts +++ b/src/utils/jingdong-error.ts @@ -53,7 +53,7 @@ const SCENE_FALLBACK: Record = { edit: '这次调整没成功,请换个描述再试', upload: '照片上传失败,请换一张或稍后重试', inspect: '照片识别失败,请换一张或稍后重试', - download: '下载失败,请稍后重试', + download: '下载失败,请换用其他浏览器,或长按效果图保存', common: '操作失败了,请稍后重试', } diff --git a/src/views/jingdong/components/GenerationStage.vue b/src/views/jingdong/components/GenerationStage.vue index 8effca9..05b3d35 100644 --- a/src/views/jingdong/components/GenerationStage.vue +++ b/src/views/jingdong/components/GenerationStage.vue @@ -6,20 +6,20 @@ :src="loadingGif" alt="" draggable="false" - :style="{ opacity: gifOpacity }" + :style="gifStyle" /> -

+

{{ statusLabel }}

- + {{ progressLabel }} @@ -27,7 +27,7 @@ @@ -188,6 +257,8 @@ onUnmounted(() => { object-fit: cover; object-position: center; pointer-events: none; + will-change: opacity, transform; + backface-visibility: hidden; } .jd-stage__gif { @@ -196,7 +267,8 @@ onUnmounted(() => { .jd-stage__result { z-index: 3; - object-fit: contain; + /* 与结果卡片一致,避免切换时构图跳变 */ + object-position: center top; background: #f0f4f9; } @@ -212,6 +284,7 @@ onUnmounted(() => { line-height: 1.3; text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9); pointer-events: none; + transition: opacity 0.25s ease; } .jd-stage__percent { @@ -233,5 +306,6 @@ onUnmounted(() => { line-height: 1; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); pointer-events: none; + transition: opacity 0.25s ease; } diff --git a/src/views/jingdong/components/ResultCard.vue b/src/views/jingdong/components/ResultCard.vue index 5897623..321e21b 100644 --- a/src/views/jingdong/components/ResultCard.vue +++ b/src/views/jingdong/components/ResultCard.vue @@ -90,6 +90,18 @@ function onEditClick() { border: 1.5px solid transparent; } +/* 从生成态切过来时,操作层缓入,避免整卡突兀弹出 */ +@keyframes jd-result-chrome-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .result-card.is-editing .result-card__media { border-color: #3a8e4d; } @@ -114,6 +126,7 @@ function onEditClick() { color: #fff; background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px); + animation: jd-result-chrome-in 0.55s ease both; } .result-card__view { @@ -148,6 +161,7 @@ function onEditClick() { font-size: 12px; color: #999; line-height: 1.4; + animation: jd-result-chrome-in 0.55s ease 0.06s both; } .result-card__buy { @@ -166,6 +180,7 @@ function onEditClick() { font-weight: 600; cursor: pointer; transition: opacity 0.15s ease, transform 0.15s ease; + animation: jd-result-chrome-in 0.55s ease 0.12s both; &:active { opacity: 0.9; diff --git a/src/views/jingdong/index.vue b/src/views/jingdong/index.vue index 1c9cc87..e7257b9 100644 --- a/src/views/jingdong/index.vue +++ b/src/views/jingdong/index.vue @@ -132,6 +132,7 @@ import { type ImageInspect, } from '@/services/jingdong/image-inspect' import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image' +import { downloadFallbackTip, downloadImageCompat } from '@/utils/jingdong-download' import { friendlyError } from '@/utils/jingdong-error' import { productTitleSpecFromOrder, @@ -497,33 +498,30 @@ function previewImages(images: string[], startPosition = 0) { } async function downloadImage(url: string) { + const href = String(url || '').trim() + if (!href) { + showToast(config.messages.downloadFail) + return + } try { - const res = await fetch(url) - const blob = await res.blob() - const objectUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = objectUrl - a.download = `suwa-figure-${Date.now()}.png` - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(objectUrl) - showToast(config.messages.downloadOk) - } catch { - // 跨域或本地资源失败时直接打开新窗口兜底 - try { - const a = document.createElement('a') - a.href = url - a.target = '_blank' - a.rel = 'noopener' - a.download = `suwa-figure-${Date.now()}.png` - document.body.appendChild(a) - a.click() - a.remove() + const outcome = await downloadImageCompat(href, `suwa-figure-${Date.now()}.png`) + if (outcome === 'saved') { showToast(config.messages.downloadOk) - } catch (error) { - showToast(friendlyError(error, 'download', config.messages.downloadFail)) + return } + if (outcome === 'opened') { + showToast({ + message: downloadFallbackTip() || config.messages.downloadOpened, + duration: 3600, + }) + return + } + showToast({ message: config.messages.downloadFail, duration: 3200 }) + } catch (error) { + showToast({ + message: friendlyError(error, 'download', config.messages.downloadFail), + duration: 3200, + }) } }