2026-03-20 00:57:42 +08:00

1363 lines
37 KiB
Vue
Executable File
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 class="task-time-row" v-if="getTaskTimeRangeText(task) || getTaskCountdownText(task, nowMs)">
<text class="task-time-range" v-if="getTaskTimeRangeText(task)">{{ getTaskTimeRangeText(task) }}</text>
<text class="task-time-countdown" :class="{ expired: getTaskCountdownText(task, nowMs) === '已截止' }" v-if="getTaskCountdownText(task, nowMs)">
{{ getTaskCountdownText(task, nowMs) }}
</text>
</view>
<text class="task-rule-tip" v-if="getTaskRuleTip(task)">{{ getTaskRuleTip(task) }}</text>
<!-- 新增独立进度展示 (当存在 sub_progress 时显示) -->
<view class="sub-progress-list" v-if="taskProgress[task.id]?.subProgress?.length > 0">
<view
v-for="sub in taskProgress[task.id].subProgress"
:key="sub.activity_id ?? sub.label"
class="sub-progress-item"
>
<text class="sub-label">{{ getSubProgressLabel(sub) }}</text>
<view class="sub-bar-bg">
<view class="sub-bar-fill" :style="{ width: getSubProgressWidth(sub, task) }"></view>
</view>
<text class="sub-value">{{ getSubProgressValue(sub, task) }}</text>
</view>
</view>
</view>
</view>
<view class="task-status-wrap">
<view v-if="task.quota > 0 && task.quota <= task.claimed_count" class="task-quota-tag exhausted">
已领完
</view>
<view v-else-if="task.quota > 0" class="task-quota-tag">
剩余 {{ task.quota - task.claimed_count }} 份
</view>
<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="task.quota > 0 && task.quota <= task.claimed_count" class="tier-btn disabled">
<text>已领完</text>
</view>
<!-- 已领完 (档位级限量逻辑) -->
<view v-else-if="tier.quota > 0 && tier.remaining === 0" class="tier-btn disabled">
<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>
<!-- 普通档位进度条(非 first_order 类型才显示) -->
<view class="tier-bar-bg" v-if="getTierProgressPercent(task, tier) !== ''">
<view class="tier-bar-fill" :style="{ width: getTierProgressPercent(task, tier) }"></view>
</view>
<!-- 限量进度提示 -->
<text class="quota-text" v-if="tier.quota > 0">剩余 {{ tier.remaining }} 份</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 } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
import { parseTimeMs } from '@/utils/format.js'
const tasks = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const expandedTasks = reactive({})
const claiming = reactive({})
const nowMs = ref(Date.now())
let countdownTimer = null
// 用户进度 (汇总 - 用于顶部统计卡片显示)
const userProgress = reactive({
orderCount: 0,
orderAmount: 0,
inviteCount: 0,
firstOrder: false,
claimedTiers: {} // { taskId: [tierId1, tierId2] }
})
// BUG修复每个任务独立存储进度数据
const taskProgress = reactive({}) // { taskId: { orderCount, orderAmount, inviteCount, firstOrder, subProgress } }
// 获取用户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 parseTaskTimeMs(value) {
const ms = parseTimeMs(value)
if (!ms || ms <= 0) return null
return ms
}
function formatMonthDay(timestampMs) {
if (!timestampMs) return ''
const date = new Date(timestampMs)
if (Number.isNaN(date.getTime())) return ''
return `${date.getMonth() + 1}.${date.getDate()}`
}
function getTaskTimeRangeText(task) {
const startMs = parseTaskTimeMs(task?.start_time)
const endMs = parseTaskTimeMs(task?.end_time)
if (startMs && endMs) return `活动时间 ${formatMonthDay(startMs)}-${formatMonthDay(endMs)}`
if (startMs) return `活动开始 ${formatMonthDay(startMs)}`
if (endMs) return `活动截止 ${formatMonthDay(endMs)}`
return ''
}
function formatCountdown(diffMs) {
const totalMinutes = Math.ceil(diffMs / (60 * 1000))
if (totalMinutes <= 0) return '0分钟'
const day = Math.floor(totalMinutes / (24 * 60))
const hour = Math.floor((totalMinutes % (24 * 60)) / 60)
const minute = totalMinutes % 60
if (day > 0) {
if (hour > 0) return `${day}天${hour}小时`
return `${day}天`
}
if (hour > 0) {
if (minute > 0) return `${hour}小时${minute}分钟`
return `${hour}小时`
}
return `${minute}分钟`
}
function getTaskCountdownText(task, currentMs = Date.now()) {
const endMs = parseTaskTimeMs(task?.end_time)
if (!endMs) return ''
const diffMs = endMs - currentMs
if (diffMs <= 0) return '已截止'
return `距截止 ${formatCountdown(diffMs)}`
}
function hasDailyWindow(task) {
const tiers = task?.tiers
if (!Array.isArray(tiers) || tiers.length === 0) return false
return tiers.some(tier => String(tier?.window || '').toLowerCase() === 'daily')
}
function getTaskRuleTip(task) {
if (hasDailyWindow(task)) return '按当日消费统计,次日重置'
return ''
}
function startCountdownTimer() {
stopCountdownTimer()
countdownTimer = setInterval(() => {
nowMs.value = Date.now()
}, 60 * 1000)
}
function stopCountdownTimer() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function buildWindowedSubProgress(task, progressData) {
const tierList = Array.isArray(task?.tiers) ? task.tiers : []
const tierProgressList = Array.isArray(progressData?.tier_progress) ? progressData.tier_progress : []
if (tierList.length === 0 || tierProgressList.length === 0) return []
const metricWhitelist = new Set(['order_amount', 'order_count'])
const relevantTiers = tierList.filter(tier => metricWhitelist.has(tier?.metric))
if (relevantTiers.length === 0) return []
const tierProgressMap = new Map(
tierProgressList.map(tp => [Number(tp?.tier_id || 0), tp])
)
const activityOrder = []
const activitySeen = new Set()
const activityStatsMap = new Map()
relevantTiers.forEach(tier => {
const activityId = Number(tier?.activity_id || 0)
if (!activitySeen.has(activityId)) {
activitySeen.add(activityId)
activityOrder.push(activityId)
}
const current = activityStatsMap.get(activityId) || {
activity_id: activityId,
order_amount: 0,
order_count: 0
}
const tp = tierProgressMap.get(Number(tier?.id || 0))
if (tp) {
if (tier.metric === 'order_amount') {
current.order_amount = Math.max(current.order_amount, Number(tp.order_amount || 0))
} else if (tier.metric === 'order_count') {
current.order_count = Math.max(current.order_count, Number(tp.order_count || 0))
}
}
activityStatsMap.set(activityId, current)
})
return activityOrder.map(activityId => activityStatsMap.get(activityId)).filter(Boolean)
}
function normalizeSubProgress(task, progressData) {
const windowedList = buildWindowedSubProgress(task, progressData)
const fallbackList = Array.isArray(progressData?.sub_progress)
? progressData.sub_progress.map(item => ({
activity_id: item.activity_id,
order_amount: item.order_amount || 0,
order_count: item.order_count || 0
}))
: []
const rawList = windowedList.length > 0 ? [...windowedList] : fallbackList
const hasGlobalTier = (task?.tiers || []).some(
t => (t.activity_id || 0) === 0 && (t.metric === 'order_amount' || t.metric === 'order_count')
)
const hasGlobalEntry = rawList.some(item => !item.activity_id)
if (hasGlobalTier && !hasGlobalEntry) {
rawList.unshift({
activity_id: 0,
order_amount: progressData?.order_amount || 0,
order_count: progressData?.order_count || 0,
label: '累计进度'
})
}
return rawList
}
function getSubProgressLabel(sub) {
if (!sub) return ''
if (sub.label) return sub.label
if (sub.activity_id > 0) return `活动 ${sub.activity_id}`
return '整体进度'
}
function getSubProgressValue(sub, task) {
if (!sub) return ''
const activityId = sub.activity_id || 0
if (hasTierMetric(task, activityId, 'order_amount')) {
return `¥${formatAmount(sub.order_amount || 0)}`
}
if (hasTierMetric(task, activityId, 'order_count')) {
return `${sub.order_count || 0}单`
}
if ((sub.order_amount || 0) > 0) {
return `¥${formatAmount(sub.order_amount)}`
}
return `${sub.order_count || 0}单`
}
// 获取任务状态类
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 (task.quota > 0 && task.quota <= task.claimed_count) 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 (task.quota > 0 && task.quota <= task.claimed_count) 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 name = r.reward_name || ''
const payload = r.reward_payload || {}
const qty = r.quantity || 1
// 优先使用后端返回的 reward_name
if (name) {
if (type === 'points') {
const points = payload.points || qty
return points > 1 ? `${points}${name}` : name
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `${name}(¥${value / 100})` : name
}
return qty > 1 ? `${name}×${qty}` : name
}
// 回退:从 payload 解析
if (type === 'points') {
const value = payload.points || payload.value || payload.amount || qty
return `${value}积分`
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `¥${value / 100}优惠券` : '优惠券'
}
if (type === 'item_card') {
return payload.name || '道具卡'
}
if (type === 'title') {
return payload.name || '专属称号'
}
if (type === 'game_ticket') {
return payload.game_code ? `${payload.amount || 1}张抽奖券` : '抽奖券'
}
return '奖励'
})
return texts.join(' + ')
}
// 是否已领取
function isTierClaimed(taskId, tierId) {
const claimed = userProgress.claimedTiers[taskId] || []
return claimed.includes(tierId)
}
// 是否可领取 - 优先使用 tier 级别窗口化进度(与后端 ClaimTier 一致)
function isTierClaimable(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
const operator = tier.operator || '>='
const progress = taskProgress[task.id] || {}
// 优先使用 tier 级别窗口化进度(与后端 ClaimTier 使用同一数据源)
if (progress.tierProgress && progress.tierProgress.length > 0) {
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
if (tp) {
let current = 0
if (metric === 'first_order') return tp.first_order || false
else if (metric === 'order_count') current = tp.order_count || 0
else if (metric === 'order_amount') current = tp.order_amount || 0
else if (metric === 'invite_count') current = tp.invite_count || 0
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
}
// 回退:使用 subProgress 或全局进度
if (tier.activity_id > 0) {
if (progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
if (sub) {
if (metric === 'order_amount') {
const current = sub.order_amount || 0
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
} else if (metric === 'order_count') {
const current = sub.order_count || 0
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
} else {
return false
}
}
}
let current = 0
if (metric === 'first_order') {
return progress.firstOrder || false
} else if (metric === 'order_count') {
current = progress.orderCount || 0
} else if (metric === 'order_amount') {
current = progress.orderAmount || 0
} else if (metric === 'invite_count') {
current = progress.inviteCount || 0
}
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
// 获取进度文字 - 优先使用 tier 级别窗口化进度
function getTierProgressText(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
const progress = taskProgress[task.id] || {}
// 优先使用 tier 级别窗口化进度
if (progress.tierProgress && progress.tierProgress.length > 0) {
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
if (tp) {
if (metric === 'first_order') return tp.first_order ? '已完成' : '未完成'
if (metric === 'order_amount') return `¥${(tp.order_amount || 0) / 100}/¥${threshold / 100}`
if (metric === 'order_count') return `${tp.order_count || 0}/${threshold}`
if (metric === 'invite_count') return `${tp.invite_count || 0}/${threshold}`
}
}
// 回退:活动独立进度
if (tier.activity_id > 0 && progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
if (sub) {
if (metric === 'order_amount') return `¥${(sub.order_amount || 0) / 100}/¥${threshold / 100}`
if (metric === 'order_count') return `${sub.order_count || 0}/${threshold}`
} else {
if (metric === 'order_amount') return `¥0/¥${threshold / 100}`
return `0/${threshold}`
}
}
// 回退:任务总进度
let current = 0
if (metric === 'first_order') {
return progress.firstOrder ? '已完成' : '未完成'
} else if (metric === 'order_count') {
current = progress.orderCount || 0
} else if (metric === 'order_amount') {
current = progress.orderAmount || 0
return `¥${current / 100}/¥${threshold / 100}`
} else if (metric === 'invite_count') {
current = progress.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) {
// 初始化汇总数据
userProgress.orderCount = 0
userProgress.orderAmount = 0
userProgress.inviteCount = 0
userProgress.firstOrder = false
userProgress.claimedTiers = {}
// 并行获取所有任务的进度
const progressPromises = list.map(t =>
getTaskProgress(t.id, userId).catch(err => {
console.warn(`[Tasks] 获取任务 ${t.id} 进度失败:`, err)
return null
})
)
const progressResults = await Promise.allSettled(progressPromises)
progressResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const p = result.value
const taskId = list[index].id
const currentTask = list[index]
const normalizedSubProgress = normalizeSubProgress(currentTask, p)
// BUG修复每个任务独立存储进度数据
taskProgress[taskId] = {
orderCount: p.order_count || 0,
orderAmount: p.order_amount || 0,
inviteCount: p.invite_count || 0,
firstOrder: p.first_order || false,
subProgress: normalizedSubProgress,
tierProgress: p.tier_progress || []
}
// 聚合进度指标 (取各任务返回的最大值 - 仅用于顶部统计卡片显示)
userProgress.orderCount = Math.max(userProgress.orderCount, p.order_count || 0)
userProgress.orderAmount = Math.max(userProgress.orderAmount, p.order_amount || 0)
userProgress.inviteCount = Math.max(userProgress.inviteCount, p.invite_count || 0)
if (p.first_order) userProgress.firstOrder = true
// 记录各任务已领取的档位
userProgress.claimedTiers[taskId] = p.claimed_tiers || []
}
})
console.log('[Tasks] 汇总后的进度数据:', userProgress)
}
} catch (e) {
console.error('获取任务失败:', e)
} finally {
loading.value = false
}
}
onLoad(() => {
nowMs.value = Date.now()
startCountdownTimer()
fetchData()
})
onUnload(() => {
stopCountdownTimer()
})
// 计算普通档位进度条百分比
function getTierProgressPercent(task, tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return ''
const threshold = tier.threshold || 0
if (threshold <= 0) return ''
const progress = taskProgress[task.id] || {}
let current = 0
// 优先使用 tier 级别窗口化进度
if (progress.tierProgress && progress.tierProgress.length > 0) {
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
if (tp) {
if (metric === 'order_count') current = tp.order_count || 0
else if (metric === 'order_amount') current = tp.order_amount || 0
else if (metric === 'invite_count') current = tp.invite_count || 0
return Math.min(current / threshold * 100, 100).toFixed(0) + '%'
}
}
// 回退:活动独立进度
if (tier.activity_id > 0 && progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
if (sub) {
if (metric === 'order_amount') current = sub.order_amount || 0
else if (metric === 'order_count') current = sub.order_count || 0
}
} else {
if (metric === 'order_count') current = progress.orderCount || 0
else if (metric === 'order_amount') current = progress.orderAmount || 0
else if (metric === 'invite_count') current = progress.inviteCount || 0
}
return Math.min(current / threshold * 100, 100).toFixed(0) + '%'
}
// 计算子进度条宽度
function getSubProgressWidth(sub, task) {
if (!sub || !task) return '0%'
const activityId = sub.activity_id || 0
let metric = null
if (hasTierMetric(task, activityId, 'order_amount')) {
metric = 'order_amount'
} else if (hasTierMetric(task, activityId, 'order_count')) {
metric = 'order_count'
} else if ((sub.order_amount || 0) > 0) {
metric = 'order_amount'
} else {
metric = 'order_count'
}
const maxThreshold = getMaxThresholdByMetric(task, metric, activityId)
let denominator = maxThreshold
if (denominator <= 0) {
denominator = metric === 'order_count' ? Math.max(sub.order_count || 0, 100) : Math.max(sub.order_amount || 0, 20000)
}
const current = metric === 'order_count' ? (sub.order_count || 0) : (sub.order_amount || 0)
const percent = denominator === 0 ? 0 : Math.min((current / denominator) * 100, 100)
return `${percent}%`
}
function hasTierMetric(task, activityId, metric) {
if (!task?.tiers || !metric) return false
const target = activityId || 0
return task.tiers.some(t => (t.activity_id || 0) === target && t.metric === metric)
}
function getMaxThresholdByMetric(task, metric, activityId) {
if (!task?.tiers || !metric) return 0
const target = activityId || 0
const related = task.tiers.filter(t => (t.activity_id || 0) === target && t.metric === metric)
if (related.length === 0) return 0
return Math.max(...related.map(t => t.threshold || 0))
}
function formatAmount(cents) {
const value = Number(cents || 0) / 100
if (Number.isNaN(value)) return '0'
return Number.isInteger(value) ? value.toString() : value.toFixed(2)
}
</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;
margin-bottom: 8rpx;
}
.task-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 6rpx;
}
.task-time-range {
font-size: 22rpx;
color: $text-tertiary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-time-countdown {
font-size: 22rpx;
color: $brand-primary;
font-weight: 700;
white-space: nowrap;
&.expired {
color: $text-tertiary;
font-weight: 600;
}
}
.task-rule-tip {
display: block;
font-size: 20rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
/* 独立进度条样式 */
.sub-progress-list {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 8rpx;
}
.sub-progress-item {
display: flex;
align-items: center;
font-size: 20rpx;
color: $text-tertiary;
}
.sub-label {
margin-right: 8rpx;
min-width: 80rpx;
}
.sub-bar-bg {
flex: 1;
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
margin-right: 8rpx;
overflow: hidden;
}
.sub-bar-fill {
height: 100%;
background: $brand-primary;
border-radius: 4rpx;
transition: width 0.3s ease;
}
.sub-value {
font-family: 'DIN Alternate';
font-weight: 700;
color: $text-sub;
min-width: 60rpx;
text-align: right;
}
.task-status-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 16rpx;
gap: 8rpx;
}
.task-quota-tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 100rpx;
background: rgba(#ff9500, 0.1);
color: #ff9500;
font-weight: 600;
white-space: nowrap;
&.exhausted {
background: rgba(0, 0, 0, 0.05);
color: $text-tertiary;
}
}
.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-progress {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.progress-text {
font-size: 24rpx;
color: $text-sub;
font-weight: 600;
}
.tier-bar-bg {
width: 120rpx;
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
margin-top: 8rpx;
overflow: hidden;
}
.tier-bar-fill {
height: 100%;
background: $brand-primary;
border-radius: 4rpx;
transition: width 0.3s ease;
}
.quota-text {
font-size: 18rpx;
color: $brand-primary;
margin-top: 4rpx;
opacity: 0.8;
}
.tier-btn {
&.disabled {
background: #f0f0f0;
color: #ccc;
cursor: not-allowed;
}
}
.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>