chore: sync local updates

This commit is contained in:
2026-05-16 19:03:36 +08:00
parent 37983f3b60
commit f1417891ac
101 changed files with 4626 additions and 307 deletions

View File

@@ -193,12 +193,13 @@
}
.product-card-description {
min-height: 46px;
flex: 1 1 auto;
min-height: 86px;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 6;
}
@media (min-width: 720px) {
@@ -235,7 +236,8 @@
}
.product-card-description {
min-height: 38px;
min-height: 62px;
-webkit-line-clamp: 4;
}
}

View File

@@ -56,6 +56,7 @@
</template>
<template v-else>
<CheckoutOrderHeader
v-if="confirmed || needPayChannel || !(confirming && !needPayChannel)"
:order-id="orderResult.orderId"
:is-mengya-pending-pay="isMengyaPendingPay"
:copy-hint="orderIdCopyHint"
@@ -68,6 +69,13 @@
:is-manual-delivery="isManualDelivery"
/>
<p
v-else-if="confirming && !needPayChannel"
class="tag text-[15px] py-8 m-0 text-center"
>
正在加载购买内容
</p>
<CheckoutAwaitingFulfillment
v-else
:order-result="orderResult"
@@ -123,6 +131,38 @@ import CheckoutOrderDone from './components/CheckoutOrderDone.vue'
/** Shown alone when mengya poll reports cancelled (timeout). Keep in sync with onCancelled below. */
const MENGYA_PAYMENT_TIMEOUT_CANCEL_MSG = '订单未在时限内到账,已自动取消'
/** 统一后端字段(含 snake_case / 大小写差异),并保证数组类型安全 */
function normalizeCheckoutResponse(raw) {
if (!raw || typeof raw !== 'object') return {}
const orderId = raw.orderId ?? raw.order_id
const status = raw.status ?? raw.Status ?? ''
const rawCodes = raw.deliveredCodes ?? raw.delivered_codes
const codes = Array.isArray(rawCodes) ? rawCodes : []
const deliveryMode = raw.deliveryMode ?? raw.delivery_mode ?? 'auto'
const rawManual = raw.isManual ?? raw.is_manual
const isManual = typeof rawManual === 'boolean' ? rawManual : deliveryMode === 'manual'
const pay = raw.paymentExpectedTotal ?? raw.payment_expected_total
const next = {
...raw,
orderId,
status,
deliveredCodes: codes,
deliveryMode,
isManual
}
if (typeof pay === 'number') next.paymentExpectedTotal = pay
return next
}
/** 结账接口已返回 completed 且含发货结果时,无需再点「确认领取」 */
function canRevealCheckoutImmediately(r) {
const st = String(r.status || '').toLowerCase()
if (!r.orderId || st !== 'completed') return false
if ((r.deliveredCodes || []).length > 0) return true
if (r.isManual || r.deliveryMode === 'manual') return true
return false
}
const route = useRoute()
const router = useRouter()
const product = ref(null)
@@ -180,10 +220,11 @@ async function finalizeOrderFromServer() {
if (!orderResult.value?.orderId) return false
try {
const result = await confirmOrder(orderResult.value.orderId)
deliveredCodes.value = result.deliveredCodes || []
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
const normalized = normalizeCheckoutResponse(result)
deliveredCodes.value = normalized.deliveredCodes || []
isManualDelivery.value = normalized.isManual || normalized.deliveryMode === 'manual'
confirmed.value = true
orderResult.value = { ...orderResult.value, ...result }
orderResult.value = { ...orderResult.value, ...normalized }
stopPollingPayment()
stopPaymentCountdown()
return true
@@ -244,13 +285,42 @@ const submitOrder = async () => {
notifyEmail: authState.email || ''
}
const token = isLoggedIn() ? authState.token : null
const response = await createOrder(payload, token)
orderResult.value = response
const response = normalizeCheckoutResponse(await createOrder(payload, token))
if (typeof response.paymentExpectedTotal === 'number') {
paidAmountSnapshot.value = response.paymentExpectedTotal
} else {
paidAmountSnapshot.value = unitPrice.value
}
if (canRevealCheckoutImmediately(response)) {
orderResult.value = response
deliveredCodes.value = response.deliveredCodes || []
isManualDelivery.value = response.isManual || response.deliveryMode === 'manual'
confirmed.value = true
stopPollingPayment()
stopPaymentCountdown()
} else if (response.orderId && String(response.status || '').toLowerCase() === 'completed') {
orderResult.value = response
confirming.value = true
confirmError.value = ''
try {
const result = await confirmOrder(response.orderId)
orderResult.value = { ...response, ...normalizeCheckoutResponse(result) }
deliveredCodes.value = orderResult.value.deliveredCodes || []
isManualDelivery.value =
orderResult.value.isManual || orderResult.value.deliveryMode === 'manual'
confirmed.value = true
stopPollingPayment()
stopPaymentCountdown()
} catch (err) {
confirmError.value = err?.response?.data?.error || '加载购买内容失败,请点击「确认领取」重试'
confirmed.value = false
} finally {
confirming.value = false
}
} else {
orderResult.value = response
}
} catch (err) {
error.value = err?.response?.data?.error || '下单失败,请稍候再试'
} finally {
@@ -264,10 +334,11 @@ const doConfirm = async () => {
confirming.value = true
try {
const result = await confirmOrder(orderResult.value.orderId)
deliveredCodes.value = result.deliveredCodes || []
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
const normalized = normalizeCheckoutResponse(result)
deliveredCodes.value = normalized.deliveredCodes || []
isManualDelivery.value = normalized.isManual || normalized.deliveryMode === 'manual'
confirmed.value = true
orderResult.value = { ...orderResult.value, ...result }
orderResult.value = { ...orderResult.value, ...normalized }
stopPollingPayment()
stopPaymentCountdown()
} catch (err) {

View File

@@ -1,13 +1,17 @@
<template>
<span class="inline-block px-4 py-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold mb-3">订单已完成</span>
<div v-if="deliveredCodes.length" class="my-3 flex flex-col items-center gap-2">
<textarea
class="w-[min(520px,100%)] min-h-[120px] px-4 py-3.5 rounded-lg border border-white/35 bg-white/90 text-apptext text-base leading-[1.7] resize-none"
style="font-family: var(--font-serif);"
readonly
:value="deliveredCodes.join('\n')"
></textarea>
<div v-if="deliveredCodes.length" class="relative w-full max-w-[min(96vw,980px)] mx-auto my-6 sm:my-7">
<div ref="deliveredCardRef" class="delivered-shell flex-1 min-w-0 rounded-[var(--shell-radius)] p-[var(--shell-border-width)]">
<textarea
class="delivered-textarea w-full min-h-[min(54vh,420px)] sm:min-h-[360px] px-5 py-5 sm:px-6 sm:py-6 rounded-[calc(var(--shell-radius)-var(--shell-border-width))] bg-white/92 text-apptext text-[15px] sm:text-base leading-[1.75] resize-y shadow-[inset_0_1px_0_rgba(255,255,255,0.72)]"
style="font-family: var(--font-serif);"
readonly
:value="deliveredCodes.join('\n')"
></textarea>
</div>
</div>
<div v-else-if="isManualDelivery" class="flex items-start gap-3 p-4 bg-[rgba(90,180,120,0.08)] border border-[rgba(90,180,120,0.25)] rounded-lg text-apptext max-w-lg mx-auto text-left">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
<div>
@@ -19,8 +23,262 @@
</template>
<script setup>
defineProps({
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
deliveredCodes: { type: Array, default: () => [] },
isManualDelivery: { type: Boolean, default: false }
})
const deliveredCardRef = ref(null)
/** 亮暖色粒子,与流光边框统一 */
const FIREWORK_COLORS = [
'#ff8fa3',
'#ffb180',
'#ffe680',
'#a8f0a5',
'#8ee8f5',
'#9db7ff',
'#d8a7ff'
]
let fireworksCleanup = null
function prefersReducedMotion() {
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
}
function startCanvasFireworks() {
if (prefersReducedMotion() || typeof document === 'undefined') return
const target = deliveredCardRef.value
if (!target) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.style.position = 'fixed'
canvas.style.left = '0'
canvas.style.top = '0'
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.zIndex = '9999'
canvas.style.pointerEvents = 'none'
document.body.appendChild(canvas)
let width = 0
let height = 0
let cardRect = null
const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)
const particles = []
let isRunning = true
let rafId = 0
function resize() {
width = window.innerWidth
height = window.innerHeight
canvas.width = Math.floor(width * devicePixelRatio)
canvas.height = Math.floor(height * devicePixelRatio)
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
cardRect = target.getBoundingClientRect()
}
window.addEventListener('resize', resize)
resize()
class Particle {
constructor(x, y, color) {
this.x = x
this.y = y
this.color = color
const angle = Math.random() * Math.PI * 2
const force = Math.pow(Math.random(), 2) * 7 + 1.8
this.vx = Math.cos(angle) * force
this.vy = Math.sin(angle) * force
this.radius = Math.random() * 2.4 + 1.1
this.alpha = 1
this.decay = Math.random() * 0.018 + 0.014
this.gravity = 0.045
this.friction = 0.95
this.history = []
for (let i = 0; i < 6; i++) this.history.push({ x, y })
}
update() {
this.history.shift()
this.history.push({ x: this.x, y: this.y })
this.vx *= this.friction
this.vy *= this.friction
this.vy += this.gravity
this.x += this.vx
this.y += this.vy
this.alpha -= this.decay
}
draw() {
ctx.shadowColor = this.color
ctx.shadowBlur = 10
ctx.beginPath()
ctx.moveTo(this.history[0].x, this.history[0].y)
for (let i = 1; i < this.history.length; i++) {
ctx.lineTo(this.history[i].x, this.history[i].y)
}
ctx.strokeStyle = this.color
ctx.lineWidth = this.radius
ctx.lineCap = 'round'
ctx.globalAlpha = this.alpha
ctx.stroke()
ctx.globalAlpha = 1
ctx.shadowBlur = 0
}
}
function createFirework(x, y) {
if (!isRunning) return
const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]
for (let i = 0; i < 72; i++) {
particles.push(new Particle(x, y, color))
}
}
function createCardFirework() {
if (!cardRect) return
const insetX = Math.min(54, cardRect.width * 0.08)
const insetY = Math.min(46, cardRect.height * 0.12)
const x = cardRect.left + insetX + Math.random() * Math.max(1, cardRect.width - insetX * 2)
const y = cardRect.top + insetY + Math.random() * Math.max(1, cardRect.height - insetY * 2)
createFirework(x, y)
}
function loop() {
if (isRunning) cardRect = target.getBoundingClientRect()
ctx.clearRect(0, 0, width, height)
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.update()
if (p.alpha <= 0) particles.splice(i, 1)
else p.draw()
}
if (!isRunning && particles.length === 0) {
window.clearTimeout(stopSpawningTimer)
teardown()
fireworksCleanup = null
return
}
rafId = requestAnimationFrame(loop)
}
function teardown() {
window.removeEventListener('resize', resize)
window.removeEventListener('mousedown', onPointerDown)
cancelAnimationFrame(rafId)
if (canvas.parentNode) canvas.parentNode.removeChild(canvas)
}
const stopSpawningTimer = window.setTimeout(() => {
isRunning = false
}, 1400)
function scheduleAuto() {
if (!isRunning) return
createCardFirework()
window.setTimeout(scheduleAuto, Math.random() * 180 + 140)
}
const onPointerDown = (e) => {
if (!isRunning || !cardRect) return
const isInsideCard =
e.clientX >= cardRect.left &&
e.clientX <= cardRect.right &&
e.clientY >= cardRect.top &&
e.clientY <= cardRect.bottom
if (isInsideCard) createFirework(e.clientX, e.clientY)
}
window.addEventListener('mousedown', onPointerDown)
loop()
for (let i = 0; i < 5; i++) createCardFirework()
scheduleAuto()
fireworksCleanup = () => {
window.clearTimeout(stopSpawningTimer)
isRunning = false
teardown()
fireworksCleanup = null
}
}
function tryStartFireworks() {
if (!props.deliveredCodes?.length) return
if (fireworksCleanup) fireworksCleanup()
nextTick(startCanvasFireworks)
}
onMounted(() => {
tryStartFireworks()
})
watch(
() => props.deliveredCodes.length,
(n) => {
if (n) tryStartFireworks()
}
)
onUnmounted(() => {
fireworksCleanup?.()
})
</script>
<style scoped>
.delivered-shell {
--shell-radius: 1.05rem;
--shell-border-width: 5px;
position: relative;
isolation: isolate;
overflow: hidden;
background:
linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0)) padding-box,
conic-gradient(
from var(--border-angle),
#ff8fa3 0deg,
#ffb180 50deg,
#ffe680 100deg,
#a8f0a5 150deg,
#8ee8f5 205deg,
#9db7ff 260deg,
#d8a7ff 315deg,
#ff8fa3 360deg
) border-box;
border: var(--shell-border-width) solid transparent;
padding: 0;
animation: warm-border-spin 3.4s linear infinite;
box-shadow:
0 18px 48px rgba(255, 143, 163, 0.2),
0 8px 22px rgba(255, 230, 128, 0.18),
0 0 24px rgba(142, 232, 245, 0.16);
}
@property --border-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes warm-border-spin {
0% {
--border-angle: 0deg;
}
100% {
--border-angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.delivered-shell {
animation: none;
}
}
</style>