Zuncle 27a05210ee fix(auth): 修复活动页和商品详情页未登录即弹登录框导致审核失败
问题背景:
- 平台审核结论:页面未完整浏览、体验详情时即要求授权登录,属于不合规
- 用户应能先浏览页面内容,仅在执行操作(抽奖/兑换/购买)时才引导登录

根因分析:
1. api/appUser.js 中活动浏览类 API(getActivityDetail 等)使用 authRequest,
   虽然后端接口是公开的,但同页面的 getGamePasses 等需认证接口返回 401
   触发全局登录弹窗
2. getProductDetail 使用 authRequest 调用认证接口,未登录直接 401
3. 全局 401 拦截器不区分浏览请求和操作请求

修改内容:
1. api/appUser.js: 6 个浏览类 API 函数从 authRequest 改为 request
   - getActivityDetail, getActivityIssues, getActivityIssueRewards
   - getIssueDrawLogs, getMatchingCardTypes, getProductDetail
   这些接口在后端均为公开路由,不需要携带 token

2. 活动页面 onLoad 中条件调用认证接口:
   - wuxianshang/index.vue: fetchPasses() 仅在已登录时调用
   - yifanshang/index.vue: fetchPasses() 仅在已登录时调用
   - duiduipeng/index.vue: fetchGamePasses() 仅在已登录时调用
   次数卡(game passes)接口需要认证,未登录时跳过即可,
   不影响页面浏览体验

3. utils/request.js: request() 函数增加 suppressAuthModal 参数
   支持调用方按需静默 401 弹窗,作为安全兜底机制

验证场景:
- 未登录 → 打开无限赏/一番赏/对对碰/商品详情 → 正常显示,无登录弹窗
- 未登录 → 点击抽奖/兑换按钮 → 弹出登录提示(符合平台规范)
- 已登录 → 所有功能正常,次数卡信息正常加载
2026-03-26 14:35:26 +08:00

