topfans/frontend/pages/tasks/GuideModal.vue
2026-05-15 21:39:21 +08:00

781 lines
19 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 v-if="visible" class="guide-modal-wrapper" @touchmove.stop.prevent="handlePreventMove">
<!-- 背景遮罩 -->
<view class="modal-mask" @click="handleClose"></view>
<!-- 弹窗容器 -->
<view class="modal-container" @click.stop>
<!-- 背景图片 -->
<image class="modal-background" src="/static/nft/beijingban.png" mode="aspectFill"></image>
<!-- 蒙层 -->
<view class="modal-overlay"></view>
<!-- 内容区域 -->
<view class="modal-content">
<!-- 顶部区域返回按钮和标题 -->
<view class="modal-header">
<!-- 返回按钮 -->
<view class="back-button" @click="handleClose">
<image class="back-icon" src="/static/starbookcontent/tuichu.png" mode="aspectFit" />
</view>
<!-- 标题 -->
<view class="modal-title">新手引导</view>
</view>
<!-- 引导列表 -->
<scroll-view class="modal-body" scroll-y :show-scrollbar="false">
<view
v-for="item in guideList"
:key="item.key"
class="guide-item"
:class="{ 'in-progress': item.status === 'in_progress' }"
>
<view class="guide-item-header">
<view class="guide-status-badge" :class="item.status">
{{ item.statusText }}
</view>
<text class="guide-name">{{ item.name }}</text>
</view>
<view class="guide-item-body">
<text class="guide-desc">{{ item.desc }}</text>
</view>
<!-- 进度条(进行中状态显示) -->
<view v-if="item.status === 'in_progress'" class="guide-progress">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: item.progress.percentage + '%' }"
></view>
</view>
<text class="progress-text">{{ item.progress.completed }}/{{ item.progress.total }} 步骤</text>
</view>
<view class="guide-item-footer">
<!-- 未开始 -->
<view
v-if="item.buttonType === 'start'"
class="guide-btn start-btn"
@click="handleStartGuide(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 进行中 -->
<view
v-else-if="item.buttonType === 'continue'"
class="guide-btn continue-btn"
@click="handleContinueGuide(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 已完成(可领取) -->
<view
v-else-if="item.buttonType === 'claim'"
class="guide-btn claim-btn"
@click="handleClaimReward(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 已领取 -->
<view
v-else
class="guide-btn disabled-btn"
>
{{ item.buttonText }}
</view>
</view>
</view>
</scroll-view>
<!-- 底部统计 -->
<view class="modal-footer">
<view class="footer-stats">
已完成 {{ doneCount }}/{{ totalCount }}
</view>
<view v-if="claimableCount > 0" class="footer-claim-tip">
{{ claimableCount }} 个奖励可领取
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getOnboardingStatus, claimOnboardingReward, completeGuide } from '@/utils/task-api.js'
import {
getGuideConfig,
getGuideStatusList,
getStepProgress,
hasGuideProgress,
getNextIncompleteStep,
getStepPage,
clearSubStepProgress,
resetGuide,
claimGuideReward,
markGuideDone,
isGuideDone,
markGuideRewardClaimed,
onboardingStages
} from '@/utils/guideConfig.js'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(true)
const guideList = ref([])
const backendStages = ref([])
// 统计数据
const totalCount = computed(() => guideList.value.length)
const doneCount = computed(() => guideList.value.filter(item =>
item.status === 'completed' || item.status === 'reward_claimed'
).length)
const claimableCount = computed(() => guideList.value.filter(item => item.status === 'completed').length)
// 保存滚动位置和页面状态
let scrollTop = 0
let bodyOverflow = ''
let pageScrollEnabled = true
// 锁定背景页面滚动
function lockBodyScroll() {
// 获取当前页面滚动位置
uni.createSelectorQuery().selectViewport().scrollOffset(res => {
if (res) {
scrollTop = res.scrollTop || 0
}
}).exec()
// #ifdef H5
// H5 平台:锁定 body 滚动
const body = document.body
bodyOverflow = body.style.overflow
body.style.overflow = 'hidden'
body.style.position = 'fixed'
body.style.top = `-${scrollTop}px`
body.style.width = '100%'
// #endif
// #ifdef MP
// 小程序平台:禁用页面滚动
uni.pageScrollTo({
scrollTop: scrollTop,
duration: 0
})
// #endif
// #ifdef APP-PLUS
// APP 平台:禁用页面滚动
const currentWebview = plus.webview.currentWebview()
if (currentWebview) {
pageScrollEnabled = currentWebview.isScrollEnabled()
currentWebview.setStyle({
scrollIndicator: 'none',
bounce: 'none'
})
// 尝试禁用滚动
try {
currentWebview.setStyle({ scrollEnabled: false })
} catch (e) {
console.log('APP 平台禁用滚动失败:', e)
}
}
// #endif
}
// 解锁背景页面滚动
function unlockBodyScroll() {
// #ifdef H5
// H5 平台:恢复 body 滚动
const body = document.body
body.style.overflow = bodyOverflow
body.style.position = ''
body.style.top = ''
body.style.width = ''
// 恢复滚动位置
window.scrollTo(0, scrollTop)
// #endif
// #ifdef MP
// 小程序平台:恢复页面滚动
uni.pageScrollTo({
scrollTop: scrollTop,
duration: 0
})
// #endif
// #ifdef APP-PLUS
// APP 平台:恢复页面滚动
const currentWebview = plus.webview.currentWebview()
if (currentWebview) {
currentWebview.setStyle({
scrollIndicator: 'auto',
bounce: 'vertical'
})
// 恢复滚动
try {
currentWebview.setStyle({ scrollEnabled: pageScrollEnabled })
} catch (e) {
console.log('APP 平台恢复滚动失败:', e)
}
}
// #endif
}
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
initBackend()
// 锁定背景页面滚动
lockBodyScroll()
} else {
// 解锁背景页面滚动
unlockBodyScroll()
}
}, { immediate: true })
// 刷新引导列表
function refreshList() {
const rawList = getGuideStatusList()
guideList.value = rawList.map(item => {
const progress = getStepProgress(item.key)
const status = calculateStatus(item.key, item.done, item.claimed)
return {
...item,
progress,
status,
statusText: getStatusText(status, progress),
buttonText: getButtonText(status),
buttonType: getButtonType(status)
}
})
}
function calculateStatus(key, done, claimed) {
// 优先使用后端状态判断
const backendStage = backendStages.value.find(s =>
s.required_task_keys && s.required_task_keys.includes(key)
)
if (backendStage) {
// 后端状态为 in_progress 表示已领取
if (backendStage.status === 'in_progress') {
return 'reward_claimed'
}
// 后端状态为 completed 表示任务完成但未领取(可领取按钮)
if (backendStage.status === 'completed' || backendStage.all_tasks_completed) {
return 'completed'
}
// 后端状态为 locked 表示未开始
if (backendStage.status === 'locked') {
return 'not_started'
}
}
// 回退到本地存储判断
if (claimed) return 'reward_claimed'
if (done && !claimed) return 'completed'
if (hasGuideProgress(key)) return 'in_progress'
return 'not_started'
}
function getStatusText(status, progress) {
if (status === 'in_progress') {
return `${progress.completed}/${progress.total} 步骤`
}
const map = {
not_started: '未开始',
completed: '已完成',
reward_claimed: '已领取'
}
return map[status] || '未开始'
}
function getButtonText(status) {
const map = {
not_started: '开始',
in_progress: '继续',
completed: '领取奖励',
reward_claimed: '已领取'
}
return map[status] || '开始'
}
function getButtonType(status) {
const map = {
not_started: 'start',
in_progress: 'continue',
completed: 'claim',
reward_claimed: 'claimed'
}
return map[status] || 'start'
}
// 开始引导
function handleStartGuide(key) {
console.log('[GuideModal] handleStartGuide:', key)
clearSubStepProgress(key)
resetGuide(key)
const targetPage = getStepPage(key, 0)
if (targetPage) {
emit('close')
uni.navigateTo({
url: targetPage + '?guide_key=' + key + '&guide_step=0'
})
}
}
// 继续引导
function handleContinueGuide(key) {
console.log('[GuideModal] handleContinueGuide:', key)
const resumeStep = getNextIncompleteStep(key)
const targetPage = getStepPage(key, resumeStep)
if (targetPage) {
emit('close')
uni.navigateTo({
url: targetPage + '?guide_key=' + key + '&guide_step=' + resumeStep
})
}
}
// 完成引导并同步后端
async function completeGuideAndSync(key) {
try {
markGuideDone(key)
await completeGuide(key, onboardingStages)
console.log('[GuideModal] completeGuideAndSync success:', key)
} catch (err) {
console.error('[GuideModal] completeGuideAndSync error:', err)
}
}
// 检查并同步所有完成的引导到后端
async function checkAndSyncCompletedGuides() {
const rawList = getGuideStatusList()
for (const item of rawList) {
if (item.done && !item.claimed) {
await completeGuideAndSync(item.key)
}
}
}
// 领取奖励
async function handleClaimReward(key) {
try {
const config = getGuideConfig(key)
if (!config) return
// 从 onboardingStages 找到对应 stage
let stageNum = -1
for (const s of onboardingStages) {
if (s.required_task_keys.includes(key)) {
stageNum = s.stage
break
}
}
if (stageNum >= 0) {
const res = await claimOnboardingReward(stageNum)
// 更新本地存储的余额并通知 Header 组件
if (res.data?.crystal_balance !== undefined) {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
const user = JSON.parse(userStr)
user.crystal_balance = parseInt(res.data.crystal_balance)
uni.setStorageSync('user', JSON.stringify(user))
}
} catch (e) {
console.error('更新本地存储失败:', e)
}
uni.$emit('balanceUpdated', { crystal_balance: res.data.crystal_balance, experience: res.data.experience })
}
}
// 本地标记已领取
claimGuideReward(key)
refreshList()
emit('updated')
} catch (err) {
console.error('handleClaimReward error:', err)
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
// 初始化时同步后端
async function initBackend() {
try {
loading.value = true
const res = await getOnboardingStatus()
const data = res.data || {}
// 保存后端stages数据
backendStages.value = data.stages || []
// 从后端同步已完成状态到本地
if (data.stages && data.stages.length > 0) {
for (const stage of data.stages) {
// all_tasks_completed 是后端返回的字段名(下划线格式)
// 同时检查 status === 'completed' 作为备用判断
if ((stage.all_tasks_completed || stage.status === 'completed') && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) {
// 无论本地是否已标记,都同步后端状态
if (!isGuideDone(taskKey)) {
console.log('[GuideModal] 从后端同步任务完成状态:', taskKey)
markGuideDone(taskKey)
}
}
}
// status === 'in_progress' 表示已领取奖励
if (stage.status === 'in_progress' && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) {
markGuideRewardClaimed(taskKey)
}
}
}
}
refreshList()
await checkAndSyncCompletedGuides()
} catch (err) {
console.error('initBackend error:', err)
} finally {
loading.value = false
}
}
// 阻止移动事件
const handlePreventMove = (e) => {
e.preventDefault()
e.stopPropagation()
return false
}
// 关闭弹窗
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
.guide-modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
-webkit-overflow-scrolling: auto;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1;
touch-action: none;
-webkit-overflow-scrolling: auto;
overscroll-behavior: contain;
}
.modal-container {
position: relative;
width: 748rpx;
min-height: 66vh;
border-radius: 40rpx;
overflow: hidden;
z-index: 2;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.modal-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 1;
background: #00000021;
}
.modal-content {
position: relative;
z-index: 2;
padding: 64rpx;
display: flex;
flex-direction: column;
max-height: 85vh;
overflow: hidden;
touch-action: pan-y;
}
/* 顶部区域 */
.modal-header {
display: flex;
align-items: center;
margin-bottom: 30rpx;
position: relative;
}
/* 返回按钮 */
.back-button {
position: absolute;
left: 0;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-icon {
width: 40rpx;
height: 40rpx;
}
/* 标题 */
.modal-title {
flex: 1;
font-size: 36rpx;
font-weight: bold;
color: #fff;
text-align: center;
font-family: 'yt', sans-serif;
text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.3);
}
/* 列表区域 */
.modal-body {
flex: 1;
max-height: 800rpx;
overflow-y: auto;
margin-bottom: 20rpx;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.guide-item {
background: linear-gradient(45deg, rgba(255, 136, 109, 0.6) 0%, rgba(202, 88, 180, 0.6) 33%, rgba(235, 230, 178, 0.6) 100%);
border-radius: 24rpx;
padding: 16rpx 30rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.guide-item:last-child {
margin-bottom: 0;
}
.guide-item.in-progress {
border-left: 4rpx solid #4a90e2;
}
.guide-item-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.guide-status-badge {
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
margin-right: 12rpx;
font-family: 'yt', sans-serif;
}
.guide-status-badge.not_started {
background: rgba(255, 255, 255, 0.3);
border: 2rpx solid rgba(255, 255, 255, 0.4);
color: #fff;
}
.guide-status-badge.in_progress {
background: rgba(74, 144, 226, 0.6);
border: 2rpx solid rgba(255, 255, 255, 0.5);
color: #fff;
}
.guide-status-badge.completed {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.6) 0%, rgba(60, 179, 113, 0.6) 100%);
border: 2rpx solid rgba(255, 255, 255, 0.5);
color: #fff;
}
.guide-status-badge.reward_claimed {
background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6);
color: #fff;
}
.guide-name {
font-size: 28rpx;
font-weight: bold;
color: #fff;
flex: 1;
font-family: 'yt', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.guide-item-body {
margin-bottom: 16rpx;
}
.guide-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
font-family: 'yt', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.guide-progress {
margin-bottom: 16rpx;
}
.progress-bar {
height: 8rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 8rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.progress-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
font-family: 'yt', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.guide-item-footer {
display: flex;
justify-content: flex-end;
}
.guide-btn {
min-width: 120rpx;
width: 120rpx;
height: 50rpx;
padding: 0;
color: #fff;
border-radius: 25rpx;
font-size: 24rpx;
font-weight: bold;
font-family: 'yt', sans-serif;
display: flex;
align-items: center;
justify-content: center;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.start-btn {
background: linear-gradient(to bottom right, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid rgba(255, 255, 255, 0.5);
}
.continue-btn {
background: linear-gradient(to bottom right, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid rgba(255, 255, 255, 0.5);
}
.claim-btn {
background: linear-gradient(to bottom right, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid rgba(255, 255, 255, 0.5);
}
.disabled-btn {
min-width: 120rpx;
width: 120rpx;
height: 50rpx;
background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6);
box-shadow: none;
color: #fff;
}
/* 底部统计 */
.modal-footer {
padding: 24rpx 0 0;
border-top: 2rpx solid rgba(255, 255, 255, 0.3);
}
.footer-stats {
font-size: 28rpx;
color: #fff;
text-align: center;
margin-bottom: 8rpx;
font-weight: bold;
font-family: 'yt', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.footer-claim-tip {
font-size: 26rpx;
color: #FFD700;
text-align: center;
font-family: 'yt', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
</style>