修改:下单页面优化
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-11-03 15:30:19 +08:00
1 parent add2409f1f
commit 6dd4b4a724
7 files changed
+327 -51

No files matched your search

+3 -1
View File
@@ -68,7 +68,9 @@ export const jingdongConfig = {
generating: '正在生成效果图,请稍候…', generating: '正在生成效果图,请稍候…',
generatingTip: '效果图生成中,完成后可继续操作', generatingTip: '效果图生成中,完成后可继续操作',
downloadOk: '已开始下载效果图', downloadOk: '已开始下载效果图',
downloadFail: '下载失败,请稍后重试', downloadOpened:
'当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载',
downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存',
effectPreparing: '效果图准备中,请稍后再试', effectPreparing: '效果图准备中,请稍后再试',
generateFail: '这次没生成成功,请换个描述或照片再试', generateFail: '这次没生成成功,请换个描述或照片再试',
editFail: '这次调整没成功,请换个描述再试', editFail: '这次调整没成功,请换个描述再试',
+1 -1
View File
@@ -43,7 +43,7 @@ export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration
borderRadius: '12px', borderRadius: '12px',
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'center', objectPosition: 'center',
background: '#e8eaed', background: '#ffffff',
zIndex: '10000', zIndex: '10000',
pointerEvents: 'none', pointerEvents: 'none',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)', boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)',
+187
View File
@@ -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<HTMLImageElement>((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<Blob | null>((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<DownloadOutcome> {
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,或长按图片保存'
}
+1 -1
View File
@@ -53,7 +53,7 @@ const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = {
edit: '这次调整没成功,请换个描述再试', edit: '这次调整没成功,请换个描述再试',
upload: '照片上传失败,请换一张或稍后重试', upload: '照片上传失败,请换一张或稍后重试',
inspect: '照片识别失败,请换一张或稍后重试', inspect: '照片识别失败,请换一张或稍后重试',
download: '下载失败,请稍后重试', download: '下载失败,请换用其他浏览器,或长按效果图保存',
common: '操作失败了,请稍后重试', common: '操作失败了,请稍后重试',
} }
@@ -6,20 +6,20 @@
:src="loadingGif" :src="loadingGif"
alt="" alt=""
draggable="false" draggable="false"
:style="{ opacity: gifOpacity }" :style="gifStyle"
/> />
<img <img
v-if="resultUrl" v-if="resultUrl"
class="jd-stage__result" class="jd-stage__result"
:src="resultUrl" :src="resultUrl"
alt="" alt=""
:style="{ opacity: reveal }" :style="resultStyle"
@load="onResultLoad" @load="onResultLoad"
/> />
<p class="jd-stage__status" :style="{ opacity: Math.max(0, 1 - reveal * 1.4) }"> <p class="jd-stage__status" :style="{ opacity: overlayOpacity }">
{{ statusLabel }} {{ statusLabel }}
</p> </p>
<span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: Math.max(0, 1 - reveal * 1.2) }"> <span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: overlayOpacity }">
{{ progressLabel }} {{ progressLabel }}
</span> </span>
</div> </div>
@@ -27,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import loadingGif from '@/assets/jingdong/picture_cloud.gif' import loadingGif from '@/assets/jingdong/picture_cloud.gif'
const props = defineProps<{ const props = defineProps<{
@@ -41,9 +41,14 @@ const emit = defineEmits<{
}>() }>()
const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const
const REVEAL_MS = 1600 /** 交叉淡入总时长:偏慢,避免突兀闪切 */
const REVEAL_MS = 2800
/** 效果图已完全显现后再停留片刻,再交给结果卡片,减少组件切换闪烁 */
const HOLD_MS = 480
/** gif 从何时开始明显淡出(0~1) */
const GIF_FADE_START = 0.28
const phase = ref<'waiting' | 'reveal'>('waiting') const phase = ref<'waiting' | 'reveal' | 'hold'>('waiting')
const progress = ref(0) const progress = ref(0)
const displayProgress = ref(0) const displayProgress = ref(0)
const reveal = ref(0) const reveal = ref(0)
@@ -55,6 +60,8 @@ let raf = 0
let finished = false let finished = false
let startedAt = performance.now() let startedAt = performance.now()
let revealStarted = 0 let revealStarted = 0
let holdStarted = 0
let readyToken = 0
const progressLabel = computed(() => { const progressLabel = computed(() => {
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0 const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
@@ -62,12 +69,27 @@ const progressLabel = computed(() => {
}) })
const statusLabel = computed(() => { const statusLabel = computed(() => {
if (phase.value === 'reveal') return '即将完成' if (phase.value === 'reveal' || phase.value === 'hold') return '即将完成'
const elapsed = Math.max(0, clock.value - startedAt) const elapsed = Math.max(0, clock.value - startedAt)
const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
return WAIT_STATUS[index] return WAIT_STATUS[index]
}) })
const overlayOpacity = computed(() => {
if (phase.value === 'hold') return 0
return Math.max(0, 1 - reveal.value * 1.15)
})
const gifStyle = computed(() => ({
opacity: gifOpacity.value,
transform: `scale(${1 + reveal.value * 0.02})`,
}))
const resultStyle = computed(() => ({
opacity: reveal.value,
transform: `scale(${1.03 - reveal.value * 0.03})`,
}))
function safeProgress(value: number) { function safeProgress(value: number) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, value)) return Math.max(0, Math.min(100, value))
@@ -83,6 +105,19 @@ function smoother(value: number) {
return amount * amount * amount * (amount * (amount * 6 - 15) + 10) return amount * amount * amount * (amount * (amount * 6 - 15) + 10)
} }
/** 结果先淡入,gif 稍后错开淡出,中间始终有一层可见,避免空闪 */
function applyRevealAmount(raw: number) {
const amount = smoother(raw)
reveal.value = amount
if (raw <= GIF_FADE_START) {
gifOpacity.value = 1
} else {
const gifRaw = (raw - GIF_FADE_START) / (1 - GIF_FADE_START)
gifOpacity.value = Math.max(0, 1 - smoother(gifRaw))
}
progress.value = safeProgress(88 + amount * 12)
}
function finish() { function finish() {
if (finished) return if (finished) return
finished = true finished = true
@@ -105,40 +140,73 @@ function tick(now: number) {
revealStarted = now revealStarted = now
} }
} else if (phase.value === 'reveal') { } else if (phase.value === 'reveal') {
const amount = smoother((now - revealStarted) / REVEAL_MS) const raw = Math.min(1, (now - revealStarted) / REVEAL_MS)
reveal.value = amount applyRevealAmount(raw)
gifOpacity.value = Math.max(0, 1 - amount) if (raw >= 1) {
progress.value = safeProgress(88 + amount * 12) phase.value = 'hold'
if (amount >= 1) { holdStarted = now
gifOpacity.value = 0
reveal.value = 1
progress.value = 100
}
} else if (phase.value === 'hold') {
gifOpacity.value = 0
reveal.value = 1
progress.value = 100
if (now - holdStarted >= HOLD_MS) {
finish() finish()
return return
} }
} }
const gap = progress.value - displayProgress.value const gap = progress.value - displayProgress.value
displayProgress.value = safeProgress(displayProgress.value + gap * 0.12) displayProgress.value = safeProgress(displayProgress.value + gap * 0.08)
if (Math.abs(gap) < 0.05) displayProgress.value = progress.value if (Math.abs(gap) < 0.05) displayProgress.value = progress.value
if (!finished) raf = window.requestAnimationFrame(tick) if (!finished) raf = window.requestAnimationFrame(tick)
} }
function onResultLoad() { async function prepareResult(url: string) {
const token = ++readyToken
resultReady.value = false
if (!url) return
try {
const img = new Image()
img.src = url
if (typeof img.decode === 'function') {
await img.decode()
} else {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('load failed'))
})
}
} catch {
// 解码失败仍继续,避免卡住
}
if (token !== readyToken) return
await nextTick()
// 再等一帧,确保 DOM 已挂上结果层再开淡入
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve())
})
if (token !== readyToken) return
resultReady.value = true resultReady.value = true
} }
function onResultLoad() {
if (props.resultUrl) void prepareResult(props.resultUrl)
}
watch( watch(
() => props.resultUrl, () => props.resultUrl,
(url) => { (url) => {
if (!url) { if (!url) {
readyToken += 1
resultReady.value = false resultReady.value = false
return return
} }
// 可能已缓存,直接进入 reveal void prepareResult(url)
const img = new Image()
img.onload = () => {
resultReady.value = true
}
img.src = url
}, },
{ immediate: true }, { immediate: true },
) )
@@ -159,6 +227,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
readyToken += 1
window.cancelAnimationFrame(raf) window.cancelAnimationFrame(raf)
}) })
</script> </script>
@@ -188,6 +257,8 @@ onUnmounted(() => {
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
pointer-events: none; pointer-events: none;
will-change: opacity, transform;
backface-visibility: hidden;
} }
.jd-stage__gif { .jd-stage__gif {
@@ -196,7 +267,8 @@ onUnmounted(() => {
.jd-stage__result { .jd-stage__result {
z-index: 3; z-index: 3;
object-fit: contain; /* 与结果卡片一致,避免切换时构图跳变 */
object-position: center top;
background: #f0f4f9; background: #f0f4f9;
} }
@@ -212,6 +284,7 @@ onUnmounted(() => {
line-height: 1.3; line-height: 1.3;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9); text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
pointer-events: none; pointer-events: none;
transition: opacity 0.25s ease;
} }
.jd-stage__percent { .jd-stage__percent {
@@ -233,5 +306,6 @@ onUnmounted(() => {
line-height: 1; line-height: 1;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
pointer-events: none; pointer-events: none;
transition: opacity 0.25s ease;
} }
</style> </style>
@@ -90,6 +90,18 @@ function onEditClick() {
border: 1.5px solid transparent; 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 { .result-card.is-editing .result-card__media {
border-color: #3a8e4d; border-color: #3a8e4d;
} }
@@ -114,6 +126,7 @@ function onEditClick() {
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
animation: jd-result-chrome-in 0.55s ease both;
} }
.result-card__view { .result-card__view {
@@ -148,6 +161,7 @@ function onEditClick() {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
line-height: 1.4; line-height: 1.4;
animation: jd-result-chrome-in 0.55s ease 0.06s both;
} }
.result-card__buy { .result-card__buy {
@@ -166,6 +180,7 @@ function onEditClick() {
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease; transition: opacity 0.15s ease, transform 0.15s ease;
animation: jd-result-chrome-in 0.55s ease 0.12s both;
&:active { &:active {
opacity: 0.9; opacity: 0.9;
+24 -26
View File
@@ -132,6 +132,7 @@ import {
type ImageInspect, type ImageInspect,
} from '@/services/jingdong/image-inspect' } from '@/services/jingdong/image-inspect'
import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image' import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image'
import { downloadFallbackTip, downloadImageCompat } from '@/utils/jingdong-download'
import { friendlyError } from '@/utils/jingdong-error' import { friendlyError } from '@/utils/jingdong-error'
import { import {
productTitleSpecFromOrder, productTitleSpecFromOrder,
@@ -497,33 +498,30 @@ function previewImages(images: string[], startPosition = 0) {
} }
async function downloadImage(url: string) { async function downloadImage(url: string) {
try { const href = String(url || '').trim()
const res = await fetch(url) if (!href) {
const blob = await res.blob() showToast(config.messages.downloadFail)
const objectUrl = URL.createObjectURL(blob) return
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()
showToast(config.messages.downloadOk)
} catch (error) {
showToast(friendlyError(error, 'download', config.messages.downloadFail))
} }
try {
const outcome = await downloadImageCompat(href, `suwa-figure-${Date.now()}.png`)
if (outcome === 'saved') {
showToast(config.messages.downloadOk)
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,
})
} }
} }