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: '正在生成效果图,请稍候…',
|
generating: '正在生成效果图,请稍候…',
|
||||||
generatingTip: '效果图生成中,完成后可继续操作',
|
generatingTip: '效果图生成中,完成后可继续操作',
|
||||||
downloadOk: '已开始下载效果图',
|
downloadOk: '已开始下载效果图',
|
||||||
downloadFail: '下载失败,请稍后重试',
|
downloadOpened:
|
||||||
|
'当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载',
|
||||||
|
downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||||
effectPreparing: '效果图准备中,请稍后再试',
|
effectPreparing: '效果图准备中,请稍后再试',
|
||||||
generateFail: '这次没生成成功,请换个描述或照片再试',
|
generateFail: '这次没生成成功,请换个描述或照片再试',
|
||||||
editFail: '这次调整没成功,请换个描述再试',
|
editFail: '这次调整没成功,请换个描述再试',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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: '这次调整没成功,请换个描述再试',
|
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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
const href = String(url || '').trim()
|
||||||
|
if (!href) {
|
||||||
|
showToast(config.messages.downloadFail)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const outcome = await downloadImageCompat(href, `suwa-figure-${Date.now()}.png`)
|
||||||
const blob = await res.blob()
|
if (outcome === 'saved') {
|
||||||
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()
|
|
||||||
showToast(config.messages.downloadOk)
|
showToast(config.messages.downloadOk)
|
||||||
} catch (error) {
|
return
|
||||||
showToast(friendlyError(error, 'download', config.messages.downloadFail))
|
|
||||||
}
|
}
|
||||||
|
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