1075 lines
27 KiB
Vue
1075 lines
27 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 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">{{ crystalBalance || 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 { getEarningsSummaryApi } from "@/utils/api";
|
||
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);
|
||
const crystalBalance = ref(0);
|
||
|
||
async function loadUserInfo() {
|
||
try {
|
||
const userStr = uni.getStorageSync("user");
|
||
if (userStr) {
|
||
userInfo.value =
|
||
typeof userStr === "string" ? JSON.parse(userStr) : userStr;
|
||
}
|
||
|
||
// 从API获取真实余额
|
||
const res = await getEarningsSummaryApi();
|
||
if (res.code === 0 && res.data) {
|
||
crystalBalance.value = res.data.crystal_balance || 0;
|
||
if (userInfo.value) {
|
||
userInfo.value.crystal_balance = crystalBalance.value;
|
||
uni.setStorageSync("user", JSON.stringify(userInfo.value));
|
||
}
|
||
// 通知其他组件(如 Header)余额已更新
|
||
uni.$emit("balanceUpdated", { crystal_balance: crystalBalance.value });
|
||
} else {
|
||
crystalBalance.value = userInfo.value?.crystal_balance || 0;
|
||
}
|
||
} catch (error) {
|
||
console.error("加载用户信息失败:", error);
|
||
crystalBalance.value = userInfo.value?.crystal_balance || 0;
|
||
}
|
||
}
|
||
|
||
// 网络状态监听
|
||
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();
|
||
|
||
// 默认选中第一个道具
|
||
if (items.value && items.value.length > 0) {
|
||
selectedItem.value = items.value[0].type;
|
||
}
|
||
|
||
// 监听网络状态
|
||
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 * Number(quantity.value);
|
||
const hasBalance = await validateBalance(totalCost);
|
||
if (!hasBalance) {
|
||
showResultToast("", "余额不足");
|
||
return;
|
||
}
|
||
|
||
// 弹出确认框
|
||
const confirmed = await showConfirmModal({ ...item, cost: totalCost });
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
// 调用贡献API一次,传入总数量(确保为 number 类型)
|
||
const res = await contributeItem(item, true, true, Number(quantity.value));
|
||
console.log(`批量购买返回值:`, res);
|
||
|
||
// 更新本地余额
|
||
if (res && res.remainingBalance !== null) {
|
||
await updateLocalBalanceFromResult(res.remainingBalance);
|
||
}
|
||
|
||
// 显示自定义结果提示
|
||
if (res && res.success) {
|
||
showResultToast(
|
||
"✅",
|
||
`贡献成功\n+${item.cost * Number(quantity.value)} 贡献值已到账`,
|
||
);
|
||
// 通知父组件更新进度
|
||
emit("contribute", item.type, res.remainingBalance);
|
||
} else {
|
||
showResultToast("❌", res?.message || "贡献失败");
|
||
}
|
||
} finally {
|
||
processingItems.delete(item.type);
|
||
isContributing.value = false;
|
||
}
|
||
}
|
||
|
||
// 验证用户余额(优先用缓存,避免重复读取存储)
|
||
async function validateBalance(cost) {
|
||
try {
|
||
// 确保余额已加载
|
||
if (crystalBalance.value === 0 && userInfo.value?.crystal_balance) {
|
||
crystalBalance.value = userInfo.value.crystal_balance;
|
||
}
|
||
return crystalBalance.value >= cost;
|
||
} catch (error) {
|
||
console.error("验证余额失败:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 贡献道具
|
||
async function contributeItem(item, isRetry = false, silent = false, qty = 1) {
|
||
try {
|
||
// 使用 activity-config.js 中的 purchaseItem 函数
|
||
const result = await purchaseItem(props.activityId, item.type, qty);
|
||
console.log(
|
||
`[contributeItem] result:`,
|
||
result,
|
||
"isRetry:",
|
||
isRetry,
|
||
"silent:",
|
||
silent,
|
||
"qty:",
|
||
qty,
|
||
);
|
||
|
||
// 检查购买结果
|
||
if (!result.success) {
|
||
const msg = result.message || "活动不在进行中,无法购买";
|
||
if (!silent) showResultToast("", msg);
|
||
return { success: false, message: msg };
|
||
}
|
||
|
||
// 成功:触发反馈动画(重试时静默,不触发)
|
||
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,
|
||
success: true,
|
||
}
|
||
: { success: true };
|
||
} catch (error) {
|
||
console.error("贡献失败:", error);
|
||
|
||
// 如果不是重试操作,先乐观扣除本地余额再入队
|
||
if (!isRetry && !silent) {
|
||
deductLocalBalance(item.cost);
|
||
const queued = addToPendingQueue(item);
|
||
if (!queued) {
|
||
// 队列已满,退还刚扣除的余额
|
||
refundLocalBalance(item.cost);
|
||
return { success: false, message: "网络异常,已加入队列" };
|
||
}
|
||
return { success: false, message: "网络异常,已加入队列" };
|
||
}
|
||
|
||
return { success: false, message: error.message || "贡献失败" };
|
||
}
|
||
}
|
||
|
||
// 添加到离线队列(上限 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;
|
||
crystalBalance.value = user.crystal_balance;
|
||
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;
|
||
crystalBalance.value = user.crystal_balance;
|
||
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;
|
||
crystalBalance.value = user.crystal_balance;
|
||
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;
|
||
background: linear-gradient(
|
||
to bottom right,
|
||
#f0e4b1 0%,
|
||
#f08399 50%,
|
||
#b94e73 100%
|
||
);
|
||
border-radius: 24rpx;
|
||
box-shadow:
|
||
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||
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: space-between;
|
||
gap: 24rpx;
|
||
padding-top: 24rpx;
|
||
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.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: 112rpx;
|
||
height: 112rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin-bottom: 8rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.item-icon {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 名称包装器 */
|
||
.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>
|