feat: 新增活动相关工具函数、缓存管理、Vue组合式函数及多个活动页面组件,并优化了YifanSelector的UI。
This commit is contained in:
parent
97cfe3f3da
commit
d1f005225a
@ -354,8 +354,7 @@ async function onPaymentConfirm(paymentData) {
|
||||
|
||||
/* 网格包装 */
|
||||
.grid-wrapper {
|
||||
padding-bottom: 200rpx; /* 留出底部操作栏空间 */
|
||||
padding: 0 20rpx 200rpx;
|
||||
padding: 0 20rpx 140rpx; /* 减少底部padding */
|
||||
}
|
||||
|
||||
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
|
||||
@ -476,22 +475,22 @@ async function onPaymentConfirm(paymentData) {
|
||||
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* ============= 底部操作栏 - 高级重置 ============= */
|
||||
/* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
left: 30rpx;
|
||||
right: 30rpx;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(30rpx);
|
||||
padding: 24rpx 40rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
|
||||
}
|
||||
@ -533,6 +532,10 @@ async function onPaymentConfirm(paymentData) {
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
259
components/activity/ActivityHeader.vue
Normal file
259
components/activity/ActivityHeader.vue
Normal file
@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<view class="header-card animate-enter">
|
||||
<image class="header-cover" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="header-info">
|
||||
<view class="header-title">{{ title }}</view>
|
||||
<view class="header-price-row" v-if="price !== undefined">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-num">{{ formattedPrice }}</text>
|
||||
<text class="price-unit">{{ priceUnit }}</text>
|
||||
</view>
|
||||
<view class="header-time-row" v-if="scheduledTime">
|
||||
<text class="time-label">本期结束</text>
|
||||
<text class="time-value">{{ scheduledTime }}</text>
|
||||
</view>
|
||||
<view class="header-tags" v-if="tags && tags.length">
|
||||
<view class="tag-item" v-for="(tag, idx) in tags" :key="idx">{{ tag }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="action-btn" @tap="$emit('show-rules')">
|
||||
<view class="action-icon rules-icon"></view>
|
||||
<text class="action-label">规则</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="$emit('go-cabinet')">
|
||||
<view class="action-icon cabinet-icon"></view>
|
||||
<text class="action-label">盒柜</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
priceUnit: {
|
||||
type: String,
|
||||
default: '/发'
|
||||
},
|
||||
coverUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
scheduledTime: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['show-rules', 'go-cabinet'])
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const cents = Number(props.price || 0)
|
||||
return (cents / 100).toFixed(2)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
头部卡片 - 与原始设计完全一致
|
||||
============================================ */
|
||||
|
||||
.header-card {
|
||||
margin: $spacing-xl $spacing-lg;
|
||||
background: rgba($bg-card, 0.85);
|
||||
backdrop-filter: blur(24rpx);
|
||||
border-radius: $radius-xl;
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: $shadow-card;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2rpx;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.8), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.header-cover {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: $radius-md;
|
||||
margin-right: $spacing-lg;
|
||||
background: $bg-secondary;
|
||||
box-shadow: $shadow-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 6rpx 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: $font-xl;
|
||||
font-weight: 800;
|
||||
color: $text-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.header-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: $brand-primary;
|
||||
margin-bottom: $spacing-sm;
|
||||
text-shadow: 0 2rpx 4rpx rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.price-symbol {
|
||||
font-size: $font-md;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-num {
|
||||
font-size: $font-xxl;
|
||||
font-weight: 900;
|
||||
margin: 0 4rpx;
|
||||
font-family: 'DIN Alternate', sans-serif;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: $font-sm;
|
||||
color: $text-sub;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
|
||||
.header-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: $font-xs;
|
||||
color: $text-tertiary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: $font-sm;
|
||||
color: $text-sub;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-tags {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
font-size: $font-xs;
|
||||
color: $brand-primary-dark;
|
||||
background: rgba($brand-primary, 0.08);
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-weight: 600;
|
||||
border: 1rpx solid rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
margin-left: 16rpx;
|
||||
padding-left: 24rpx;
|
||||
border-left: 2rpx solid #E8E8E8;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin-bottom: 8rpx;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.rules-icon {
|
||||
background-color: #999;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
|
||||
.cabinet-icon {
|
||||
background-color: #999;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* 入场动画 */
|
||||
.animate-enter {
|
||||
animation: slideUp 0.5s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
components/activity/ActivityPageLayout.vue
Normal file
127
components/activity/ActivityPageLayout.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="page-wrapper">
|
||||
<!-- 背景装饰 - 与原始设计一致 -->
|
||||
<view class="bg-decoration">
|
||||
<view class="orb orb-1"></view>
|
||||
<view class="orb orb-2"></view>
|
||||
</view>
|
||||
|
||||
<!-- 顶部背景图(模糊处理) -->
|
||||
<view class="page-bg">
|
||||
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="bg-mask"></view>
|
||||
</view>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<scroll-view class="main-scroll" scroll-y>
|
||||
<!-- 头部插槽 -->
|
||||
<slot name="header"></slot>
|
||||
|
||||
<!-- 主内容插槽 -->
|
||||
<slot name="content"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<!-- 底部垫高 -->
|
||||
<view :style="{ height: bottomPadding }"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏插槽 -->
|
||||
<slot name="footer"></slot>
|
||||
|
||||
<!-- 弹窗插槽 -->
|
||||
<slot name="modals"></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
coverUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
bottomPadding: {
|
||||
type: String,
|
||||
default: '180rpx'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
页面框架 - 与原始设计完全一致
|
||||
============================================ */
|
||||
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80rpx);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 500rpx;
|
||||
height: 500rpx;
|
||||
background: radial-gradient(circle, rgba($brand-primary, 0.2) 0%, transparent 70%);
|
||||
top: -100rpx;
|
||||
left: -100rpx;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 600rpx;
|
||||
height: 600rpx;
|
||||
background: radial-gradient(circle, rgba($accent-gold, 0.15) 0%, transparent 70%);
|
||||
bottom: -100rpx;
|
||||
right: -100rpx;
|
||||
}
|
||||
|
||||
/* 顶部背景 */
|
||||
.page-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 700rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(30rpx) brightness(0.9);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.bg-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba($bg-page, 0.2) 0%, $bg-page 90%, $bg-page 100%);
|
||||
}
|
||||
|
||||
.main-scroll {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
116
components/activity/ActivityTabs.vue
Normal file
116
components/activity/ActivityTabs.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<view class="section-container animate-enter" :class="staggerClass">
|
||||
<!-- Modern Tabs - 与原始设计一致 -->
|
||||
<view class="modern-tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: modelValue === tab.key }"
|
||||
@tap="$emit('update:modelValue', tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<view v-if="modelValue === tab.key" class="active-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<slot :name="modelValue"></slot>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'pool'
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ key: 'pool', label: '本机奖池' },
|
||||
{ key: 'records', label: '购买记录' }
|
||||
]
|
||||
},
|
||||
stagger: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const staggerClass = computed(() => `stagger-${props.stagger}`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Section Container - 与原始设计一致 */
|
||||
.section-container {
|
||||
margin: 0 $spacing-lg $spacing-lg;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: $radius-xl;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
/* Modern Tabs - 与原始设计完全一致 */
|
||||
.modern-tabs {
|
||||
display: flex;
|
||||
background: $bg-secondary;
|
||||
padding: 8rpx;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: $spacing-md 0;
|
||||
font-size: $font-md;
|
||||
color: $text-sub;
|
||||
border-radius: $radius-md;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: $brand-primary;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.active-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
background: $brand-primary;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
bottom: 8rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 入场动画 */
|
||||
.animate-enter {
|
||||
animation: slideUp 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 0.1s; }
|
||||
.stagger-2 { animation-delay: 0.2s; }
|
||||
.stagger-3 { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
components/activity/RecordsList.vue
Normal file
107
components/activity/RecordsList.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<view>
|
||||
<view class="records-list" v-if="records && records.length">
|
||||
<view v-for="(item, idx) in records" :key="item.id || idx" class="record-item">
|
||||
<image class="record-img" :src="item.image" mode="aspectFill" />
|
||||
<view class="record-info">
|
||||
<view class="record-title">{{ item.title }}</view>
|
||||
<view class="record-meta">
|
||||
<text class="record-count">x{{ item.count }}</text>
|
||||
<text v-if="item.percent" class="record-percent">{{ item.percent }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon">📝</text>
|
||||
<text class="empty-text">{{ emptyText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
records: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无购买记录'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.records-list {
|
||||
padding: $spacing-xs 0;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.record-img {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: $radius-md;
|
||||
margin-right: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
@include text-ellipsis(1);
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: $font-sm;
|
||||
color: $brand-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.record-percent {
|
||||
font-size: $font-xs;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-sm;
|
||||
}
|
||||
</style>
|
||||
219
components/activity/RewardsPopup.vue
Normal file
219
components/activity/RewardsPopup.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<view v-if="visible" class="rewards-overlay" @touchmove.stop.prevent>
|
||||
<view class="rewards-mask" @tap="$emit('update:visible', false)"></view>
|
||||
<view class="rewards-panel" @tap.stop>
|
||||
<view class="rewards-header">
|
||||
<text class="rewards-title">{{ title }}</text>
|
||||
<text class="rewards-close" @tap="$emit('update:visible', false)">×</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="rewards-list">
|
||||
<view v-if="rewardGroups.length > 0">
|
||||
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level">
|
||||
<view class="group-header-row">
|
||||
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}赏</text>
|
||||
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item">
|
||||
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
|
||||
<view class="rewards-info">
|
||||
<view class="rewards-name-row">
|
||||
<text class="rewards-name">{{ item.title || '-' }}</text>
|
||||
<view class="rewards-tag" v-if="item.boss">BOSS</view>
|
||||
</view>
|
||||
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="rewards-empty">{{ emptyText }}</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatPercent } from '@/utils/format'
|
||||
|
||||
defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '奖品与概率'
|
||||
},
|
||||
rewardGroups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无奖品数据'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:visible'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rewards-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rewards-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.rewards-panel {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-lg;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.rewards-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-lg;
|
||||
border-bottom: 1rpx solid $border-color-light;
|
||||
}
|
||||
|
||||
.rewards-title {
|
||||
font-size: $font-lg;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.rewards-close {
|
||||
font-size: 48rpx;
|
||||
color: $text-sub;
|
||||
line-height: 1;
|
||||
padding: $spacing-xs;
|
||||
}
|
||||
|
||||
.rewards-list {
|
||||
max-height: 60vh;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.rewards-group-v2 {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.group-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
font-size: $font-xs;
|
||||
font-weight: 700;
|
||||
color: $brand-primary;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&.badge-boss {
|
||||
background: $gradient-gold;
|
||||
color: #6b4b1f;
|
||||
}
|
||||
}
|
||||
|
||||
.group-total-prob {
|
||||
font-size: $font-xs;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.rewards-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rewards-thumb {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: $radius-md;
|
||||
margin-right: $spacing-md;
|
||||
background: $bg-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rewards-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rewards-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.rewards-name {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
@include text-ellipsis(1);
|
||||
}
|
||||
|
||||
.rewards-tag {
|
||||
font-size: $font-xxs;
|
||||
font-weight: 700;
|
||||
color: #6b4b1f;
|
||||
background: $gradient-gold;
|
||||
padding: 2rpx 8rpx;
|
||||
border-radius: $radius-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rewards-percent {
|
||||
font-size: $font-sm;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.rewards-empty {
|
||||
text-align: center;
|
||||
color: $text-sub;
|
||||
padding: $spacing-xl;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
</style>
|
||||
236
components/activity/RewardsPreview.vue
Normal file
236
components/activity/RewardsPreview.vue
Normal file
@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<view>
|
||||
<view class="section-header">
|
||||
<text class="section-title">{{ title }}</text>
|
||||
<text class="section-more" @tap="$emit('view-all')">查看全部</text>
|
||||
</view>
|
||||
|
||||
<!-- 分组展示 -->
|
||||
<view v-if="grouped && rewardGroups.length > 0">
|
||||
<view class="prize-level-row" v-for="group in rewardGroups" :key="group.level">
|
||||
<view class="level-header-row">
|
||||
<view class="level-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}赏</view>
|
||||
<text class="level-prob">总概率 {{ group.totalPercent }}%</text>
|
||||
</view>
|
||||
<scroll-view class="preview-scroll" scroll-x>
|
||||
<view class="preview-item" v-for="(item, idx) in group.rewards" :key="item.id || idx">
|
||||
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : group.level }}</view>
|
||||
<image class="preview-img" :src="item.image" mode="aspectFill" />
|
||||
<view class="preview-name">{{ item.title }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 简单列表展示 -->
|
||||
<view v-else-if="rewards.length > 0">
|
||||
<scroll-view class="preview-scroll" scroll-x>
|
||||
<view class="preview-item" v-for="(item, idx) in rewards" :key="idx">
|
||||
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : (item.level || '赏') }}</view>
|
||||
<image class="preview-img" :src="item.image" mode="aspectFill" />
|
||||
<view class="preview-name">{{ item.title }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text class="empty-text">{{ emptyText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { groupRewardsByLevel } from '@/utils/activity'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '奖池配置'
|
||||
},
|
||||
rewards: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
grouped: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无奖品配置'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['view-all'])
|
||||
|
||||
const rewardGroups = computed(() => {
|
||||
if (!props.grouped) return []
|
||||
return groupRewardsByLevel(props.rewards)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
奖池预览 - 与原始设计完全一致
|
||||
============================================ */
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: $font-md;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: $font-sm;
|
||||
color: $text-tertiary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: '>';
|
||||
font-family: monospace;
|
||||
margin-left: 6rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* 等级分组 */
|
||||
.prize-level-row {
|
||||
margin-bottom: $spacing-lg;
|
||||
background: rgba(0,0,0,0.02);
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-lg;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.level-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: inline-block;
|
||||
font-size: $font-xs;
|
||||
font-weight: 900;
|
||||
color: $text-main;
|
||||
background: #F0F0F0;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-style: italic;
|
||||
border: 1rpx solid rgba(0,0,0,0.05);
|
||||
box-shadow: $shadow-xs;
|
||||
|
||||
&.badge-boss {
|
||||
background: $gradient-gold;
|
||||
color: #78350F;
|
||||
border-color: rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.level-prob {
|
||||
font-size: 22rpx;
|
||||
color: $brand-primary;
|
||||
font-weight: 800;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 预览滚动区域 */
|
||||
.preview-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: inline-block;
|
||||
width: 180rpx;
|
||||
margin-right: $spacing-md;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-secondary;
|
||||
margin-bottom: $spacing-sm;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1rpx solid rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: $font-xs;
|
||||
color: $text-secondary;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 奖品标签 */
|
||||
.prize-tag {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 10rpx;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
font-size: $font-xs;
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
z-index: 10;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(4rpx);
|
||||
transform: scale(0.9);
|
||||
transform-origin: top left;
|
||||
|
||||
&.tag-boss {
|
||||
background: $gradient-brand;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-sm;
|
||||
}
|
||||
</style>
|
||||
10
components/activity/index.js
Normal file
10
components/activity/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Activity 组件统一导出
|
||||
*/
|
||||
|
||||
export { default as ActivityPageLayout } from './ActivityPageLayout.vue'
|
||||
export { default as ActivityHeader } from './ActivityHeader.vue'
|
||||
export { default as ActivityTabs } from './ActivityTabs.vue'
|
||||
export { default as RewardsPreview } from './RewardsPreview.vue'
|
||||
export { default as RewardsPopup } from './RewardsPopup.vue'
|
||||
export { default as RecordsList } from './RecordsList.vue'
|
||||
8
composables/index.js
Normal file
8
composables/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Composables 统一导出
|
||||
*/
|
||||
|
||||
export { useActivity } from './useActivity'
|
||||
export { useIssues } from './useIssues'
|
||||
export { useRewards } from './useRewards'
|
||||
export { useRecords } from './useRecords'
|
||||
69
composables/useActivity.js
Normal file
69
composables/useActivity.js
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 活动数据管理 Composable
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { getActivityDetail } from '@/api/appUser'
|
||||
import { cleanUrl } from '@/utils/format'
|
||||
import { statusToText } from '@/utils/activity'
|
||||
|
||||
/**
|
||||
* 活动数据管理
|
||||
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
|
||||
*/
|
||||
export function useActivity(activityIdRef) {
|
||||
const detail = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
const d = detail.value || {}
|
||||
return cleanUrl(d.image || d.banner || d.cover || '')
|
||||
})
|
||||
|
||||
const statusText = computed(() => statusToText(detail.value?.status))
|
||||
|
||||
const pricePerDraw = computed(() => {
|
||||
const cents = Number(detail.value?.price_draw || 0)
|
||||
return cents / 100
|
||||
})
|
||||
|
||||
const activityName = computed(() => {
|
||||
const d = detail.value || {}
|
||||
return d.name || d.title || ''
|
||||
})
|
||||
|
||||
const scheduledTime = computed(() => detail.value?.scheduled_time || detail.value?.scheduledTime || '')
|
||||
|
||||
async function fetchDetail() {
|
||||
const id = activityIdRef?.value || activityIdRef
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getActivityDetail(id)
|
||||
detail.value = data || {}
|
||||
} catch (e) {
|
||||
console.error('fetchDetail error', e)
|
||||
detail.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setNavigationTitle(fallback = '活动') {
|
||||
const title = activityName.value || fallback
|
||||
try {
|
||||
uni.setNavigationBarTitle({ title })
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
loading,
|
||||
coverUrl,
|
||||
statusText,
|
||||
pricePerDraw,
|
||||
activityName,
|
||||
scheduledTime,
|
||||
fetchDetail,
|
||||
setNavigationTitle
|
||||
}
|
||||
}
|
||||
89
composables/useIssues.js
Normal file
89
composables/useIssues.js
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 期数据管理 Composable
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { getActivityIssues } from '@/api/appUser'
|
||||
import { normalizeIssues, pickLatestIssueId } from '@/utils/activity'
|
||||
|
||||
/**
|
||||
* 期数据管理
|
||||
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
|
||||
*/
|
||||
export function useIssues(activityIdRef) {
|
||||
const issues = ref([])
|
||||
const selectedIssueIndex = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const currentIssueId = computed(() => {
|
||||
const arr = issues.value || []
|
||||
const cur = arr[selectedIssueIndex.value]
|
||||
return (cur && cur.id) || ''
|
||||
})
|
||||
|
||||
const currentIssue = computed(() => {
|
||||
const arr = issues.value || []
|
||||
return arr[selectedIssueIndex.value] || null
|
||||
})
|
||||
|
||||
const currentIssueTitle = computed(() => {
|
||||
const cur = currentIssue.value
|
||||
if (!cur) return '-'
|
||||
return cur.title || ('第' + (cur.no || '-') + '期')
|
||||
})
|
||||
|
||||
const currentIssueStatusText = computed(() => {
|
||||
const cur = currentIssue.value
|
||||
return (cur && cur.status_text) || ''
|
||||
})
|
||||
|
||||
async function fetchIssues() {
|
||||
const id = activityIdRef?.value || activityIdRef
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getActivityIssues(id)
|
||||
issues.value = normalizeIssues(data)
|
||||
const latestId = pickLatestIssueId(issues.value)
|
||||
setSelectedById(latestId)
|
||||
} catch (e) {
|
||||
console.error('fetchIssues error', e)
|
||||
issues.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedById(id) {
|
||||
const arr = issues.value || []
|
||||
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
|
||||
selectedIssueIndex.value = idx
|
||||
}
|
||||
|
||||
function prevIssue() {
|
||||
const arr = issues.value || []
|
||||
if (!arr.length) return
|
||||
const next = Math.max(0, Number(selectedIssueIndex.value || 0) - 1)
|
||||
selectedIssueIndex.value = next
|
||||
}
|
||||
|
||||
function nextIssue() {
|
||||
const arr = issues.value || []
|
||||
if (!arr.length) return
|
||||
const next = Math.min(arr.length - 1, Number(selectedIssueIndex.value || 0) + 1)
|
||||
selectedIssueIndex.value = next
|
||||
}
|
||||
|
||||
return {
|
||||
issues,
|
||||
selectedIssueIndex,
|
||||
loading,
|
||||
currentIssueId,
|
||||
currentIssue,
|
||||
currentIssueTitle,
|
||||
currentIssueStatusText,
|
||||
fetchIssues,
|
||||
setSelectedById,
|
||||
prevIssue,
|
||||
nextIssue
|
||||
}
|
||||
}
|
||||
65
composables/useRecords.js
Normal file
65
composables/useRecords.js
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 购买记录管理 Composable
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { getIssueDrawLogs } from '@/api/appUser'
|
||||
|
||||
/**
|
||||
* 购买记录管理
|
||||
*/
|
||||
export function useRecords() {
|
||||
const winRecords = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 获取购买记录
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
*/
|
||||
async function fetchWinRecords(activityId, issueId) {
|
||||
if (!activityId || !issueId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getIssueDrawLogs(activityId, issueId)
|
||||
const list = (res && res.list) || (Array.isArray(res) ? res : [])
|
||||
|
||||
// 聚合同一奖品的记录
|
||||
const aggregate = {}
|
||||
list.forEach(it => {
|
||||
const key = it.reward_id || it.id
|
||||
if (!aggregate[key]) {
|
||||
aggregate[key] = {
|
||||
id: key,
|
||||
title: it.reward_name || it.title || it.name || '-',
|
||||
image: it.reward_image || it.image || '',
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
aggregate[key].count += 1
|
||||
})
|
||||
|
||||
const total = list.length || 1
|
||||
winRecords.value = Object.values(aggregate).map(it => ({
|
||||
...it,
|
||||
percent: ((it.count / total) * 100).toFixed(1)
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('fetchWinRecords error', e)
|
||||
winRecords.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecords() {
|
||||
winRecords.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
winRecords,
|
||||
loading,
|
||||
fetchWinRecords,
|
||||
clearRecords
|
||||
}
|
||||
}
|
||||
107
composables/useRewards.js
Normal file
107
composables/useRewards.js
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 奖励数据管理 Composable
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getActivityIssueRewards } from '@/api/appUser'
|
||||
import { normalizeRewards, groupRewardsByLevel } from '@/utils/activity'
|
||||
import { cleanUrl } from '@/utils/format'
|
||||
import { getRewardCacheItem, setRewardCache, isFresh } from '@/utils/cache'
|
||||
|
||||
/**
|
||||
* 奖励数据管理
|
||||
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
|
||||
* @param {Ref<string>} currentIssueIdRef - 当前期ID的响应式引用
|
||||
*/
|
||||
export function useRewards(activityIdRef, currentIssueIdRef) {
|
||||
const rewardsMap = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
const currentIssueRewards = computed(() => {
|
||||
const issueId = currentIssueIdRef?.value || currentIssueIdRef
|
||||
const m = rewardsMap.value || {}
|
||||
return (issueId && Array.isArray(m[issueId])) ? m[issueId] : []
|
||||
})
|
||||
|
||||
const rewardGroups = computed(() => {
|
||||
return groupRewardsByLevel(currentIssueRewards.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取多期的奖励数据(带缓存)
|
||||
* @param {Array} issueList - 期列表
|
||||
*/
|
||||
async function fetchRewardsForIssues(issueList) {
|
||||
const activityId = activityIdRef?.value || activityIdRef
|
||||
if (!activityId) return
|
||||
|
||||
const list = issueList || []
|
||||
const toFetch = []
|
||||
|
||||
// 先从缓存加载
|
||||
list.forEach(issue => {
|
||||
const cached = getRewardCacheItem(activityId, issue.id)
|
||||
if (cached) {
|
||||
rewardsMap.value = { ...rewardsMap.value, [issue.id]: cached }
|
||||
} else {
|
||||
toFetch.push(issue)
|
||||
}
|
||||
})
|
||||
|
||||
if (!toFetch.length) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
results.forEach((res, i) => {
|
||||
const issueId = toFetch[i]?.id
|
||||
if (!issueId) return
|
||||
const value = res.status === 'fulfilled' ? normalizeRewards(res.value, cleanUrl) : []
|
||||
rewardsMap.value = { ...rewardsMap.value, [issueId]: value }
|
||||
setRewardCache(activityId, issueId, value)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('fetchRewardsForIssues error', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单期的奖励数据
|
||||
* @param {string} issueId - 期ID
|
||||
*/
|
||||
async function fetchRewardsForIssue(issueId) {
|
||||
const activityId = activityIdRef?.value || activityIdRef
|
||||
if (!activityId || !issueId) return
|
||||
|
||||
// 先检查缓存
|
||||
const cached = getRewardCacheItem(activityId, issueId)
|
||||
if (cached) {
|
||||
rewardsMap.value = { ...rewardsMap.value, [issueId]: cached }
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getActivityIssueRewards(activityId, issueId)
|
||||
const value = normalizeRewards(res, cleanUrl)
|
||||
rewardsMap.value = { ...rewardsMap.value, [issueId]: value }
|
||||
setRewardCache(activityId, issueId, value)
|
||||
} catch (e) {
|
||||
console.error('fetchRewardsForIssue error', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rewardsMap,
|
||||
loading,
|
||||
currentIssueRewards,
|
||||
rewardGroups,
|
||||
fetchRewardsForIssues,
|
||||
fetchRewardsForIssue
|
||||
}
|
||||
}
|
||||
208
docs/代码重构分析/ALIGNMENT_代码冗余分析.md
Normal file
208
docs/代码重构分析/ALIGNMENT_代码冗余分析.md
Normal file
@ -0,0 +1,208 @@
|
||||
# bindbox-mini 代码冗余分析
|
||||
|
||||
## 项目概述
|
||||
|
||||
bindbox-mini 是一个基于 uni-app 的微信小程序项目,主要实现盲盒/抽赏类活动功能。
|
||||
|
||||
### 技术栈
|
||||
- 框架:uni-app (Vue 3 Composition API)
|
||||
- 样式:SCSS
|
||||
- 状态管理:Vue ref/computed
|
||||
|
||||
### 核心页面
|
||||
| 页面 | 路径 | 行数 | 功能描述 |
|
||||
|------|------|------|----------|
|
||||
| 一番赏 | `pages/activity/yifanshang/index.vue` | 1229 | 格位选择抽奖 |
|
||||
| 对对碰 | `pages/activity/duiduipeng/index.vue` | 2291 | 配对游戏 |
|
||||
| 无限赏 | `pages/activity/wuxianshang/index.vue` | 1559 | 多次抽奖 |
|
||||
| 扭蛋(啪嗒) | `pages/activity/pata/index.vue` | 399 | 入口页面 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 已识别的冗余问题
|
||||
|
||||
### 1. 模板结构重复
|
||||
|
||||
三个主要活动页面(yifanshang/duiduipeng/wuxianshang)共享**几乎相同的页面布局结构**:
|
||||
|
||||
```vue
|
||||
<!-- 重复出现在每个页面 -->
|
||||
<view class="page-wrapper">
|
||||
<view class="bg-decoration">
|
||||
<view class="orb orb-1"></view>
|
||||
<view class="orb orb-2"></view>
|
||||
</view>
|
||||
<view class="page-bg">
|
||||
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="bg-mask"></view>
|
||||
</view>
|
||||
<scroll-view class="main-scroll" scroll-y>
|
||||
<view class="header-card animate-enter"><!-- 相同的 header-card 结构 --></view>
|
||||
<view class="section-container"><!-- tabs/pool/records --></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
```
|
||||
|
||||
**冗余程度**:约100-150行相似模板代码 × 3个页面 = ~400行冗余
|
||||
|
||||
---
|
||||
|
||||
### 2. 工具函数重复
|
||||
|
||||
以下函数在多个页面中**完全重复定义**:
|
||||
|
||||
| 函数名 | 出现位置 | 功能 |
|
||||
|--------|----------|------|
|
||||
| `cleanUrl(u)` | yifanshang, duiduipeng, wuxianshang | 清理URL字符串 |
|
||||
| `truthy(v)` | yifanshang, duiduipeng, wuxianshang | 判断真值 |
|
||||
| `detectBoss(i)` | yifanshang, duiduipeng, wuxianshang | 检测BOSS奖 |
|
||||
| `unwrap(list)` | yifanshang, duiduipeng, wuxianshang | 解包API返回 |
|
||||
| `normalizeIssues(list)` | yifanshang, duiduipeng, wuxianshang | 标准化期数据 |
|
||||
| `normalizeRewards(list)` | yifanshang, duiduipeng, wuxianshang | 标准化奖励数据 |
|
||||
| `statusToText(s)` | yifanshang, duiduipeng, wuxianshang | 状态转文本 |
|
||||
| `formatPercent(v)` | yifanshang, duiduipeng, wuxianshang | 格式化百分比 |
|
||||
| `levelToAlpha(level)` | duiduipeng, wuxianshang | 等级数字转字母 |
|
||||
| `isFresh(ts)` | yifanshang, duiduipeng, wuxianshang | 判断缓存新鲜度 |
|
||||
| `getRewardCache()` | yifanshang, duiduipeng, wuxianshang | 获取奖励缓存 |
|
||||
| `pickLatestIssueId(list)` | yifanshang, duiduipeng, wuxianshang | 查找最新期ID |
|
||||
| `setSelectedById(id)` | yifanshang, duiduipeng, wuxianshang | 设置选中期 |
|
||||
| `prevIssue()` / `nextIssue()` | yifanshang, duiduipeng, wuxianshang | 期数切换 |
|
||||
|
||||
**冗余程度**:约200-300行工具函数 × 3个页面 = ~700行冗余
|
||||
|
||||
---
|
||||
|
||||
### 3. API调用逻辑重复
|
||||
|
||||
以下API调用模式在多个页面中重复:
|
||||
|
||||
```javascript
|
||||
// fetchDetail - 获取活动详情(3处重复)
|
||||
async function fetchDetail(id) {
|
||||
const data = await getActivityDetail(id)
|
||||
detail.value = data || {}
|
||||
statusText.value = statusToText(detail.value.status)
|
||||
// ...
|
||||
}
|
||||
|
||||
// fetchIssues - 获取期列表(3处重复)
|
||||
async function fetchIssues(id) {
|
||||
const data = await getActivityIssues(id)
|
||||
issues.value = normalizeIssues(data)
|
||||
// ...
|
||||
}
|
||||
|
||||
// fetchRewardsForIssues - 获取奖励(3处重复)
|
||||
async function fetchRewardsForIssues(activityId) {
|
||||
// ~50行相似代码
|
||||
}
|
||||
|
||||
// fetchWinRecords - 获取购买记录(3处重复)
|
||||
async function fetchWinRecords(actId, issId) {
|
||||
// ~30行相似代码
|
||||
}
|
||||
```
|
||||
|
||||
**冗余程度**:约150-200行API调用代码 × 3个页面 = ~500行冗余
|
||||
|
||||
---
|
||||
|
||||
### 4. 样式代码重复
|
||||
|
||||
以下SCSS样式在三个页面中几乎**完全相同**:
|
||||
|
||||
```scss
|
||||
// 基础布局(~80行)
|
||||
.page-wrapper, .bg-decoration, .orb, @keyframes float
|
||||
.page-bg, .bg-image, .bg-mask, .main-scroll
|
||||
|
||||
// 头部卡片(~100行)
|
||||
.header-card, .header-cover, .header-info, .header-title
|
||||
.header-price-row, .price-symbol, .price-num, .price-unit
|
||||
.header-tags, .tag-item, .header-actions, .action-btn, .action-icon
|
||||
|
||||
// 板块容器(~50行)
|
||||
.section-container, .section-header, .section-title, .section-more
|
||||
|
||||
// Tabs切换(~50行)
|
||||
.modern-tabs, .tab-item, .active-dot
|
||||
|
||||
// 奖池预览(~80行)
|
||||
.preview-scroll, .preview-item, .preview-img, .preview-name, .prize-tag
|
||||
|
||||
// 购买记录(~60行)
|
||||
.records-list, .record-item, .record-img, .record-info
|
||||
|
||||
// 弹窗样式(~100行)
|
||||
.rewards-overlay, .rewards-mask, .rewards-panel, .rewards-header, .rewards-list
|
||||
```
|
||||
|
||||
**冗余程度**:约500-600行样式代码 × 3个页面 = ~1500行冗余
|
||||
|
||||
---
|
||||
|
||||
### 5. 状态管理重复
|
||||
|
||||
以下响应式状态在多个页面中重复定义:
|
||||
|
||||
```javascript
|
||||
// 每个页面都有类似的状态定义
|
||||
const detail = ref({})
|
||||
const issues = ref([])
|
||||
const rewardsMap = ref({})
|
||||
const currentIssueId = ref('')
|
||||
const selectedIssueIndex = ref(0)
|
||||
const activityId = ref('')
|
||||
const tabActive = ref('pool')
|
||||
const winRecords = ref([])
|
||||
const rewardsVisible = ref(false)
|
||||
// ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 冗余统计汇总
|
||||
|
||||
| 类别 | 估算冗余行数 | 占比 |
|
||||
|------|-------------|------|
|
||||
| 模板结构 | ~400行 | 13% |
|
||||
| 工具函数 | ~700行 | 22% |
|
||||
| API调用逻辑 | ~500行 | 16% |
|
||||
| SCSS样式 | ~1500行 | 48% |
|
||||
| **合计** | **~3100行** | **100%** |
|
||||
|
||||
当前三个主要活动页面总计约 **5079行**(1229+2291+1559),冗余代码约占 **61%**。
|
||||
|
||||
---
|
||||
|
||||
## ❓ 需要确认的问题
|
||||
|
||||
1. **重构方向**:是希望进行完整的组件化重构,还是仅提取共用工具函数?
|
||||
|
||||
2. **优先级**:
|
||||
- 先处理工具函数冗余?(影响最小,风险最低)
|
||||
- 先处理模板/组件冗余?(收益最大,但改动较大)
|
||||
- 先处理样式冗余?(提取公共样式文件)
|
||||
|
||||
3. **兼容性考虑**:是否需要保留现有的页面独立性(便于后续定制化)?
|
||||
|
||||
4. **测试策略**:目前项目有自动化测试吗?重构后如何验证功能正确性?
|
||||
|
||||
---
|
||||
|
||||
## 🎯 初步建议
|
||||
|
||||
### 方案A:渐进式重构(推荐)
|
||||
|
||||
1. **第一步**:提取共用工具函数到 `utils/activity.js`
|
||||
2. **第二步**:提取共用样式到 `styles/activity-common.scss`
|
||||
3. **第三步**:创建共用组件(ActivityHeader, ActivityTabs, RewardsPopup)
|
||||
4. **第四步**:重构各活动页面使用共用组件
|
||||
|
||||
### 方案B:完全组件化
|
||||
|
||||
创建通用活动页面框架 `ActivityPageLayout.vue`,各玩法页面只需实现差异化部分。
|
||||
|
||||
---
|
||||
|
||||
*文档创建时间:2025-12-25*
|
||||
323
docs/代码重构分析/DESIGN_组件化重构.md
Normal file
323
docs/代码重构分析/DESIGN_组件化重构.md
Normal file
@ -0,0 +1,323 @@
|
||||
# bindbox-mini 组件化重构设计
|
||||
|
||||
## 架构目标
|
||||
|
||||
将三个活动页面(yifanshang/duiduipeng/wuxianshang)共约5079行代码减少至约2500行,消除61%的冗余。
|
||||
|
||||
---
|
||||
|
||||
## 架构设计图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Utils[工具层 utils/]
|
||||
A1[activity.js<br>活动相关工具函数]
|
||||
A2[format.js<br>格式化工具]
|
||||
A3[cache.js<br>缓存管理]
|
||||
end
|
||||
|
||||
subgraph Composables[组合式函数 composables/]
|
||||
B1[useActivity.js<br>活动数据管理]
|
||||
B2[useIssues.js<br>期数据管理]
|
||||
B3[useRewards.js<br>奖励数据管理]
|
||||
B4[usePayment.js<br>支付流程]
|
||||
end
|
||||
|
||||
subgraph Components[组件层 components/]
|
||||
subgraph Layout[布局组件]
|
||||
C1[ActivityPageLayout.vue<br>活动页面框架]
|
||||
C2[ActivityHeader.vue<br>头部卡片]
|
||||
end
|
||||
subgraph Biz[业务组件]
|
||||
C3[ActivityTabs.vue<br>Tab切换]
|
||||
C4[RewardsPopup.vue<br>奖品弹窗]
|
||||
C5[RecordsList.vue<br>购买记录]
|
||||
C6[RewardsPreview.vue<br>奖池预览]
|
||||
end
|
||||
subgraph Existing[已有组件]
|
||||
C7[PaymentPopup.vue]
|
||||
C8[FlipGrid.vue]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Pages[页面层 pages/activity/]
|
||||
D1[yifanshang - 选号+专属业务]
|
||||
D2[duiduipeng - 对对碰游戏+专属业务]
|
||||
D3[wuxianshang - 多档抽奖+专属业务]
|
||||
end
|
||||
|
||||
Utils --> Composables
|
||||
Composables --> Pages
|
||||
Components --> Pages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 详细模块设计
|
||||
|
||||
### 1. 工具函数层 `utils/`
|
||||
|
||||
#### `utils/activity.js` - 活动相关工具 [NEW]
|
||||
|
||||
```javascript
|
||||
// 数据标准化
|
||||
export function unwrap(list) { /* ... */ }
|
||||
export function normalizeIssues(list) { /* ... */ }
|
||||
export function normalizeRewards(list) { /* ... */ }
|
||||
|
||||
// 值判断
|
||||
export function truthy(v) { /* ... */ }
|
||||
export function detectBoss(i) { /* ... */ }
|
||||
export function levelToAlpha(level) { /* ... */ }
|
||||
|
||||
// 状态转换
|
||||
export function statusToText(s) { /* ... */ }
|
||||
```
|
||||
|
||||
#### `utils/format.js` - 格式化工具 [NEW]
|
||||
|
||||
```javascript
|
||||
export function cleanUrl(u) { /* ... */ }
|
||||
export function formatPercent(v) { /* ... */ }
|
||||
export function formatDateTime(v) { /* ... */ }
|
||||
export function formatPrice(cents) { /* ... */ }
|
||||
```
|
||||
|
||||
#### `utils/cache.js` - 缓存管理 [NEW]
|
||||
|
||||
```javascript
|
||||
export function isFresh(ts, ttl = 24 * 60 * 60 * 1000) { /* ... */ }
|
||||
export function getRewardCache() { /* ... */ }
|
||||
export function setRewardCache(activityId, issueId, value) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 组合式函数层 `composables/`
|
||||
|
||||
#### `composables/useActivity.js` [NEW]
|
||||
|
||||
```javascript
|
||||
export function useActivity(activityId) {
|
||||
const detail = ref({})
|
||||
const coverUrl = computed(() => cleanUrl(detail.value?.image || detail.value?.banner || ''))
|
||||
const statusText = computed(() => statusToText(detail.value?.status))
|
||||
const pricePerDraw = computed(() => (Number(detail.value?.price_draw || 0) / 100))
|
||||
|
||||
async function fetchDetail() { /* ... */ }
|
||||
|
||||
return { detail, coverUrl, statusText, pricePerDraw, fetchDetail }
|
||||
}
|
||||
```
|
||||
|
||||
#### `composables/useIssues.js` [NEW]
|
||||
|
||||
```javascript
|
||||
export function useIssues(activityId) {
|
||||
const issues = ref([])
|
||||
const selectedIssueIndex = ref(0)
|
||||
const currentIssueId = computed(() => issues.value[selectedIssueIndex.value]?.id || '')
|
||||
const currentIssueTitle = computed(() => /* ... */)
|
||||
|
||||
async function fetchIssues() { /* ... */ }
|
||||
function prevIssue() { /* ... */ }
|
||||
function nextIssue() { /* ... */ }
|
||||
function setSelectedById(id) { /* ... */ }
|
||||
|
||||
return { issues, selectedIssueIndex, currentIssueId, currentIssueTitle, fetchIssues, prevIssue, nextIssue, setSelectedById }
|
||||
}
|
||||
```
|
||||
|
||||
#### `composables/useRewards.js` [NEW]
|
||||
|
||||
```javascript
|
||||
export function useRewards(activityId, currentIssueId) {
|
||||
const rewardsMap = ref({})
|
||||
const currentIssueRewards = computed(() => rewardsMap.value[currentIssueId.value] || [])
|
||||
const rewardGroups = computed(() => /* 按level分组 */)
|
||||
|
||||
async function fetchRewardsForIssues(issueList) { /* 带缓存 */ }
|
||||
|
||||
return { rewardsMap, currentIssueRewards, rewardGroups, fetchRewardsForIssues }
|
||||
}
|
||||
```
|
||||
|
||||
#### `composables/useRecords.js` [NEW]
|
||||
|
||||
```javascript
|
||||
export function useRecords() {
|
||||
const winRecords = ref([])
|
||||
|
||||
async function fetchWinRecords(activityId, issueId) { /* ... */ }
|
||||
|
||||
return { winRecords, fetchWinRecords }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 组件层 `components/`
|
||||
|
||||
#### `ActivityPageLayout.vue` [NEW] - 页面框架组件
|
||||
|
||||
Props:
|
||||
- `coverUrl: String` - 背景图URL
|
||||
|
||||
Slots:
|
||||
- `header` - 头部卡片区域
|
||||
- `content` - 主要内容(tabs等)
|
||||
- `footer` - 底部操作栏
|
||||
- `modals` - 弹窗区域
|
||||
|
||||
#### `ActivityHeader.vue` [NEW] - 头部卡片
|
||||
|
||||
Props:
|
||||
- `title: String`
|
||||
- `price: Number` (分)
|
||||
- `priceUnit: String` - 价格单位(如"/发"、"/次")
|
||||
- `coverUrl: String`
|
||||
- `tags: Array<String>`
|
||||
- `scheduledTime: String` (可选)
|
||||
|
||||
Events:
|
||||
- `@show-rules`
|
||||
- `@go-cabinet`
|
||||
|
||||
#### `ActivityTabs.vue` [NEW] - Tab切换
|
||||
|
||||
Props:
|
||||
- `modelValue: String` - 当前tab ('pool' | 'records')
|
||||
- `tabs: Array<{key, label}>`
|
||||
|
||||
Events:
|
||||
- `@update:modelValue`
|
||||
|
||||
#### `RewardsPreview.vue` [NEW] - 奖池预览
|
||||
|
||||
Props:
|
||||
- `rewards: Array`
|
||||
- `grouped: Boolean` - 是否按等级分组显示
|
||||
|
||||
#### `RewardsPopup.vue` [NEW] - 奖品弹窗
|
||||
|
||||
Props:
|
||||
- `visible: Boolean`
|
||||
- `title: String`
|
||||
- `rewardGroups: Array` - 按等级分组的奖励
|
||||
|
||||
Events:
|
||||
- `@update:visible`
|
||||
|
||||
#### `RecordsList.vue` [NEW] - 购买记录列表
|
||||
|
||||
Props:
|
||||
- `records: Array`
|
||||
- `emptyText: String`
|
||||
|
||||
---
|
||||
|
||||
### 4. 样式层 `styles/`
|
||||
|
||||
#### `styles/activity-common.scss` [NEW]
|
||||
|
||||
提取共用样式(约600行):
|
||||
- 页面布局:`.page-wrapper`, `.bg-decoration`, `.orb`, `@keyframes float`
|
||||
- 背景处理:`.page-bg`, `.bg-image`, `.bg-mask`
|
||||
- 入场动画:`.animate-enter`, `.stagger-*`
|
||||
- 头部卡片样式(可在ActivityHeader组件内联)
|
||||
- 板块容器:`.section-container`, `.section-header`
|
||||
- Tabs样式(可在ActivityTabs组件内联)
|
||||
- 预览列表:`.preview-scroll`, `.preview-item`
|
||||
- 记录列表:`.records-list`, `.record-item`
|
||||
- 弹窗样式(可在RewardsPopup组件内联)
|
||||
|
||||
---
|
||||
|
||||
## 重构后页面结构示例
|
||||
|
||||
### yifanshang/index.vue (预计约400行→优化后)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ActivityPageLayout :cover-url="coverUrl">
|
||||
<template #header>
|
||||
<ActivityHeader
|
||||
:title="detail.name"
|
||||
:price="detail.price_draw"
|
||||
price-unit="/发"
|
||||
:cover-url="coverUrl"
|
||||
:tags="['公开透明', '拒绝套路']"
|
||||
:scheduled-time="scheduledTimeText"
|
||||
@show-rules="showRules"
|
||||
@go-cabinet="goCabinet"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<ActivityTabs v-model="tabActive">
|
||||
<template #pool>
|
||||
<RewardsPreview :rewards="currentIssueRewards" @view-all="openRewardsPopup" />
|
||||
</template>
|
||||
<template #records>
|
||||
<RecordsList :records="winRecords" />
|
||||
</template>
|
||||
</ActivityTabs>
|
||||
|
||||
<!-- 一番赏专属:选号组件 -->
|
||||
<YifanSelector ... />
|
||||
</template>
|
||||
|
||||
<template #modals>
|
||||
<RewardsPopup v-model:visible="rewardsVisible" ... />
|
||||
<FlipGrid ref="flipRef" ... />
|
||||
</template>
|
||||
</ActivityPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useActivity, useIssues, useRewards, useRecords } from '@/composables'
|
||||
// 专注于一番赏特有的业务逻辑
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件路径 | 行数估算 | 说明 |
|
||||
|----------|---------|------|
|
||||
| `utils/activity.js` | ~80 | 活动工具函数 |
|
||||
| `utils/format.js` | ~50 | 格式化工具 |
|
||||
| `utils/cache.js` | ~40 | 缓存管理 |
|
||||
| `composables/useActivity.js` | ~50 | 活动数据composable |
|
||||
| `composables/useIssues.js` | ~80 | 期数据composable |
|
||||
| `composables/useRewards.js` | ~80 | 奖励数据composable |
|
||||
| `composables/useRecords.js` | ~40 | 记录composable |
|
||||
| `components/ActivityPageLayout.vue` | ~150 | 页面框架 |
|
||||
| `components/ActivityHeader.vue` | ~200 | 头部卡片 |
|
||||
| `components/ActivityTabs.vue` | ~100 | Tab切换 |
|
||||
| `components/RewardsPreview.vue` | ~120 | 奖池预览 |
|
||||
| `components/RewardsPopup.vue` | ~150 | 奖品弹窗 |
|
||||
| `components/RecordsList.vue` | ~80 | 记录列表 |
|
||||
| **小计** | **~1220** | |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件路径 | 原行数 | 预计行数 | 变化 |
|
||||
|----------|-------|---------|------|
|
||||
| `yifanshang/index.vue` | 1229 | ~400 | -829 |
|
||||
| `duiduipeng/index.vue` | 2291 | ~800 | -1491 |
|
||||
| `wuxianshang/index.vue` | 1559 | ~500 | -1059 |
|
||||
| **小计** | **5079** | **~1700** | **-3379** |
|
||||
|
||||
### 净变化
|
||||
|
||||
- 新增:~1220行
|
||||
- 删除:~3379行
|
||||
- **净减少:~2159行(42%)**
|
||||
|
||||
---
|
||||
|
||||
*设计文档创建时间:2025-12-25*
|
||||
@ -1,41 +1,17 @@
|
||||
<template>
|
||||
<view class="page-wrapper">
|
||||
<view class="bg-decoration">
|
||||
<view class="orb orb-1"></view>
|
||||
<view class="orb orb-2"></view>
|
||||
</view>
|
||||
<ActivityPageLayout :cover-url="coverUrl">
|
||||
<template #header>
|
||||
<ActivityHeader
|
||||
:title="detail.name || detail.title || '对对碰'"
|
||||
:price="detail.price_draw"
|
||||
price-unit="/次"
|
||||
:cover-url="coverUrl"
|
||||
:tags="['随机玩法', '理性消费']"
|
||||
@show-rules="showRules"
|
||||
@go-cabinet="goCabinet"
|
||||
/>
|
||||
|
||||
<view class="page-bg">
|
||||
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="bg-mask"></view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="main-scroll" scroll-y>
|
||||
<view class="header-card animate-enter">
|
||||
<image class="header-cover" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="header-info">
|
||||
<view class="header-title">{{ detail.name || detail.title || '-' }}</view>
|
||||
<view class="header-price-row" v-if="detail.price_draw !== undefined">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-num">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
|
||||
<text class="price-unit">/次</text>
|
||||
</view>
|
||||
<view class="header-tags">
|
||||
<view class="tag-item">随机玩法</view>
|
||||
<view class="tag-item">理性消费</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="action-btn" @tap="showRules">
|
||||
<view class="action-icon rules-icon"></view>
|
||||
<text class="action-label">规则</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="goCabinet">
|
||||
<view class="action-icon cabinet-icon"></view>
|
||||
<text class="action-label">盒柜</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="section-container animate-enter stagger-1">
|
||||
<view class="section-header">
|
||||
@ -60,67 +36,29 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="section-container animate-enter stagger-2">
|
||||
<!-- 奖池/记录切换 -->
|
||||
<ActivityTabs
|
||||
v-model:current="tabActive"
|
||||
:tabs="[{key: 'pool', label: '本机奖池'}, {key: 'records', label: '购买记录'}]"
|
||||
/>
|
||||
|
||||
<view class="modern-tabs">
|
||||
<view class="tab-item" :class="{ active: tabActive === 'pool' }" @tap="tabActive = 'pool'">
|
||||
本机奖池
|
||||
<view v-if="tabActive === 'pool'" class="active-dot"></view>
|
||||
</view>
|
||||
<view class="tab-item" :class="{ active: tabActive === 'records' }" @tap="tabActive = 'records'">
|
||||
购买记录
|
||||
<view v-if="tabActive === 'records'" class="active-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 奖池预览 -->
|
||||
<RewardsPreview
|
||||
v-if="tabActive === 'pool'"
|
||||
:reward-groups="rewardGroups"
|
||||
@view-all="openRewardsPopup"
|
||||
/>
|
||||
|
||||
<view v-show="tabActive === 'pool'">
|
||||
<view class="section-header">
|
||||
<text class="section-title">奖池配置</text>
|
||||
<text class="section-more" @tap="openRewardsPopup">查看全部</text>
|
||||
</view>
|
||||
<view v-if="rewardGroups.length > 0">
|
||||
<view class="prize-level-row" v-for="group in rewardGroups" :key="group.level">
|
||||
<view class="level-header-row">
|
||||
<view class="level-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}赏</view>
|
||||
<text class="level-prob">总概率 {{ group.totalPercent }}%</text>
|
||||
</view>
|
||||
<scroll-view class="preview-scroll" scroll-x>
|
||||
<view class="preview-item" v-for="(item, idx) in group.rewards" :key="item.id || idx">
|
||||
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : group.level }}</view>
|
||||
<image class="preview-img" :src="item.image" mode="aspectFill" />
|
||||
<view class="preview-name">{{ item.title }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text class="empty-text">暂无奖励配置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-show="tabActive === 'records'">
|
||||
<view class="records-list" v-if="winRecords.length">
|
||||
<view v-for="(it, idx) in winRecords" :key="it.id" class="record-item">
|
||||
<image class="record-img" :src="it.image" mode="aspectFill" />
|
||||
<view class="record-info">
|
||||
<view class="record-title">{{ it.title }}</view>
|
||||
<view class="record-meta">
|
||||
<text class="record-count">x{{ it.count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon">📝</text>
|
||||
<text class="empty-text">暂无购买记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 购买记录 -->
|
||||
<RecordsList
|
||||
v-if="tabActive === 'records'"
|
||||
:records="winRecords"
|
||||
/>
|
||||
|
||||
<view style="height: 180rpx;"></view>
|
||||
</scroll-view>
|
||||
|
||||
<template #footer>
|
||||
<view class="float-bar">
|
||||
<view class="float-bar-inner">
|
||||
<view class="float-price">
|
||||
@ -137,8 +75,10 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #modals>
|
||||
<!-- 游戏弹窗 -->
|
||||
<view v-if="gameVisible" class="rewards-overlay" @touchmove.stop.prevent>
|
||||
<view class="rewards-mask" @tap="closeGame"></view>
|
||||
<view class="rewards-panel" @tap.stop>
|
||||
@ -146,14 +86,14 @@
|
||||
<text class="rewards-title">对对碰游戏</text>
|
||||
<text class="rewards-close" @tap="closeGame">×</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="rewards-list">
|
||||
<view v-if="gameLoading" class="rewards-empty">加载中...</view>
|
||||
<view v-else-if="gameError" class="rewards-empty">{{ gameError }}</view>
|
||||
<scroll-view scroll-y class="game-scroll-list">
|
||||
<view v-if="gameLoading" class="game-empty-state">加载中...</view>
|
||||
<view v-else-if="gameError" class="game-empty-state">{{ gameError }}</view>
|
||||
<view v-else>
|
||||
<view class="record-item">
|
||||
<view class="record-info">
|
||||
<view class="record-title">总对数:{{ totalPairs }} 摸牌机会:{{ chance }}</view>
|
||||
<view class="record-meta">
|
||||
<view class="game-info-card">
|
||||
<view class="game-info-content">
|
||||
<view class="game-info-title">总对数:{{ totalPairs }} 摸牌机会:{{ chance }}</view>
|
||||
<view class="game-info-meta">
|
||||
<text>牌组剩余 {{ deckRemaining }}</text>
|
||||
<text v-if="selectedPositionText">位置 {{ selectedPositionText }}</text>
|
||||
<text v-if="gameIdText">ID {{ gameIdText }}</text>
|
||||
@ -182,36 +122,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="rewardsVisible" class="rewards-overlay" @touchmove.stop.prevent>
|
||||
<view class="rewards-mask" @tap="closeRewardsPopup"></view>
|
||||
<view class="rewards-panel" @tap.stop>
|
||||
<view class="rewards-header">
|
||||
<text class="rewards-title">{{ currentIssueTitle }} · 奖品与概率</text>
|
||||
<text class="rewards-close" @tap="closeRewardsPopup">×</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="rewards-list">
|
||||
<view v-if="rewardGroups.length > 0">
|
||||
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level">
|
||||
<view class="group-header-row">
|
||||
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}赏</text>
|
||||
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item">
|
||||
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
|
||||
<view class="rewards-info">
|
||||
<view class="rewards-name-row">
|
||||
<text class="rewards-name">{{ item.title || '-' }}</text>
|
||||
<view class="rewards-tag" v-if="item.boss">BOSS</view>
|
||||
</view>
|
||||
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="rewards-empty">暂无奖品数据</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 奖品详情弹窗 -->
|
||||
<RewardsPopup
|
||||
v-model:visible="rewardsVisible"
|
||||
:title="`${detail.name || '奖池'} · 奖品与概率`"
|
||||
:reward-groups="rewardGroups"
|
||||
/>
|
||||
|
||||
<PaymentPopup
|
||||
v-model:visible="paymentVisible"
|
||||
@ -221,12 +137,20 @@
|
||||
:showCards="true"
|
||||
@confirm="onPaymentConfirm"
|
||||
/>
|
||||
</template>
|
||||
</ActivityPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import PaymentPopup from '../../../components/PaymentPopup.vue'
|
||||
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
|
||||
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
|
||||
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
|
||||
import RewardsPreview from '@/components/activity/RewardsPreview.vue'
|
||||
import RewardsPopup from '@/components/activity/RewardsPopup.vue'
|
||||
import RecordsList from '@/components/activity/RecordsList.vue'
|
||||
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs } from '../../../api/appUser'
|
||||
|
||||
const detail = ref({})
|
||||
@ -1421,204 +1345,6 @@ onLoad((opts) => {
|
||||
margin-bottom: 8rpx;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.rules-icon {
|
||||
background-color: #999;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
.cabinet-icon {
|
||||
background-color: #999;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
.action-label {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
margin: 0 $spacing-lg $spacing-lg;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: $radius-xl;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: $font-sm;
|
||||
color: $text-tertiary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: '>';
|
||||
font-family: monospace;
|
||||
margin-left: 6rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30rpx;
|
||||
background: $bg-grey;
|
||||
border-radius: $radius-round;
|
||||
padding: 10rpx;
|
||||
border: 1rpx solid $border-color-light;
|
||||
}
|
||||
.issue-switch-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-card;
|
||||
border-radius: 50%;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.2s;
|
||||
color: $text-secondary;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: $bg-secondary;
|
||||
color: $brand-primary;
|
||||
}
|
||||
}
|
||||
.arrow {
|
||||
font-size: $font-sm;
|
||||
font-weight: 800;
|
||||
}
|
||||
.issue-info-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
.issue-current-text {
|
||||
font-size: $font-lg;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
.issue-status-badge {
|
||||
font-size: $font-xs;
|
||||
color: $uni-color-success;
|
||||
background: rgba($uni-color-success, 0.1);
|
||||
padding: 2rpx $spacing-md;
|
||||
border-radius: $radius-round;
|
||||
margin-top: 4rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.preview-item {
|
||||
display: inline-block;
|
||||
width: 180rpx;
|
||||
margin-right: $spacing-md;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.preview-img {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-secondary;
|
||||
margin-bottom: $spacing-sm;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1rpx solid rgba(0,0,0,0.03);
|
||||
}
|
||||
.preview-name {
|
||||
font-size: $font-xs;
|
||||
color: $text-secondary;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prize-level-row {
|
||||
margin-bottom: $spacing-lg;
|
||||
background: rgba(0,0,0,0.02);
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-lg;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
.level-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: inline-block;
|
||||
font-size: $font-xs;
|
||||
font-weight: 900;
|
||||
color: $text-main;
|
||||
background: #F0F0F0;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-style: italic;
|
||||
border: 1rpx solid rgba(0,0,0,0.05);
|
||||
box-shadow: $shadow-xs;
|
||||
|
||||
&.badge-boss {
|
||||
background: $gradient-gold;
|
||||
color: #78350F;
|
||||
border-color: rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.level-prob {
|
||||
font-size: 22rpx;
|
||||
color: $brand-primary;
|
||||
font-weight: 800;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.prize-tag {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 10rpx;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
font-size: $font-xs;
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
z-index: 10;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(4rpx);
|
||||
transform: scale(0.9);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.prize-tag.tag-boss {
|
||||
background: $gradient-brand;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
.banner-wrapper {
|
||||
margin: $spacing-md $spacing-lg;
|
||||
@ -1703,48 +1429,40 @@ onLoad((opts) => {
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header-section {
|
||||
padding: 0 $spacing-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
.title-row {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
.title-text {
|
||||
font-size: $font-xxl;
|
||||
font-weight: 900;
|
||||
background: $gradient-brand;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: inline-block;
|
||||
}
|
||||
.price-tag {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
background: rgba($bg-card, 0.6);
|
||||
padding: $spacing-xs $spacing-lg;
|
||||
border-radius: $radius-round;
|
||||
backdrop-filter: blur(20rpx);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
.price-label { font-size: $font-sm; color: $text-sub; margin-right: $spacing-xs; }
|
||||
.price-symbol { font-size: $font-sm; color: $brand-primary; font-weight: 700; }
|
||||
.price-value { font-size: $font-xl; color: $brand-primary; font-weight: 900; font-family: 'DIN Alternate', sans-serif; }
|
||||
|
||||
/* Glass Card */
|
||||
.glass-card {
|
||||
margin: 0 $spacing-lg $spacing-lg;
|
||||
background: rgba($bg-card, 0.8);
|
||||
backdrop-filter: blur(40rpx);
|
||||
border-radius: $radius-xl;
|
||||
/* Game Modal Styles */
|
||||
.game-scroll-list {
|
||||
max-height: 60vh;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-card;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||
animation: fadeInUp 0.6s ease-out 0.2s backwards;
|
||||
}
|
||||
.game-empty-state {
|
||||
padding: 60rpx 0 20rpx;
|
||||
text-align: center;
|
||||
color: $text-tertiary;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
.game-info-card {
|
||||
display: flex;
|
||||
background: #FFFFFF;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
.game-info-content {
|
||||
flex: 1;
|
||||
}
|
||||
.game-info-title {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
.game-info-meta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-sm;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@ -1803,145 +1521,6 @@ onLoad((opts) => {
|
||||
&.status-active { background: #D1FAE5; color: #059669; }
|
||||
}
|
||||
|
||||
/* Modern Tabs */
|
||||
.modern-tabs {
|
||||
display: flex;
|
||||
background: $bg-secondary;
|
||||
padding: 8rpx;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: $spacing-md 0;
|
||||
font-size: $font-md;
|
||||
color: $text-sub;
|
||||
border-radius: $radius-md;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: $brand-primary;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
.active-dot {
|
||||
width: 8rpx; height: 8rpx;
|
||||
background: $brand-primary;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
bottom: 8rpx; left: 50%; transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Rewards Grid */
|
||||
.rewards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
.reward-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1rpx solid rgba(0,0,0,0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-md;
|
||||
height: 44rpx;
|
||||
}
|
||||
.card-title {
|
||||
font-size: $font-md;
|
||||
color: $text-main;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
.badge-boss {
|
||||
font-size: $font-xs;
|
||||
background: $gradient-gold;
|
||||
color: #78350F;
|
||||
padding: 2rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
.reward-image {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
}
|
||||
.prob-tag {
|
||||
position: absolute;
|
||||
top: 8rpx; left: 8rpx;
|
||||
font-size: $font-xs;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4rpx);
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Records List */
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
.record-item {
|
||||
display: flex;
|
||||
background: #FFFFFF;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
align-items: center;
|
||||
}
|
||||
.record-img {
|
||||
width: 100rpx; height: 100rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-secondary;
|
||||
margin-right: $spacing-lg;
|
||||
}
|
||||
.record-info {
|
||||
flex: 1;
|
||||
}
|
||||
.record-title {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
.record-meta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-sm;
|
||||
color: $text-sub;
|
||||
}
|
||||
.record-count {
|
||||
background: rgba($brand-primary, 0.1);
|
||||
color: $brand-primary;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
149
utils/activity.js
Normal file
149
utils/activity.js
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 活动相关工具函数
|
||||
* 从 yifanshang/duiduipeng/wuxianshang 页面中提取的公共逻辑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解包API返回的数据
|
||||
* @param {any} list - API返回的数据
|
||||
* @returns {Array} 数组
|
||||
*/
|
||||
export 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 : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断真值(支持多种格式)
|
||||
* @param {any} v - 待判断的值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为BOSS奖
|
||||
* @param {Object} item - 奖品对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function detectBoss(item) {
|
||||
const i = item || {}
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级数字转字母 (1 -> A, 2 -> B, ...)
|
||||
* @param {number|string} level - 等级
|
||||
* @returns {string}
|
||||
*/
|
||||
export function levelToAlpha(level) {
|
||||
if (level === 'BOSS') return 'BOSS'
|
||||
const n = Number(level)
|
||||
if (isNaN(n) || n <= 0) return String(level || '赏')
|
||||
return String.fromCharCode(64 + n)
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态转文本
|
||||
* @param {number} status - 状态码
|
||||
* @returns {string}
|
||||
*/
|
||||
export function statusToText(status) {
|
||||
if (status === 1) return '进行中'
|
||||
if (status === 0) return '未开始'
|
||||
if (status === 2) return '已结束'
|
||||
return String(status || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化期列表数据
|
||||
* @param {any} list - API返回的期列表
|
||||
* @returns {Array}
|
||||
*/
|
||||
export 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 ? '已结束' : '')
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化奖励列表数据
|
||||
* @param {any} list - API返回的奖励列表
|
||||
* @param {Function} cleanUrl - URL清理函数
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function normalizeRewards(list, cleanUrl = (u) => u) {
|
||||
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),
|
||||
level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏'))
|
||||
}))
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找最新的期ID
|
||||
* @param {Array} list - 期列表
|
||||
* @returns {string}
|
||||
*/
|
||||
export 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) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 按等级分组奖励
|
||||
* @param {Array} rewards - 奖励列表
|
||||
* @returns {Array} 分组后的奖励
|
||||
*/
|
||||
export function groupRewardsByLevel(rewards) {
|
||||
const groups = {}
|
||||
; (rewards || []).forEach(item => {
|
||||
const level = item.level || '赏'
|
||||
if (!groups[level]) groups[level] = []
|
||||
groups[level].push(item)
|
||||
})
|
||||
return Object.keys(groups).sort((a, b) => {
|
||||
if (a === 'BOSS') return -1
|
||||
if (b === 'BOSS') return 1
|
||||
return a.localeCompare(b)
|
||||
}).map(key => {
|
||||
const levelRewards = groups[key]
|
||||
const total = levelRewards.reduce((sum, item) => sum + (Number(item.percent) || 0), 0)
|
||||
return {
|
||||
level: key,
|
||||
rewards: levelRewards,
|
||||
totalPercent: total.toFixed(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
145
utils/cache.js
Normal file
145
utils/cache.js
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 缓存管理工具
|
||||
*/
|
||||
|
||||
const REWARD_CACHE_KEY = 'reward_cache_v1'
|
||||
const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1'
|
||||
|
||||
/**
|
||||
* 判断缓存是否新鲜
|
||||
* @param {number} timestamp - 缓存时间戳
|
||||
* @param {number} ttl - 有效期(毫秒),默认24小时
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isFresh(timestamp, ttl = 24 * 60 * 60 * 1000) {
|
||||
const now = Date.now()
|
||||
const v = Number(timestamp || 0)
|
||||
return now - v < ttl
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取奖励缓存
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getRewardCache() {
|
||||
const obj = uni.getStorageSync(REWARD_CACHE_KEY) || {}
|
||||
return typeof obj === 'object' && obj ? obj : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置奖励缓存
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
* @param {any} value - 缓存值
|
||||
*/
|
||||
export function setRewardCache(activityId, issueId, value) {
|
||||
const cache = getRewardCache()
|
||||
const act = cache[activityId] || {}
|
||||
act[issueId] = { value, ts: Date.now() }
|
||||
cache[activityId] = act
|
||||
uni.setStorageSync(REWARD_CACHE_KEY, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取奖励缓存项
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function getRewardCacheItem(activityId, issueId) {
|
||||
const cache = getRewardCache()
|
||||
const act = cache[activityId] || {}
|
||||
const c = act[issueId]
|
||||
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
|
||||
return c.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对对碰游戏缓存
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getMatchingGameCache() {
|
||||
const obj = uni.getStorageSync(MATCHING_GAME_CACHE_KEY) || {}
|
||||
return typeof obj === 'object' && obj ? obj : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对对碰游戏缓存项
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function readMatchingGameCacheEntry(activityId, issueId) {
|
||||
const activityKey = String(activityId || '')
|
||||
const issueKey = String(issueId || '')
|
||||
if (!activityKey || !issueKey) return null
|
||||
const cache = getMatchingGameCache()
|
||||
const act = cache[activityKey] || {}
|
||||
const entry = act && act[issueKey]
|
||||
const ok = entry && typeof entry === 'object' && entry.game_id
|
||||
return ok ? entry : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入对对碰游戏缓存项
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
* @param {Object} entry - 缓存数据
|
||||
*/
|
||||
export function writeMatchingGameCacheEntry(activityId, issueId, entry) {
|
||||
const activityKey = String(activityId || '')
|
||||
const issueKey = String(issueId || '')
|
||||
if (!activityKey || !issueKey) return
|
||||
const cache = getMatchingGameCache()
|
||||
const act = (cache[activityKey] && typeof cache[activityKey] === 'object') ? cache[activityKey] : {}
|
||||
act[issueKey] = entry
|
||||
cache[activityKey] = act
|
||||
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除对对碰游戏缓存项
|
||||
* @param {string} activityId - 活动ID
|
||||
* @param {string} issueId - 期ID
|
||||
*/
|
||||
export function clearMatchingGameCacheEntry(activityId, issueId) {
|
||||
const activityKey = String(activityId || '')
|
||||
const issueKey = String(issueId || '')
|
||||
const cache = getMatchingGameCache()
|
||||
const act = cache[activityKey]
|
||||
if (!act || typeof act !== 'object') return
|
||||
if (act[issueKey] !== undefined) delete act[issueKey]
|
||||
if (Object.keys(act).length === 0) delete cache[activityKey]
|
||||
else cache[activityKey] = act
|
||||
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找最新的对对碰游戏缓存
|
||||
* @param {string} activityId - 活动ID
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function findLatestMatchingGameCacheEntry(activityId) {
|
||||
const activityKey = String(activityId || '')
|
||||
if (!activityKey) return null
|
||||
const cache = getMatchingGameCache()
|
||||
const act = cache[activityKey]
|
||||
if (!act || typeof act !== 'object') return null
|
||||
let bestIssueId = ''
|
||||
let bestEntry = null
|
||||
let bestTs = -Infinity
|
||||
Object.keys(act).forEach(issueId => {
|
||||
const entry = act[issueId]
|
||||
if (!entry || typeof entry !== 'object' || !entry.game_id) return
|
||||
const ts = Number(entry.ts || 0)
|
||||
if (!bestEntry || ts > bestTs) {
|
||||
bestTs = ts
|
||||
bestIssueId = issueId
|
||||
bestEntry = entry
|
||||
}
|
||||
})
|
||||
if (!bestEntry) return null
|
||||
return { issue_id: bestIssueId, entry: bestEntry }
|
||||
}
|
||||
76
utils/format.js
Normal file
76
utils/format.js
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清理URL字符串
|
||||
* @param {string} url - 原始URL
|
||||
* @returns {string} 清理后的URL
|
||||
*/
|
||||
export function cleanUrl(url) {
|
||||
const s = String(url || '').trim()
|
||||
const m = s.match(/https?:\/\/[^\s'"`]+/)
|
||||
if (m && m[0]) return m[0]
|
||||
return s.replace(/[`'"]/g, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param {number} value - 百分比值
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatPercent(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0%'
|
||||
return `${n}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
* @param {string|number|Date} value - 日期值
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(value) {
|
||||
const s = String(value || '').trim()
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化价格(分转元)
|
||||
* @param {number} cents - 分
|
||||
* @param {number} decimals - 小数位数
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatPrice(cents, decimals = 2) {
|
||||
const yuan = Number(cents || 0) / 100
|
||||
return yuan.toFixed(decimals)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间为毫秒戳
|
||||
* @param {any} value - 时间值
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function parseTimeMs(value) {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null
|
||||
return value < 1e12 ? value * 1000 : value
|
||||
}
|
||||
const s = String(value).trim()
|
||||
if (!s) return null
|
||||
const asNum = Number(s)
|
||||
if (Number.isFinite(asNum)) return asNum < 1e12 ? asNum * 1000 : asNum
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
return d.getTime()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user