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:
祀梦
2026-05-18 23:00:24 +08:00
parent 4ee1e39454
commit 4ce7de48c4
12 changed files with 529 additions and 20 deletions

View File

@@ -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}`)
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,
}
})

View File

@@ -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>

View File

@@ -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"