feat: 新增开奖加载弹窗组件,统一活动等级显示逻辑,并优化柜子库存加载性能。

This commit is contained in:
邹方成 2025-12-27 21:21:30 +08:00
parent e19ec06d74
commit 75638f895b
7 changed files with 374 additions and 41 deletions

View File

@ -0,0 +1,319 @@
<template>
<view v-if="visible" class="draw-loading-overlay" @touchmove.stop.prevent>
<!-- 背景渐变 -->
<view class="bg-gradient"></view>
<!-- 光圈效果 -->
<view class="light-ring"></view>
<view class="light-ring ring-2"></view>
<!-- 主内容 -->
<view class="loading-content">
<!-- 3D礼盒动画 -->
<view class="gift-container">
<view class="gift-box">
<view class="gift-lid">
<view class="lid-top"></view>
<view class="lid-ribbon"></view>
</view>
<view class="gift-body">
<view class="body-ribbon"></view>
</view>
</view>
<!-- 闪光粒子 -->
<view class="sparkle sparkle-1"></view>
<view class="sparkle sparkle-2"></view>
<view class="sparkle sparkle-3"></view>
<view class="sparkle sparkle-4">💫</view>
</view>
<!-- 文字区域 -->
<view class="text-area">
<text class="loading-title">{{ title }}</text>
<view class="loading-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
<!-- 进度条当有多次抽奖时显示 -->
<view v-if="total > 1" class="progress-area">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
<text class="progress-text">{{ progress }} / {{ total }}</text>
</view>
<!-- 提示文字 -->
<text class="tip-text">请稍候好运即将到来...</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '正在开奖中' },
progress: { type: Number, default: 0 },
total: { type: Number, default: 1 }
})
const progressPercent = computed(() => {
if (props.total <= 0) return 0
return Math.min(100, Math.round((props.progress / props.total) * 100))
})
</script>
<style lang="scss" scoped>
.draw-loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 背景 */
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center,
rgba(255, 140, 0, 0.15) 0%,
rgba(30, 20, 50, 0.98) 50%,
rgba(10, 5, 20, 0.99) 100%
);
}
/* 光圈 */
.light-ring {
position: absolute;
width: 500rpx; height: 500rpx;
border: 4rpx solid rgba(255, 200, 100, 0.3);
border-radius: 50%;
animation: ringExpand 2s ease-out infinite;
}
.ring-2 {
animation-delay: 1s;
}
@keyframes ringExpand {
0% {
transform: scale(0.5);
opacity: 0.8;
border-width: 8rpx;
}
100% {
transform: scale(2);
opacity: 0;
border-width: 2rpx;
}
}
/* 主内容 */
.loading-content {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
animation: contentPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes contentPop {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* 礼盒容器 */
.gift-container {
position: relative;
width: 240rpx;
height: 240rpx;
margin-bottom: 60rpx;
}
/* 礼盒动画 */
.gift-box {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
animation: boxBounce 1.5s ease-in-out infinite;
}
@keyframes boxBounce {
0%, 100% { transform: translate(-50%, -50%); }
50% { transform: translate(-50%, -60%); }
}
.gift-lid {
position: relative;
animation: lidShake 1.5s ease-in-out infinite;
transform-origin: center bottom;
}
@keyframes lidShake {
0%, 100% { transform: rotate(0deg) translateY(0); }
25% { transform: rotate(-5deg) translateY(-10rpx); }
75% { transform: rotate(5deg) translateY(-10rpx); }
}
.lid-top {
width: 140rpx; height: 30rpx;
background: linear-gradient(135deg, #FF6B35, #FF8C00);
border-radius: 8rpx 8rpx 0 0;
box-shadow: 0 -4rpx 16rpx rgba(255, 107, 53, 0.5);
}
.lid-ribbon {
position: absolute;
left: 50%; top: -20rpx;
transform: translateX(-50%);
width: 40rpx; height: 50rpx;
background: linear-gradient(135deg, #FFD700, #FFA500);
border-radius: 8rpx;
&::before, &::after {
content: '';
position: absolute;
top: 36rpx;
width: 30rpx; height: 30rpx;
background: linear-gradient(135deg, #FFD700, #FFA500);
border-radius: 50%;
}
&::before { left: -20rpx; }
&::after { right: -20rpx; }
}
.gift-body {
width: 120rpx; height: 100rpx;
background: linear-gradient(135deg, #FF8C00, #FF6B35);
border-radius: 0 0 12rpx 12rpx;
margin: 0 auto;
margin-top: -2rpx;
box-shadow:
0 12rpx 32rpx rgba(255, 107, 53, 0.4),
inset 0 -10rpx 20rpx rgba(0,0,0,0.1);
}
.body-ribbon {
width: 30rpx; height: 100%;
background: linear-gradient(180deg, #FFD700, #FFA500);
margin: 0 auto;
}
/* 闪光粒子 */
.sparkle {
position: absolute;
font-size: 32rpx;
animation: sparkleFloat 2s ease-in-out infinite;
}
.sparkle-1 { top: 10rpx; left: 20rpx; animation-delay: 0s; }
.sparkle-2 { top: 30rpx; right: 20rpx; animation-delay: 0.5s; }
.sparkle-3 { bottom: 40rpx; left: 0; animation-delay: 1s; }
.sparkle-4 { bottom: 20rpx; right: 10rpx; animation-delay: 1.5s; }
@keyframes sparkleFloat {
0%, 100% {
opacity: 0.4;
transform: translateY(0) scale(0.8);
}
50% {
opacity: 1;
transform: translateY(-20rpx) scale(1.2);
}
}
/* 文字区域 */
.text-area {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 40rpx;
}
.loading-title {
font-size: 40rpx;
font-weight: 800;
color: #FFF;
text-shadow: 0 0 30rpx rgba(255, 180, 100, 0.8);
letter-spacing: 4rpx;
}
.loading-dots {
display: flex;
gap: 8rpx;
}
.dot {
width: 12rpx; height: 12rpx;
background: #FFD700;
border-radius: 50%;
animation: dotBounce 1.4s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
@keyframes dotBounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 进度条 */
.progress-area {
width: 400rpx;
margin-bottom: 30rpx;
}
.progress-bar {
height: 16rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 8rpx;
overflow: hidden;
box-shadow: inset 0 2rpx 4rpx rgba(0,0,0,0.2);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #FFD700, #FF8C00, #FF6B35);
border-radius: 8rpx;
transition: width 0.3s ease-out;
box-shadow: 0 0 16rpx rgba(255, 200, 0, 0.6);
}
.progress-text {
display: block;
text-align: center;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 12rpx;
font-weight: 600;
}
/* 提示文字 */
.tip-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 2rpx;
}
</style>

View File

@ -3,6 +3,7 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { getIssueDrawLogs } from '@/api/appUser' import { getIssueDrawLogs } from '@/api/appUser'
import { levelToAlpha } from '@/utils/activity'
/** /**
* 购买记录管理 * 购买记录管理
@ -55,9 +56,8 @@ export function useRecords() {
function getLevelName(level) { function getLevelName(level) {
if (!level) return '' if (!level) return ''
// 尝试映射 1->A, 2->B ... const alpha = levelToAlpha(level)
const map = { 1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E', 6: 'F', 7: 'G', 8: 'H', 9: 'I', 10: 'J' } return alpha + '赏'
return map[level] || (level + '赏')
} }
function clearRecords() { function clearRecords() {

View File

@ -187,6 +187,7 @@ import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue' import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue' import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards } from '../../../api/appUser' import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards } from '../../../api/appUser'
import { levelToAlpha } from '@/utils/activity'
const detail = ref({}) const detail = ref({})
const statusText = ref('') const statusText = ref('')
@ -634,13 +635,6 @@ function formatPercent(v) {
return `${n}%` return `${n}%`
} }
function levelToAlpha(level) {
if (level === 'BOSS') return 'BOSS'
const n = Number(level)
if (isNaN(n) || n <= 0) return String(level || '赏')
// 1 -> A, 2 -> B ... 26 -> Z
return String.fromCharCode(64 + n)
}
function openRewardsPopup() { function openRewardsPopup() {
rewardsVisible.value = true rewardsVisible.value = true

View File

@ -103,6 +103,13 @@
v-model:visible="cabinetVisible" v-model:visible="cabinetVisible"
:activity-id="activityId" :activity-id="activityId"
/> />
<!-- 开奖加载弹窗 -->
<DrawLoadingPopup
:visible="showDrawLoading"
:progress="drawProgress"
:total="drawTotal"
/>
</template> </template>
</ActivityPageLayout> </ActivityPageLayout>
</template> </template>
@ -120,6 +127,7 @@ import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue' import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue' import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue' import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import DrawLoadingPopup from '@/components/activity/DrawLoadingPopup.vue'
import PaymentPopup from '@/components/PaymentPopup.vue' import PaymentPopup from '@/components/PaymentPopup.vue'
// Composables // Composables
import { useActivity, useIssues, useRewards, useRecords } from '@/composables' import { useActivity, useIssues, useRewards, useRecords } from '@/composables'
@ -164,6 +172,9 @@ const cabinetVisible = ref(false)
const showResultPopup = ref(false) const showResultPopup = ref(false)
const drawResults = ref([]) const drawResults = ref([])
const drawLoading = ref(false) const drawLoading = ref(false)
const showDrawLoading = ref(false)
const drawProgress = ref(0)
const drawTotal = ref(1)
const isDevMode = ref(false) const isDevMode = ref(false)
const customDrawCount = ref(1) const customDrawCount = ref(1)
@ -353,6 +364,11 @@ async function onMachineDraw(count) {
}) })
}) })
//
drawTotal.value = times
drawProgress.value = 0
showDrawLoading.value = true
// //
let resultRes = await getLotteryResult(orderNo) let resultRes = await getLotteryResult(orderNo)
let pollCount = 0 let pollCount = 0
@ -361,16 +377,22 @@ async function onMachineDraw(count) {
while (resultRes?.status === 'paid_waiting' && while (resultRes?.status === 'paid_waiting' &&
resultRes?.completed < resultRes?.count && resultRes?.completed < resultRes?.count &&
pollCount < maxPolls) { pollCount < maxPolls) {
//
drawProgress.value = resultRes?.completed || 0
await new Promise(r => setTimeout(r, resultRes?.nextPollMs || 2000)) await new Promise(r => setTimeout(r, resultRes?.nextPollMs || 2000))
resultRes = await getLotteryResult(orderNo) resultRes = await getLotteryResult(orderNo)
pollCount++ pollCount++
} }
//
showDrawLoading.value = false
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value) const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
drawResults.value = items drawResults.value = items
showResultPopup.value = true showResultPopup.value = true
} catch (e) { } catch (e) {
showDrawLoading.value = false
uni.showToast({ title: e.message || '操作失败', icon: 'none' }) uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally { } finally {
drawLoading.value = false drawLoading.value = false

View File

@ -249,7 +249,7 @@ onShow(() => {
if (currentTab.value === 1) { if (currentTab.value === 1) {
loadShipments(uid) loadShipments(uid)
} else { } else {
loadAllInventory(uid) loadInventory(uid) // onReachBottom
} }
}) })
@ -265,6 +265,7 @@ onReachBottom(() => {
}) })
function switchTab(index) { function switchTab(index) {
if (loading.value) return //
currentTab.value = index currentTab.value = index
// //
page.value = 1 page.value = 1
@ -275,7 +276,7 @@ function switchTab(index) {
if (currentTab.value === 1) { if (currentTab.value === 1) {
loadShipments(uid) loadShipments(uid)
} else { } else {
loadAllInventory(uid) loadInventory(uid) //
} }
} }
@ -498,6 +499,7 @@ async function loadInventory(uid) {
original_ids: [item.id], // id original_ids: [item.id], // id
name: (item.product_name || item.name || '').trim(), name: (item.product_name || item.name || '').trim(),
image: imageUrl, image: imageUrl,
price: item.product_price ? item.product_price / 100 : null, // 使
count: 1, count: 1,
selected: false, selected: false,
selectedCount: 1, selectedCount: 1,
@ -594,33 +596,6 @@ async function loadInventory(uid) {
} }
} }
async function loadAllInventory(uid) {
try {
while (hasMore.value) {
await loadInventory(uid)
}
fetchProductPrices()
} catch (e) {}
}
async function fetchProductPrices() {
const currentList = currentTab.value === 1 ? shippedList : aggregatedList
const list = currentList.value
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item.id && !item.price) {
try {
const meta = await fetchProductMeta(item.id)
if (meta) {
if (!item.price && meta.price !== null) item.price = meta.price
}
} catch (e) {
console.error('Fetch price failed for:', item.id, e)
}
}
}
}
function toggleSelect(item) { function toggleSelect(item) {
uni.vibrateShort({ type: 'light' }) uni.vibrateShort({ type: 'light' })
item.selected = !item.selected item.selected = !item.selected

View File

@ -54,7 +54,7 @@
<text class="tag-text">已开启</text> <text class="tag-text">已开启</text>
</view> </view>
<view class="level-tag" v-if="order.reward_level"> <view class="level-tag" v-if="order.reward_level">
<text class="tag-text">{{ order.reward_level }}</text> <text class="tag-text">{{ getLevelLabel(order.reward_level) }}</text>
</view> </view>
</view> </view>
@ -234,6 +234,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser' import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser'
import { levelToAlpha } from '@/utils/activity'
const orderId = ref('') const orderId = ref('')
const order = ref(null) const order = ref(null)
@ -433,6 +434,10 @@ function getSourceTypeText(type) {
return '其他' return '其他'
} }
function getLevelLabel(level) {
return levelToAlpha(level)
}
function showProofHelp() { function showProofHelp() {
uni.showModal({ uni.showModal({
title: '抽奖凭证说明', title: '抽奖凭证说明',

View File

@ -39,14 +39,32 @@ export function detectBoss(item) {
} }
/** /**
* 等级数字转字母 (1 -> A, 2 -> B, ...) * 奖品等级映射 (与管理端保持一致)
*/
export const PRIZE_LEVEL_LABELS = {
1: 'S',
2: 'A',
3: 'B',
4: 'C',
5: 'D',
6: 'E',
7: 'F',
8: 'G',
9: 'H',
11: 'Last'
}
/**
* 等级数字转字母/标签
* @param {number|string} level - 等级 * @param {number|string} level - 等级
* @returns {string} * @returns {string}
*/ */
export function levelToAlpha(level) { export function levelToAlpha(level) {
if (level === 'BOSS') return 'BOSS' if (level === 'BOSS') return 'BOSS'
const n = Number(level) const n = Number(level)
if (PRIZE_LEVEL_LABELS[n]) return PRIZE_LEVEL_LABELS[n]
if (isNaN(n) || n <= 0) return String(level || '赏') if (isNaN(n) || n <= 0) return String(level || '赏')
// 兜底逻辑:如果超出定义的映射,使用 A, B, C...
return String.fromCharCode(64 + n) return String.fromCharCode(64 + n)
} }