refactor(components): 重构ElCard、FlipGrid、YifanSelector和PaymentPopup组件样式 refactor(pages): 优化地址管理、商品详情、订单列表、积分记录和活动页面UI style: 更新uni.scss全局样式变量和设计系统 docs: 添加说明文档记录UI优化进度
574 lines
15 KiB
Vue
574 lines
15 KiB
Vue
<template>
|
|
<view class="page-wrapper">
|
|
<!-- Rebuild Trigger -->
|
|
<!-- 背景层 -->
|
|
<image class="bg-fixed" :src="detail.banner || ''" mode="aspectFill" />
|
|
<view class="bg-mask"></view>
|
|
|
|
<view class="content-area">
|
|
<!-- 顶部信息 -->
|
|
<view class="header-section">
|
|
<view class="title-box">
|
|
<text class="main-title">{{ detail.name || detail.title || '爬塔挑战' }}</text>
|
|
<text class="sub-title">层层突围 赢取大奖</text>
|
|
</view>
|
|
<view class="rule-btn" @tap="showRules">规则</view>
|
|
</view>
|
|
|
|
<!-- 挑战区域 (模拟塔层) -->
|
|
<view class="tower-container">
|
|
<view class="tower-level current">
|
|
<view class="level-info">
|
|
<text class="level-num">当前挑战</text>
|
|
<text class="level-name">{{ currentIssueTitle || '第1层' }}</text>
|
|
</view>
|
|
<view class="level-status">进行中</view>
|
|
</view>
|
|
|
|
<!-- 奖池预览 -->
|
|
<view class="rewards-preview" v-if="currentIssueRewards.length">
|
|
<scroll-view scroll-x class="rewards-scroll">
|
|
<view class="reward-item" v-for="(r, idx) in currentIssueRewards" :key="idx">
|
|
<image class="reward-img" :src="r.image" mode="aspectFill" />
|
|
<view class="reward-name">{{ r.title }}</view>
|
|
<view class="reward-prob" v-if="r.percent">概率 {{ r.percent }}%</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 操作区 -->
|
|
<view class="action-area">
|
|
<view class="price-display">
|
|
<text class="currency">¥</text>
|
|
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
|
|
<text class="unit">/次</text>
|
|
</view>
|
|
<button class="challenge-btn" :loading="drawLoading" @tap="onStartChallenge">
|
|
立即挑战
|
|
</button>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 结果弹窗 -->
|
|
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
|
|
<view class="flip-mask" @tap="closeFlip"></view>
|
|
<view class="flip-content">
|
|
<FlipGrid ref="flipRef" :rewards="winItems" :controls="false" />
|
|
<button class="close-btn" @tap="closeFlip">收下奖励</button>
|
|
</view>
|
|
</view>
|
|
|
|
<PaymentPopup
|
|
v-model:visible="paymentVisible"
|
|
:amount="paymentAmount"
|
|
:coupons="coupons"
|
|
:propCards="propCards"
|
|
@confirm="onPaymentConfirm"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { onLoad } from '@dcloudio/uni-app'
|
|
import FlipGrid from '../../../components/FlipGrid.vue'
|
|
import PaymentPopup from '../../../components/PaymentPopup.vue'
|
|
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '../../../api/appUser'
|
|
|
|
const activityId = ref('')
|
|
const detail = ref({})
|
|
const issues = ref([])
|
|
const currentIssueId = ref('')
|
|
const rewardsMap = ref({})
|
|
const drawLoading = ref(false)
|
|
const showFlip = ref(false)
|
|
const winItems = ref([])
|
|
const flipRef = ref(null)
|
|
|
|
// Payment
|
|
const paymentVisible = ref(false)
|
|
const paymentAmount = ref('0.00')
|
|
const coupons = ref([])
|
|
const propCards = ref([])
|
|
const selectedCoupon = ref(null)
|
|
const selectedCard = ref(null)
|
|
const pendingCount = ref(1)
|
|
|
|
const currentIssueTitle = computed(() => {
|
|
const i = issues.value.find(x => x.id === currentIssueId.value)
|
|
return i ? (i.title || `第${i.no}期`) : ''
|
|
})
|
|
|
|
const currentIssueRewards = computed(() => {
|
|
return (currentIssueId.value && rewardsMap.value[currentIssueId.value]) || []
|
|
})
|
|
|
|
const priceVal = computed(() => Number(detail.value.price_draw || 0) / 100)
|
|
|
|
async function loadData(id) {
|
|
try {
|
|
const d = await getActivityDetail(id)
|
|
detail.value = d || {}
|
|
|
|
const is = await getActivityIssues(id)
|
|
issues.value = normalizeIssues(is)
|
|
|
|
if (issues.value.length) {
|
|
const first = issues.value[0]
|
|
currentIssueId.value = first.id
|
|
loadRewards(id, first.id)
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
async function loadRewards(aid, iid) {
|
|
try {
|
|
const res = await getActivityIssueRewards(aid, iid)
|
|
rewardsMap.value[iid] = normalizeRewards(res)
|
|
} catch (e) {}
|
|
}
|
|
|
|
function onStartChallenge() {
|
|
const token = uni.getStorageSync('token')
|
|
const phoneBound = !!uni.getStorageSync('phone_bound')
|
|
if (!token || !phoneBound) {
|
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
|
// In real app, redirect to login
|
|
return
|
|
}
|
|
|
|
if (!currentIssueId.value) {
|
|
uni.showToast({ title: '暂无挑战场次', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
paymentAmount.value = priceVal.value.toFixed(2)
|
|
pendingCount.value = 1
|
|
paymentVisible.value = true
|
|
// Fetch coupons/cards in background
|
|
fetchPropCards()
|
|
fetchCoupons()
|
|
}
|
|
|
|
async function onPaymentConfirm(data) {
|
|
selectedCoupon.value = data?.coupon || null
|
|
selectedCard.value = data?.card || null
|
|
paymentVisible.value = false
|
|
await doDraw()
|
|
}
|
|
|
|
async function doDraw() {
|
|
drawLoading.value = true
|
|
try {
|
|
const openid = uni.getStorageSync('openid')
|
|
const joinRes = await joinLottery({
|
|
activity_id: Number(activityId.value),
|
|
issue_id: Number(currentIssueId.value),
|
|
channel: 'miniapp',
|
|
count: 1,
|
|
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0
|
|
})
|
|
|
|
if (!joinRes) throw new Error('下单失败')
|
|
const orderNo = joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no
|
|
|
|
// Simulate Wechat Pay flow (simplified)
|
|
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
|
await new Promise((resolve, reject) => {
|
|
uni.requestPayment({
|
|
provider: 'wxpay',
|
|
...payRes,
|
|
success: resolve,
|
|
fail: reject
|
|
})
|
|
})
|
|
|
|
// Get Result
|
|
const res = await getLotteryResult(orderNo)
|
|
const raw = res.list || res.items || res.data || res.result || (Array.isArray(res) ? res : [res])
|
|
winItems.value = raw.map(i => ({
|
|
title: i.title || i.name || '未知奖励',
|
|
image: i.image || i.img || ''
|
|
}))
|
|
|
|
showFlip.value = true
|
|
setTimeout(() => {
|
|
if(flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(winItems.value)
|
|
}, 100)
|
|
|
|
} catch (e) {
|
|
uni.showToast({ title: e.message || '挑战失败', icon: 'none' })
|
|
} finally {
|
|
drawLoading.value = false
|
|
}
|
|
}
|
|
|
|
function normalizeIssues(list) {
|
|
if (!Array.isArray(list)) return []
|
|
return list.map(i => ({
|
|
id: i.id,
|
|
title: i.title || i.name,
|
|
no: i.no,
|
|
}))
|
|
}
|
|
|
|
function normalizeRewards(list) {
|
|
if (!Array.isArray(list)) return []
|
|
return list.map(i => ({
|
|
title: i.name || i.title,
|
|
image: i.image || i.img || i.pic,
|
|
percent: i.percent || 0
|
|
}))
|
|
}
|
|
|
|
async function fetchPropCards() { /* implementation same as other pages */ }
|
|
async function fetchCoupons() { /* implementation same as other pages */ }
|
|
|
|
function showRules() {
|
|
uni.showModal({ title: '规则', content: detail.value.rules || '暂无规则', showCancel: false })
|
|
}
|
|
|
|
function closeFlip() { showFlip.value = false }
|
|
|
|
onLoad((opts) => {
|
|
if (opts.id) {
|
|
activityId.value = opts.id
|
|
loadData(opts.id)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
/* ============================================
|
|
爬塔页面 - 沉浸式暗黑风格 (SCSS Integration)
|
|
============================================ */
|
|
|
|
$local-gold: #FFD700; // 特殊金色,比全局更亮
|
|
|
|
.page-wrapper {
|
|
min-height: 100vh;
|
|
position: relative;
|
|
background: $bg-dark;
|
|
color: $text-dark-main;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 背景装饰 - 暗黑版 */
|
|
.bg-decoration {
|
|
position: absolute;
|
|
top: 0; left: 0; width: 100%; height: 100%;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -10%; left: -20%;
|
|
width: 600rpx; height: 600rpx;
|
|
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
|
|
filter: blur(80rpx);
|
|
border-radius: 50%;
|
|
opacity: 0.6;
|
|
animation: float 10s ease-in-out infinite;
|
|
}
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 10%; right: -10%;
|
|
width: 500rpx; height: 500rpx;
|
|
background: radial-gradient(circle, rgba($local-gold, 0.08) 0%, transparent 70%);
|
|
filter: blur(60rpx);
|
|
border-radius: 50%;
|
|
opacity: 0.5;
|
|
animation: float 12s ease-in-out infinite reverse;
|
|
}
|
|
}
|
|
|
|
@keyframes float {
|
|
0%, 100% { transform: translate(0, 0); }
|
|
50% { transform: translate(20rpx, 30rpx); }
|
|
}
|
|
|
|
.bg-fixed {
|
|
position: absolute;
|
|
top: 0; left: 0; width: 100%; height: 100%;
|
|
opacity: 0.3;
|
|
z-index: 0;
|
|
filter: blur(8rpx);
|
|
}
|
|
.bg-mask {
|
|
position: absolute;
|
|
top: 0; left: 0; width: 100%; height: 100%;
|
|
background: linear-gradient(180deg, rgba($bg-dark, 0.85), $bg-dark 95%);
|
|
z-index: 1;
|
|
}
|
|
|
|
.content-area {
|
|
position: relative;
|
|
z-index: 2;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: $spacing-lg;
|
|
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
|
}
|
|
|
|
.header-section {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: $spacing-xl;
|
|
animation: fadeInDown 0.6s ease-out;
|
|
}
|
|
.title-box {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.main-title {
|
|
font-size: 60rpx;
|
|
font-weight: 900;
|
|
font-style: italic;
|
|
display: block;
|
|
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6);
|
|
background: linear-gradient(180deg, #fff, #b3b3b3);
|
|
-webkit-background-clip: text;
|
|
color: transparent;
|
|
letter-spacing: 2rpx;
|
|
}
|
|
.sub-title {
|
|
font-size: 26rpx;
|
|
opacity: 0.8;
|
|
margin-top: $spacing-xs;
|
|
display: block;
|
|
letter-spacing: 4rpx;
|
|
color: $brand-primary;
|
|
text-transform: uppercase;
|
|
}
|
|
.rule-btn {
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1px solid $border-dark;
|
|
padding: 12rpx 32rpx;
|
|
border-radius: 100rpx;
|
|
font-size: 24rpx;
|
|
backdrop-filter: blur(10rpx);
|
|
transition: all 0.2s;
|
|
color: rgba(255,255,255,0.9);
|
|
|
|
&:active {
|
|
background: rgba(255,255,255,0.25);
|
|
transform: scale(0.96);
|
|
}
|
|
}
|
|
|
|
.tower-container {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding-bottom: 40rpx;
|
|
}
|
|
|
|
.tower-level {
|
|
width: 100%;
|
|
background: $bg-dark-card;
|
|
backdrop-filter: blur(20rpx);
|
|
padding: 48rpx;
|
|
border-radius: $radius-xl;
|
|
box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.3);
|
|
margin-bottom: 40rpx;
|
|
border: 1px solid $border-dark;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
animation: zoomIn 0.5s ease-out backwards;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; height: 1px;
|
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
|
}
|
|
|
|
&.current {
|
|
background: rgba($local-gold, 0.15);
|
|
border-color: rgba($local-gold, 0.5);
|
|
box-shadow: 0 0 40rpx rgba($local-gold, 0.15), inset 0 0 20rpx rgba($local-gold, 0.05);
|
|
}
|
|
}
|
|
.level-info { display: flex; flex-direction: column; z-index: 1; }
|
|
.level-num {
|
|
font-size: 24rpx;
|
|
color: $text-dark-sub;
|
|
margin-bottom: 8rpx;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2rpx;
|
|
}
|
|
.level-name {
|
|
font-size: 48rpx;
|
|
font-weight: 700;
|
|
color: $text-dark-main;
|
|
text-shadow: 0 4rpx 8rpx rgba(0,0,0,0.3);
|
|
}
|
|
.level-status {
|
|
font-size: 24rpx;
|
|
background: linear-gradient(135deg, $local-gold, $brand-secondary);
|
|
color: #3e2723;
|
|
padding: 8rpx 20rpx;
|
|
border-radius: 12rpx;
|
|
font-weight: 800;
|
|
box-shadow: 0 4rpx 16rpx rgba($brand-secondary, 0.3);
|
|
z-index: 1;
|
|
}
|
|
|
|
.rewards-preview {
|
|
width: 100%;
|
|
margin-top: 40rpx;
|
|
}
|
|
.rewards-scroll {
|
|
white-space: nowrap;
|
|
width: 100%;
|
|
}
|
|
.reward-item {
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
width: 160rpx;
|
|
margin-right: 24rpx;
|
|
animation: fadeInUp 0.5s ease-out backwards;
|
|
|
|
@for $i from 1 through 5 {
|
|
&:nth-child(#{$i}) {
|
|
animation-delay: #{$i * 0.1}s;
|
|
}
|
|
}
|
|
}
|
|
.reward-img {
|
|
width: 120rpx; height: 120rpx;
|
|
border-radius: 24rpx;
|
|
background: rgba(255,255,255,0.05);
|
|
margin-bottom: 16rpx;
|
|
border: 1px solid $border-dark;
|
|
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2);
|
|
}
|
|
.reward-name {
|
|
font-size: 22rpx;
|
|
color: $text-dark-sub;
|
|
width: 100%;
|
|
text-align: center;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.reward-prob {
|
|
font-size: 20rpx;
|
|
color: $local-gold;
|
|
font-weight: 600;
|
|
margin-top: 4rpx;
|
|
}
|
|
|
|
.action-area {
|
|
background: $bg-dark-card;
|
|
backdrop-filter: blur(40rpx);
|
|
padding: 24rpx 32rpx;
|
|
border-radius: 100rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border: 1px solid $border-dark;
|
|
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.5);
|
|
margin-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
|
|
animation: slideUp 0.6s ease-out backwards;
|
|
animation-delay: 0.3s;
|
|
}
|
|
.price-display {
|
|
display: flex;
|
|
align-items: baseline;
|
|
color: $local-gold;
|
|
font-weight: 700;
|
|
margin-left: 20rpx;
|
|
text-shadow: 0 0 20rpx rgba(255, 215, 0, 0.2);
|
|
}
|
|
.currency { font-size: 28rpx; }
|
|
.amount { font-size: 48rpx; margin: 0 4rpx; font-family: 'DIN Alternate', sans-serif; }
|
|
.unit { font-size: 24rpx; opacity: 0.8; font-weight: normal; }
|
|
|
|
.challenge-btn {
|
|
background: $gradient-brand;
|
|
color: #fff;
|
|
font-weight: 900;
|
|
border-radius: 100rpx;
|
|
padding: 0 60rpx;
|
|
height: 88rpx;
|
|
line-height: 88rpx;
|
|
font-size: 32rpx;
|
|
box-shadow: 0 8rpx 24rpx rgba(255, 107, 0, 0.3);
|
|
border: none;
|
|
position: relative;
|
|
overflow: hidden;
|
|
transition: all 0.2s;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: -100%; width: 100%; height: 100%;
|
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
|
animation: shimmer 3s infinite;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.96);
|
|
box-shadow: 0 4rpx 12rpx rgba(255, 107, 0, 0.2);
|
|
}
|
|
}
|
|
|
|
.flip-overlay {
|
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999;
|
|
}
|
|
.flip-mask {
|
|
position: absolute; top: 0; bottom: 0; width: 100%; background: rgba(0,0,0,0.85);
|
|
backdrop-filter: blur(10rpx);
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
.flip-content {
|
|
position: relative;
|
|
z-index: 2;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 40rpx;
|
|
justify-content: center;
|
|
animation: zoomIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
}
|
|
.close-btn {
|
|
margin-top: 60rpx;
|
|
background: #fff;
|
|
color: #333;
|
|
border-radius: 100rpx;
|
|
font-weight: 700;
|
|
width: 50%;
|
|
height: 80rpx;
|
|
line-height: 80rpx;
|
|
align-self: center;
|
|
box-shadow: 0 10rpx 30rpx rgba(255,255,255,0.15);
|
|
transition: all 0.2s;
|
|
|
|
&:active {
|
|
transform: scale(0.95);
|
|
}
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { left: -100%; }
|
|
50%, 100% { left: 200%; }
|
|
}
|
|
</style>
|