615 lines
17 KiB
Vue
615 lines
17 KiB
Vue
<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
|
||
v-for="item in items"
|
||
:key="item.type"
|
||
class="action-item"
|
||
@click="handleClick(item)"
|
||
>
|
||
<!-- 图标区域:添加白色背景和阴影 -->
|
||
<view class="icon-wrapper">
|
||
<image :src="item.icon" class="item-icon" mode="aspectFill" lazy-load />
|
||
</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="feedbackItem === item.type" class="feedback-layer">
|
||
<view class="feedback-icon" :style="{ color: itemFeedbackColor(item.type) }">+1</view>
|
||
</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
|
||
|
||
// 按索引分配颜色,不依赖后端 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);
|
||
|
||
// 网络状态监听
|
||
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
|
||
|
||
// 恢复离线队列
|
||
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'
|
||
}
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (networkListener) uni.offNetworkStatusChange(networkListener)
|
||
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
|
||
if (resultToastTimer) clearTimeout(resultToastTimer)
|
||
})
|
||
|
||
// 存储键前缀
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 处理道具点击
|
||
async function handleClick(item) {
|
||
// 防止同一道具重复点击
|
||
if (processingItems.has(item.type)) return
|
||
processingItems.add(item.type)
|
||
|
||
try {
|
||
// 验证余额前给一个轻量反馈
|
||
uni.showLoading({ title: '请稍候...', mask: true })
|
||
const hasBalance = await validateBalance(item.cost)
|
||
uni.hideLoading()
|
||
|
||
if (!hasBalance) {
|
||
showResultToast('', '余额不足')
|
||
return
|
||
}
|
||
|
||
// 弹出确认框,提示消耗钻石数量
|
||
const confirmed = await showConfirmModal(item)
|
||
if (!confirmed) return
|
||
|
||
// 调用贡献API
|
||
await contributeItem(item)
|
||
} finally {
|
||
processingItems.delete(item.type)
|
||
}
|
||
}
|
||
|
||
// 验证用户余额(优先用缓存,避免重复读取存储)
|
||
async function validateBalance(cost) {
|
||
try {
|
||
if (!userInfo.value) {
|
||
const userStr = uni.getStorageSync('user')
|
||
if (userStr) userInfo.value = JSON.parse(userStr)
|
||
}
|
||
const balance = userInfo.value?.crystal_balance || 0
|
||
return balance >= cost
|
||
} catch (error) {
|
||
console.error('验证余额失败:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 贡献道具
|
||
async function contributeItem(item, isRetry = false) {
|
||
try {
|
||
// 使用 activity-config.js 中的 purchaseItem 函数
|
||
const result = await purchaseItem(props.activityId, item.type, 1)
|
||
|
||
// 成功:触发反馈动画(重试时静默,不触发)
|
||
if (!isRetry) {
|
||
feedbackItem.value = item.type
|
||
setTimeout(() => { feedbackItem.value = null }, 800)
|
||
}
|
||
|
||
// 更新本地用户余额:非重试时直接用服务端余额;重试时由 syncPendingActions 统一处理
|
||
if (!isRetry) {
|
||
await updateLocalBalanceFromResult(result.remainingBalance)
|
||
}
|
||
|
||
// 通知父组件更新进度(使用返回的当前进度)
|
||
emit('contribute', item.type, result.currentProgress)
|
||
|
||
// 如果是重试成功,不单独弹 toast,由 syncPendingActions 统一汇总提示
|
||
if (!isRetry) {
|
||
showResultToast('✅', `贡献值 +${result.totalContribution}`)
|
||
}
|
||
|
||
// 重试时返回完整结果供 syncPendingActions 汇总;正常时返回 true
|
||
return isRetry ? { contribution: result.totalContribution, remainingBalance: result.remainingBalance } : true
|
||
} catch (error) {
|
||
console.error('贡献失败:', error)
|
||
|
||
// 如果不是重试操作,先乐观扣除本地余额再入队
|
||
if (!isRetry) {
|
||
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) {
|
||
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||
user.crystal_balance = Math.max(0, (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) {
|
||
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||
user.crystal_balance = (user.crystal_balance || 0) + cost
|
||
uni.setStorageSync('user', JSON.stringify(user))
|
||
userInfo.value = user
|
||
}
|
||
} catch (error) {
|
||
console.error('退还本地余额失败:', error)
|
||
}
|
||
}
|
||
async function updateLocalBalanceFromResult(newBalance) {
|
||
try {
|
||
const userStr = uni.getStorageSync('user')
|
||
if (userStr) {
|
||
const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||
user.crystal_balance = newBalance
|
||
uni.setStorageSync('user', JSON.stringify(user))
|
||
userInfo.value = user
|
||
uni.$emit('balanceUpdated', { crystal_balance: newBalance })
|
||
}
|
||
} 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%;
|
||
position: fixed;
|
||
bottom: 0; /* 通常底部栏贴底,通过 padding 留出安全区 */
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 核心容器:模仿图中的粉色渐变长条 */
|
||
.bar-container {
|
||
min-width: 50%;
|
||
background: linear-gradient(to bottom right,
|
||
#F0E4B1 0%, /* 左:浅橙粉 */
|
||
#F08399 50%,
|
||
#B94E73 100% /* 右:柔粉红 */
|
||
);
|
||
border-radius: 40rpx;
|
||
padding: 20rpx 40rpx;
|
||
box-shadow: 0 10rpx 30rpx rgba(255, 107, 157, 0.4);
|
||
display: flex;
|
||
gap: 24rpx;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 128rpx;
|
||
}
|
||
|
||
.action-item {
|
||
width:120rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
position: relative;
|
||
transition: transform 0.1s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.action-item:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
/* 图标包装器:白色圆角背景 */
|
||
.icon-wrapper {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.item-icon {
|
||
width: 100%;
|
||
height: 100%;
|
||
transform: scale(2);
|
||
}
|
||
|
||
/* 价格包装器:横向排列钻石和数字 */
|
||
.cost-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
/* 钻石图标样式 */
|
||
.diamond-icon {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
}
|
||
|
||
.item-cost {
|
||
font-size: 24rpx;
|
||
color: #FFA500; /* 橙色,接近图中 LISA 的颜色 */
|
||
text-shadow:
|
||
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
|
||
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
|
||
font-family: 'ZaoZiGongFangJianHei-1', 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> |