feat: 新增 BoxReveal 和 LotteryResultPopup 组件,优化对对碰活动道具卡聚合逻辑,并调整商店道具卡页面为“暂未开放”提示。

This commit is contained in:
邹方成 2025-12-26 12:46:17 +08:00
parent 7e08aa5f43
commit 6183fcaf15
10 changed files with 1356 additions and 256 deletions

338
components/BoxReveal.vue Normal file
View File

@ -0,0 +1,338 @@
<template>
<view class="box-reveal-root">
<!-- Stage 1: The Box -->
<view v-if="stage === 'box'" class="box-stage" :class="{ shaking: isShaking }">
<view class="mystery-box">
<image class="box-img" src="/static/images/mystery-box.png" mode="widthFix" />
<view class="box-glow"></view>
</view>
<text class="box-tip">{{ isShaking ? '正在开启...' : '准备开启' }}</text>
</view>
<!-- Stage 2: Reveal Results -->
<view v-else-if="stage === 'result'" class="result-stage">
<view class="result-light-burst"></view>
<!-- Single Reward -->
<view v-if="rewards.length === 1" class="single-reward">
<view class="reward-card large bounce-in">
<image class="reward-img" :src="rewards[0].image" mode="aspectFit" />
<view class="reward-info">
<text class="reward-name">{{ rewards[0].title }}</text>
<text class="reward-desc">恭喜获得</text>
</view>
</view>
</view>
<!-- Multiple Rewards (Horizontal Scroll) -->
<scroll-view v-else scroll-x class="multi-rewards-scroll">
<view class="rewards-track">
<view
v-for="(item, index) in rewards"
:key="index"
class="reward-card small slide-in-right"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<view class="card-inner">
<image class="reward-img" :src="item.image" mode="aspectFit" />
<text class="reward-name">{{ item.title }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="action-area">
<button class="confirm-btn" @tap="onConfirm">收下奖励</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
// No specific props needed for now, rewards passed via method
})
const emit = defineEmits(['close'])
const stage = ref('box') // box, result
const isShaking = ref(false)
const rewards = ref([])
// Public method to reset state
function reset() {
stage.value = 'box'
isShaking.value = false
rewards.value = []
}
// Public method to reveal results
function revealResults(list) {
const arr = Array.isArray(list) ? list : (list ? [list] : [])
rewards.value = arr
// Start animation sequence
isShaking.value = true
// Shake for 1.5s then open
setTimeout(() => {
isShaking.value = false
stage.value = 'result'
uni.vibrateLong()
}, 1500)
}
function onConfirm() {
emit('close')
}
defineExpose({ reset, revealResults })
</script>
<style lang="scss" scoped>
.box-reveal-root {
width: 100%;
min-height: 600rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
/* Stage 1: Box */
.box-stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mystery-box {
width: 400rpx;
height: 400rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.box-img {
width: 100%;
height: 100%;
position: relative;
z-index: 2;
// Fallback if image missing, use a block
min-height: 300rpx;
}
.box-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.6) 0%, transparent 70%);
z-index: 1;
filter: blur(40rpx);
animation: pulse 2s infinite;
}
.box-tip {
margin-top: 40rpx;
font-size: 32rpx;
color: $text-main;
font-weight: 600;
letter-spacing: 2rpx;
}
.shaking .mystery-box {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) infinite both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0) rotate(-2deg); }
20%, 80% { transform: translate3d(4px, 0, 0) rotate(2deg); }
30%, 50%, 70% { transform: translate3d(-8px, 0, 0) rotate(-4deg); }
40%, 60% { transform: translate3d(8px, 0, 0) rotate(4deg); }
}
@keyframes pulse {
0% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.9); }
50% { opacity: 0.8; transform: translate(-50%, -50%) scale(1.1); }
100% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.9); }
}
/* Stage 2: Result */
.result-stage {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
animation: fade-in 0.5s ease-out;
}
.result-light-burst {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 800rpx;
height: 800rpx;
background: radial-gradient(circle, rgba(255, 200, 50, 0.2) 0%, transparent 70%);
animation: rotate-slow 10s linear infinite;
pointer-events: none;
z-index: 0;
}
/* Single Reward */
.single-reward {
position: relative;
z-index: 2;
margin-bottom: 60rpx;
}
.reward-card.large {
width: 460rpx;
background: #fff;
border-radius: 32rpx;
padding: 40rpx;
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
align-items: center;
border: 4rpx solid $bg-secondary;
}
.reward-card.large .reward-img {
width: 320rpx;
height: 320rpx;
margin-bottom: 30rpx;
}
.reward-card.large .reward-name {
font-size: 36rpx;
font-weight: bold;
color: $text-main;
text-align: center;
margin-bottom: 12rpx;
line-height: 1.4;
}
.reward-card.large .reward-desc {
font-size: 24rpx;
color: $text-tertiary;
}
/* Multiple Rewards */
.multi-rewards-scroll {
width: 100%;
white-space: nowrap;
padding: 20rpx 0;
margin-bottom: 40rpx;
}
.rewards-track {
display: flex;
padding: 0 40rpx;
align-items: center;
}
.reward-card.small {
display: inline-block;
width: 240rpx;
height: 320rpx;
background: #fff;
border-radius: 20rpx;
margin-right: 24rpx;
box-shadow: $shadow-md;
overflow: hidden;
position: relative;
vertical-align: top;
}
.card-inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
box-sizing: border-box;
}
.reward-card.small .reward-img {
width: 160rpx;
height: 160rpx;
margin-bottom: 20rpx;
margin-top: 20rpx;
}
.reward-card.small .reward-name {
font-size: 24rpx;
color: $text-main;
font-weight: 600;
text-align: center;
white-space: normal;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* Animations */
.bounce-in {
animation: bounce-in 0.8s cubic-bezier(0.215, 0.61, 0.355, 1);
}
@keyframes bounce-in {
0% { opacity: 0; transform: scale(0.3); }
20% { transform: scale(1.1); }
40% { transform: scale(0.9); }
60% { opacity: 1; transform: scale(1.03); }
80% { transform: scale(0.97); }
100% { opacity: 1; transform: scale(1); }
}
.slide-in-right {
animation: slide-in-right 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
@keyframes slide-in-right {
0% { transform: translateX(100rpx); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rotate-slow {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.confirm-btn {
background: $gradient-brand;
color: #fff;
border-radius: 999rpx;
padding: 0 80rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: $shadow-warm;
border: none;
&:active {
transform: scale(0.96);
}
}
</style>

View File

@ -15,7 +15,8 @@
<view class="popup-body">
<view class="amount-section" v-if="amount !== undefined && amount !== null">
<text class="label">支付金额</text>
<text class="amount">¥{{ amount }}</text>
<text class="amount">¥{{ finalPayAmount }}</text>
<text v-if="finalPayAmount < amount" class="original-amount" style="text-decoration: line-through; color: #999; font-size: 24rpx; margin-left: 10rpx;">¥{{ amount }}</text>
</view>
<view class="form-item">
@ -30,7 +31,10 @@
:disabled="!coupons || coupons.length === 0"
>
<view class="picker-display">
<text v-if="selectedCoupon" class="selected-text">{{ selectedCoupon.name }} (-¥{{ selectedCoupon.amount }})</text>
<text v-if="selectedCoupon" class="selected-text">
{{ selectedCoupon.name }} (-¥{{ effectiveCouponDiscount.toFixed(2) }})
<text v-if="selectedCoupon.amount > maxDeductible" style="font-size: 20rpx; color: #FF9800;">(最高抵扣50%)</text>
</text>
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
<text v-else class="placeholder">请选择优惠券</text>
<text class="arrow"></text>
@ -43,15 +47,18 @@
<picker
class="picker-full"
mode="selector"
:range="propCards"
range-key="name"
:range="displayCards"
range-key="displayName"
@change="onCardChange"
:value="cardIndex"
:disabled="!propCards || propCards.length === 0"
:disabled="!displayCards || displayCards.length === 0"
>
<view class="picker-display">
<text v-if="selectedCard" class="selected-text">{{ selectedCard.name }}</text>
<text v-else-if="!propCards || propCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-if="selectedCard" class="selected-text">
{{ selectedCard.name }}
<text v-if="Number(selectedCard.count) > 1" style="color: #999; font-size: 24rpx; margin-left: 6rpx;">(拥有: {{ selectedCard.count }})</text>
</text>
<text v-else-if="!displayCards || displayCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-else class="placeholder">请选择道具卡</text>
<text class="arrow"></text>
</view>
@ -90,18 +97,51 @@ const selectedCoupon = computed(() => {
return null
})
const maxDeductible = computed(() => {
const amt = Number(props.amount) || 0
return amt * 0.5
})
const effectiveCouponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
const couponAmt = Number(selectedCoupon.value.amount) || 0
return Math.min(couponAmt, maxDeductible.value)
})
const displayCards = computed(() => {
if (!Array.isArray(props.propCards)) return []
return props.propCards.map(c => ({
...c,
displayName: (c.count && Number(c.count) > 1) ? `${c.name} (x${c.count})` : c.name
}))
})
// Auto-select if only one card available? Or existing logic is fine.
// The watch logic handles index reset.
const selectedCard = computed(() => {
if (cardIndex.value >= 0 && props.propCards[cardIndex.value]) {
return props.propCards[cardIndex.value]
if (cardIndex.value >= 0 && displayCards.value[cardIndex.value]) {
return displayCards.value[cardIndex.value]
}
return null
})
const finalPayAmount = computed(() => {
const amt = Number(props.amount) || 0
return Math.max(0, amt - effectiveCouponDiscount.value).toFixed(2)
})
watch(
[() => props.visible, () => (Array.isArray(props.coupons) ? props.coupons.length : 0)],
([vis, len]) => {
if (!vis) return
cardIndex.value = -1
// Auto-select best coupon?
// Current logic just selects the first one if none selected and count > 0?
// "if (couponIndex.value < 0) couponIndex.value = 0"
// This logic is preserved below.
if (len <= 0) {
couponIndex.value = -1
return

View File

@ -0,0 +1,410 @@
<template>
<view v-if="visible" class="lottery-overlay" @touchmove.stop.prevent>
<!-- 背景光效 -->
<view class="bg-glow"></view>
<view class="bg-rays"></view>
<!-- 彩带粒子 -->
<view class="confetti-container">
<view v-for="i in 20" :key="i" class="confetti" :style="getConfettiStyle(i)"></view>
</view>
<!-- 主内容区 -->
<view class="lottery-content">
<!-- 中奖标题 -->
<view class="title-area">
<view class="crown-icon">🎉</view>
<text class="main-title">恭喜中奖</text>
</view>
<!-- 奖品展示区 -->
<scroll-view scroll-y class="prizes-scroll">
<view class="prizes-grid">
<view
v-for="(item, index) in groupedResults"
:key="index"
class="prize-card"
:style="{ animationDelay: `${0.2 + index * 0.15}s` }"
>
<!-- 光效边框 -->
<view class="card-glow-border"></view>
<!-- 卡片内容 -->
<view class="card-inner">
<view class="qty-badge" v-if="item.quantity > 1">x{{ item.quantity }}</view>
<view class="image-wrap">
<image
v-if="item.image"
class="prize-img"
:src="item.image"
mode="aspectFill"
@tap="previewImage(item.image)"
/>
<view v-else class="prize-placeholder">🎁</view>
</view>
<view class="prize-details">
<text class="prize-name">{{ item.title }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="action-area">
<view class="claim-btn" @tap="handleClose">
<view class="btn-glow"></view>
<view class="btn-inner">
<text class="btn-icon"></text>
<text class="btn-text">收下奖励</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
results: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:visible', 'close'])
const groupedResults = computed(() => {
const map = new Map()
const arr = Array.isArray(props.results) ? props.results : []
arr.forEach(item => {
const key = item.title || item.name || '神秘奖品'
if (map.has(key)) {
map.get(key).quantity++
} else {
map.set(key, {
title: key,
image: item.image || item.img || item.pic || '',
quantity: 1
})
}
})
return Array.from(map.values())
})
function getConfettiStyle(i) {
const colors = ['#FF6B35', '#FFD93D', '#6BCB77', '#4D96FF', '#FF6B6B', '#C9B1FF']
const left = Math.random() * 100
const delay = Math.random() * 2
const duration = 2 + Math.random() * 2
const size = 8 + Math.random() * 8
return {
left: `${left}%`,
animationDelay: `${delay}s`,
animationDuration: `${duration}s`,
width: `${size}rpx`,
height: `${size * 1.5}rpx`,
background: colors[i % colors.length]
}
}
function handleClose() {
emit('update:visible', false)
emit('close')
}
function previewImage(url) {
if (url) uni.previewImage({ urls: [url], current: url })
}
</script>
<style lang="scss" scoped>
.lottery-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, rgba(30, 20, 50, 0.95) 0%, rgba(10, 5, 20, 0.98) 100%);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 背景光效 */
.bg-glow {
position: absolute;
top: 20%; left: 50%;
transform: translateX(-50%);
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba(255, 180, 100, 0.4) 0%, transparent 70%);
filter: blur(60rpx);
animation: pulse 3s ease-in-out infinite;
}
.bg-rays {
position: absolute;
top: 15%; left: 50%;
transform: translateX(-50%);
width: 800rpx; height: 800rpx;
background: conic-gradient(from 0deg, transparent, rgba(255, 200, 100, 0.1), transparent, rgba(255, 200, 100, 0.1), transparent);
animation: rotate 20s linear infinite;
}
@keyframes rotate { to { transform: translateX(-50%) rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.6; transform: translateX(-50%) scale(1); } 50% { opacity: 1; transform: translateX(-50%) scale(1.1); } }
/* 彩带 */
.confetti-container {
position: absolute;
top: 0; left: 0; right: 0;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.confetti {
position: absolute;
top: -20rpx;
border-radius: 4rpx;
animation: confettiFall 3s linear infinite;
}
@keyframes confettiFall {
0% { transform: translateY(-20rpx) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
/* 主内容 */
.lottery-content {
position: relative;
width: 88%;
max-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
animation: contentPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes contentPop {
from { opacity: 0; transform: scale(0.8) translateY(40rpx); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* 标题区 */
.title-area {
position: relative;
text-align: center;
margin-bottom: 40rpx;
z-index: 10;
}
.crown-icon {
font-size: 80rpx;
display: block;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
.main-title {
font-size: 56rpx;
font-weight: 900;
color: #fff;
text-shadow: 0 0 40rpx rgba(255, 180, 100, 0.8), 0 4rpx 20rpx rgba(0, 0, 0, 0.5);
display: block;
letter-spacing: 8rpx;
}
/* 奖品滚动区 */
.prizes-scroll {
width: 100%;
max-height: 50vh;
padding: 0 10rpx;
}
.prizes-grid {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
justify-content: center;
padding: 20rpx 0;
}
/* 奖品卡片 */
.prize-card {
position: relative;
width: calc(50% - 12rpx);
max-width: 300rpx;
animation: cardReveal 0.6s ease-out backwards;
}
@keyframes cardReveal {
from { opacity: 0; transform: scale(0.8) rotateY(-30deg); }
to { opacity: 1; transform: scale(1) rotateY(0); }
}
.card-glow-border {
position: absolute;
inset: -4rpx;
background: linear-gradient(135deg, #FFD700, #FF8C00, #FFD700, #FF6347, #FFD700);
background-size: 400% 400%;
border-radius: 28rpx;
animation: borderGlow 3s ease infinite;
z-index: 0;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.card-inner {
position: relative;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 240, 0.95));
border-radius: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
}
.qty-badge {
position: absolute;
top: -12rpx; right: -12rpx;
background: linear-gradient(135deg, #FF6B35, #FF8C00);
color: #fff;
font-size: 24rpx;
font-weight: 800;
padding: 8rpx 16rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(255, 107, 53, 0.5);
z-index: 10;
}
.image-wrap {
width: 160rpx; height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
background: linear-gradient(145deg, #FFF8F3, #FFE8D1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.prize-img {
width: 100%; height: 100%;
}
.prize-placeholder {
font-size: 64rpx;
}
.prize-details {
margin-top: 16rpx;
text-align: center;
width: 100%;
}
.prize-name {
font-size: 24rpx;
font-weight: 700;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
/* 底部按钮 - 重新设计 */
.action-area {
width: 100%;
padding: 40rpx 20rpx 20rpx;
}
.claim-btn {
position: relative;
width: 100%;
height: 110rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .btn-inner {
transform: scale(0.96);
}
}
.btn-glow {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #FFD700, #FF8C00, #FF6B35);
border-radius: 55rpx;
filter: blur(15rpx);
opacity: 0.6;
animation: btnPulse 2s ease-in-out infinite;
}
@keyframes btnPulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.02); }
}
.btn-inner {
position: relative;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #FFD700 0%, #FF8C00 50%, #FF6B35 100%);
border-radius: 55rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow:
0 8rpx 32rpx rgba(255, 140, 0, 0.5),
inset 0 2rpx 0 rgba(255, 255, 255, 0.4),
inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: btnShine 2.5s ease-in-out infinite;
}
}
@keyframes btnShine {
0% { left: -100%; }
50%, 100% { left: 100%; }
}
.btn-icon {
font-size: 36rpx;
}
.btn-text {
font-size: 34rpx;
font-weight: 800;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
letter-spacing: 4rpx;
}
</style>

View File

@ -1198,16 +1198,36 @@ async function fetchPropCards() {
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
propCards.value = list.map((i, idx) => {
const count = i.count ?? i.remaining ?? 1
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? i.card_name ?? '道具卡'
return {
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: `${name} (×${count})`,
rawName: name,
count: count
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: name,
count: 0
})
}
// If the API returns a 'count' or 'remaining', use it. Otherwise assume 1.
const inc = (i.count !== undefined && i.count !== null) ? Number(i.count) : ((i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : 1)
groupedMap.get(name).count += inc
})
propCards.value = Array.from(groupedMap.values()).map(item => ({
id: item.id,
name: item.name, // PaymentPopup will handle the " (xN)" display if we pass it correctly.
// Wait, PaymentPopup.vue expects 'name' to be the display string?
// Let's check PaymentPopup.vue again.
// It shows {{ selectedCoupon.name }} (-¥...).
// For cards: {{ selectedCard.name }}.
// So I should format the name here OR update PaymentPopup to show count.
// The plan said "Update PaymentPopup text if needed (e.g. show count)".
// I will format it here for consistency if PaymentPopup is generic,
// BUT updating PaymentPopup to show "Name (xCount)" is cleaner.
// For now, I'll pass 'count' property.
count: item.count
}))
} catch (e) {
propCards.value = []
}

View File

@ -57,13 +57,11 @@
:reward-groups="rewardGroups"
/>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
<LotteryResultPopup
v-model:visible="showResultPopup"
:results="drawResults"
@close="onResultClose"
/>
<PaymentPopup
v-model:visible="paymentVisible"
@ -97,7 +95,7 @@ import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import FlipGrid from '@/components/FlipGrid.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import PaymentPopup from '@/components/PaymentPopup.vue'
// Composables
import { useActivity, useIssues, useRewards, useRecords } from '@/composables'
@ -139,8 +137,8 @@ const tabActive = ref('pool')
const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showFlip = ref(false)
const flipRef = ref(null)
const showResultPopup = ref(false)
const drawResults = ref([])
const drawLoading = ref(false)
//
@ -161,8 +159,9 @@ function goCabinet() {
cabinetVisible.value = true
}
function closeFlip() {
showFlip.value = false
function onResultClose() {
showResultPopup.value = false
drawResults.value = []
}
function openPayment(count) {
@ -198,10 +197,22 @@ async function fetchPropCards() {
try {
const res = await getItemCards(user_id)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
propCards.value = list.map((i, idx) => ({
id: i.id ?? i.card_id ?? String(idx),
name: i.name ?? i.title ?? '道具卡'
}))
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? '道具卡'
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? String(idx),
name: name,
count: 0
})
}
groupedMap.get(name).count++
})
propCards.value = Array.from(groupedMap.values())
} catch (e) {
propCards.value = []
}
@ -230,6 +241,10 @@ async function fetchCoupons() {
function extractResultList(resultRes) {
const root = resultRes?.data ?? resultRes?.result ?? resultRes
if (!root) return []
// Backend now returns results array with all draw logs including doubled
if (resultRes?.results && Array.isArray(resultRes.results) && resultRes.results.length > 0) {
return resultRes.results
}
return root.results || root.list || root.items || root.data || []
}
@ -250,7 +265,7 @@ function mapResultsToFlipItems(resultRes, poolRewards) {
const it = fromId || fromName || null
return {
title: rewardName || it?.title || '奖励',
image: it?.image || d.image || d.img || d.pic || d.product_image || ''
image: d.image || it?.image || d.img || d.pic || d.product_image || ''
}
})
}
@ -313,12 +328,8 @@ async function onMachineDraw(count) {
const resultRes = await getLotteryResult(orderNo)
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
showFlip.value = true
await nextTick()
try { flipRef.value?.reset?.() } catch (_) {}
setTimeout(() => {
flipRef.value?.revealResults?.(items)
}, 100)
drawResults.value = items
showResultPopup.value = true
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {

View File

@ -8,17 +8,19 @@
<text class="label">手机号</text>
<input class="input" v-model="mobile" placeholder="请输入手机号" />
</view>
<view class="form-item">
<text class="label">省份</text>
<input class="input" v-model="province" placeholder="请输入省份" />
</view>
<view class="form-item">
<text class="label">城市</text>
<input class="input" v-model="city" placeholder="请输入城市" />
</view>
<view class="form-item">
<text class="label">区县</text>
<input class="input" v-model="district" placeholder="请输入区县" />
<view class="form-item region-picker" @click="openRegionPicker">
<text class="label">省市区</text>
<picker
mode="region"
:value="regionValue"
@change="onRegionChange"
@cancel="onRegionCancel"
>
<view class="picker-value" :class="{ placeholder: !hasRegion }">
{{ hasRegion ? `${province} ${city} ${district}` : '请选择省市区' }}
<text class="arrow-icon"></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
@ -34,7 +36,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { addAddress, updateAddress, listAddresses, setDefaultAddress } from '../../api/appUser'
@ -49,6 +51,25 @@ let isDefault = false
const loading = ref(false)
const error = ref('')
//
const regionValue = computed(() => [province.value, city.value, district.value])
const hasRegion = computed(() => province.value && city.value && district.value)
function onRegionChange(e) {
const values = e.detail.value
province.value = values[0] || ''
city.value = values[1] || ''
district.value = values[2] || ''
}
function onRegionCancel() {
// picker
}
function openRegionPicker() {
// picker
}
function fill(data) {
name.value = data.name || data.realname || ''
mobile.value = data.mobile || data.phone || ''
@ -193,6 +214,37 @@ onLoad((opts) => {
height: 48rpx;
}
/* 省市区选择器 */
.region-picker {
cursor: pointer;
picker {
flex: 1;
}
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
font-size: $font-md;
color: $text-main;
height: 48rpx;
line-height: 48rpx;
&.placeholder {
color: $text-tertiary;
}
}
.arrow-icon {
font-size: 36rpx;
color: $text-tertiary;
margin-left: 12rpx;
transform: rotate(0deg);
transition: transform 0.2s;
}
/* 提交按钮 */
.submit {
width: 100%;

View File

@ -1,40 +1,84 @@
<template>
<view class="wrap">
<view class="header">
<button class="add" @click="toAdd">新增地址</button>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">地址管理</view>
<view class="page-subtitle">Address Management</view>
</view>
<view v-if="error" class="error">{{ error }}</view>
<view v-if="list.length === 0 && !loading" class="empty">暂无地址</view>
<view v-for="item in list" :key="item.id" class="addr">
<view class="addr-main">
<view class="addr-row">
<text class="name">姓名{{ item.name || item.realname }}</text>
</view>
<view class="addr-row">
<text class="phone">手机号{{ item.phone || item.mobile }}</text>
</view>
<view class="addr-row" v-if="item.is_default">
<text class="default">默认</text>
</view>
<view class="addr-row">
<text class="region">省市区{{ item.province }}{{ item.city }}{{ item.district }}</text>
</view>
<view class="addr-row">
<text class="detail">详细地址{{ item.address || item.detail }}</text>
<view class="action-bar">
<button class="add-btn" @click="toAdd">
<text class="plus-icon">+</text>
<text>新增收货地址</text>
</button>
</view>
<scroll-view scroll-y class="content-scroll">
<view v-if="error" class="error-tip">{{ error }}</view>
<!-- 空状态 -->
<view v-if="list.length === 0 && !loading" class="empty-state">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无收货地址</text>
</view>
<!-- 地址列表 -->
<view class="addr-list">
<view
v-for="(item, index) in list"
:key="item.id"
class="addr-card"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<view class="addr-content" @click="toEdit(item)">
<view class="addr-header">
<view class="user-info">
<text class="name">{{ item.name || item.realname }}</text>
<text class="phone">{{ item.phone || item.mobile }}</text>
</view>
<view v-if="item.is_default" class="default-tag">默认</view>
</view>
<view class="addr-detail">
<text class="region">{{ item.province }} {{ item.city }} {{ item.district }}</text>
<text class="detail-text">{{ item.address || item.detail }}</text>
</view>
</view>
<!-- 分割线 -->
<view class="card-divider"></view>
<!-- 操作栏 -->
<view class="addr-actions">
<view class="action-left" @click.stop="onSetDefault(item)">
<view class="radio-circle" :class="{ checked: item.is_default }"></view>
<text class="action-text">{{ item.is_default ? '默认地址' : '设为默认' }}</text>
</view>
<view class="action-right">
<view class="action-btn" @click.stop="toEdit(item)">
<text class="btn-icon"></text>
<text>编辑</text>
</view>
<view class="action-btn delete" @click.stop="onDelete(item)">
<text class="btn-icon">🗑</text>
<text>删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="addr-actions">
<button size="mini" @click="toEdit(item)">编辑</button>
<button size="mini" type="warn" @click="onDelete(item)">删除</button>
<button size="mini" :disabled="item.is_default" @click="onSetDefault(item)">设为默认</button>
</view>
</view>
<view class="bottom-spacer"></view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { listAddresses, deleteAddress, setDefaultAddress } from '../../api/appUser'
const list = ref([])
@ -45,26 +89,22 @@ async function fetchList() {
const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!user_id || !token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
//
if (!user_id || !token) {
//
return
}
loading.value = true
error.value = ''
try {
const data = await listAddresses(user_id)
list.value = Array.isArray(data) ? data : (data && (data.list || data.items)) || []
} catch (e) {
error.value = e && (e.message || e.errMsg) || '获取地址失败'
//
console.error(e)
// error.value = ''
} finally {
loading.value = false
}
@ -89,6 +129,7 @@ function onDelete(item) {
if (res.confirm) {
try {
await deleteAddress(user_id, item.id)
uni.showToast({ title: '已删除', icon: 'none' })
fetchList()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
@ -99,9 +140,11 @@ function onDelete(item) {
}
async function onSetDefault(item) {
if (item.is_default) return
try {
const user_id = uni.getStorageSync('user_id')
await setDefaultAddress(user_id, item.id)
uni.showToast({ title: '设置成功', icon: 'none' })
fetchList()
} catch (e) {
uni.showToast({ title: '设置失败', icon: 'none' })
@ -111,160 +154,312 @@ async function onSetDefault(item) {
onLoad(() => {
fetchList()
})
onShow(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
/* ============================================
柯大鸭潮玩 - 地址管理页面
采用暖橙色调的卡片列表设计
============================================ */
.wrap {
padding: $spacing-md;
.page-container {
min-height: 100vh;
background-color: $bg-page;
}
.header {
background: $bg-page;
position: relative;
overflow: hidden;
display: flex;
justify-content: flex-end;
margin-bottom: $spacing-lg;
}
.add {
font-size: $font-md;
background: $gradient-brand !important;
color: #FFFFFF !important;
border-radius: $radius-round;
padding: 0 $spacing-xl;
height: 72rpx;
line-height: 72rpx;
font-weight: 600;
box-shadow: $shadow-warm;
}
.add:active {
transform: scale(0.96);
flex-direction: column;
}
/* 地址卡片 */
.addr {
background: #FFFFFF;
border-radius: $radius-md;
padding: $spacing-lg;
margin-bottom: $spacing-md;
box-shadow: $shadow-sm;
animation: fadeInUp 0.4s ease-out backwards;
}
/* 背景装饰 - 与优惠券页面统一 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
@for $i from 1 through 10 {
.addr:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
.addr-main {
margin-bottom: $spacing-md;
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.addr-row {
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
.action-bar {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-md;
padding: 20rpx;
z-index: 10;
}
.add-btn {
background: $gradient-brand;
color: #fff;
border: none;
border-radius: 100rpx;
height: 88rpx;
display: flex;
align-items: center;
margin-bottom: $spacing-sm;
}
.addr-row:last-child {
margin-bottom: 0;
}
.name {
font-size: $font-lg;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
color: $text-main;
}
.phone {
font-size: $font-md;
color: $text-sub;
}
.default {
font-size: $font-xs;
color: #FFFFFF;
background: $gradient-brand;
padding: 4rpx $spacing-sm;
border-radius: $radius-round;
font-weight: 500;
}
.region {
font-size: $font-sm;
color: $text-sub;
}
.detail {
font-size: $font-md;
color: $text-main;
line-height: 1.5;
}
/* 操作按钮 */
.addr-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-md;
margin-top: $spacing-lg;
padding-top: $spacing-lg;
border-top: 1rpx solid $border-color-light;
}
.addr-actions button {
font-size: $font-sm;
height: 52rpx;
line-height: 52rpx;
padding: 0 $spacing-lg;
border-radius: $radius-round;
margin: 0;
font-weight: 600;
border: none;
background: $bg-secondary;
color: $text-main;
&::after { border: none; }
box-shadow: $shadow-warm;
transition: all 0.3s;
&:active {
transform: scale(0.96);
background: darken($bg-secondary, 5%);
transform: scale(0.98);
opacity: 0.9;
}
}
.addr-actions button[type="warn"] {
background: rgba($color-error, 0.1) !important;
color: $color-error !important;
.plus-icon {
font-size: 40rpx;
margin-right: 12rpx;
margin-top: -4rpx;
font-weight: 300;
}
.addr-actions button:not([type]) {
background: $bg-secondary !important;
color: $text-main !important;
.content-scroll {
flex: 1;
height: 0;
padding: 0 $spacing-lg;
box-sizing: border-box;
z-index: 1;
}
/* 空状态 */
.empty {
text-align: center;
color: $text-sub;
margin-top: 120rpx;
font-size: $font-md;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
/* 错误提示 */
.error {
color: $color-error;
font-size: $font-sm;
margin-bottom: $spacing-md;
padding: $spacing-md;
background: rgba($color-error, 0.1);
border-radius: $radius-md;
text-align: center;
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
opacity: 0.8;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 地址列表 */
.addr-list {
padding-bottom: 40rpx;
}
.addr-card {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
box-shadow: $shadow-sm;
overflow: hidden;
animation: fadeInUp 0.5s ease-out backwards;
/* Removed border from glass style */
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.addr-content {
padding: 30rpx;
}
.addr-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.name {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
.phone {
font-size: 28rpx;
color: $text-sub;
font-family: monospace; /* 数字等宽 */
}
.default-tag {
font-size: 20rpx;
color: #fff;
background: linear-gradient(135deg, $brand-primary, $brand-secondary);
padding: 4rpx 12rpx;
border-radius: 8rpx 0 8rpx 0;
font-weight: 600;
box-shadow: 0 2rpx 6rpx rgba($brand-primary, 0.2);
}
.addr-detail {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.region {
font-size: 26rpx;
color: $text-sub;
}
.detail-text {
font-size: 28rpx;
color: $text-main;
line-height: 1.5;
font-weight: 500;
}
/* 分割线 */
.card-divider {
height: 1rpx;
background: #f0f0f0;
margin: 0 30rpx;
}
/* 操作栏 */
.addr-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
background: rgba(249, 249, 249, 0.5);
}
.action-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.radio-circle {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 2rpx solid #ddd;
position: relative;
transition: all 0.2s;
&.checked {
border-color: $brand-primary;
background: $brand-primary;
&::after {
content: '';
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 12rpx; height: 6rpx;
border-left: 3rpx solid #fff;
border-bottom: 3rpx solid #fff;
transform: translate(-50%, -60%) rotate(-45deg);
}
}
}
.action-text {
font-size: 24rpx;
color: $text-sub;
}
.action-right {
display: flex;
gap: 30rpx;
}
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 26rpx;
color: $text-sub;
padding: 10rpx;
.btn-icon {
font-size: 28rpx;
}
&:active {
opacity: 0.6;
}
&.delete {
color: $color-error; // 使
}
}
.error-tip {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.1);
padding: 20rpx;
border-radius: 12rpx;
text-align: center;
font-size: 26rpx;
margin-bottom: 20rpx;
}
.bottom-spacer {
height: 40rpx;
}
</style>

View File

@ -165,12 +165,17 @@ function formatValue(val) {
//
function formatExpiry(item) {
if (!item.end_time) return '长期有效'
const d = new Date(item.end_time)
// valid_end
const endTime = item.valid_end || item.end_time
if (!endTime) return '长期有效'
const d = new Date(endTime)
// Check for invalid date (e.g., "0001-01-01" from Go zero value)
if (isNaN(d.getTime()) || d.getFullYear() < 2000) return '长期有效'
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `有效期至 ${y}-${m}-${day}`
const label = currentTab.value === 3 ? '过期时间' : '有效期至'
return `${label} ${y}-${m}-${day}`
}
//
@ -528,10 +533,12 @@ onLoad(() => {
.coupon-right {
flex: 1;
padding: 24rpx;
padding-right: 130rpx; /* Prevent text overlap with button */
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative; /* Ensure padding works with absolute button */
}
.coupon-header {
@ -613,7 +620,8 @@ onLoad(() => {
.coupon-action-wrapper {
position: absolute;
right: 24rpx;
bottom: 24rpx;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}

View File

@ -18,6 +18,10 @@
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
<text class="tab-text">已过期</text>
<view class="tab-indicator" v-if="currentTab === 2"></view>
</view>
</view>
<!-- 内容区 -->
@ -37,7 +41,7 @@
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🃏</text>
<text class="empty-text">{{ currentTab === 0 ? '暂无可用道具卡' : '暂无使用记录' }}</text>
<text class="empty-text">{{ getEmptyText() }}</text>
</view>
<!-- 道具卡列表 -->
@ -46,7 +50,7 @@
v-for="(item, index) in list"
:key="item.id || index"
class="item-ticket"
:class="{ 'used': currentTab === 1 }"
:class="{ 'used': currentTab === 1, 'expired': currentTab === 2 }"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧图标区域 -->
@ -54,9 +58,6 @@
<view class="card-icon-wrap">
<text class="card-icon">{{ getCardIcon(item.type || item.name) }}</text>
</view>
<view class="card-count-badge" v-if="currentTab === 0">
<text class="count-num">×{{ item.remaining ?? item.count ?? 1 }}</text>
</view>
</view>
<!-- 中间分割线 -->
@ -83,6 +84,13 @@
<text class="detail-val highlight">{{ item.used_reward_name }}</text>
</view>
</view>
<view class="usage-info" v-if="currentTab === 2">
<text class="card-use-time" v-if="item.valid_end">过期时间{{ formatDateTime(item.valid_end) }}</text>
</view>
<!-- Unused State: Show Validity -->
<view class="usage-info" v-if="currentTab === 0">
<text class="card-use-time" v-if="item.valid_end">有效期至{{ formatDateTime(item.valid_end) }}</text>
</view>
</view>
<!-- 优化后的按钮位置 -->
@ -92,9 +100,12 @@
<view class="btn-shine"></view>
</view>
</view>
<view class="card-used-badge" v-else>
<view class="card-used-badge" v-else-if="currentTab === 1">
<text class="used-text">已使用</text>
</view>
<view class="card-used-badge expired" v-else-if="currentTab === 2">
<text class="used-text">已过期</text>
</view>
</view>
</view>
</view>
@ -117,6 +128,13 @@ function getUserId() {
return uni.getStorageSync('user_id')
}
function getEmptyText() {
if (currentTab.value === 0) return '暂无可用道具卡'
if (currentTab.value === 1) return '暂无使用记录'
if (currentTab.value === 2) return '暂无过期道具卡'
return ''
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
@ -185,23 +203,13 @@ async function fetchData() {
loading.value = true
try {
const userId = getUserId()
// status: 1=unused, 2=used
const status = currentTab.value === 0 ? 1 : 2
// status: 1=unused, 2=used, 3=expired
const status = currentTab.value === 0 ? 1 : (currentTab.value === 1 ? 2 : 3)
const res = await getItemCards(userId, status)
let items = Array.isArray(res) ? res : (res.list || res.data || [])
// count
items = items.map(item => ({
...item,
count: item.count ?? item.remaining ?? 1
}))
// 使0
if (currentTab.value === 0) {
items = items.filter(i => i.count > 0)
}
// items is now a direct list of individual cards (backend aggregation removed)
list.value = items
} catch (e) {
console.error('获取道具卡失败:', e)
@ -455,7 +463,7 @@ onLoad(() => {
.card-info {
display: flex;
flex-direction: column;
padding-right: 100rpx;
padding-right: 130rpx;
}
.card-name {
@ -605,6 +613,35 @@ onLoad(() => {
}
}
/* 已过期状态 */
.item-ticket.expired {
.ticket-left {
background: #fdfdfd;
}
.card-icon-wrap {
filter: grayscale(1) sepia(0.2);
opacity: 0.4;
}
.card-name {
color: $text-tertiary;
text-decoration: line-through;
}
.card-desc {
color: $text-tertiary;
}
.card-used-badge.expired {
background: #f0f0f0;
.used-text {
color: #999;
}
}
}
/* 加载动画 */
.spinner {
width: 28rpx;

View File

@ -86,25 +86,14 @@
<!-- 道具卡视图 (列表卡片) -->
<view v-else-if="currentTab === 'item_card'" class="item-cards-list">
<view class="item-card" v-for="ic in items" :key="ic.id">
<view class="ic-icon-wrap">
<text class="ic-icon">🃏</text>
</view>
<view class="ic-info">
<text class="ic-name">{{ ic.title || ic.name }}</text>
<text class="ic-desc">{{ ic.description || '可在抽奖时使用' }}</text>
<view class="ic-bottom">
<view class="ic-price">
<text class="p-val">{{ ic.points || 0 }}</text>
<text class="p-unit">积分</text>
</view>
<view class="ic-btn" @tap="onRedeemTap(ic)">兑换</view>
</view>
</view>
<!-- TODO: 后续我们要打开的 -->
<view class="empty-state" style="padding-top: 50rpx;">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂未开放</text>
</view>
</view>
<view v-if="!items.length" class="empty-state">
<view v-if="!items.length && currentTab !== 'item_card'" class="empty-state">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂无相关兑换项</text>
</view>