425 lines
9.7 KiB
Vue
425 lines
9.7 KiB
Vue
<template>
|
||
<view class="guide-container">
|
||
<!-- 顶部导航 -->
|
||
<Header :show-back="true" back-icon-color="#e6e6e6" :show-guide-icon="false" :show-task-icon="false" :show-star-activity-icon="false" />
|
||
|
||
<!-- 页面标题 -->
|
||
<view class="page-title">新手引导</view>
|
||
|
||
<!-- 加载状态 -->
|
||
<view v-if="loading" class="loading-state">
|
||
<view class="loading-spinner"></view>
|
||
<text class="loading-text">加载中...</text>
|
||
</view>
|
||
|
||
<!-- 错误状态 -->
|
||
<view v-else-if="errorMessage" class="error-state">
|
||
<text class="error-text">{{ errorMessage }}</text>
|
||
<button class="retry-btn" @click="loadStatus">重试</button>
|
||
</view>
|
||
|
||
<!-- 引导内容 -->
|
||
<view v-else class="guide-content">
|
||
<!-- 当前阶段信息 -->
|
||
<view class="current-stage-card">
|
||
<view class="stage-header">
|
||
<text class="stage-label">当前阶段</text>
|
||
<text class="stage-number">第 {{ currentStage }} 阶段</text>
|
||
</view>
|
||
<view class="stage-name">{{ currentStageName }}</view>
|
||
<view class="stage-reward" v-if="currentStageReward > 0">
|
||
<text class="reward-icon">💎</text>
|
||
<text class="reward-value">{{ currentStageReward }}</text>
|
||
<text class="reward-sep">|</text>
|
||
<text class="reward-icon">⭐</text>
|
||
<text class="reward-value">{{ currentStageExp }} 经验</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 阶段列表 -->
|
||
<view class="stages-list">
|
||
<view v-for="stage in stages" :key="stage.stage" class="stage-item" :class="{ 'is-current': stage.is_current, 'is-completed': stage.status === 'completed' }">
|
||
<view class="stage-item-header">
|
||
<view class="stage-item-name">
|
||
<text v-if="stage.status === 'completed'" class="check-icon">✓</text>
|
||
<text>{{ stage.name }}</text>
|
||
</view>
|
||
<view v-if="stage.is_current" class="current-tag">进行中</view>
|
||
</view>
|
||
|
||
<!-- 该阶段的任务列表 -->
|
||
<view class="task-list-mini">
|
||
<view v-for="taskKey in stage.required_task_keys" :key="taskKey" class="task-key-item">
|
||
<text class="task-check" :class="{ 'is-done': isTaskCompleted(stage, taskKey) }">
|
||
{{ isTaskCompleted(stage, taskKey) ? '✓' : '○' }}
|
||
</text>
|
||
<text class="task-key-name">{{ taskKey }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 操作按钮 -->
|
||
<view class="action-bar">
|
||
<!-- 进入下一阶段按钮 -->
|
||
<button
|
||
v-if="canAdvance"
|
||
class="advance-btn"
|
||
:loading="advancing"
|
||
@click="handleAdvance"
|
||
>
|
||
进入下一阶段
|
||
</button>
|
||
<view v-else-if="!allCurrentTasksDone" class="advance-hint">
|
||
完成当前阶段所有任务后可进入下一阶段
|
||
</view>
|
||
|
||
<!-- 领取奖励按钮 -->
|
||
<button
|
||
v-if="canClaimReward"
|
||
class="claim-btn"
|
||
:loading="claimingReward"
|
||
@click="handleClaimReward"
|
||
>
|
||
领取奖励
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 下拉刷新 -->
|
||
<view v-if="!loading" class="pull-to-refresh" @click="handleRefresh">
|
||
<text class="refresh-text">{{ refreshing ? '刷新中...' : '下拉刷新' }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import Header from '../components/Header.vue'
|
||
import { getOnboardingStatus, advanceStage, claimOnboardingReward } from '@/utils/task-api.js'
|
||
|
||
const loading = ref(true)
|
||
const refreshing = ref(false)
|
||
const errorMessage = ref('')
|
||
const currentStage = ref(0)
|
||
const currentStatus = ref('')
|
||
const stages = ref([])
|
||
const advancing = ref(false)
|
||
const claimingReward = ref(false)
|
||
|
||
// 当前阶段配置
|
||
const currentStageConfig = computed(() => stages.value.find(s => s.is_current))
|
||
const currentStageName = computed(() => currentStageConfig.value?.name || '新手引导')
|
||
const currentStageReward = computed(() => currentStageConfig.value?.crystal_reward || 0)
|
||
const currentStageExp = computed(() => currentStageConfig.value?.exp_reward || 0)
|
||
|
||
// 当前阶段所有任务是否完成
|
||
const currentTasksDone = computed(() => {
|
||
if (!currentStageConfig.value) return false
|
||
return currentStageConfig.value.required_task_keys.every(key =>
|
||
isTaskCompleted(currentStageConfig.value, key)
|
||
)
|
||
})
|
||
|
||
const allCurrentTasksDone = computed(() => currentTasksDone.value)
|
||
const canAdvance = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
|
||
const canClaimReward = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
|
||
|
||
// 检查某个阶段的任务是否完成(简化:直接从 stages 状态判断)
|
||
function isTaskCompleted(stage, taskKey) {
|
||
// 这里需要根据实际数据结构判断
|
||
// 假设 completed 状态的任务 key 会被标记
|
||
// 暂时返回 false,实际需要后端返回更详细的状态
|
||
return false
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
|
||
const res = await getOnboardingStatus()
|
||
const data = res.data || {}
|
||
|
||
currentStage.value = data.current_stage || 0
|
||
currentStatus.value = data.status || 'pending'
|
||
stages.value = data.stages || []
|
||
} catch (err) {
|
||
console.error('loadStatus error:', err)
|
||
errorMessage.value = err.message || '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
refreshing.value = false
|
||
}
|
||
}
|
||
|
||
async function handleAdvance() {
|
||
if (advancing.value) return
|
||
try {
|
||
advancing.value = true
|
||
const nextStage = currentStage.value + 1
|
||
await advanceStage(nextStage)
|
||
await loadStatus()
|
||
uni.showToast({ title: '已进入下一阶段', icon: 'success' })
|
||
} catch (err) {
|
||
console.error('handleAdvance error:', err)
|
||
uni.showToast({ title: err.message || '进入下一阶段失败', icon: 'none' })
|
||
} finally {
|
||
advancing.value = false
|
||
}
|
||
}
|
||
|
||
async function handleClaimReward() {
|
||
if (claimingReward.value) return
|
||
try {
|
||
claimingReward.value = true
|
||
await claimOnboardingReward(currentStage.value)
|
||
await loadStatus()
|
||
uni.showToast({ title: '奖励领取成功', icon: 'success' })
|
||
} catch (err) {
|
||
console.error('handleClaimReward error:', err)
|
||
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||
} finally {
|
||
claimingReward.value = false
|
||
}
|
||
}
|
||
|
||
function handleRefresh() {
|
||
refreshing.value = true
|
||
loadStatus()
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadStatus()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.guide-container {
|
||
min-height: 100vh;
|
||
background-color: #f5f5f5;
|
||
padding-bottom: 120rpx;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
text-align: center;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.loading-state,
|
||
.error-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 100rpx 0;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
border: 4rpx solid #e0e0e0;
|
||
border-top-color: #6c5ce7;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.loading-text,
|
||
.error-text {
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.retry-btn {
|
||
margin-top: 20rpx;
|
||
padding: 16rpx 40rpx;
|
||
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||
color: #fff;
|
||
border-radius: 50rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.guide-content {
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.current-stage-card {
|
||
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||
border-radius: 20rpx;
|
||
padding: 40rpx;
|
||
margin-bottom: 30rpx;
|
||
color: #fff;
|
||
}
|
||
|
||
.stage-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.stage-label {
|
||
font-size: 28rpx;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.stage-number {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.stage-name {
|
||
font-size: 40rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.stage-reward {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.reward-icon {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.reward-value {
|
||
font-weight: bold;
|
||
}
|
||
|
||
.reward-sep {
|
||
opacity: 0.5;
|
||
margin: 0 10rpx;
|
||
}
|
||
|
||
.stages-list {
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.stage-item {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 30rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.stage-item.is-current {
|
||
border: 2rpx solid #6c5ce7;
|
||
}
|
||
|
||
.stage-item.is-completed {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.stage-item-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.stage-item-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.check-icon {
|
||
color: #52c41a;
|
||
font-size: 32rpx;
|
||
}
|
||
|
||
.current-tag {
|
||
padding: 6rpx 16rpx;
|
||
background: #6c5ce7;
|
||
color: #fff;
|
||
border-radius: 20rpx;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.task-list-mini {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.task-key-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 8rpx 16rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 20rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.task-check {
|
||
color: #ddd;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.task-check.is-done {
|
||
color: #52c41a;
|
||
}
|
||
|
||
.task-key-name {
|
||
color: #666;
|
||
}
|
||
|
||
.action-bar {
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.advance-btn {
|
||
width: 100%;
|
||
padding: 24rpx 0;
|
||
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||
color: #fff;
|
||
border-radius: 50rpx;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.advance-hint {
|
||
text-align: center;
|
||
color: #999;
|
||
font-size: 26rpx;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.claim-btn {
|
||
width: 100%;
|
||
padding: 24rpx 0;
|
||
background: linear-gradient(135deg, #ffd700, #ffb347);
|
||
color: #333;
|
||
border-radius: 50rpx;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.pull-to-refresh {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 30rpx 0;
|
||
}
|
||
|
||
.refresh-text {
|
||
color: #999;
|
||
font-size: 24rpx;
|
||
}
|
||
</style>
|