邹方成 6f7207da2d feat: 优化UI设计并重构样式系统
refactor(components): 重构ElCard、FlipGrid、YifanSelector和PaymentPopup组件样式
refactor(pages): 优化地址管理、商品详情、订单列表、积分记录和活动页面UI
style: 更新uni.scss全局样式变量和设计系统
docs: 添加说明文档记录UI优化进度
2025-12-17 14:32:55 +08:00

763 lines
22 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="bg-decoration"></view>
<scroll-view class="page" scroll-y>
<!-- 顶部 Banner -->
<view class="banner" v-if="detail.banner">
<image class="banner-img" :src="detail.banner" mode="widthFix" />
</view>
<!-- 商品信息卡片 -->
<view class="product-card">
<view class="product-info">
<image v-if="detail.banner" class="product-thumb" :src="detail.banner" mode="aspectFill" />
<view class="product-detail">
<view class="product-name">{{ detail.name || detail.title || '无限赏活动' }}</view>
<view class="product-price">¥{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</view>
</view>
<view class="product-actions">
<view class="action-btn">📦 盒柜</view>
</view>
</view>
</view>
<!-- 期号切换条 -->
<view class="issue-bar" v-if="showIssues && issues.length">
<button class="nav-btn" @click="prevIssue"></button>
<view class="issue-info">
<text class="issue-label">{{ currentIssueTitle }}</text>
</view>
<button class="nav-btn" @click="nextIssue"></button>
</view>
<!-- 玩法福利标签 -->
<view class="gameplay-tags">
<view class="tag tag-pool">聚宝盆</view>
<view class="tag tag-drop">随机掉落 10%</view>
<view class="tag tag-free">随机免单 10%</view>
</view>
</scroll-view>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<button class="tier-btn" @click="() => openPayment(1)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</button>
<button class="tier-btn" @click="() => openPayment(3)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</button>
<button class="tier-btn" @click="() => openPayment(5)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</button>
<button class="tier-btn tier-hot" @click="() => openPayment(10)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</button>
</view>
<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>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import FlipGrid from '../../../components/FlipGrid.vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const drawLoading = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const pricePerDrawYuan = computed(() => ((Number(detail.value.price_draw || 0) / 100) || 0))
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDrawYuan.value * times).toFixed(2)
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
fetchPropCards()
fetchCoupons()
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data && data.coupon ? data.coupon : null
selectedCard.value = data && data.card ? data.card : null
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = []
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) => ({
id: i.id ?? i.card_id ?? String(idx),
name: i.name ?? i.title ?? '道具卡'
}))
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = []
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
coupons.value = list.map((i, idx) => {
const amountCents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
const amt = isNaN(amountCents) ? 0 : (amountCents / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: Number(amt).toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
async function onMachineDraw(count) {
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) { uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' }); return }
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value && selectedCoupon.value.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value && selectedCard.value.id ? Number(selectedCard.value.id) : 0
})
const orderNo = joinRes && (joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no)
if (!orderNo) throw new Error('未获取到订单号')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
const resultRes = await getLotteryResult(orderNo)
const raw = resultRes && (resultRes.list || resultRes.items || resultRes.data || resultRes.result || resultRes)
const arr = Array.isArray(raw) ? raw : (Array.isArray(resultRes?.data) ? resultRes.data : [raw])
const items = arr.filter(Boolean).map(d => {
const title = String((d && (d.title || d.name || d.product_name)) || '奖励')
const image = String((d && (d.image || d.img || d.pic || d.product_image)) || '')
return { title, image }
})
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
} catch (e) {
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults([{ title: e.message || '抽选失败', image: '' }])
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
function onMachineTry() {
const list = rewardsMap.value[currentIssueId.value] || []
if (!list.length) { uni.showToast({ title: '暂无奖池', icon: 'none' }); return }
const idx = Math.floor(Math.random() * list.length)
const it = list[idx]
uni.showModal({ title: '试一试', content: it.title || '随机预览', showCancel: false, success: () => { if (it.image) uni.previewImage({ urls: [it.image], current: it.image }) } })
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
}
})
function closeFlip() { showFlip.value = false }
</script>
<style lang="scss" scoped>
/* 奇盒潮玩 - 无限赏活动页面 */
.page {
min-height: 100vh;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
background: transparent;
position: relative;
z-index: 1;
}
.bg-decoration {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: $bg-page;
z-index: 0;
overflow: hidden;
pointer-events: none;
&::before, &::after {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
}
&::before {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.12), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
&::after {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15), transparent 70%);
bottom: 10%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.banner {
padding: $spacing-lg $spacing-lg 0;
animation: fadeInDown 0.6s $ease-out;
}
.banner-img {
width: 100%;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
}
/* 商品信息卡片 */
.product-card {
margin: $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-card;
animation: fadeInUp 0.6s $ease-out 0.1s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.product-info {
display: flex;
align-items: flex-start;
gap: $spacing-lg;
}
.product-thumb {
width: 140rpx;
height: 140rpx;
border-radius: $radius-md;
flex-shrink: 0;
background: $bg-page;
box-shadow: $shadow-inner;
}
.product-detail {
flex: 1;
min-width: 0;
}
.product-name {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-sm;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.product-price {
font-size: $font-xl;
font-weight: 800;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
}
.product-actions {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.action-btn {
background: rgba($brand-primary, 0.05);
border: 1rpx solid rgba($brand-primary, 0.2);
border-radius: $radius-sm;
padding: $spacing-sm $spacing-lg;
font-size: $font-sm;
color: $brand-primary-dark;
text-align: center;
font-weight: 600;
transition: all $transition-fast;
}
.action-btn:active {
background: rgba($brand-primary, 0.1);
transform: scale(0.95);
}
/* 期号切换条 */
.issue-bar {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-lg;
margin: 0 $spacing-lg $spacing-lg;
padding: $spacing-md $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-round;
box-shadow: $shadow-sm;
animation: fadeInUp 0.6s $ease-out 0.2s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.nav-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: $bg-page;
color: $text-sub;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-sm;
padding: 0;
margin: 0;
line-height: 1;
transition: all $transition-fast;
border: none;
&:active {
background: darken($bg-page, 5%);
transform: scale(0.9);
}
}
.issue-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 200rpx;
}
.issue-label {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
/* 玩法福利标签 */
.gameplay-tags {
display: flex;
gap: $spacing-md;
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
flex-wrap: wrap;
animation: fadeInUp 0.6s $ease-out 0.3s backwards;
}
.tag {
padding: $spacing-sm $spacing-lg;
border-radius: $radius-round;
font-size: $font-sm;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: $shadow-sm;
backdrop-filter: blur(4px);
}
.tag-pool {
background: $color-success;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($color-success, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-drop {
background: $gradient-brand;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-free {
background: $gradient-gold;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($accent-gold, 0.3);
text-shadow: 0 1rpx 2rpx rgba(0,0,0,0.1);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
/* 底部多档位抽赏按钮 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: $spacing-md;
padding: $spacing-lg $spacing-lg;
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.08);
z-index: 999;
animation: slideUp $transition-slow $ease-out backwards;
border-top: 1rpx solid rgba(0,0,0,0.05);
}
.tier-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-md $spacing-xs;
background: $bg-card;
border: 1rpx solid $border-color-light;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
transition: all $transition-fast;
&:active {
transform: scale(0.95);
background: $bg-page;
}
}
.tier-price {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
}
.tier-label {
font-size: $font-xs;
color: $text-sub;
margin-top: 4rpx;
font-weight: 500;
}
.tier-hot {
background: $gradient-brand;
border: none;
box-shadow: $shadow-warm;
position: relative;
overflow: hidden;
.tier-price, .tier-label {
color: #fff;
}
&::after {
content: 'HOT';
position: absolute;
top: 0;
right: 0;
background: linear-gradient(135deg, $accent-red, #D32F2F);
color: #fff;
font-size: 18rpx;
font-weight: 800;
padding: 4rpx 10rpx;
border-bottom-left-radius: $radius-md;
box-shadow: -2rpx 2rpx 4rpx rgba(0,0,0,0.1);
}
&:active {
opacity: 0.9;
transform: scale(0.96);
}
}
.tier-hot .tier-price, .tier-hot .tier-label {
color: #FFFFFF;
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
}
.flip-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(10px);
z-index: 1;
}
.flip-content {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
padding: 24rpx;
z-index: 2;
justify-content: center;
align-items: center;
animation: zoomIn 0.3s $ease-bounce;
}
.overlay-close {
margin-top: 60rpx;
width: 240rpx;
height: 88rpx;
line-height: 88rpx;
background: rgba(255,255,255,0.15) !important;
border: 1rpx solid rgba(255,255,255,0.3);
color: #FFFFFF !important;
border-radius: $radius-round;
font-weight: 600;
font-size: 30rpx;
backdrop-filter: blur(10px);
transition: all $transition-fast;
&:active {
background: rgba(255,255,255,0.25) !important;
transform: scale(0.95);
}
}
</style>