feat: add cumulative checkin tracking mode for goals
Goals can now choose between milestone-based progress (existing) and cumulative checkin-based progress (new). Cumulative mode supports cross-unit conversion (e.g. kcal → g fat) via a configurable conversion rate. New GoalCheckin model stores daily inputs; progress auto-recalculates on every checkin C/U/D. Backup import/export covers the new table. Frontend GoalDialog, GoalDetailPage and GoalPage cards adapt to show cumulative progress or milestone progress based on track_type. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
Goal, GoalDetail, GoalFormData,
|
||||
GoalStep, GoalStepFormData,
|
||||
GoalReview, GoalReviewFormData,
|
||||
GoalCheckin, GoalCheckinFormData,
|
||||
} from './types'
|
||||
|
||||
// ============ Goals ============
|
||||
@@ -73,3 +74,21 @@ export function linkTask(goalId: number, taskId: number): Promise<{ message: str
|
||||
export function unlinkTask(goalId: number, taskId: number): Promise<{ message: string }> {
|
||||
return del<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
// ============ Checkins (累计打卡) ============
|
||||
|
||||
export function getCheckins(goalId: number): Promise<GoalCheckin[]> {
|
||||
return get<GoalCheckin[]>(`/goals/${goalId}/checkins`)
|
||||
}
|
||||
|
||||
export function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin> {
|
||||
return post<GoalCheckin>(`/goals/${goalId}/checkins`, data)
|
||||
}
|
||||
|
||||
export function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>): Promise<GoalCheckin> {
|
||||
return put<GoalCheckin>(`/goals/${goalId}/checkins/${checkinId}`, data)
|
||||
}
|
||||
|
||||
export function deleteCheckin(goalId: number, checkinId: number): Promise<{ message: string }> {
|
||||
return del<{ message: string }>(`/goals/${goalId}/checkins/${checkinId}`)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ export interface AnniversaryFormData {
|
||||
export type GoalStatus = 'active' | 'paused' | 'completed' | 'abandoned'
|
||||
export type StepType = 'phase' | 'milestone'
|
||||
export type StepStatus = 'pending' | 'in_progress' | 'completed'
|
||||
export type TrackType = 'milestone' | 'cumulative'
|
||||
|
||||
export interface Goal {
|
||||
id: number
|
||||
@@ -207,7 +208,13 @@ export interface Goal {
|
||||
title: string
|
||||
description?: string | null
|
||||
status: GoalStatus
|
||||
track_type: TrackType
|
||||
progress: number
|
||||
target_value?: number | null
|
||||
target_unit?: string | null
|
||||
input_unit?: string | null
|
||||
conversion_rate: number
|
||||
current_value: number
|
||||
target_date?: string | null
|
||||
completed_at?: string | null
|
||||
category_id?: number | null
|
||||
@@ -224,6 +231,7 @@ export interface Goal {
|
||||
export interface GoalDetail extends Goal {
|
||||
steps: GoalStep[]
|
||||
reviews: GoalReview[]
|
||||
checkins: GoalCheckin[]
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
@@ -255,6 +263,11 @@ export interface GoalFormData {
|
||||
title: string
|
||||
description?: string | null
|
||||
status: GoalStatus
|
||||
track_type: TrackType
|
||||
target_value?: number | null
|
||||
target_unit?: string | null
|
||||
input_unit?: string | null
|
||||
conversion_rate: number
|
||||
target_date?: string | null
|
||||
category_id?: number | null
|
||||
color: string
|
||||
@@ -276,6 +289,22 @@ export interface GoalReviewFormData {
|
||||
rating?: number | null
|
||||
}
|
||||
|
||||
export interface GoalCheckin {
|
||||
id: number
|
||||
uuid?: string
|
||||
goal_id: number
|
||||
value: number
|
||||
note?: string | null
|
||||
checkin_date: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GoalCheckinFormData {
|
||||
value: number
|
||||
note?: string | null
|
||||
checkin_date: string
|
||||
}
|
||||
|
||||
// ============ 证书相关 ============
|
||||
|
||||
export interface CertificateCategory {
|
||||
|
||||
@@ -23,6 +23,11 @@ const form = ref<GoalFormData>({
|
||||
title: '',
|
||||
description: null,
|
||||
status: 'active',
|
||||
track_type: 'milestone',
|
||||
target_value: null,
|
||||
target_unit: null,
|
||||
input_unit: null,
|
||||
conversion_rate: 1.0,
|
||||
target_date: null,
|
||||
category_id: null,
|
||||
color: '#FFB7C5',
|
||||
@@ -42,6 +47,11 @@ watch(() => props.visible, (val) => {
|
||||
title: props.editingGoal.title,
|
||||
description: props.editingGoal.description ?? null,
|
||||
status: props.editingGoal.status,
|
||||
track_type: props.editingGoal.track_type ?? 'milestone',
|
||||
target_value: props.editingGoal.target_value ?? null,
|
||||
target_unit: props.editingGoal.target_unit ?? null,
|
||||
input_unit: props.editingGoal.input_unit ?? null,
|
||||
conversion_rate: props.editingGoal.conversion_rate ?? 1.0,
|
||||
target_date: props.editingGoal.target_date ?? null,
|
||||
category_id: props.editingGoal.category_id ?? null,
|
||||
color: props.editingGoal.color,
|
||||
@@ -54,6 +64,11 @@ watch(() => props.visible, (val) => {
|
||||
title: '',
|
||||
description: null,
|
||||
status: 'active',
|
||||
track_type: 'milestone',
|
||||
target_value: null,
|
||||
target_unit: null,
|
||||
input_unit: null,
|
||||
conversion_rate: 1.0,
|
||||
target_date: null,
|
||||
category_id: null,
|
||||
color: '#FFB7C5',
|
||||
@@ -120,6 +135,41 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="追踪方式">
|
||||
<el-radio-group v-model="form.track_type">
|
||||
<el-radio value="milestone">里程碑模式</el-radio>
|
||||
<el-radio value="cumulative">累计打卡模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.track_type === 'cumulative'">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值">
|
||||
<el-input-number v-model="form.target_value" :min="0" :precision="1" placeholder="如 600" style="width:100%" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标单位">
|
||||
<el-input v-model="form.target_unit" maxlength="20" placeholder="如 g、kg、次" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="打卡单位">
|
||||
<el-input v-model="form.input_unit" maxlength="20" placeholder="如 kcal、次、km" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="换算率">
|
||||
<el-input-number v-model="form.conversion_rate" :min="0.01" :precision="2" :step="0.1" style="width:100%" controls-position="right" />
|
||||
<div class="field-hint">多少打卡单位 = 1 目标单位</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="分类">
|
||||
@@ -184,6 +234,12 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Goal, GoalDetail, GoalStep, GoalReview, GoalFormData, GoalStepFormData, GoalReviewFormData } from '@/api/types'
|
||||
import type { Goal, GoalDetail, GoalStep, GoalReview, GoalCheckin, GoalFormData, GoalStepFormData, GoalReviewFormData, GoalCheckinFormData } from '@/api/types'
|
||||
import * as goalApi from '@/api/goals'
|
||||
|
||||
export const useGoalStore = defineStore('goal', () => {
|
||||
@@ -200,6 +200,55 @@ export const useGoalStore = defineStore('goal', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Checkins (累计打卡) ============
|
||||
|
||||
async function fetchCheckins(goalId: number): Promise<GoalCheckin[]> {
|
||||
try {
|
||||
return await goalApi.getCheckins(goalId)
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.detail || '获取打卡记录失败'
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin | null> {
|
||||
try {
|
||||
const checkin = await goalApi.createCheckin(goalId, data)
|
||||
if (currentGoal.value?.id === goalId) {
|
||||
await fetchGoal(goalId)
|
||||
}
|
||||
await fetchGoals()
|
||||
return checkin
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.detail || '创建打卡记录失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>) {
|
||||
try {
|
||||
await goalApi.updateCheckin(goalId, checkinId, data)
|
||||
if (currentGoal.value?.id === goalId) {
|
||||
await fetchGoal(goalId)
|
||||
}
|
||||
await fetchGoals()
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.detail || '更新打卡记录失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCheckin(goalId: number, checkinId: number) {
|
||||
try {
|
||||
await goalApi.deleteCheckin(goalId, checkinId)
|
||||
if (currentGoal.value?.id === goalId) {
|
||||
await fetchGoal(goalId)
|
||||
}
|
||||
await fetchGoals()
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.detail || '删除打卡记录失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
goals, currentGoal, loading, error,
|
||||
activeGoals, pausedGoals, completedGoals,
|
||||
@@ -207,5 +256,6 @@ export const useGoalStore = defineStore('goal', () => {
|
||||
createStep, updateStep, deleteStep, toggleStep,
|
||||
createReview, deleteReview,
|
||||
linkTask, unlinkTask,
|
||||
fetchCheckins, createCheckin, updateCheckin, deleteCheckin,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useTaskStore } from '@/stores/useTaskStore'
|
||||
import { reorderSteps } from '@/api/goals'
|
||||
import GoalDialog from '@/components/GoalDialog.vue'
|
||||
import type { Goal, GoalStep } from '@/api/types'
|
||||
import type { Goal, GoalStep, GoalDetail } from '@/api/types'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -25,6 +25,11 @@ const reviewFormVisible = ref(false)
|
||||
const reviewContent = ref('')
|
||||
const reviewRating = ref<number | null>(null)
|
||||
|
||||
const checkinFormVisible = ref(false)
|
||||
const checkinValue = ref<number>(0)
|
||||
const checkinNote = ref('')
|
||||
const checkinDate = ref(new Date().toISOString().slice(0, 10))
|
||||
|
||||
const editDialogVisible = ref(false)
|
||||
const linkTaskVisible = ref(false)
|
||||
const selectedTaskId = ref<number | null>(null)
|
||||
@@ -108,6 +113,39 @@ async function handleDeleteReview(reviewId: number) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ============ Checkin handlers ============
|
||||
|
||||
async function handleAddCheckin() {
|
||||
if (checkinValue.value <= 0) return
|
||||
await goalStore.createCheckin(goalId.value, {
|
||||
value: checkinValue.value,
|
||||
note: checkinNote.value || null,
|
||||
checkin_date: checkinDate.value,
|
||||
})
|
||||
checkinValue.value = 0
|
||||
checkinNote.value = ''
|
||||
checkinDate.value = new Date().toISOString().slice(0, 10)
|
||||
checkinFormVisible.value = false
|
||||
}
|
||||
|
||||
async function handleDeleteCheckin(checkinId: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此打卡记录吗?', '删除确认', { type: 'warning' })
|
||||
await goalStore.deleteCheckin(goalId.value, checkinId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const currentGoalCheckins = computed(() => {
|
||||
return goalStore.currentGoal?.checkins ?? []
|
||||
})
|
||||
|
||||
function formatCheckinProgress(goal: GoalDetail): string {
|
||||
if (!goal.target_value) return ''
|
||||
const rate = goal.conversion_rate || 1
|
||||
const progressInTarget = goal.current_value / rate
|
||||
return `${progressInTarget.toFixed(1)} / ${goal.target_value} ${goal.target_unit || ''}`
|
||||
}
|
||||
|
||||
async function handleLinkTask() {
|
||||
if (!selectedTaskId.value) return
|
||||
await goalStore.linkTask(goalId.value, selectedTaskId.value)
|
||||
@@ -124,6 +162,11 @@ const editableGoal = computed(() => goalStore.currentGoal ? {
|
||||
title: goalStore.currentGoal.title,
|
||||
description: goalStore.currentGoal.description ?? null,
|
||||
status: goalStore.currentGoal.status,
|
||||
track_type: goalStore.currentGoal.track_type ?? 'milestone',
|
||||
target_value: goalStore.currentGoal.target_value ?? null,
|
||||
target_unit: goalStore.currentGoal.target_unit ?? null,
|
||||
input_unit: goalStore.currentGoal.input_unit ?? null,
|
||||
conversion_rate: goalStore.currentGoal.conversion_rate ?? 1.0,
|
||||
target_date: goalStore.currentGoal.target_date ?? null,
|
||||
category_id: goalStore.currentGoal.category_id ?? null,
|
||||
color: goalStore.currentGoal.color,
|
||||
@@ -251,17 +294,79 @@ const statusLabel: Record<string, string> = {
|
||||
|
||||
<div class="detail-meta">
|
||||
<el-tag>{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }}</el-tag>
|
||||
<span v-if="goalStore.currentGoal.track_type === 'cumulative' && goalStore.currentGoal.target_value">
|
||||
{{ formatCheckinProgress(goalStore.currentGoal) }}
|
||||
</span>
|
||||
<span v-if="goalStore.currentGoal.target_date">目标日期: {{ goalStore.currentGoal.target_date }}</span>
|
||||
<span v-if="goalStore.currentGoal.category">
|
||||
<el-icon :size="14"><Folder /></el-icon>
|
||||
{{ goalStore.currentGoal.category.name }}
|
||||
</span>
|
||||
<span>{{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成</span>
|
||||
<span v-if="goalStore.currentGoal.track_type !== 'cumulative'">{{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<!-- 阶段 & 里程碑 -->
|
||||
<section class="steps-section">
|
||||
<!-- 累计打卡模式 -->
|
||||
<section v-if="goalStore.currentGoal.track_type === 'cumulative'" class="checkins-section">
|
||||
<div class="section-header">
|
||||
<h3>每日打卡</h3>
|
||||
<el-button size="small" :icon="Plus" @click="checkinFormVisible = true">记录打卡</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="checkinFormVisible" class="checkin-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="`数值 (${goalStore.currentGoal.input_unit || '次'})`">
|
||||
<el-input-number v-model="checkinValue" :min="0.01" :precision="1" style="width:100%" controls-position="right" @keyup.enter="handleAddCheckin" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker v-model="checkinDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="checkinNote" placeholder="可选" maxlength="200" @keyup.enter="handleAddCheckin" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="checkin-form-actions">
|
||||
<el-button size="small" type="primary" @click="handleAddCheckin" :disabled="checkinValue <= 0">提交</el-button>
|
||||
<el-button size="small" @click="checkinFormVisible = false">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="goalStore.currentGoal.target_value" class="checkin-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">累计</span>
|
||||
<span class="summary-value">{{ goalStore.currentGoal.current_value.toFixed(1) }} {{ goalStore.currentGoal.input_unit || '' }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">折算</span>
|
||||
<span class="summary-value">{{ (goalStore.currentGoal.current_value / (goalStore.currentGoal.conversion_rate || 1)).toFixed(1) }} {{ goalStore.currentGoal.target_unit || '' }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">目标</span>
|
||||
<span class="summary-value">{{ goalStore.currentGoal.target_value }} {{ goalStore.currentGoal.target_unit || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentGoalCheckins.length === 0" class="empty-hint">还没有打卡记录,点击上方按钮开始记录</div>
|
||||
<div v-for="checkin in currentGoalCheckins" :key="checkin.id" class="checkin-row">
|
||||
<div class="checkin-left">
|
||||
<span class="checkin-value">+{{ checkin.value }} {{ goalStore.currentGoal.input_unit || '' }}</span>
|
||||
<span v-if="checkin.note" class="checkin-note">{{ checkin.note }}</span>
|
||||
</div>
|
||||
<div class="checkin-right">
|
||||
<span class="checkin-date">{{ checkin.checkin_date }}</span>
|
||||
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteCheckin(checkin.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 里程碑模式:阶段 & 里程碑 -->
|
||||
<section v-if="goalStore.currentGoal.track_type !== 'cumulative'" class="steps-section">
|
||||
<div class="section-header">
|
||||
<h3>阶段 & 里程碑</h3>
|
||||
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'milestone'">添加里程碑</el-button>
|
||||
@@ -617,4 +722,54 @@ section {
|
||||
}
|
||||
|
||||
.loading-state { text-align: center; padding: 80px; }
|
||||
|
||||
// Checkins (累计打卡)
|
||||
.checkin-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
.checkin-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #f5f7fa, #e8ecf1);
|
||||
border-radius: 8px;
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.summary-label { font-size: 12px; color: #999; }
|
||||
.summary-value { font-size: 16px; font-weight: 700; color: #333; }
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
&:last-child { border-bottom: none; }
|
||||
.checkin-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.checkin-value { font-size: 16px; font-weight: 600; color: #67C23A; }
|
||||
.checkin-note { font-size: 13px; color: #999; }
|
||||
}
|
||||
.checkin-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.checkin-date { font-size: 12px; color: #bbb; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -115,7 +115,10 @@ const statusColor: Record<string, string> = {
|
||||
<div class="progress-section">
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">{{ goal.progress }}%</span>
|
||||
<span class="progress-steps">{{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑</span>
|
||||
<span v-if="goal.track_type === 'cumulative' && goal.target_value" class="progress-steps">
|
||||
{{ (goal.current_value / (goal.conversion_rate || 1)).toFixed(1) }}/{{ goal.target_value }} {{ goal.target_unit || '' }}
|
||||
</span>
|
||||
<span v-else class="progress-steps">{{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="goal.progress"
|
||||
|
||||
Reference in New Issue
Block a user