530 lines
12 KiB
Vue
530 lines
12 KiB
Vue
<template>
|
||
<view class="guide-container">
|
||
<!-- 背景图片 -->
|
||
<image class="background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
|
||
|
||
<!-- 左上角返回按钮 -->
|
||
<view class="back-button" :style="{ top: backButtonTop }" @click="handleBack">
|
||
<image class="back-icon" src="/static/icon/back.png" mode="aspectFit" />
|
||
</view>
|
||
|
||
<!-- 页面标题 -->
|
||
<view class="page-title">新手引导</view>
|
||
|
||
<!-- 引导列表 -->
|
||
<view class="guide-list">
|
||
<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>
|
||
</view>
|
||
|
||
<!-- 底部统计 -->
|
||
<view class="guide-footer">
|
||
<view class="footer-stats">
|
||
已完成 {{ doneCount }}/{{ totalCount }}
|
||
</view>
|
||
<view v-if="claimableCount > 0" class="footer-claim-tip">
|
||
有 {{ claimableCount }} 个奖励可领取
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { getOnboardingStatus, advanceStage, claimOnboardingReward, completeGuide } from '@/utils/task-api.js'
|
||
import {
|
||
guideConfig,
|
||
getGuideConfig,
|
||
getGuideStatusList,
|
||
getStepProgress,
|
||
hasGuideProgress,
|
||
getNextIncompleteStep,
|
||
getStepPage,
|
||
clearSubStepProgress,
|
||
resetGuide,
|
||
claimGuideReward,
|
||
markGuideDone,
|
||
isGuideDone,
|
||
isGuideRewardClaimed,
|
||
onboardingStages
|
||
} from '@/utils/guideConfig.js'
|
||
|
||
const isAndroid = ref(false)
|
||
|
||
const backButtonTop = computed(() => {
|
||
if (isAndroid.value) {
|
||
return 'calc(env(safe-area-inset-top) + 80rpx)'
|
||
}
|
||
return 'calc(env(safe-area-inset-top) + 32rpx)'
|
||
})
|
||
|
||
const handleBack = () => {
|
||
uni.navigateBack()
|
||
}
|
||
|
||
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)
|
||
|
||
// 刷新引导列表
|
||
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('[guide] handleStartGuide:', key)
|
||
// 清除进度从头开始
|
||
clearSubStepProgress(key)
|
||
// 重置引导"已显示"状态,允许重新开始
|
||
resetGuide(key)
|
||
const targetPage = getStepPage(key, 0)
|
||
|
||
if (targetPage) {
|
||
uni.navigateTo({
|
||
url: targetPage + '?guide_key=' + key + '&guide_step=0'
|
||
})
|
||
}
|
||
}
|
||
|
||
// 继续引导
|
||
function handleContinueGuide(key) {
|
||
console.log('[guide] handleContinueGuide:', key)
|
||
// 从第一个未完成步骤继续
|
||
const resumeStep = getNextIncompleteStep(key)
|
||
const targetPage = getStepPage(key, resumeStep)
|
||
console.log('[guide] handleContinueGuide, resumeStep:', resumeStep, 'targetPage:', targetPage)
|
||
|
||
if (targetPage) {
|
||
uni.navigateTo({
|
||
url: targetPage + '?guide_key=' + key + '&guide_step=' + resumeStep
|
||
})
|
||
}
|
||
}
|
||
|
||
// 完成引导并同步后端
|
||
async function completeGuideAndSync(key) {
|
||
try {
|
||
// 标记本地已完成
|
||
markGuideDone(key)
|
||
// 同步到后端
|
||
await completeGuide(key, onboardingStages)
|
||
console.log('[guide] completeGuideAndSync success:', key)
|
||
} catch (err) {
|
||
console.error('[guide] 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 {
|
||
// 找到对应的 stage
|
||
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)
|
||
|
||
// uni.showToast({
|
||
// title: `领取成功!${config.reward?.exp || 0}经验 ${config.reward?.diamond || 0}钻`,
|
||
// icon: 'none'
|
||
// })
|
||
|
||
refreshList()
|
||
} 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 {
|
||
// 后端有配置,根据后端的 stage 状态更新本地存储
|
||
// 遍历后端返回的 stages,根据 allTasksCompleted 状态更新本地
|
||
for (const stage of data.stages) {
|
||
if (stage.allTasksCompleted && stage.required_task_keys) {
|
||
for (const taskKey of stage.required_task_keys) {
|
||
// 如果后端显示该任务已完成,标记本地为已完成
|
||
if (!isGuideDone(taskKey)) {
|
||
console.log('[Guide] 从后端同步任务完成状态:', taskKey)
|
||
markGuideDone(taskKey)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 刷新列表
|
||
refreshList()
|
||
} catch (err) {
|
||
console.error('initBackend error:', err)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
isAndroid.value = systemInfo.platform === 'android'
|
||
initBackend().then(() => {
|
||
// 初始化后检查并同步已完成的引导
|
||
checkAndSyncCompletedGuides()
|
||
refreshList()
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.guide-container {
|
||
position: relative;
|
||
min-height: 100vh;
|
||
padding: 20rpx;
|
||
padding-bottom: 200rpx;
|
||
}
|
||
|
||
.background-image {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 0;
|
||
}
|
||
|
||
.back-button {
|
||
position: fixed;
|
||
left: 32rpx;
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
border-radius: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 101;
|
||
}
|
||
|
||
.back-icon {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
}
|
||
|
||
.page-title {
|
||
position: relative;
|
||
z-index: 1;
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
text-align: center;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.guide-list {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.guide-item {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.guide-status-badge.not_started {
|
||
background: #f0f0f0;
|
||
color: #999;
|
||
}
|
||
|
||
.guide-status-badge.in_progress {
|
||
background: #e6f0ff;
|
||
color: #4a90e2;
|
||
}
|
||
|
||
.guide-status-badge.completed {
|
||
background: #fff3e6;
|
||
color: #ff9500;
|
||
}
|
||
|
||
.guide-status-badge.reward_claimed {
|
||
background: #e8f5e9;
|
||
color: #4caf50;
|
||
}
|
||
|
||
.guide-name {
|
||
font-size: 30rpx;
|
||
font-weight: 500;
|
||
color: #333;
|
||
flex: 1;
|
||
}
|
||
|
||
.guide-desc {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.guide-progress {
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 8rpx;
|
||
background: #eee;
|
||
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: #999;
|
||
}
|
||
|
||
.guide-item-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.guide-btn {
|
||
padding: 12rpx 32rpx;
|
||
color: #fff;
|
||
border-radius: 30rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.start-btn {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
}
|
||
|
||
.continue-btn {
|
||
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
|
||
}
|
||
|
||
.claim-btn {
|
||
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
|
||
}
|
||
|
||
.disabled-btn {
|
||
background: #ddd;
|
||
color: #999;
|
||
}
|
||
|
||
.guide-footer {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 24rpx 32rpx;
|
||
background: #fff;
|
||
border-top: 1rpx solid #eee;
|
||
z-index: 10;
|
||
}
|
||
|
||
.footer-stats {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
text-align: center;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.footer-claim-tip {
|
||
font-size: 26rpx;
|
||
color: #ff6b6b;
|
||
text-align: center;
|
||
}
|
||
</style>
|