topfans/frontend/pages/support-activity/components/ActionBar.vue
2026-04-07 23:08:49 +08:00

615 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<template>
<view class="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>