819 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 class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">任务中心</view>
<view class="page-subtitle">Task Center</view>
</view>
<!-- 进度统计卡片 - 毛玻璃风格 -->
<view class="progress-card glass-card">
<view class="progress-header">
<text class="progress-title">📊 我的任务进度</text>
</view>
<view class="progress-stats">
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.orderCount || 0 }}</text>
<text class="stat-label">累计订单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.inviteCount || 0 }}</text>
<text class="stat-label">邀请人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-value first-order-check" :class="{ done: userProgress.firstOrder }">
{{ userProgress.firstOrder ? '✓' : '—' }}
</view>
<text class="stat-label">首单完成</text>
</view>
</view>
</view>
<!-- 任务列表 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading && tasks.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="tasks.length === 0" class="empty-state">
<text class="empty-icon">📝</text>
<text class="empty-text">暂无可用任务</text>
<text class="empty-hint">敬请期待更多精彩活动</text>
</view>
<!-- 任务卡片列表 -->
<view v-else class="task-list">
<view
v-for="(task, index) in tasks"
:key="task.id"
class="task-card"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 任务头部 -->
<view class="task-header" @click="toggleTask(task.id)">
<view class="task-info">
<text class="task-icon">{{ getTaskIcon(task) }}</text>
<view class="task-meta">
<text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</text>
</view>
</view>
<view class="task-status-wrap">
<view class="task-status" :class="getTaskStatusClass(task)">
{{ getTaskStatusText(task) }}
</view>
<text class="expand-arrow" :class="{ expanded: expandedTasks[task.id] }"></text>
</view>
</view>
<!-- 档位列表 (可展开) -->
<view class="tier-list" v-if="expandedTasks[task.id] && task.tiers && task.tiers.length > 0">
<view
v-for="tier in task.tiers"
:key="tier.id"
class="tier-item"
:class="{ 'tier-claimed': isTierClaimed(task.id, tier.id), 'tier-claimable': isTierClaimable(task, tier) }"
>
<view class="tier-left">
<view class="tier-condition">
<text class="tier-badge">{{ getTierBadge(tier) }}</text>
<text class="tier-text">{{ getTierConditionText(tier) }}</text>
</view>
<view class="tier-reward">
<text class="reward-icon">🎁</text>
<text class="reward-text">{{ getTierRewardText(task, tier) }}</text>
</view>
</view>
<view class="tier-right">
<!-- 已领取 -->
<view v-if="isTierClaimed(task.id, tier.id)" class="tier-btn claimed">
<text>已领取</text>
</view>
<!-- 可领取 -->
<view v-else-if="isTierClaimable(task, tier)" class="tier-btn claimable" @click="claimReward(task, tier)">
<text>{{ claiming[`${task.id}_${tier.id}`] ? '领取中...' : '领取' }}</text>
</view>
<!-- 进度中 -->
<view v-else class="tier-progress">
<text class="progress-text">{{ getTierProgressText(task, tier) }}</text>
</view>
</view>
</view>
</view>
<!-- 无档位提示 -->
<view class="no-tier-hint" v-if="expandedTasks[task.id] && (!task.tiers || task.tiers.length === 0)">
<text>暂无可领取档位</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
const tasks = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const expandedTasks = reactive({})
const claiming = reactive({})
// 用户进度 (汇总)
const userProgress = reactive({
orderCount: 0,
orderAmount: 0,
inviteCount: 0,
firstOrder: false,
claimedTiers: {} // { taskId: [tierId1, tierId2] }
})
// 获取用户ID
function getUserId() {
return uni.getStorageSync('user_id')
}
// 检查登录状态
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
// 获取任务图标
function getTaskIcon(task) {
const name = (task.name || '').toLowerCase()
if (name.includes('首单') || name.includes('first')) return '🎁'
if (name.includes('订单') || name.includes('order')) return '📦'
if (name.includes('邀请') || name.includes('invite')) return '👥'
if (name.includes('签到') || name.includes('check')) return '📅'
if (name.includes('分享') || name.includes('share')) return '📣'
return '⭐'
}
// 获取任务状态类
function getTaskStatusClass(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return 'status-waiting'
// 检查是否全部完成
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return 'status-done'
// 检查是否有可领取的
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return 'status-claimable'
}
return 'status-progress'
}
// 获取任务状态文字
function getTaskStatusText(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return '暂无档位'
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return '已完成'
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return '可领取'
}
return '进行中'
}
// 展开/收起任务
function toggleTask(taskId) {
expandedTasks[taskId] = !expandedTasks[taskId]
}
// 获取档位徽章
function getTierBadge(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '首'
if (metric === 'order_count') return `${tier.threshold}`
if (metric === 'order_amount') return `¥${tier.threshold / 100}`
if (metric === 'invite_count') return `${tier.threshold}`
return tier.threshold || ''
}
// 获取档位条件文字
function getTierConditionText(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '完成首笔订单'
if (metric === 'order_count') return `累计下单 ${tier.threshold}`
if (metric === 'order_amount') return `累计消费 ¥${tier.threshold / 100}`
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
return `达成 ${tier.threshold}`
}
// 获取档位奖励文字
function getTierRewardText(task, tier) {
const rewards = (task.rewards || []).filter(r => r.tier_id === tier.id)
if (rewards.length === 0) return '神秘奖励'
const texts = rewards.map(r => {
const type = r.reward_type || ''
const payload = r.reward_payload || {}
const qty = r.quantity || 1
if (type === 'points') {
const value = payload.value || payload.amount || qty
return `${value}积分`
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `¥${value / 100}优惠券` : '优惠券'
}
if (type === 'item_card') {
const name = payload.name || '道具卡'
return qty > 1 ? `${name}×${qty}` : name
}
if (type === 'title') {
return payload.name || '专属称号'
}
return '奖励'
})
return texts.join(' + ')
}
// 是否已领取
function isTierClaimed(taskId, tierId) {
const claimed = userProgress.claimedTiers[taskId] || []
return claimed.includes(tierId)
}
// 是否可领取
function isTierClaimable(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
const operator = tier.operator || '>='
let current = 0
if (metric === 'first_order') {
return userProgress.firstOrder
} else if (metric === 'order_count') {
current = userProgress.orderCount || 0
} else if (metric === 'order_amount') {
current = userProgress.orderAmount || 0
} else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0
}
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
// 获取进度文字
function getTierProgressText(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
let current = 0
if (metric === 'first_order') {
return userProgress.firstOrder ? '已完成' : '未完成'
} else if (metric === 'order_count') {
current = userProgress.orderCount || 0
} else if (metric === 'order_amount') {
current = userProgress.orderAmount || 0
return `¥${current / 100}${threshold / 100}`
} else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0
}
return `${current}/${threshold}`
}
// 领取奖励
async function claimReward(task, tier) {
const key = `${task.id}_${tier.id}`
if (claiming[key]) return
vibrateShort()
claiming[key] = true
try {
const userId = getUserId()
await claimTaskReward(task.id, userId, tier.id)
// 更新本地状态
if (!userProgress.claimedTiers[task.id]) {
userProgress.claimedTiers[task.id] = []
}
userProgress.claimedTiers[task.id].push(tier.id)
uni.showToast({ title: '领取成功!', icon: 'success' })
} catch (e) {
console.error('领取失败:', e)
uni.showToast({ title: e.message || '领取失败', icon: 'none' })
} finally {
claiming[key] = false
}
}
// 下拉刷新
async function onRefresh() {
isRefreshing.value = true
await fetchData()
isRefreshing.value = false
}
// 获取数据
async function fetchData() {
if (!checkAuth()) return
loading.value = true
try {
const userId = getUserId()
// 获取任务列表
const res = await getTasks(1, 50)
const list = res.list || res.data || []
tasks.value = list
// 默认展开第一个任务
if (list.length > 0 && Object.keys(expandedTasks).length === 0) {
expandedTasks[list[0].id] = true
}
// 获取用户进度 (取第一个任务的进度作为汇总)
if (list.length > 0) {
try {
const progressRes = await getTaskProgress(list[0].id, userId)
userProgress.orderCount = progressRes.order_count || 0
userProgress.orderAmount = progressRes.order_amount || 0
userProgress.inviteCount = progressRes.invite_count || 0
userProgress.firstOrder = progressRes.first_order || false
// 初始化已领取的档位
if (progressRes.claimed_tiers) {
userProgress.claimedTiers[list[0].id] = progressRes.claimed_tiers
}
} catch (e) {
console.error('获取进度失败:', e)
}
// 并行获取其他任务的进度
const otherTasks = list.slice(1)
const progressPromises = otherTasks.map(t =>
getTaskProgress(t.id, userId).catch(() => null)
)
const progressResults = await Promise.allSettled(progressPromises)
progressResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const taskId = otherTasks[index].id
userProgress.claimedTiers[taskId] = result.value.claimed_tiers || []
}
})
}
} catch (e) {
console.error('获取任务失败:', e)
} finally {
loading.value = false
}
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* 进度统计卡片 */
.progress-card {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-lg;
padding: 30rpx;
}
.progress-header {
margin-bottom: 24rpx;
}
.progress-title {
font-size: 26rpx;
font-weight: 700;
color: $text-sub;
}
.progress-stats {
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
line-height: 1.2;
}
.stat-value.highlight {
color: $brand-primary;
}
.stat-value.first-order-check {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
color: $text-tertiary;
&.done {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
}
.stat-label {
font-size: 22rpx;
color: $text-tertiary;
margin-top: 8rpx;
font-weight: 500;
}
.stat-divider {
width: 1px;
height: 50rpx;
background: $border-color-light;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 400rpx);
padding: 0 $spacing-lg $spacing-lg;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 任务列表 */
.task-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 任务卡片 */
.task-card {
background: #fff;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-sm;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-header {
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
&:active {
background: rgba(0, 0, 0, 0.02);
}
}
.task-info {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.task-icon {
font-size: 40rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.task-meta {
flex: 1;
overflow: hidden;
}
.task-name {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
display: block;
margin-bottom: 4rpx;
}
.task-desc {
font-size: 24rpx;
color: $text-sub;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 16rpx;
}
.task-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 100rpx;
margin-right: 8rpx;
&.status-done {
background: rgba($uni-color-success, 0.1);
color: $uni-color-success;
}
&.status-claimable {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
font-weight: 700;
}
&.status-progress {
background: rgba($brand-primary, 0.05);
color: $text-sub;
}
&.status-waiting {
background: #f5f5f5;
color: $text-tertiary;
}
}
.expand-arrow {
font-size: 28rpx;
color: $text-tertiary;
transition: transform 0.3s;
&.expanded {
transform: rotate(90deg);
}
}
/* 档位列表 */
.tier-list {
border-top: 1rpx solid $border-color-light;
padding: 16rpx 24rpx 24rpx;
background: #fafafa;
}
.tier-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 20rpx;
background: #fff;
border-radius: $radius-md;
margin-bottom: 12rpx;
border: 1rpx solid $border-color-light;
&:last-child {
margin-bottom: 0;
}
&.tier-claimed {
background: #f5f5f5;
opacity: 0.7;
}
&.tier-claimable {
border-color: $brand-primary;
background: rgba($brand-primary, 0.02);
}
}
.tier-left {
flex: 1;
overflow: hidden;
}
.tier-condition {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.tier-badge {
background: $text-main;
color: #fff;
font-size: 18rpx;
padding: 4rpx 10rpx;
border-radius: 6rpx;
margin-right: 12rpx;
font-weight: 700;
}
.tier-text {
font-size: 26rpx;
color: $text-main;
font-weight: 500;
}
.tier-reward {
display: flex;
align-items: center;
}
.reward-icon {
font-size: 20rpx;
margin-right: 6rpx;
}
.reward-text {
font-size: 22rpx;
color: $brand-primary;
}
.tier-right {
flex-shrink: 0;
margin-left: 16rpx;
}
.tier-btn {
padding: 10rpx 24rpx;
border-radius: 100rpx;
font-size: 24rpx;
font-weight: 600;
&.claimed {
background: #eee;
color: $text-tertiary;
}
&.claimable {
background: $brand-primary;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.95);
}
}
}
.tier-progress {
padding: 10rpx 16rpx;
}
.progress-text {
font-size: 24rpx;
color: $text-sub;
font-family: 'DIN Alternate', sans-serif;
}
.no-tier-hint {
padding: 30rpx;
text-align: center;
color: $text-tertiary;
font-size: 24rpx;
background: #fafafa;
border-top: 1rpx solid $border-color-light;
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>