style: 修改每日任务和引导任务的样式

This commit is contained in:
zerosaturation 2026-04-22 15:08:05 +08:00
parent a0dae45edb
commit dd838fccc4
5 changed files with 1229 additions and 678 deletions

View File

@ -82,9 +82,8 @@
<!-- 任务弹窗 --> <!-- 任务弹窗 -->
<DailyTasks :visible="showTaskModal" @close="showTaskModal = false" /> <DailyTasks :visible="showTaskModal" @close="showTaskModal = false" />
<!-- 新手引导列表弹窗 --> <!-- 新手引导弹窗 -->
<GuideListModal :visible="showGuideListModal" @start-guide="handleStartGuide" <GuideModal :visible="showGuideModal" @close="showGuideModal = false" @updated="handleGuideUpdated" />
@claim-success="handleClaimSuccess" @close="showGuideListModal = false" />
</view> </view>
</template> </template>
@ -93,7 +92,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Avatar from './Avatar.vue'; import Avatar from './Avatar.vue';
import DailyTasks from '@/pages/tasks/daily-tasks.vue'; import DailyTasks from '@/pages/tasks/daily-tasks.vue';
import GuideListModal from '@/components/GuideListModal.vue'; import GuideModal from '@/pages/tasks/GuideModal.vue';
import { getActivityListApi } from '@/utils/api.js'; import { getActivityListApi } from '@/utils/api.js';
import { reportEvent } from '@/utils/task-api.js'; import { reportEvent } from '@/utils/task-api.js';
@ -135,7 +134,7 @@ const avatarKey = ref(0); // 用于强制刷新Avatar组件
const showTaskModal = ref(false); const showTaskModal = ref(false);
// //
const showGuideListModal = ref(false); const showGuideModal = ref(false);
// //
const loadUserInfo = () => { const loadUserInfo = () => {
@ -308,9 +307,13 @@ const handleStarActivityClick = async () => {
// //
const handleGuideClick = () => { const handleGuideClick = () => {
uni.navigateTo({ showGuideModal.value = true;
url: '/pages/tasks/guide' };
});
//
const handleGuideUpdated = () => {
//
console.log('[Header] Guide updated');
}; };
// //

View File

@ -0,0 +1,744 @@
<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>

View File

@ -1,27 +1,37 @@
<template> <template>
<view v-if="visible" class="modal-wrapper" @touchmove.stop.prevent @click.stop> <view v-if="visible" class="modal-wrapper" @touchmove.stop.prevent="handlePreventMove" @click.stop>
<transition name="fade"> <transition name="fade">
<view v-if="visible" class="modal-mask" @touchstart.stop="handleMaskTouchStart" @click.stop> <view v-if="visible" class="modal-mask" @touchstart.stop="handleMaskTouchStart" @touchmove.stop.prevent="handlePreventMove" @click.stop>
</view> </view>
</transition> </transition>
<transition name="scale"> <transition name="scale">
<view v-if="visible" class="modal-container" @click.stop> <view v-if="visible" class="modal-container" @click.stop @touchmove.stop="handleContainerMove">
<!-- 背景图片 --> <!-- 背景图片 -->
<image class="modal-background" src="/static/background/friend-list-bg.png" mode="aspectFill"></image> <image class="modal-background" src="/static/nft/beijingban.png" mode="aspectFill"></image>
<!-- 蒙层 --> <!-- 蒙层 -->
<view class="modal-overlay"></view> <view class="modal-overlay"></view>
<!-- 关闭按钮 -->
<view class="close-button" @touchstart.stop="handleCloseTouchStart" @touchend.stop="handleCloseTouchEnd" @click="handleCloseClick">
<text class="close-icon">×</text>
</view>
<!-- 内容区域 --> <!-- 内容区域 -->
<view class="modal-content" @touchstart.stop @touchend.stop @click.stop> <view class="modal-content" @touchstart.stop @touchmove.stop @touchend.stop @click.stop>
<!-- 标题 --> <!-- 顶部区域返回按钮和Tab -->
<text class="modal-title">每日任务</text> <view class="top-bar">
<!-- 返回按钮 -->
<view class="back-button" @touchstart.stop="handleCloseTouchStart" @touchend.stop="handleCloseTouchEnd" @click="handleCloseClick">
<image class="back-icon" src="/static/icon/back.png" mode="aspectFit" />
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view class="tab-item active">
<text class="tab-text">每日任务</text>
</view>
<view class="tab-item">
<text class="tab-text">每周任务</text>
</view>
</view>
</view>
<!-- 加载状态 --> <!-- 加载状态 -->
<view v-if="loading" class="loading-state"> <view v-if="loading" class="loading-state">
@ -36,44 +46,58 @@
</view> </view>
<!-- 任务列表 --> <!-- 任务列表 -->
<view v-else class="task-list"> <scroll-view v-else class="task-list" scroll-y :show-scrollbar="false">
<!-- 空状态 --> <!-- 空状态 -->
<view v-if="tasks.length === 0" class="empty-state"> <view v-if="sortedTasks.length === 0" class="empty-state">
<text class="empty-text">暂无每日任务</text> <text class="empty-text">暂无每日任务</text>
</view> </view>
<!-- 任务项 --> <!-- 任务项 -->
<view v-for="task in tasks" :key="task.task_key" class="task-item"> <view v-for="task in sortedTasks" :key="task.task_key" class="task-item">
<view class="task-info"> <view class="task-info">
<text class="task-name">{{ task.name }}</text> <text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</text> <text class="task-progress" v-if="task.current_count !== undefined">{{ task.current_count }}/{{ task.target_count }}</text>
<view class="task-reward">
<text class="reward-icon">💎</text>
<text class="reward-value">+{{ task.crystal_reward }}</text>
<text class="reward-sep">|</text>
<text class="reward-icon"></text>
<text class="reward-value">+{{ task.exp_reward }}</text>
</view>
</view> </view>
<view class="task-action"> <view class="task-right">
<!-- 状态标签 --> <!-- 礼盒图标 - 点击展开/收起 -->
<view v-if="!task.can_claim" class="status-badge" :class="getStatusClass(task.status)"> <view class="reward-gift" @click.stop="toggleReward(task.task_key)">
<text class="status-text">{{ getStatusText(task.status) }}</text> <image
class="gift-icon"
:class="{ 'gift-open': expandedTaskKey === task.task_key }"
:src="expandedTaskKey === task.task_key ? '/static/nft/lihe_kaiqi.png' : '/static/nft/lihe.png'"
mode="aspectFit"
></image>
<!-- 奖励详情弹出 -->
<view v-if="expandedTaskKey === task.task_key" class="reward-popup">
<view class="reward-item">
<image class="reward-icon-img" src="/static/icon/crystal.png" mode="aspectFit"></image>
<text class="reward-value">{{ task.crystal_reward }}</text>
</view>
<view class="reward-item">
<image class="reward-icon-img" src="/static/nft/jingyanzhi.png" mode="aspectFit"></image>
<text class="reward-value">{{ task.exp_reward }}</text>
</view>
<view class="reward-item">
<image class="reward-icon-img" src="/static/nft/huoyuezhitubiao.png" mode="aspectFit"></image>
<text class="reward-value">{{ task.activity_reward || 10 }}</text>
</view>
</view>
</view> </view>
<!-- 状态按钮/标签 -->
<!-- 领取按钮 -->
<button <button
v-if="task.can_claim"
class="claim-btn" class="claim-btn"
:class="getStatusClass(task.status)"
:loading="claimingTask === task.task_key" :loading="claimingTask === task.task_key"
@click="handleClaim(task)" :disabled="!task.can_claim"
@click="task.can_claim && handleClaim(task)"
> >
领取 {{ task.can_claim ? '待领取' : getStatusText(task.status) }}
</button> </button>
</view> </view>
</view> </view>
</view> </scroll-view>
<!-- 底部进度区域和一键领取 --> <!-- 底部进度区域和一键领取 -->
<view v-if="!loading && !errorMessage" class="bottom-section"> <view v-if="!loading && !errorMessage" class="bottom-section">
@ -86,15 +110,33 @@
class="progress-node" class="progress-node"
:class="{ 'active': index < completedCount }" :class="{ 'active': index < completedCount }"
> >
<view class="node-circle"></view> <!-- 奖励图标 -->
<text class="node-label">{{ milestone }}</text> <image
class="milestone-icon"
:src="[20, 40, 80].includes(milestone.value) ? '/static/icon/crystal.png' : '/static/nft/lihe.png'"
mode="aspectFit"
></image>
<!-- 进度节点 -->
<image
class="node-circle"
:class="{ 'node-inactive': index >= completedCount }"
src="/static/nft/huoyuezhi_jiedian.png"
mode="aspectFit"
></image>
<text class="node-label">{{ milestone.value }}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 一键领取按钮 --> <!-- 一键领取按钮 -->
<view v-if="hasClaimableTasks" class="claim-all-bar"> <view class="claim-all-bar">
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll"> <button
class="claim-all-btn"
:class="{ 'disabled': !hasClaimableTasks }"
:loading="claimingAll"
:disabled="!hasClaimableTasks"
@click="handleClaimAll"
>
一键领取 ({{ claimableCount }}) 一键领取 ({{ claimableCount }})
</button> </button>
</view> </view>
@ -124,12 +166,43 @@ const tasks = ref([])
const claimingTask = ref('') const claimingTask = ref('')
const claimingAll = ref(false) const claimingAll = ref(false)
const starId = ref(1) const starId = ref(1)
const expandedTaskKey = ref('') //
// > >
const sortedTasks = computed(() => {
const tasksCopy = [...tasks.value]
return tasksCopy.sort((a, b) => {
// can_claim() > pending() > claimed()
const getPriority = (task) => {
if (task.can_claim) return 1
if (task.status === 'pending') return 2
if (task.status === 'claimed') return 3
return 4
}
return getPriority(a) - getPriority(b)
})
})
const hasClaimableTasks = computed(() => tasks.value.some(t => t.can_claim)) const hasClaimableTasks = computed(() => tasks.value.some(t => t.can_claim))
const claimableCount = computed(() => tasks.value.filter(t => t.can_claim).length) const claimableCount = computed(() => tasks.value.filter(t => t.can_claim).length)
// /
function toggleReward(taskKey) {
if (expandedTaskKey.value === taskKey) {
expandedTaskKey.value = ''
} else {
expandedTaskKey.value = taskKey
}
}
// //
const milestones = ref(['任务1', '任务2', '任务3', '任务4']) const milestones = ref([
{ value: 20 },
{ value: 40 },
{ value: 60 },
{ value: 80 },
{ value: 100 }
])
// //
const completedCount = computed(() => { const completedCount = computed(() => {
@ -137,10 +210,105 @@ const completedCount = computed(() => {
return tasks.value.filter(t => t.status === 'claimed').length return tasks.value.filter(t => t.status === 'claimed').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 // visible
watch(() => props.visible, (newVal) => { watch(() => props.visible, (newVal) => {
if (newVal) { if (newVal) {
loadTasks() loadTasks()
//
lockBodyScroll()
} else {
//
unlockBodyScroll()
} }
}, { immediate: true }) }, { immediate: true })
@ -155,9 +323,9 @@ function getStatusClass(status) {
function getStatusText(status) { function getStatusText(status) {
switch (status) { switch (status) {
case 'pending': return '进行中' case 'pending': return '前往'
case 'completed': return '可领取' case 'completed': return '可领取'
case 'claimed': return '已领取' case 'claimed': return '已完成'
default: return status default: return status
} }
} }
@ -214,13 +382,23 @@ async function handleClaimAll() {
} }
// //
let maskTouchStartTime = 0
const handleMaskTouchStart = (e) => { const handleMaskTouchStart = (e) => {
maskTouchStartTime = Date.now()
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
} }
//
const handlePreventMove = (e) => {
e.preventDefault()
e.stopPropagation()
return false
}
//
const handleContainerMove = (e) => {
e.stopPropagation()
}
// //
let closeTouchStartTime = 0 let closeTouchStartTime = 0
let closeTouchLocked = false let closeTouchLocked = false
@ -260,6 +438,9 @@ const handleCloseClick = (e) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
touch-action: none;
-webkit-overflow-scrolling: auto;
} }
.modal-mask { .modal-mask {
@ -270,15 +451,21 @@ const handleCloseClick = (e) => {
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
z-index: 1; z-index: 1;
touch-action: none;
-webkit-overflow-scrolling: auto;
overscroll-behavior: contain;
} }
.modal-container { .modal-container {
position: relative; position: relative;
width: 580rpx; width: 748rpx;
max-height: 85vh; max-height: 85vh;
border-radius: 40rpx; border-radius: 40rpx;
overflow: hidden; overflow: hidden;
z-index: 2; z-index: 2;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
.modal-background { .modal-background {
@ -302,7 +489,7 @@ const handleCloseClick = (e) => {
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1; z-index: 1;
background: rgba(0, 0, 0, 0.5); background: #00000021;
} }
.close-button { .close-button {
@ -327,21 +514,72 @@ const handleCloseClick = (e) => {
.modal-content { .modal-content {
position: relative; position: relative;
z-index: 2; z-index: 2;
padding: 60rpx 40rpx 40rpx; padding: 64rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 85vh; max-height: 85vh;
overflow-y: auto; overflow: hidden;
touch-action: pan-y;
} }
.modal-title { /* 顶部区域 */
font-size: 40rpx; .top-bar {
display: flex;
align-items: center;
margin-bottom: 30rpx;
position: relative;
}
/* 返回按钮 */
.back-button {
position: absolute;
left: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-icon {
width: 64rpx;
height: 64rpx;
}
/* Tab 样式 */
.tab-bar {
display: flex;
gap: 20rpx;
justify-content: center;
flex: 1;
}
.tab-item {
padding: 24rpx 32rpx;
border-radius: 50rpx;
background-image: url('/static/nft/dingbutubiao_an.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
border: none;
}
.tab-item.active {
background-image: url('/static/nft/dingbutubiao_liang.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
border: none;
box-shadow: 0 8rpx 20rpx rgba(240, 131, 153, 0.3);
}
.tab-text {
font-size: 24rpx;
font-weight: bold; font-weight: bold;
color: #e6e6e6; color: #fff;
text-align: center;
margin-bottom: 40rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
} }
.loading-state, .loading-state,
@ -400,74 +638,128 @@ const handleCloseClick = (e) => {
} }
.task-list { .task-list {
display: flex; max-height: 448rpx;
flex-direction: column;
gap: 20rpx;
margin-bottom: 30rpx; margin-bottom: 30rpx;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.task-list .task-item {
margin-bottom: 16rpx;
}
.task-list .task-item:last-child {
margin-bottom: 0;
} }
.task-item { .task-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20rpx 0; padding: 16rpx 30rpx;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1); 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%);
/* backdrop-filter: blur(10rpx); */
border-radius: 24rpx;
/* border: 2rpx solid rgba(255, 255, 255, 0.3); */
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
} }
.task-info { .task-info {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 8rpx; gap: 8rpx;
} }
.task-name { .task-name {
font-size: 30rpx; font-size: 28rpx;
font-weight: bold; font-weight: bold;
color: #e6e6e6; color: #fff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
} }
.task-desc { .task-progress {
font-size: 24rpx; font-size: 24rpx;
color: rgba(230, 230, 230, 0.7); color: rgba(255, 255, 255, 0.8);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
} }
.task-reward { .task-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8rpx; gap: 12rpx;
margin-top: 8rpx;
} }
.reward-icon { .reward-gift {
font-size: 24rpx; position: relative;
display: flex;
align-items: center;
cursor: pointer;
}
.gift-icon {
width: 50rpx;
height: 50rpx;
transition: transform 0.3s ease;
}
.gift-icon.gift-open {
transform: scale(1.1);
}
.reward-popup {
position: absolute;
right: 60rpx;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 12rpx;
padding: 12rpx 20rpx;
background: linear-gradient(135deg, rgba(255, 182, 193, 0.95) 0%, rgba(173, 216, 230, 0.95) 100%);
backdrop-filter: blur(10rpx);
border-radius: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
z-index: 10;
animation: popupShow 0.3s ease;
}
@keyframes popupShow {
from {
opacity: 0;
transform: translateY(-50%) scale(0.8);
}
to {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.reward-item {
display: flex;
align-items: center;
gap: 4rpx;
}
.reward-icon-img {
width: 32rpx;
height: 32rpx;
} }
.reward-value { .reward-value {
font-size: 26rpx; font-size: 22rpx;
color: #FFB800; color: #FFD700;
font-weight: bold;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
} min-width: 30rpx;
.reward-sep {
color: rgba(255, 255, 255, 0.3);
margin: 0 8rpx;
}
.task-action {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
} }
.status-badge { .status-badge {
min-width: 120rpx; min-width: 100rpx;
height: 50rpx; height: 50rpx;
border-radius: 25rpx; border-radius: 25rpx;
display: flex; display: flex;
@ -477,69 +769,106 @@ const handleCloseClick = (e) => {
} }
.status-badge.status-pending { .status-badge.status-pending {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.3);
border: 2rpx solid rgba(255, 255, 255, 0.4);
} }
.status-badge.status-completed { .status-badge.status-completed {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%); 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);
} }
.status-badge.status-claimed { .status-badge.status-claimed {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.3);
} }
.status-text { .status-text {
font-size: 24rpx; font-size: 24rpx;
color: #e6e6e6; color: #fff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
} }
.claim-btn { .claim-btn {
min-width: 140rpx; min-width: 120rpx;
width: 120rpx;
height: 50rpx; height: 50rpx;
border-radius: 25rpx; border-radius: 25rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%); /* 渐变:左浅橙粉 → 右柔粉红 */
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);
color: #fff; color: #fff;
font-size: 26rpx; font-size: 24rpx;
font-weight: bold;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
padding: 0;
} }
.claim-all-bar { .claim-btn.status-pending {
margin-top: 20rpx; background: rgba(255, 255, 255, 0.3);
border: 2rpx solid rgba(255, 255, 255, 0.4);
box-shadow: none;
color: #fff;
}
.claim-btn.status-claimed {
background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6);
box-shadow: none;
color: #fff;
} }
.bottom-section { .bottom-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20rpx; gap: 20rpx;
margin-top: 20rpx;
} }
.progress-section { .progress-section {
width: 100%; width: 100%;
padding: 30rpx 20rpx;
/* backdrop-filter: blur(10rpx); */
} }
.progress-bar { .progress-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-evenly;
align-items: center; align-items: flex-end;
position: relative; position: relative;
height: 60rpx; padding: 0 10rpx;
padding: 0 30rpx;
} }
.progress-bar::before { .progress-bar::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%; bottom: 40rpx;
left: 50rpx; left: 0;
right: 50rpx; right: 10%;
height: 4rpx; height: 32rpx;
background: rgba(255, 255, 255, 0.2); background-image: url('/static/nft/huoyuezhi_changtiao.png');
transform: translateY(-50%); background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
z-index: 0; z-index: 0;
} }
@ -549,40 +878,63 @@ const handleCloseClick = (e) => {
align-items: center; align-items: center;
position: relative; position: relative;
z-index: 1; z-index: 1;
gap: 8rpx;
/* flex: 1; */
}
.milestone-icon {
width: 60rpx;
height: 60rpx;
margin-bottom: 8rpx;
} }
.node-circle { .node-circle {
width: 24rpx; width: 50rpx;
height: 24rpx; height: 50rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: 4rpx solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.progress-node.active .node-circle { .node-circle.node-inactive {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%); filter: grayscale(100%) brightness(0.6);
border-color: #F08399; /* opacity: 0.5; */
}
.progress-node.active .milestone-icon {
filter: drop-shadow(0 4rpx 12rpx rgba(240, 131, 153, 0.6));
} }
.node-label { .node-label {
font-size: 22rpx; font-size: 22rpx;
color: rgba(230, 230, 230, 0.7); color: #fff;
margin-top: 8rpx; font-weight: bold;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.claim-all-bar {
margin-top: 10rpx;
} }
.claim-all-btn { .claim-all-btn {
width: 100%; width: 100%;
padding: 24rpx 0; padding: 24rpx 0;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%); background: linear-gradient(135deg, rgba(240, 228, 177, 0.8) 0%, rgba(240, 131, 153, 0.8) 50%, rgba(185, 78, 115, 0.8) 100%);
/* backdrop-filter: blur(10rpx); */
/* border: 2rpx solid rgba(255, 255, 255, 0.5); */
color: #fff; color: #fff;
border-radius: 50rpx; border-radius: 50rpx;
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: bold;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif; font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3); text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
box-shadow: 0 8rpx 20rpx rgba(240, 131, 153, 0.4);
}
.claim-all-btn.disabled {
background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6);
box-shadow: none;
color: #fff;
} }
/* 动画效果 */ /* 动画效果 */

View File

@ -1,540 +0,0 @@
<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 = () => {
//
const pages = getCurrentPages();
if (pages.length > 1) {
//
uni.navigateBack();
} else {
uni.removeStorageSync('is_new_user')
// square
uni.reLaunch({
url: '/pages/square/square'
});
}
}
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>

View File

@ -239,21 +239,13 @@ export function getSentFriendRequestsApi(page = 1, pageSize = 10) {
} }
// 发送好友请求(添加好友) // 发送好友请求(添加好友)
// friendUserId: 好友用户ID可选 export function sendFriendRequestApi(friendUserId) {
// nickname: 昵称(可选,与 friendUserId 二选一)
// searchMode: 是否为搜索模式true=仅搜索返回匹配用户false=正常发送请求)
export function sendFriendRequestApi(friendUserId = null, nickname = '', searchMode = true) {
const data = {}
if (searchMode && nickname) {
data.nickname = nickname
data.search_mode = true
} else if (friendUserId) {
data.friend_user_id = friendUserId
}
return request({ return request({
url: '/api/v1/social/friend-requests', url: '/api/v1/social/friend-requests',
method: 'POST', method: 'POST',
data data: {
friend_user_id: friendUserId
}
}) })
} }