feat: 新增活动相关工具函数、缓存管理、Vue组合式函数及多个活动页面组件,并优化了YifanSelector的UI。

This commit is contained in:
邹方成 2025-12-25 20:35:42 +08:00
parent 97cfe3f3da
commit d1f005225a
21 changed files with 2999 additions and 2482 deletions

View File

@ -354,8 +354,7 @@ async function onPaymentConfirm(paymentData) {
/* 网格包装 */ /* 网格包装 */
.grid-wrapper { .grid-wrapper {
padding-bottom: 200rpx; /* 留出底部操作栏空间 */ padding: 0 20rpx 140rpx; /* 减少底部padding */
padding: 0 20rpx 200rpx;
} }
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */ /* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
@ -476,22 +475,22 @@ async function onPaymentConfirm(paymentData) {
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1); text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
} }
/* ============= 底部操作栏 - 高级重置 ============= */ /* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */
.action-bar { .action-bar {
position: fixed; position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom)); bottom: calc(40rpx + env(safe-area-inset-bottom));
left: 30rpx;
right: 30rpx;
background: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx); backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx; padding: 24rpx 40rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border-radius: 999rpx; border-radius: 999rpx;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
z-index: 100; z-index: 100;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6); border: 1rpx solid rgba(255, 255, 255, 0.6);
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards; 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); transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: none; border: none;
&::after {
border: none;
}
&:active { &:active {
transform: scale(0.92); transform: scale(0.92);
} }

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@ -0,0 +1,8 @@
/**
* Composables 统一导出
*/
export { useActivity } from './useActivity'
export { useIssues } from './useIssues'
export { useRewards } from './useRewards'
export { useRecords } from './useRecords'

View 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
View 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
View 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
View 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
}
}

View 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*

View 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*

View File