692 lines
19 KiB
Vue
Executable File
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>
<ActivityPageLayout :cover-url="coverUrl" bottom-padding="220rpx">
<template #header>
<ActivityHeader
:title="detail.name || detail.title || '无限赏'"
:price="detail.price_draw"
price-unit="/"
:cover-url="coverUrl"
:tags="['公开透明', '可验证']"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<template #content>
<ActivityTabs v-model="tabActive" :stagger="1">
<template #pool>
<RewardsPreview
title="奖池配置"
:rewards="currentIssueRewards"
:grouped="true"
@view-all="rewardsVisible = true"
/>
</template>
<template #records>
<RecordsList :records="winRecords" />
</template>
</ActivityTabs>
</template>
<template #footer>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<view class="tier-btn" @tap="openPayment(1)">
<text class="tier-price">¥{{ (pricePerDraw * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</view>
<view class="tier-btn" @tap="openPayment(3)">
<text class="tier-price">¥{{ (pricePerDraw * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</view>
<view class="tier-btn" @tap="openPayment(5)">
<text class="tier-price">¥{{ (pricePerDraw * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</view>
<view class="tier-btn tier-hot" @tap="openPayment(10)">
<text class="tier-price">¥{{ (pricePerDraw * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</view>
</view>
</template>
<template #modals>
<RewardsPopup
v-model:visible="rewardsVisible"
:title="`${currentIssueTitle} · 奖池与概率`"
:reward-groups="rewardGroups"
/>
<LotteryResultPopup
v-model:visible="showResultPopup"
:results="drawResults"
:show-retry-button="lastDrawUsedGamePass"
@close="onResultClose"
@retry="onRetryDraw"
/>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:gamePasses="gamePasses"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<!-- 开奖加载弹窗 -->
<DrawLoadingPopup
:visible="showDrawLoading"
:progress="drawProgress"
:total="drawTotal"
/>
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
<!-- 悬浮次数卡入口 -->
<view v-if="gamePassRemaining > 0 || true" class="game-pass-float" @tap="openPurchasePopup">
<view class="badge-content">
<text class="badge-icon">🎮</text>
<text class="badge-text" v-if="gamePassRemaining > 0">{{ gamePassRemaining }}</text>
<text class="badge-text" v-else>购买</text>
</view>
<view class="badge-label">使用次数</view>
</view>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// 公共组件 - uni-app需要直接导入.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 RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import DrawLoadingPopup from '@/components/activity/DrawLoadingPopup.vue'
import PaymentPopup from '@/components/PaymentPopup.vue'
import GamePassPurchasePopup from '@/components/GamePassPurchasePopup.vue'
import { getGamePasses } from '@/api/appUser'
// Composables
import { useActivity, useIssues, useRewards, useRecords } from '../../composables'
// API
import { joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '@/api/appUser'
// ============ 使用Composables ============
const activityId = ref('')
const {
detail,
coverUrl,
fetchDetail,
setNavigationTitle
} = useActivity(activityId)
const pricePerDraw = computed(() => Number(detail.value?.price_draw || 0) / 100)
const {
issues,
currentIssueId,
currentIssueTitle,
fetchIssues
} = useIssues(activityId)
const {
currentIssueRewards,
rewardGroups,
fetchRewardsForIssues
} = useRewards(activityId, currentIssueId)
const {
winRecords,
fetchWinRecords
} = useRecords()
// ============ 本地状态 ============
const tabActive = ref('pool')
const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showResultPopup = ref(false)
const drawResults = ref([])
const drawLoading = ref(false)
const showDrawLoading = ref(false)
const drawProgress = ref(0)
const drawTotal = ref(1)
const lastDrawUsedGamePass = ref(false) // 记录最后一次抽奖是否使用了次数卡
const lastDrawCount = ref(1) // 记录最后一次抽奖的数量
// 支付相关
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const useGamePassFlag = ref(false)
// ============ 次数卡逻辑 ============
const gamePasses = ref(null)
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
const purchasePopupVisible = ref(false)
async function fetchPasses() {
if (!activityId.value) return
try {
const res = await getGamePasses(activityId.value)
gamePasses.value = res || null
} catch (e) {
gamePasses.value = null
}
}
function openPurchasePopup() {
purchasePopupVisible.value = true
}
function onPurchaseSuccess() {
fetchPasses()
}
// ============ 业务方法 ============
function showRules() {
rulesVisible.value = true
}
function goCabinet() {
cabinetVisible.value = true
}
function onResultClose() {
showResultPopup.value = false
drawResults.value = []
}
function onRetryDraw() {
// 关闭结果弹窗
showResultPopup.value = false
drawResults.value = []
// 检查是否还有剩余次数卡
if (gamePassRemaining.value > 0) {
// 使用次数卡直接抽奖
useGamePassFlag.value = true
selectedCoupon.value = null
selectedCard.value = null
onMachineDraw(lastDrawCount.value)
} else {
// 次数卡已用完,打开支付弹窗
openPayment(lastDrawCount.value)
}
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDraw.value * times).toFixed(2)
const token = uni.getStorageSync('token')
// 使用统一的手机号绑定检查
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
// 并行获取道具卡和优惠券
Promise.all([fetchPropCards(), fetchCoupons()])
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null
useGamePassFlag.value = data?.useGamePass || false
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? '道具卡'
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? String(idx),
name: name,
count: 0
})
}
groupedMap.get(name).count++
})
propCards.value = Array.from(groupedMap.values())
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
list = list.filter(i => i.sub_status !== 'expired')
coupons.value = list.map((i, idx) => {
const amountCents = i.remaining ?? i.amount ?? i.value ?? 0
const amt = isNaN(amountCents) ? 0 : (Number(amountCents) / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: amt.toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
function extractResultList(resultRes) {
const root = resultRes?.data ?? resultRes?.result ?? resultRes
if (!root) return []
// Backend now returns results array with all draw logs including doubled
if (resultRes?.results && Array.isArray(resultRes.results) && resultRes.results.length > 0) {
return resultRes.results
}
return root.results || root.list || root.items || root.data || []
}
function mapResultsToFlipItems(resultRes, poolRewards) {
const list = extractResultList(resultRes)
const poolArr = Array.isArray(poolRewards) ? poolRewards : []
const lookup = new Map()
poolArr.forEach(it => {
const id = it?.id ?? it?.reward_id ?? it?.product_id
if (id !== undefined) lookup.set(Number(id), it)
})
return list.filter(Boolean).map(d => {
const rewardId = d.reward_id ?? d.rewardId ?? d.product_id ?? d.productId ?? d.id
const rewardName = String(d.reward_name ?? d.rewardName ?? d.product_name ?? d.productName ?? d.title ?? d.name ?? '')
const fromId = Number.isFinite(Number(rewardId)) ? lookup.get(Number(rewardId)) : null
const fromName = !fromId && rewardName ? poolArr.find(x => x?.title === rewardName) : null
const it = fromId || fromName || null
return {
reward_id: rewardId, // 添加reward_id用于正确分组
title: rewardName || it?.title || '奖励',
image: d.image || it?.image || d.img || d.pic || d.product_image || ''
}
})
}
async function onMachineDraw(count) {
const aid = activityId.value
const iid = currentIssueId.value
if (!aid || !iid) {
uni.showToast({ title: '期数未选择', icon: 'none' })
return
}
const token = uni.getStorageSync('token')
// 使用统一的手机号绑定检查
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0,
use_game_pass: useGamePassFlag.value
})
// 支付成功刷新次数卡
if (useGamePassFlag.value) {
fetchPasses()
}
const orderNo = joinRes?.order_no || joinRes?.data?.order_no || joinRes?.result?.order_no
if (!orderNo) throw new Error('未获取到订单号')
// Check if order is already paid (e.g. via Game Pass or Points)
const isPaid = (joinRes?.status === 2) || (joinRes?.actual_amount <= 0)
if (!isPaid) {
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
}
// 支付成功后立即显示开奖加载弹窗
drawTotal.value = times
drawProgress.value = 0
showDrawLoading.value = true
// 轮询等待开奖完成
let resultRes = await getLotteryResult(orderNo)
let pollCount = 0
const maxPolls = 15 // 最多轮询15次每次2秒共30秒
while (resultRes?.status === 'paid_waiting' &&
resultRes?.completed < resultRes?.count &&
pollCount < maxPolls) {
// 更新进度
drawProgress.value = resultRes?.completed || 0
await new Promise(r => setTimeout(r, resultRes?.nextPollMs || 2000))
resultRes = await getLotteryResult(orderNo)
pollCount++
}
// 隐藏加载弹窗
showDrawLoading.value = false
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
drawResults.value = items
// 记录最后一次抽奖是否使用了次数卡,用于"再来一次"按钮
lastDrawUsedGamePass.value = useGamePassFlag.value
lastDrawCount.value = times
showResultPopup.value = true
} catch (e) {
showDrawLoading.value = false
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
// ============ 生命周期 ============
onLoad(async (opts) => {
const id = opts?.id || ''
if (!id) return
activityId.value = id
// 并行获取活动详情和期数信息
await Promise.all([fetchDetail(), fetchIssues()])
setNavigationTitle('无限赏')
// 期数获取完成后获取奖励
await fetchRewardsForIssues(issues.value)
// 异步获取记录(不阻塞渲染)
if (currentIssueId.value) {
fetchWinRecords(id, currentIssueId.value)
}
if (uni.getStorageSync('token')) {
fetchPasses()
}
})
// 监听期切换,刷新记录
watch(currentIssueId, (newId) => {
if (newId && activityId.value) {
fetchWinRecords(activityId.value, newId)
}
})
</script>
<style lang="scss" scoped>
/* 底部多档位操作按钮 - 原始设计 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 32rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.08);
z-index: 999;
border-top: 1rpx solid rgba(255, 255, 255, 0.8);
}
.tier-btn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 10rpx;
background: #FFF;
border: 2rpx solid rgba($brand-primary, 0.1);
border-radius: 28rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
margin: 0;
line-height: normal;
&::after {
border: none;
}
&:active {
transform: scale(0.92);
background: #F9F9F9;
box-shadow: none;
}
}
.tier-price {
font-size: 34rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
letter-spacing: -1rpx;
}
.tier-label {
font-size: 22rpx;
color: $brand-primary;
margin-top: 6rpx;
font-weight: 800;
font-style: italic;
}
/* 热门/最高档位 - 高级动效 */
.tier-hot {
background: $gradient-brand !important;
border: none !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35) !important;
position: relative;
overflow: hidden;
.tier-price {
color: #FFF !important;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.tier-label {
color: rgba(255, 255, 255, 0.9) !important;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
/* 流光效果 */
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
to right,
transparent,
rgba(255, 255, 255, 0.25),
transparent
);
transform: rotate(30deg);
animation: shine 3s ease-in-out infinite;
}
&:active {
transform: scale(0.92);
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.25) !important;
}
}
@keyframes shine {
0% { left: -150%; }
50%, 100% { left: 150%; }
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.flip-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.flip-content {
position: relative;
width: 90%;
max-height: 85vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
padding: $spacing-lg;
overflow: hidden;
box-shadow: $shadow-card;
}
.overlay-close {
margin-top: $spacing-lg;
width: 100%;
background: $gradient-brand;
color: #fff;
border: none;
border-radius: $radius-lg;
font-size: $font-md;
font-weight: 600;
padding: $spacing-md;
&::after {
border: none;
}
}
/* 次数卡悬浮入口 */
.game-pass-float {
position: fixed;
right: 32rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
z-index: 990;
display: flex;
flex-direction: column;
align-items: center;
animation: float 3s ease-in-out infinite;
}
.badge-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10rpx);
border-radius: 30rpx;
padding: 8rpx 16rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15);
border: 1rpx solid rgba($brand-primary, 0.2);
}
.badge-icon { font-size: 28rpx; margin-right: 6rpx; }
.badge-text { font-size: 24rpx; font-weight: 800; color: $brand-primary; }
.badge-label {
font-size: 20rpx;
color: #fff;
background: $gradient-brand;
padding: 2rpx 8rpx;
border-radius: 8rpx;
margin-top: -6rpx;
z-index: 2;
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.2);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
</style>