topfans/frontend/pages/tasks/daily-tasks.vue
2026-04-17 17:17:32 +08:00

610 lines
13 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 v-if="visible" class="modal-wrapper" @touchmove.stop.prevent @click.stop>
<transition name="fade">
<view v-if="visible" class="modal-mask" @touchstart.stop="handleMaskTouchStart" @click.stop>
</view>
</transition>
<transition name="scale">
<view v-if="visible" class="modal-container" @click.stop>
<!-- 背景图片 -->
<image class="modal-background" src="/static/background/friend-list-bg.png" mode="aspectFill"></image>
<!-- 蒙层 -->
<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>
<!-- 标题 -->
<text class="modal-title">每日任务</text>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="errorMessage" class="error-state">
<text class="error-text">{{ errorMessage }}</text>
<button class="retry-btn" @click="loadTasks">重试</button>
</view>
<!-- 任务列表 -->
<view v-else class="task-list">
<!-- 空状态 -->
<view v-if="tasks.length === 0" class="empty-state">
<text class="empty-text">暂无每日任务</text>
</view>
<!-- 任务项 -->
<view v-for="task in tasks" :key="task.task_key" class="task-item">
<view class="task-info">
<text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</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 class="task-action">
<!-- 状态标签 -->
<view v-if="!task.can_claim" class="status-badge" :class="getStatusClass(task.status)">
<text class="status-text">{{ getStatusText(task.status) }}</text>
</view>
<!-- 领取按钮 -->
<button
v-if="task.can_claim"
class="claim-btn"
:loading="claimingTask === task.task_key"
@click="handleClaim(task)"
>
领取
</button>
</view>
</view>
</view>
<!-- 底部进度区域和一键领取 -->
<view v-if="!loading && !errorMessage" class="bottom-section">
<!-- 进度条 -->
<view class="progress-section">
<view class="progress-bar">
<view
v-for="(milestone, index) in milestones"
:key="index"
class="progress-node"
:class="{ 'active': index < completedCount }"
>
<view class="node-circle"></view>
<text class="node-label">{{ milestone }}</text>
</view>
</view>
</view>
<!-- 一键领取按钮 -->
<view v-if="hasClaimableTasks" class="claim-all-bar">
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll">
一键领取 ({{ claimableCount }})
</button>
</view>
</view>
</view>
</view>
</transition>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getDailyTasks, claimDailyTask, claimAllDailyTasks } from '@/utils/task-api.js'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(true)
const errorMessage = ref('')
const tasks = ref([])
const claimingTask = ref('')
const claimingAll = ref(false)
const starId = ref(1)
const hasClaimableTasks = computed(() => tasks.value.some(t => t.can_claim))
const claimableCount = computed(() => tasks.value.filter(t => t.can_claim).length)
// 进度里程碑
const milestones = ref(['任务1', '任务2', '任务3', '任务4'])
// 已完成的里程碑数量
const completedCount = computed(() => {
// 计算已领取的任务数量
return tasks.value.filter(t => t.status === 'claimed').length
})
// 监听 visible 变化,自动加载数据
watch(() => props.visible, (newVal) => {
if (newVal) {
loadTasks()
}
}, { immediate: true })
function getStatusClass(status) {
switch (status) {
case 'pending': return 'status-pending'
case 'completed': return 'status-completed'
case 'claimed': return 'status-claimed'
default: return ''
}
}
function getStatusText(status) {
switch (status) {
case 'pending': return '进行中'
case 'completed': return '可领取'
case 'claimed': return '已领取'
default: return status
}
}
async function loadTasks() {
try {
loading.value = true
errorMessage.value = ''
starId.value = uni.getStorageSync('star_id')
const res = await getDailyTasks(starId.value)
tasks.value = res.data?.tasks || []
} catch (err) {
console.error('loadTasks error:', err)
errorMessage.value = err.message || '加载失败'
} finally {
loading.value = false
}
}
async function handleClaim(task) {
if (claimingTask.value) return
try {
claimingTask.value = task.task_key
const res = await claimDailyTask(task.task_key, starId.value)
await loadTasks()
emit('updated')
uni.$emit('balanceUpdated', { crystal_balance: res.data?.crystal_balance, experience: res.data?.experience })
uni.showToast({ title: '领取成功', icon: 'success' })
} catch (err) {
console.error('handleClaim error:', err)
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
} finally {
claimingTask.value = ''
}
}
async function handleClaimAll() {
if (claimingAll.value) return
try {
claimingAll.value = true
const res = await claimAllDailyTasks(starId.value)
await loadTasks()
emit('updated')
uni.$emit('balanceUpdated', { crystal_balance: res.data?.crystal_balance, experience: res.data?.experience })
uni.showToast({ title: '领取成功', icon: 'success' })
} catch (err) {
console.error('handleClaimAll error:', err)
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
} finally {
claimingAll.value = false
}
}
// 遮罩层触摸处理
let maskTouchStartTime = 0
const handleMaskTouchStart = (e) => {
maskTouchStartTime = Date.now()
e.preventDefault()
e.stopPropagation()
}
// 关闭按钮触摸处理
let closeTouchStartTime = 0
let closeTouchLocked = false
const handleCloseTouchStart = (e) => {
closeTouchStartTime = Date.now()
closeTouchLocked = false
}
const handleCloseTouchEnd = (e) => {
if (closeTouchLocked) return
closeTouchLocked = true
const touchDuration = Date.now() - closeTouchStartTime
if (touchDuration < 300) {
e.preventDefault()
e.stopPropagation()
emit('close')
}
}
const handleCloseClick = (e) => {
e.preventDefault()
e.stopPropagation()
emit('close')
}
</script>
<style scoped>
.modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1;
}
.modal-container {
position: relative;
width: 580rpx;
max-height: 85vh;
border-radius: 40rpx;
overflow: hidden;
z-index: 2;
}
.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: rgba(0, 0, 0, 0.5);
}
.close-button {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.close-icon {
font-size: 50rpx;
color: #e6e6e6;
line-height: 1;
font-weight: 300;
}
.modal-content {
position: relative;
z-index: 2;
padding: 60rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
max-height: 85vh;
overflow-y: auto;
}
.modal-title {
font-size: 40rpx;
font-weight: bold;
color: #e6e6e6;
text-align: center;
margin-bottom: 40rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #F08399;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20rpx;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text,
.error-text {
color: #e6e6e6;
font-size: 28rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.retry-btn {
margin-top: 20rpx;
padding: 16rpx 40rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #fff;
border-radius: 50rpx;
font-size: 28rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
}
.empty-text {
color: #e6e6e6;
font-size: 28rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.task-list {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 30rpx;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
}
.task-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.task-name {
font-size: 30rpx;
font-weight: bold;
color: #e6e6e6;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.task-desc {
font-size: 24rpx;
color: rgba(230, 230, 230, 0.7);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.task-reward {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
}
.reward-icon {
font-size: 24rpx;
}
.reward-value {
font-size: 26rpx;
color: #FFB800;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.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 {
min-width: 120rpx;
height: 50rpx;
border-radius: 25rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20rpx;
}
.status-badge.status-pending {
background: rgba(255, 255, 255, 0.2);
}
.status-badge.status-completed {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
}
.status-badge.status-claimed {
background: rgba(255, 255, 255, 0.1);
}
.status-text {
font-size: 24rpx;
color: #e6e6e6;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.claim-btn {
min-width: 140rpx;
height: 50rpx;
border-radius: 25rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #fff;
font-size: 26rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
.claim-all-bar {
margin-top: 20rpx;
}
.bottom-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.progress-section {
width: 100%;
}
.progress-bar {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
height: 60rpx;
padding: 0 30rpx;
}
.progress-bar::before {
content: '';
position: absolute;
top: 50%;
left: 50rpx;
right: 50rpx;
height: 4rpx;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-50%);
z-index: 0;
}
.progress-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.node-circle {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: 4rpx solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.progress-node.active .node-circle {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-color: #F08399;
}
.node-label {
font-size: 22rpx;
color: rgba(230, 230, 230, 0.7);
margin-top: 8rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.claim-all-btn {
width: 100%;
padding: 24rpx 0;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #fff;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.scale-enter-active,
.scale-leave-active {
transition: all 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
</style>