topfans/frontend/pages/support-activity/components/ActionBar.vue

911 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<view class="action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<!-- 钻石消耗确认弹窗 -->
<DiamondConfirmModal :visible="confirmModal.visible" :itemLabel="confirmModal.itemLabel"
:itemCost="confirmModal.itemCost" @confirm="handleModalConfirm" @cancel="handleModalCancel" />
<!-- 主容器 -->
<view class="bar-container">
<!-- 用户信息区域 -->
<view class="user-info">
<image :src="userInfo?.avatar_url || '/static/icon/avatar-default.png'" class="user-avatar" mode="aspectFill" />
<view class="user-balance">
<image src="/static/icon/crystal.png" class="balance-icon" mode="aspectFit" />
<text class="balance-text">{{ userInfo?.crystal_balance || 0 }}</text>
</view>
</view>
<view class="items-row">
<view v-for="item in items" :key="item.type" class="action-item" :class="{ active: selectedItem === item.type }"
@click="handleItemSelect(item)">
<!-- 选中状态的背景 -->
<image v-if="selectedItem === item.type" src="/static/rank/activity-support-icon/djbeij.jpg" class="item-bg"
mode="aspectFill" />
<!-- 图标区域:添加白色背景和阴影 -->
<view class="icon-wrapper">
<image :src="item.icon" class="item-icon" mode="aspectFill" lazy-load />
</view>
<!-- 名称区域 -->
<view class="name-wrapper">
<text class="item-name">{{ item.name || item.label }}</text>
</view>
<!-- 价格区域:钻石图标 + 数字 -->
<view class="cost-wrapper">
<image src="/static/icon/crystal.png" class="diamond-icon" mode="aspectFit" lazy-load />
<text class="item-cost">{{ item.cost }}</text>
</view>
<!-- 选中状态显示"点击赠送" -->
<view v-if="selectedItem === item.type" class="gift-text"
@click="(e) => { e.stopPropagation(); handleDirectContribute(item) }">点击赠送</view>
<!-- 每个道具独立的反馈动画 -->
<view v-if="feedbackItem === item.type" class="feedback-layer">
<view class="feedback-icon" :style="{ color: itemFeedbackColor(item.type) }">+1</view>
</view>
</view>
</view>
<!-- 数量选择器和确认按钮 -->
<view class="quantity-control">
<view class="quantity-selector">
<view class="quantity-btn minus" @click.stop="decreaseQuantity">-</view>
<input type="number" v-model="quantity" class="quantity-input" />
<view class="quantity-btn plus" @click.stop="increaseQuantity">+</view>
</view>
<view class="confirm-btn" @click.stop="handleConfirmContribute">
<text>确认赠送</text>
</view>
</view>
</view>
<!-- 重试按钮 (当有失败操作时显示) -->
<view v-if="hasFailedActions" class="retry-banner" @click="retryFailedActions">
<text class="retry-text">有操作失败,点击重试</text>
</view>
<!-- 自定义结果提示(替代系统 toast/modal避免截断 -->
<view v-show="resultToast.visible" class="result-toast" :class="{ 'result-toast--visible': resultToast.visible }">
<text class="result-toast-icon">{{ resultToast.icon }}</text>
<text class="result-toast-text">{{ resultToast.text }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { purchaseItem } from '@/utils/activity-config'
import DiamondConfirmModal from './DiamondConfirmModal.vue'
const props = defineProps({
activityId: {
type: String,
required: true
},
actionItems: {
type: Array,
default: () => [
{ type: 'firework', label: '烟花', icon: '/static/rank/spark.png', cost: 1 },
{ type: 'megaphone', label: '喇叭', icon: '/static/icon/task.png', cost: 5 },
{ type: 'love', label: 'LOVE', icon: '/static/icon/castlove.png', cost: 10 }
]
}
})
const emit = defineEmits(['contribute'])
// 动态响应父组件传入的道具配置
const items = computed(() => props.actionItems)
const safeAreaBottom = ref(0)
const feedbackItem = ref(null) // 当前触发反馈动画的道具 type
const selectedItem = ref(null) // 当前选中的道具
const quantity = ref(1) // 赠送数量
const isContributing = ref(false) // 正在贡献中,防止状态被清除
// 按索引分配颜色,不依赖后端 type 字符串
const FEEDBACK_COLORS = ['#FFD700', '#00CFFF', '#FF6B9D', '#A78BFA', '#34D399']
function itemFeedbackColor(type) {
const idx = items.value.findIndex(i => i.type === type)
return FEEDBACK_COLORS[idx >= 0 ? idx % FEEDBACK_COLORS.length : 0]
}
const pendingActions = ref([])
const hasFailedActions = ref(false)
const isOnline = ref(true)
const processingItems = new Set() // 防止重复点击
const resultToast = ref({ visible: false, icon: '', text: '' })
let resultToastTimer = null
function showResultToast(icon, text) {
if (resultToastTimer) clearTimeout(resultToastTimer)
resultToast.value = { visible: true, icon, text }
resultToastTimer = setTimeout(() => {
resultToast.value.visible = false
}, 3000)
}
// 自定义确认弹窗状态
const confirmModal = ref({ visible: false, itemLabel: '', itemCost: 0 })
let confirmResolve = null
function showConfirmModal(item) {
confirmModal.value = { visible: true, itemLabel: item.label, itemCost: item.cost }
return new Promise((resolve) => { confirmResolve = resolve })
}
function handleModalConfirm() {
confirmModal.value.visible = false
confirmResolve?.(true)
}
function handleModalCancel() {
confirmModal.value.visible = false
confirmResolve?.(false)
}
const userInfo = ref(null);
function loadUserInfo() {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
userInfo.value = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
}
} catch (error) {
console.error('加载用户信息失败:', error)
}
}
// 网络状态监听
let networkListener = null
let syncDebounceTimer = null
function debouncedSync() {
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
if (resultToastTimer) clearTimeout(resultToastTimer)
syncDebounceTimer = setTimeout(() => {
syncDebounceTimer = null
syncPendingActions()
}, 1000)
}
onMounted(() => {
// 获取安全区域信息
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom || 0
// 加载用户信息
loadUserInfo()
// 恢复离线队列
loadPendingActions()
// 监听网络状态
networkListener = uni.onNetworkStatusChange((res) => {
const wasOffline = !isOnline.value
isOnline.value = res.isConnected
// 如果从离线恢复到在线,自动同步(防抖避免网络抖动重复触发)
if (wasOffline && isOnline.value && pendingActions.value.length > 0) {
debouncedSync()
}
})
// 检查当前网络状态
uni.getNetworkType({
success: (res) => {
isOnline.value = res.networkType !== 'none'
}
})
// 监听余额更新事件
uni.$on('balanceUpdated', (data) => {
if (userInfo.value) {
userInfo.value.crystal_balance = data.crystal_balance
}
})
})
onUnmounted(() => {
if (networkListener) uni.offNetworkStatusChange(networkListener)
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
if (resultToastTimer) clearTimeout(resultToastTimer)
uni.$off('balanceUpdated')
})
// 存储键前缀
const STORAGE_PREFIX = 'topfans_'
// 加载离线队列
async function loadPendingActions() {
try {
// 先检查 key 是否存在
const info = uni.getStorageInfoSync()
const key = `${STORAGE_PREFIX}pending_actions_${props.activityId}`
if (!info.keys.includes(key)) {
return // key 不存在,直接返回
}
const cached = await uni.getStorage({
key: `${STORAGE_PREFIX}pending_actions_${props.activityId}`
})
if (cached && cached[1]?.data && Array.isArray(cached[1].data)) {
pendingActions.value = cached[1].data
hasFailedActions.value = cached[1].data.length > 0
// 如果在线,尝试同步
if (isOnline.value && cached[1].data.length > 0) {
syncPendingActions()
}
}
} catch (error) {
console.error('加载离线队列失败:', error)
}
}
// 保存离线队列
async function savePendingActions() {
try {
await uni.setStorage({
key: `${STORAGE_PREFIX}pending_actions_${props.activityId}`,
data: pendingActions.value
})
hasFailedActions.value = pendingActions.value.length > 0
} catch (error) {
console.error('保存离线队列失败:', error)
}
}
// 处理道具选择(第一次点击)
function handleItemSelect(item) {
selectedItem.value = item.type
quantity.value = 1
isContributing.value = false
}
// 增加数量
function increaseQuantity() {
quantity.value++
}
// 减少数量
function decreaseQuantity() {
if (quantity.value > 1) {
quantity.value--
}
}
// 处理直接贡献(点击"点击赠送",不需要确认)
async function handleDirectContribute(item) {
// 防止同一道具重复点击
if (processingItems.has(item.type) || isContributing.value) return
processingItems.add(item.type)
isContributing.value = true
try {
// 验证余额
const hasBalance = await validateBalance(item.cost)
if (!hasBalance) {
showResultToast('', '余额不足')
// 余额不足时直接返回不调用API
return
}
// 直接调用贡献API
const success = await contributeItem(item)
if (!success) {
// 贡献失败时不显示额外提示,由 contributeItem 内部处理
return
}
} finally {
processingItems.delete(item.type)
isContributing.value = false
}
}
// 处理确认贡献(点击"确认赠送"
async function handleConfirmContribute() {
const item = items.value.find(i => i.type === selectedItem.value)
if (!item || isContributing.value) return
// 防止同一道具重复点击
if (processingItems.has(item.type)) return
processingItems.add(item.type)
isContributing.value = true
try {
// 验证余额(乘以数量)
const totalCost = item.cost * quantity.value
const hasBalance = await validateBalance(totalCost)
if (!hasBalance) {
showResultToast('', '余额不足')
return
}
// 弹出确认框
const confirmed = await showConfirmModal({ ...item, cost: totalCost })
if (!confirmed) {
return
}
// 调用贡献API多次
for (let i = 0; i < quantity.value; i++) {
await contributeItem(item, false, true)
}
} finally {
processingItems.delete(item.type)
isContributing.value = false
// 注意:不清除 selectedItem让用户可以继续赠送或选择其他道具
}
}
// 验证用户余额(优先用缓存,避免重复读取存储)
async function validateBalance(cost) {
try {
if (!userInfo.value) {
const userStr = uni.getStorageSync('user')
if (userStr) {
userInfo.value = typeof userStr === 'string' ? JSON.parse(userStr) : { ...userStr }
}
}
const balance = Number(userInfo.value?.crystal_balance) || 0
return balance >= cost
} catch (error) {
console.error('验证余额失败:', error)
return false
}
}
// 贡献道具
async function contributeItem(item, isRetry = false, silent = false) {
try {
// 使用 activity-config.js 中的 purchaseItem 函数
const result = await purchaseItem(props.activityId, item.type, 1)
// 检查购买结果
if (!result.success) {
if (!silent) showResultToast('', result.message || '活动不在进行中,无法购买')
return false
}
// 成功:触发反馈动画(重试时静默,不触发)
if (!isRetry && !silent) {
feedbackItem.value = item.type
setTimeout(() => { feedbackItem.value = null }, 800)
}
// 更新本地用户余额:非重试时直接用服务端余额;重试时由 syncPendingActions 统一处理
if (!isRetry && !silent) {
await updateLocalBalanceFromResult(result.remainingBalance)
}
// 通知父组件更新进度(使用返回的当前进度)
emit('contribute', item.type, result.currentProgress)
// 如果是重试成功或静默模式,不单独弹 toast
if (!isRetry && !silent) {
showResultToast('✅', `贡献值 +${result.totalContribution}`)
}
// 重试时返回完整结果供 syncPendingActions 汇总;正常时返回 true
return isRetry ? { contribution: result.totalContribution, remainingBalance: result.remainingBalance } : true
} catch (error) {
console.error('贡献失败:', error)
// 如果不是重试操作,先乐观扣除本地余额再入队
if (!isRetry && !silent) {
deductLocalBalance(item.cost)
const queued = addToPendingQueue(item)
if (!queued) {
// 队列已满,退还刚扣除的余额
refundLocalBalance(item.cost)
return false
}
showResultToast('', '网络异常,已加入队列')
}
return false
}
}
// 添加到离线队列(上限 10 个)
const QUEUE_LIMIT = 10
function addToPendingQueue(item) {
if (pendingActions.value.length >= QUEUE_LIMIT) {
showResultToast('⚠️', '离线队列已满\n请联网后再操作')
return false
}
const action = {
type: item.type,
cost: item.cost,
label: item.label,
icon: item.icon,
timestamp: Date.now(),
retryCount: 0
}
pendingActions.value.push(action)
savePendingActions()
return true
}
// 乐观扣除本地余额(离线入队时使用)
function deductLocalBalance(cost) {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
// 确保正确解析用户对象
let user
if (typeof userStr === 'string') {
user = JSON.parse(userStr)
} else {
user = { ...userStr }
}
// 扣除余额,确保不为负数
user.crystal_balance = Math.max(0, (Number(user.crystal_balance) || 0) - cost)
// 保存回存储
uni.setStorageSync('user', JSON.stringify(user))
userInfo.value = user
uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance })
}
} catch (error) {
console.error('扣除本地余额失败:', error)
}
}
// 退还本地余额(同步彻底失败时使用)
function refundLocalBalance(cost) {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
// 确保正确解析用户对象
let user
if (typeof userStr === 'string') {
user = JSON.parse(userStr)
} else {
user = { ...userStr }
}
// 退还余额
user.crystal_balance = (Number(user.crystal_balance) || 0) + cost
// 保存回存储
uni.setStorageSync('user', JSON.stringify(user))
userInfo.value = user
uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance })
}
} catch (error) {
console.error('退还本地余额失败:', error)
}
}
async function updateLocalBalanceFromResult(newBalance) {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
// 确保正确解析用户对象
let user
if (typeof userStr === 'string') {
user = JSON.parse(userStr)
} else {
// 如果已经是对象,创建一个新副本避免引用问题
user = { ...userStr }
}
// 更新余额,确保是数字类型
user.crystal_balance = Number(newBalance) || 0
// 保存回存储,确保是字符串格式
uni.setStorageSync('user', JSON.stringify(user))
userInfo.value = user
uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance })
}
} catch (error) {
console.error('更新本地余额失败:', error)
}
}
// 同步离线队列
async function syncPendingActions() {
if (pendingActions.value.length === 0) {
return
}
uni.showLoading({
title: '同步中...',
mask: true
})
const actions = [...pendingActions.value]
const failedActions = []
const CONCURRENCY = 3
let totalContribution = 0
let minRemainingBalance = Infinity // 并发时取最小余额,避免覆盖问题
try {
// 分批并发执行,每批最多 CONCURRENCY 个
for (let i = 0; i < actions.length; i += CONCURRENCY) {
const batch = actions.slice(i, i + CONCURRENCY)
const results = await Promise.all(batch.map(async (action) => {
if (action.retryCount >= 3) {
// 重试超限,退还之前乐观扣除的余额
refundLocalBalance(action.cost)
return { action, contribution: 0, remainingBalance: null, success: false }
}
action.retryCount = (action.retryCount || 0) + 1
const item = { type: action.type, cost: action.cost, label: action.label, icon: action.icon }
const res = await contributeItem(item, true)
const success = res !== false && res !== null
return {
action,
contribution: success ? res.contribution : 0,
remainingBalance: success ? res.remainingBalance : null,
success
}
}))
results.forEach(({ action, contribution, remainingBalance, success }) => {
if (success) {
totalContribution += contribution
if (remainingBalance !== null && remainingBalance < minRemainingBalance) {
minRemainingBalance = remainingBalance
}
} else {
failedActions.push(action)
}
})
}
} finally {
uni.hideLoading()
// 批次全部完成后,用最小余额做一次写入,避免并发覆盖
if (minRemainingBalance !== Infinity) {
await updateLocalBalanceFromResult(minRemainingBalance)
}
}
// 更新队列
pendingActions.value = failedActions
savePendingActions()
// 显示结果(自定义 toast完全控制尺寸避免系统截断
const successCount = actions.length - failedActions.length
if (successCount > 0 && failedActions.length === 0) {
showResultToast('✅', `贡献成功\n+${totalContribution} 贡献值已到账`)
} else if (successCount > 0 && failedActions.length > 0) {
showResultToast('⚠️', `成功 ${successCount} 个,+${totalContribution} 贡献值\n失败 ${failedActions.length} 个,可点击重试`)
} else {
showResultToast('❌', '贡献失败\n钻石已退还')
}
}
// 手动重试失败操作
function retryFailedActions() {
syncPendingActions()
}
// 暴露方法供父组件调用
defineExpose({
syncPendingActions,
getPendingActionsCount: () => pendingActions.value.length
})
</script>
<style scoped>
/* 整体定位 */
.action-bar {
width: 100%;
min-height: 13rem;
display: flex;
justify-content: center;
}
/* 核心容器:模仿图中的粉色渐变长条 */
.bar-container {
width: 100%;
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
background-size: 105% 130%;
background-position: center;
border-radius: 40rpx 40rpx 0 0;
padding: 20rpx 40rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* 用户信息区域 */
.user-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.3);
}
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
border: 3rpx solid #fff;
}
.user-balance {
display: flex;
align-items: center;
gap: 8rpx;
background: rgba(0, 0, 0, 0.2);
padding: 8rpx 20rpx;
border-radius: 30rpx;
}
.balance-icon {
width: 40rpx;
height: 40rpx;
}
.balance-text {
font-size: 28rpx;
color: #fff;
font-weight: bold;
}
/* 道具行容器 */
.items-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 16rpx 0;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
cursor: pointer;
padding: 16rpx;
border-radius: 20rpx;
}
/* 选中状态 */
.action-item.active .icon-wrapper,
.action-item.active .name-wrapper,
.action-item.active .cost-wrapper {
opacity: 0.6;
}
/* 选中背景 */
.item-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.gift-text {
font-size: 22rpx;
color: #fff;
white-space: nowrap;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%);
border-radius: 24rpx;
padding: 6rpx 16rpx;
margin-top: 8rpx;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
position: relative;
}
/* 数量选择器和确认按钮 */
.quantity-control {
display: flex;
align-items: center;
/* justify-content: center; */
gap: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
margin-top: 16rpx;
}
.quantity-selector {
display: flex;
align-items: center;
gap: 16rpx;
background: rgba(0, 0, 0, 0.2);
border-radius: 40rpx;
padding: 8rpx 16rpx;
}
.quantity-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(to bottom right, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
color: #fff;
font-size: 32rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.quantity-input {
width: 60rpx;
text-align: center;
font-size: 28rpx;
color: #fff;
background: transparent;
}
.confirm-btn {
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
background-size: cover;
background-position: center;
border-radius: 40rpx;
padding: 16rpx 40rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 143, 158, 0.3);
}
.confirm-btn text {
font-size: 28rpx;
color: #fff;
font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
/* 图标包装器 */
.icon-wrapper {
width: 120rpx;
height: 120rpx;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8rpx;
overflow: hidden;
}
.item-icon {
width: 100%;
height: 100%;
transform: scale(1.5);
}
/* 名称包装器 */
.name-wrapper {
margin-bottom: 4rpx;
}
.item-name {
font-size: 22rpx;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
text-align: center;
}
/* 价格包装器:横向排列钻石和数字 */
.cost-wrapper {
display: flex;
align-items: center;
gap: 4rpx;
}
/* 钻石图标样式 */
.diamond-icon {
width: 40rpx;
height: 40rpx;
}
.item-cost {
font-size: 24rpx;
color: #fff;
/* 橙色,接近图中 LISA 的颜色 */
text-shadow:
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
font-family: 'yt', sans-serif;
}
/* --- 以下保持原有逻辑样式 --- */
.feedback-layer {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
z-index: 10;
}
.feedback-icon {
font-size: 60rpx;
color: #ff6b9d;
font-weight: bold;
animation: feedback-pop 1s ease-out;
}
.retry-banner {
position: absolute;
top: -80rpx;
/* 调整位置以免遮挡渐变条 */
left: 50%;
transform: translateX(-50%);
background: rgba(255, 107, 157, 0.9);
padding: 10rpx 30rpx;
border-radius: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2);
cursor: pointer;
white-space: nowrap;
}
.retry-text {
font-size: 24rpx;
color: #fff;
font-weight: 500;
}
@keyframes feedback-pop {
0% {
opacity: 0;
transform: scale(0.5) translateY(0);
}
50% {
opacity: 1;
transform: scale(1.2) translateY(-40rpx);
}
100% {
opacity: 0;
transform: scale(1) translateY(-80rpx);
}
}
.result-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background: rgba(0, 0, 0, 0.75);
border-radius: 20rpx;
padding: 40rpx 48rpx;
min-width: 320rpx;
max-width: 560rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.result-toast--visible {
opacity: 1;
pointer-events: auto;
}
.result-toast-icon {
font-size: 56rpx;
line-height: 1;
}
.result-toast-text {
font-size: 28rpx;
color: #fff;
text-align: center;
line-height: 1.6;
white-space: pre-line;
}
</style>