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

745 lines
18 KiB
Vue

<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/icon/back.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,
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 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) {
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 || {}
if (!data.stages || data.stages.length === 0) {
await completeGuide('init', onboardingStages)
} else {
for (const stage of data.stages) {
if (stage.allTasksCompleted && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) {
if (!markGuideDone(taskKey)) {
console.log('[GuideModal] 从后端同步任务完成状态:', taskKey)
markGuideDone(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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', 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 {
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: 'ZaoZiGongFangJianHei-1', 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: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
</style>