forked from natuka/web.puabadge.com
修改:下单页面优化
This commit is contained in:
1 parent
add2409f1f
commit
6dd4b4a724
7 files changed
+325
-49
No files matched your search
@@ -68,7 +68,9 @@ export const jingdongConfig = {
|
||||
generating: '正在生成效果图,请稍候…',
|
||||
generatingTip: '效果图生成中,完成后可继续操作',
|
||||
downloadOk: '已开始下载效果图',
|
||||
downloadFail: '下载失败,请稍后重试',
|
||||
downloadOpened:
|
||||
'当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载',
|
||||
downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||
effectPreparing: '效果图准备中,请稍后再试',
|
||||
generateFail: '这次没生成成功,请换个描述或照片再试',
|
||||
editFail: '这次调整没成功,请换个描述再试',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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,或长按图片保存'
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = {
|
||||
edit: '这次调整没成功,请换个描述再试',
|
||||
upload: '照片上传失败,请换一张或稍后重试',
|
||||
inspect: '照片识别失败,请换一张或稍后重试',
|
||||
download: '下载失败,请稍后重试',
|
||||
download: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||
common: '操作失败了,请稍后重试',
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
:src="loadingGif"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="{ opacity: gifOpacity }"
|
||||
:style="gifStyle"
|
||||
/>
|
||||
<img
|
||||
v-if="resultUrl"
|
||||
class="jd-stage__result"
|
||||
:src="resultUrl"
|
||||
alt=""
|
||||
:style="{ opacity: reveal }"
|
||||
:style="resultStyle"
|
||||
@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 }}
|
||||
</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 }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -41,9 +41,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
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 displayProgress = ref(0)
|
||||
const reveal = ref(0)
|
||||
@@ -55,6 +60,8 @@ let raf = 0
|
||||
let finished = false
|
||||
let startedAt = performance.now()
|
||||
let revealStarted = 0
|
||||
let holdStarted = 0
|
||||
let readyToken = 0
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
|
||||
@@ -62,12 +69,27 @@ const progressLabel = 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 index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
|
||||
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) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
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)
|
||||
}
|
||||
|
||||
/** 结果先淡入,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() {
|
||||
if (finished) return
|
||||
finished = true
|
||||
@@ -105,40 +140,73 @@ function tick(now: number) {
|
||||
revealStarted = now
|
||||
}
|
||||
} else if (phase.value === 'reveal') {
|
||||
const amount = smoother((now - revealStarted) / REVEAL_MS)
|
||||
reveal.value = amount
|
||||
gifOpacity.value = Math.max(0, 1 - amount)
|
||||
progress.value = safeProgress(88 + amount * 12)
|
||||
if (amount >= 1) {
|
||||
const raw = Math.min(1, (now - revealStarted) / REVEAL_MS)
|
||||
applyRevealAmount(raw)
|
||||
if (raw >= 1) {
|
||||
phase.value = 'hold'
|
||||
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()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 (!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
|
||||
}
|
||||
|
||||
function onResultLoad() {
|
||||
if (props.resultUrl) void prepareResult(props.resultUrl)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resultUrl,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
readyToken += 1
|
||||
resultReady.value = false
|
||||
return
|
||||
}
|
||||
// 可能已缓存,直接进入 reveal
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resultReady.value = true
|
||||
}
|
||||
img.src = url
|
||||
void prepareResult(url)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -159,6 +227,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
readyToken += 1
|
||||
window.cancelAnimationFrame(raf)
|
||||
})
|
||||
</script>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user