Your Name eeeba7b685 fix: 修复积分显示的浮点数精度问题
使用 Math.round(score * 100) / 100 避免 JavaScript 浮点数计算误差
例如 0.1 × 46 现在正确显示为 4.60 而不是 4.600000000000005
2026-01-15 15:47:59 +08:00

658 lines
17 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>
<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>