使用 Math.round(score * 100) / 100 避免 JavaScript 浮点数计算误差 例如 0.1 × 46 现在正确显示为 4.60 而不是 4.600000000000005
658 lines
17 KiB
Vue
658 lines
17 KiB
Vue
<template>
|
||
<DefaultLayout>
|
||
<div class="goals-page">
|
||
<div class="container">
|
||
<!-- 页面标题 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<h1 class="page-title">
|
||
<Icon name="flag" size="xl" color="#31f08b" style="margin-right: 12px;" />
|
||
我的目标
|
||
</h1>
|
||
<p class="page-desc">设定目标,追踪进度,持续成长!</p>
|
||
</div>
|
||
<div class="header-right">
|
||
<a-button type="primary" @click="showCreateModal">
|
||
<template #icon><Icon name="plus" size="sm" /></template>
|
||
创建目标
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快速创建预设目标 -->
|
||
<div class="section">
|
||
<h2 class="section-title">
|
||
<Icon name="sparkles" size="lg" color="#FFD700" style="margin-right: 8px;" />
|
||
推荐目标
|
||
</h2>
|
||
<div class="preset-grid">
|
||
<div
|
||
v-for="preset in presetGoals"
|
||
:key="preset.type"
|
||
class="preset-card"
|
||
@click="handleCreatePreset(preset.type)"
|
||
>
|
||
<div class="preset-icon">
|
||
<Icon :name="getPresetIcon(preset.type)" size="xl" color="#31f08b" />
|
||
</div>
|
||
<div class="preset-info">
|
||
<h4 class="preset-name">{{ preset.name }}</h4>
|
||
<p class="preset-desc">{{ preset.description }}</p>
|
||
<div class="preset-reward">
|
||
<Icon name="trophy" size="sm" color="#FFD700" />
|
||
<span>奖励 {{ formatScore(preset.reward) }} 积分</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 进行中的目标 -->
|
||
<div class="section">
|
||
<h2 class="section-title">
|
||
<Icon name="target" size="lg" color="#31f08b" style="margin-right: 8px;" />
|
||
进行中
|
||
<span class="count">({{ activeGoals.length }})</span>
|
||
</h2>
|
||
<div v-if="loading" class="loading-container">
|
||
<a-spin size="large" />
|
||
</div>
|
||
<div v-else-if="activeGoals.length === 0" class="empty-state">
|
||
<Icon name="inbox" size="xl" color="#6B6B6B" />
|
||
<p>暂无进行中的目标</p>
|
||
<a-button type="primary" @click="showCreateModal">创建目标</a-button>
|
||
</div>
|
||
<div v-else class="goals-list">
|
||
<div v-for="goal in activeGoals" :key="goal.id" class="goal-card">
|
||
<div class="goal-header">
|
||
<div class="goal-type-badge">
|
||
<Icon :name="getGoalTypeIcon(goal.goalType)" size="sm" />
|
||
{{ getGoalTypeName(goal.goalType) }}
|
||
</div>
|
||
<div class="goal-period">
|
||
{{ getPeriodName(goal.period) }}
|
||
</div>
|
||
</div>
|
||
<h3 class="goal-name">{{ goal.goalName }}</h3>
|
||
<p class="goal-desc" v-if="goal.description">{{ goal.description }}</p>
|
||
|
||
<div class="goal-progress">
|
||
<div class="progress-info">
|
||
<span class="current">{{ goal.currentValue }}</span>
|
||
<span class="separator">/</span>
|
||
<span class="target">{{ goal.targetValue }}</span>
|
||
</div>
|
||
<a-progress
|
||
:percent="Math.round((goal.currentValue / goal.targetValue) * 100)"
|
||
:stroke-color="'#31f08b'"
|
||
:trail-color="'#3A3A3A'"
|
||
/>
|
||
</div>
|
||
|
||
<div class="goal-footer">
|
||
<div class="goal-deadline">
|
||
<Icon name="clock" size="sm" color="#AFAFAF" />
|
||
<span>截止 {{ formatDate(goal.periodEnd) }}</span>
|
||
<span class="remaining" v-if="getRemainingDays(goal.periodEnd) > 0">
|
||
(还剩 {{ getRemainingDays(goal.periodEnd) }} 天)
|
||
</span>
|
||
</div>
|
||
<div class="goal-actions">
|
||
<a-button size="small" danger @click="handleCancelGoal(goal)">取消</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="goal-reward" v-if="goal.rewardScore > 0">
|
||
<Icon name="trophy" size="sm" color="#FFD700" />
|
||
<span>完成奖励 {{ formatScore(goal.rewardScore) }} 积分</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已完成的目标 -->
|
||
<div class="section" v-if="completedGoals.length > 0">
|
||
<h2 class="section-title">
|
||
<Icon name="checkCircle" size="lg" color="#31f08b" style="margin-right: 8px;" />
|
||
已完成
|
||
<span class="count">({{ completedGoals.length }})</span>
|
||
</h2>
|
||
<div class="goals-list completed">
|
||
<div v-for="goal in completedGoals" :key="goal.id" class="goal-card completed">
|
||
<div class="goal-header">
|
||
<div class="goal-type-badge">
|
||
<Icon :name="getGoalTypeIcon(goal.goalType)" size="sm" />
|
||
{{ getGoalTypeName(goal.goalType) }}
|
||
</div>
|
||
<a-tag color="green">已完成</a-tag>
|
||
</div>
|
||
<h3 class="goal-name">{{ goal.goalName }}</h3>
|
||
|
||
<div class="goal-footer">
|
||
<div class="goal-completed-time">
|
||
<Icon name="checkCircle" size="sm" color="#31f08b" />
|
||
<span>完成于 {{ formatDateTime(goal.completedTime) }}</span>
|
||
</div>
|
||
<div class="goal-actions" v-if="goal.rewardScore > 0">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="goal.rewardClaimed === 1"
|
||
@click="handleClaimReward(goal)"
|
||
>
|
||
{{ goal.rewardClaimed === 1 ? '已领取' : `领取 ${formatScore(goal.rewardScore)} 积分` }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未完成的目标 -->
|
||
<div class="section" v-if="failedGoals.length > 0">
|
||
<h2 class="section-title">
|
||
<Icon name="xCircle" size="lg" color="#EF4444" style="margin-right: 8px;" />
|
||
未完成
|
||
<span class="count">({{ failedGoals.length }})</span>
|
||
</h2>
|
||
<div class="goals-list failed">
|
||
<div v-for="goal in failedGoals" :key="goal.id" class="goal-card failed">
|
||
<div class="goal-header">
|
||
<div class="goal-type-badge">
|
||
{{ getGoalTypeName(goal.goalType) }}
|
||
</div>
|
||
<a-tag color="red">未完成</a-tag>
|
||
</div>
|
||
<h3 class="goal-name">{{ goal.goalName }}</h3>
|
||
<div class="goal-progress-text">
|
||
完成进度:{{ goal.currentValue }} / {{ goal.targetValue }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 创建目标弹窗 -->
|
||
<a-modal
|
||
v-model:open="createModalVisible"
|
||
title="创建目标"
|
||
width="600px"
|
||
@ok="handleCreateGoal"
|
||
:confirmLoading="creating"
|
||
>
|
||
<a-form :model="goalForm" layout="vertical">
|
||
<a-form-item label="目标名称" required>
|
||
<a-input v-model:value="goalForm.goalName" placeholder="例如:本周完成10个任务" />
|
||
</a-form-item>
|
||
<a-form-item label="目标描述">
|
||
<a-textarea v-model:value="goalForm.description" :rows="2" placeholder="描述你的目标" />
|
||
</a-form-item>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="目标类型" required>
|
||
<a-select v-model:value="goalForm.goalType">
|
||
<a-select-option value="task_count">任务数量</a-select-option>
|
||
<a-select-option value="score">积分目标</a-select-option>
|
||
<a-select-option value="rank">排名目标</a-select-option>
|
||
<a-select-option value="rating">评分目标</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="目标值" required>
|
||
<a-input-number v-model:value="goalForm.targetValue" :min="1" style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="目标周期" required>
|
||
<a-select v-model:value="goalForm.period">
|
||
<a-select-option value="daily">每日</a-select-option>
|
||
<a-select-option value="weekly">每周</a-select-option>
|
||
<a-select-option value="monthly">每月</a-select-option>
|
||
<a-select-option value="quarterly">每季度</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="完成奖励积分">
|
||
<a-input-number v-model:value="goalForm.rewardScore" :min="0" style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</a-modal>
|
||
</DefaultLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||
import Icon from '@/components/Icon.vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import dayjs from 'dayjs'
|
||
import {
|
||
getMyGoals,
|
||
createGoal,
|
||
createPresetGoal,
|
||
cancelGoal,
|
||
claimGoalReward,
|
||
PRESET_GOAL_TYPES,
|
||
type UserGoal
|
||
} from '@/api/goal'
|
||
|
||
const loading = ref(false)
|
||
const goals = ref<UserGoal[]>([])
|
||
const presetGoals = PRESET_GOAL_TYPES
|
||
|
||
const activeGoals = computed(() => goals.value.filter(g => g.status === 'active'))
|
||
const completedGoals = computed(() => goals.value.filter(g => g.status === 'completed'))
|
||
const failedGoals = computed(() => goals.value.filter(g => g.status === 'failed'))
|
||
|
||
const createModalVisible = ref(false)
|
||
const creating = ref(false)
|
||
const goalForm = ref({
|
||
goalName: '',
|
||
description: '',
|
||
goalType: 'task_count',
|
||
targetValue: 10,
|
||
period: 'weekly',
|
||
rewardScore: 50
|
||
})
|
||
|
||
// 格式化积分(保留两位小数,修复浮点数精度问题)
|
||
const formatScore = (score: number | null | undefined): string => {
|
||
if (score === null || score === undefined) return '0.00'
|
||
// 先乘以100,四舍五入取整,再除以100,避免浮点数精度问题
|
||
return (Math.round(Number(score) * 100) / 100).toFixed(2)
|
||
}
|
||
|
||
const loadData = async () => {
|
||
loading.value = true
|
||
try {
|
||
goals.value = await getMyGoals()
|
||
} catch (error) {
|
||
console.error('加载目标列表失败:', error)
|
||
message.error('加载数据失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const showCreateModal = () => {
|
||
goalForm.value = {
|
||
goalName: '',
|
||
description: '',
|
||
goalType: 'task_count',
|
||
targetValue: 10,
|
||
period: 'weekly',
|
||
rewardScore: 50
|
||
}
|
||
createModalVisible.value = true
|
||
}
|
||
|
||
const handleCreateGoal = async () => {
|
||
if (!goalForm.value.goalName || !goalForm.value.targetValue) {
|
||
message.warning('请填写必填项')
|
||
return
|
||
}
|
||
|
||
creating.value = true
|
||
try {
|
||
await createGoal(goalForm.value)
|
||
message.success('目标创建成功!')
|
||
createModalVisible.value = false
|
||
loadData()
|
||
} catch (error) {
|
||
console.error('创建目标失败:', error)
|
||
message.error('创建失败')
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
const handleCreatePreset = async (type: string) => {
|
||
// 检查是否已有该类型的活跃目标
|
||
const existingPreset = activeGoals.value.find(g => {
|
||
const preset = PRESET_GOAL_TYPES.find(p => p.type === type)
|
||
return preset && g.goalName === preset.name
|
||
})
|
||
|
||
if (existingPreset) {
|
||
message.warning('您已有相同的目标在进行中')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await createPresetGoal(type)
|
||
message.success('目标创建成功!')
|
||
loadData()
|
||
} catch (error: any) {
|
||
message.error(error.message || '创建失败')
|
||
}
|
||
}
|
||
|
||
const handleCancelGoal = (goal: UserGoal) => {
|
||
Modal.confirm({
|
||
title: '取消目标',
|
||
content: `确定要取消目标「${goal.goalName}」吗?`,
|
||
okText: '确定',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await cancelGoal(goal.id)
|
||
message.success('目标已取消')
|
||
loadData()
|
||
} catch (error: any) {
|
||
message.error(error.message || '操作失败')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleClaimReward = async (goal: UserGoal) => {
|
||
try {
|
||
await claimGoalReward(goal.id)
|
||
message.success('奖励领取成功!')
|
||
loadData()
|
||
} catch (error: any) {
|
||
message.error(error.message || '领取失败')
|
||
}
|
||
}
|
||
|
||
const getGoalTypeName = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
task_count: '任务数量',
|
||
score: '积分目标',
|
||
rank: '排名目标',
|
||
rating: '评分目标'
|
||
}
|
||
return map[type] || type
|
||
}
|
||
|
||
const getGoalTypeIcon = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
task_count: 'document',
|
||
score: 'trophy',
|
||
rank: 'chartBar',
|
||
rating: 'star'
|
||
}
|
||
return map[type] || 'flag'
|
||
}
|
||
|
||
const getPeriodName = (period: string) => {
|
||
const map: Record<string, string> = {
|
||
daily: '每日',
|
||
weekly: '每周',
|
||
monthly: '每月',
|
||
quarterly: '每季度',
|
||
yearly: '每年'
|
||
}
|
||
return map[period] || period
|
||
}
|
||
|
||
const getPresetIcon = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
weekly_task: 'calendar',
|
||
monthly_task: 'document',
|
||
monthly_score: 'trophy',
|
||
top_10: 'chartBar'
|
||
}
|
||
return map[type] || 'flag'
|
||
}
|
||
|
||
const getRemainingDays = (endTime: string) => {
|
||
const end = dayjs(endTime)
|
||
const now = dayjs()
|
||
return Math.max(0, end.diff(now, 'day'))
|
||
}
|
||
|
||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD')
|
||
const formatDateTime = (date: string) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : ''
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.goals-page {
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.page-desc {
|
||
color: #AFAFAF;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.count {
|
||
color: #6B6B6B;
|
||
font-weight: normal;
|
||
margin-left: 8px;
|
||
}
|
||
}
|
||
|
||
.preset-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.preset-card {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
border-color: #31f08b;
|
||
transform: translateY(-2px);
|
||
}
|
||
}
|
||
|
||
.preset-icon {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preset-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.preset-desc {
|
||
font-size: 13px;
|
||
color: #AFAFAF;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preset-reward {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: #FFD700;
|
||
}
|
||
|
||
.goals-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
|
||
&.completed, &.failed {
|
||
.goal-card {
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
}
|
||
|
||
.goal-card {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
|
||
&.completed {
|
||
border-color: rgba(49, 240, 139, 0.3);
|
||
}
|
||
|
||
&.failed {
|
||
border-color: rgba(239, 68, 68, 0.3);
|
||
}
|
||
}
|
||
|
||
.goal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.goal-type-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
background: rgba(49, 240, 139, 0.1);
|
||
border-radius: 6px;
|
||
color: #31f08b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.goal-period {
|
||
color: #AFAFAF;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.goal-name {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.goal-desc {
|
||
color: #AFAFAF;
|
||
font-size: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.goal-progress {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.progress-info {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
|
||
.current {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #31f08b;
|
||
}
|
||
|
||
.separator {
|
||
color: #6B6B6B;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.target {
|
||
font-size: 16px;
|
||
color: #AFAFAF;
|
||
}
|
||
}
|
||
|
||
.goal-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.goal-deadline, .goal-completed-time {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #AFAFAF;
|
||
font-size: 13px;
|
||
|
||
.remaining {
|
||
color: #31f08b;
|
||
}
|
||
}
|
||
|
||
.goal-reward {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 12px;
|
||
margin-top: 16px;
|
||
background: rgba(255, 215, 0, 0.1);
|
||
border-radius: 8px;
|
||
color: #FFD700;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.goal-progress-text {
|
||
color: #AFAFAF;
|
||
font-size: 13px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 48px;
|
||
color: #6B6B6B;
|
||
|
||
p {
|
||
margin: 16px 0;
|
||
}
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 48px;
|
||
}
|
||
</style>
|