修改:下单页面优化
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
+325 -49

No files matched your search

@@ -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;
+22 -24
View File
@@ -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,
})
}
}