@ -1,232 +1,156 @@
<template> <template>
<view class="page-wrapper"> <ActivityPageLayout :cover-url="coverUrl">
<view class="bg-decoration"> <template #header>
<view class="orb orb-1"></view> <ActivityHeader
<view class="orb orb-2"></view> :title="detail.name || detail.title || '对对碰'"
:price="detail.price_draw"
price-unit="/次"
:cover-url="coverUrl"
:tags="['随机玩法', '理性消费']"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<view class="section-container animate-enter stagger-1">
<view class="section-header">
<text class="section-title">卡牌类型</text>
</view>
<view v-if="cardTypesLoading" class="card-types-loading">加载中...</view>
<view v-else-if="cardTypes.length === 0" class="card-types-empty">暂无可选卡牌类型</view>
<scroll-view v-else class="card-types-scroll" scroll-x="true">
<view class="card-types-row">
<view
v-for="it in cardTypes"
:key="it.code"
class="card-type-item"
:class="{ active: selectedCardTypeCode === it.code }"
@tap="() => selectCardType(it)"
>
<image v-if="it.image_url" class="card-type-img" :src="it.image_url" mode="aspectFill" />
<view class="card-type-name">{{ it.name }}</view>
<view v-if="it.quantity !== undefined && it.quantity !== null" class="card-type-qty">×{{ it.quantity }}</view>
</view>
</view>
</scroll-view>
</view> </view>
<view class="page-bg"> <!-- 奖池/记录切换 -->
<image class="bg-image" :src="coverUrl" mode="aspectFill" /> <ActivityTabs
<view class="bg-mask"></view> v-model:current="tabActive"
</view> :tabs="[{key: 'pool', label: '本机奖池'}, {key: 'records', label: '购买记录'}]"
/>
<scroll-view class="main-scroll" scroll-y> <!-- 奖池预览 -->
<view class="header-card animate-enter"> <RewardsPreview
<image class="header-cover" :src="coverUrl" mode="aspectFill" /> v-if="tabActive === 'pool'"
<view class="header-info"> :reward-groups="rewardGroups"
<view class="header-title">{{ detail.name || detail.title || '-' }}</view> @view-all="openRewardsPopup"
<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>
<view class="section-container animate-enter stagger-1"> <!-- 购买记录 -->
<view class="section-header"> <RecordsList
<text class="section-title">卡牌类型</text> v-if="tabActive === 'records'"
</view> :records="winRecords"
<view v-if="cardTypesLoading" class="card-types-loading">加载中...</view> />
<view v-else-if="cardTypes.length === 0" class="card-types-empty">暂无可选卡牌类型</view>
<scroll-view v-else class="card-types-scroll" scroll-x="true">
<view class="card-types-row">
<view
v-for="it in cardTypes"
:key="it.code"
class="card-type-item"
:class="{ active: selectedCardTypeCode === it.code }"
@tap="() => selectCardType(it)"
>
<image v-if="it.image_url" class="card-type-img" :src="it.image_url" mode="aspectFill" />
<view class="card-type-name">{{ it.name }}</view>
<view v-if="it.quantity !== undefined && it.quantity !== null" class="card-type-qty">×{{ it.quantity }}</view>
</view>
</view>
</scroll-view>
</view>
<view class="section-container animate-enter stagger-2">
<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>
<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>
<view style="height: 180rpx;"></view> <view style="height: 180rpx;"></view>
</scroll-view> </scroll-view>
<view class="float-bar"> <template #footer>
<view class="float-bar-inner"> <view class="float-bar">
<view class="float-price"> <view class="float-bar-inner">
<text class="currency">¥</text> <view class="float-price">
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text> <text class="currency">¥</text>
<text class="unit">/</text> <text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
</view> <text class="unit">/</text>
<view v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
继续游戏
</view>
<view v-if="!hasResumeGame" class="action-btn primary" @tap="onParticipate">
立即参与
<view class="btn-shine"></view>
</view>
</view>
</view>
</view>
<view v-if="gameVisible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="closeGame"></view>
<view class="rewards-panel" @tap.stop>
<view class="rewards-header">
<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>
<view v-else>
<view class="record-item">
<view class="record-info">
<view class="record-title">总对数{{ totalPairs }} 摸牌机会{{ chance }}</view>
<view class="record-meta">
<text>牌组剩余 {{ deckRemaining }}</text>
<text v-if="selectedPositionText">位置 {{ selectedPositionText }}</text>
<text v-if="gameIdText">ID {{ gameIdText }}</text>
</view>
</view>
</view> </view>
<view class="match-grid"> <view v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
<view 继续游戏
v-for="(cell, idx) in handGridCells" </view>
:key="idx" <view v-if="!hasResumeGame" class="action-btn primary" @tap="onParticipate">
class="match-cell" 立即参与
:class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }" <view class="btn-shine"></view>
@tap="() => onCellTap(cell)"
>
<image v-if="cell.image" class="match-cell-img" :src="cell.image" mode="aspectFill" />
<view v-else class="match-cell-img"></view>
<text v-if="!cell.empty && cell.type" class="match-cell-type">{{ cell.type }}</text>
</view>
</view> </view>
</view> </view>
</scroll-view>
<view class="flip-actions" style="padding: 20rpx 24rpx;">
<button class="close-btn" style="flex: 1;" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">摸牌</button>
<button class="close-btn" style="flex: 1; background: linear-gradient(135deg, #ff7a18, #ffb347); color: #fff;" @tap="advanceOne" :disabled="gameLoading">下一步</button>
</view> </view>
</view> </template>
</view>
<view v-if="rewardsVisible" class="rewards-overlay" @touchmove.stop.prevent> <template #modals>
<view class="rewards-mask" @tap="closeRewardsPopup"></view> <!-- 游戏弹窗 -->
<view class="rewards-panel" @tap.stop> <view v-if="gameVisible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-header"> <view class="rewards-mask" @tap="closeGame"></view>
<text class="rewards-title">{{ currentIssueTitle }} · 奖品与概率</text> <view class="rewards-panel" @tap.stop>
<text class="rewards-close" @tap="closeRewardsPopup">×</text> <view class="rewards-header">
</view> <text class="rewards-title">对对碰游戏</text>
<scroll-view scroll-y class="rewards-list"> <text class="rewards-close" @tap="closeGame">×</text>
<view v-if="rewardGroups.length > 0"> </view>
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level"> <scroll-view scroll-y class="game-scroll-list">
<view class="group-header-row"> <view v-if="gameLoading" class="game-empty-state">加载中...</view>
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}</text> <view v-else-if="gameError" class="game-empty-state">{{ gameError }}</view>
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text> <view v-else>
</view> <view class="game-info-card">
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item"> <view class="game-info-content">
<image class="rewards-thumb" :src="item.image" mode="aspectFill" /> <view class="game-info-title">总对数{{ totalPairs }} 摸牌机会{{ chance }}</view>
<view class="rewards-info"> <view class="game-info-meta">
<view class="rewards-name-row"> <text>牌组剩余 {{ deckRemaining }}</text>
<text class="rewards-name">{{ item.title || '-' }}</text> <text v-if="selectedPositionText">位置 {{ selectedPositionText }}</text>
<view class="rewards-tag" v-if="item.boss">BOSS</view> <text v-if="gameIdText">ID {{ gameIdText }}</text>
</view>
</view>
</view>
<view class="match-grid">
<view
v-for="(cell, idx) in handGridCells"
:key="idx"
class="match-cell"
:class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }"
@tap="() => onCellTap(cell)"
>
<image v-if="cell.image" class="match-cell-img" :src="cell.image" mode="aspectFill" />
<view v-else class="match-cell-img"></view>
<text v-if="!cell.empty && cell.type" class="match-cell-type">{{ cell.type }}</text>
</view> </view>
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
</view> </view>
</view> </view>
</scroll-view>
<view class="flip-actions" style="padding: 20rpx 24rpx;">
<button class="close-btn" style="flex: 1;" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">摸牌</button>
<button class="close-btn" style="flex: 1; background: linear-gradient(135deg, #ff7a18, #ffb347); color: #fff;" @tap="advanceOne" :disabled="gameLoading">下一步</button>
</view> </view>
</view> </view>
<view v-else class="rewards-empty">暂无奖品数据</view> </view>
</scroll-view>
</view>
</view>
<PaymentPopup <!-- 奖品详情弹窗 -->
v-model:visible="paymentVisible" <RewardsPopup
:amount="paymentAmount" v-model:visible="rewardsVisible"
:coupons="coupons" :title="`${detail.name || '奖池'} · 奖品与概率`"
:propCards="propCards" :reward-groups="rewardGroups"
:showCards="true" />
@confirm="onPaymentConfirm"
/> <PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
:showCards="true"
@confirm="onPaymentConfirm"
/>
</template>
</ActivityPageLayout>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue' 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' import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs } from '../../../api/appUser'
const detail = ref({}) const detail = ref({})
@ -1421,204 +1345,6 @@ onLoad((opts) => {
margin-bottom: 8rpx; margin-bottom: 8rpx;
background-size: contain; background-size: contain;
background-repeat: no-repeat; 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 */
.banner-wrapper { .banner-wrapper {
margin: $spacing-md $spacing-lg; margin: $spacing-md $spacing-lg;
@ -1703,48 +1429,40 @@ onLoad((opts) => {
color: $text-sub; color: $text-sub;
} }
/* Header */ /* Game Modal Styles */
.header-section { .game-scroll-list {
padding: 0 $spacing-lg; max-height: 60vh;
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;
padding: $spacing-lg; padding: $spacing-lg;
box-shadow: $shadow-card; }
border: 1rpx solid rgba(255, 255, 255, 0.6); .game-empty-state {
animation: fadeInUp 0.6s ease-out 0.2s backwards; 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 { .section-header {
@ -1803,145 +1521,6 @@ onLoad((opts) => {
&.status-active { background: #D1FAE5; color: #059669; } &.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 { .record-count {
background: rgba($brand-primary, 0.1); background: rgba($brand-primary, 0.1);
color: $brand-primary; 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
View 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
View 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
View 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()
}