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, Goal, GoalDetail, GoalFormData,
GoalStep, GoalStepFormData, GoalStep, GoalStepFormData,
GoalReview, GoalReviewFormData, GoalReview, GoalReviewFormData,
GoalCheckin, GoalCheckinFormData,
} from './types' } from './types'
// ============ Goals ============ // ============ 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 }> { export function unlinkTask(goalId: number, taskId: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`) 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 GoalStatus = 'active' | 'paused' | 'completed' | 'abandoned'
export type StepType = 'phase' | 'milestone' export type StepType = 'phase' | 'milestone'
export type StepStatus = 'pending' | 'in_progress' | 'completed' export type StepStatus = 'pending' | 'in_progress' | 'completed'
export type TrackType = 'milestone' | 'cumulative'
export interface Goal { export interface Goal {
id: number id: number
@@ -207,7 +208,13 @@ export interface Goal {
title: string title: string
description?: string | null description?: string | null
status: GoalStatus status: GoalStatus
track_type: TrackType
progress: number progress: number
target_value?: number | null
target_unit?: string | null
input_unit?: string | null
conversion_rate: number
current_value: number
target_date?: string | null target_date?: string | null
completed_at?: string | null completed_at?: string | null
category_id?: number | null category_id?: number | null
@@ -224,6 +231,7 @@ export interface Goal {
export interface GoalDetail extends Goal { export interface GoalDetail extends Goal {
steps: GoalStep[] steps: GoalStep[]
reviews: GoalReview[] reviews: GoalReview[]
checkins: GoalCheckin[]
tasks: Task[] tasks: Task[]
} }
@@ -255,6 +263,11 @@ export interface GoalFormData {
title: string title: string
description?: string | null description?: string | null
status: GoalStatus status: GoalStatus
track_type: TrackType
target_value?: number | null
target_unit?: string | null
input_unit?: string | null
conversion_rate: number
target_date?: string | null target_date?: string | null
category_id?: number | null category_id?: number | null
color: string color: string
@@ -276,6 +289,22 @@ export interface GoalReviewFormData {
rating?: number | null 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 { export interface CertificateCategory {

View File

@@ -23,6 +23,11 @@ const form = ref<GoalFormData>({
title: '', title: '',
description: null, description: null,
status: 'active', status: 'active',
track_type: 'milestone',
target_value: null,
target_unit: null,
input_unit: null,
conversion_rate: 1.0,
target_date: null, target_date: null,
category_id: null, category_id: null,
color: '#FFB7C5', color: '#FFB7C5',
@@ -42,6 +47,11 @@ watch(() => props.visible, (val) => {
title: props.editingGoal.title, title: props.editingGoal.title,
description: props.editingGoal.description ?? null, description: props.editingGoal.description ?? null,
status: props.editingGoal.status, 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, target_date: props.editingGoal.target_date ?? null,
category_id: props.editingGoal.category_id ?? null, category_id: props.editingGoal.category_id ?? null,
color: props.editingGoal.color, color: props.editingGoal.color,
@@ -54,6 +64,11 @@ watch(() => props.visible, (val) => {
title: '', title: '',
description: null, description: null,
status: 'active', status: 'active',
track_type: 'milestone',
target_value: null,
target_unit: null,
input_unit: null,
conversion_rate: 1.0,
target_date: null, target_date: null,
category_id: null, category_id: null,
color: '#FFB7C5', color: '#FFB7C5',
@@ -120,6 +135,41 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
</el-col> </el-col>
</el-row> </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-row :gutter="16">
<el-col :span="16"> <el-col :span="16">
<el-form-item label="分类"> <el-form-item label="分类">
@@ -184,6 +234,12 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
&:hover { color: var(--primary); } &:hover { color: var(--primary); }
} }
.field-hint {
font-size: 11px;
color: #bbb;
margin-top: 2px;
}
.color-picker { .color-picker {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' 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' import * as goalApi from '@/api/goals'
export const useGoalStore = defineStore('goal', () => { 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 { return {
goals, currentGoal, loading, error, goals, currentGoal, loading, error,
activeGoals, pausedGoals, completedGoals, activeGoals, pausedGoals, completedGoals,
@@ -207,5 +256,6 @@ export const useGoalStore = defineStore('goal', () => {
createStep, updateStep, deleteStep, toggleStep, createStep, updateStep, deleteStep, toggleStep,
createReview, deleteReview, createReview, deleteReview,
linkTask, unlinkTask, linkTask, unlinkTask,
fetchCheckins, createCheckin, updateCheckin, deleteCheckin,
} }
}) })

View File

@@ -6,7 +6,7 @@ import { useGoalStore } from '@/stores/useGoalStore'
import { useTaskStore } from '@/stores/useTaskStore' import { useTaskStore } from '@/stores/useTaskStore'
import { reorderSteps } from '@/api/goals' import { reorderSteps } from '@/api/goals'
import GoalDialog from '@/components/GoalDialog.vue' 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' import { ElMessageBox, ElMessage } from 'element-plus'
const route = useRoute() const route = useRoute()
@@ -25,6 +25,11 @@ const reviewFormVisible = ref(false)
const reviewContent = ref('') const reviewContent = ref('')
const reviewRating = ref<number | null>(null) 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 editDialogVisible = ref(false)
const linkTaskVisible = ref(false) const linkTaskVisible = ref(false)
const selectedTaskId = ref<number | null>(null) const selectedTaskId = ref<number | null>(null)
@@ -108,6 +113,39 @@ async function handleDeleteReview(reviewId: number) {
} catch {} } 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() { async function handleLinkTask() {
if (!selectedTaskId.value) return if (!selectedTaskId.value) return
await goalStore.linkTask(goalId.value, selectedTaskId.value) await goalStore.linkTask(goalId.value, selectedTaskId.value)
@@ -124,6 +162,11 @@ const editableGoal = computed(() => goalStore.currentGoal ? {
title: goalStore.currentGoal.title, title: goalStore.currentGoal.title,
description: goalStore.currentGoal.description ?? null, description: goalStore.currentGoal.description ?? null,
status: goalStore.currentGoal.status, 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, target_date: goalStore.currentGoal.target_date ?? null,
category_id: goalStore.currentGoal.category_id ?? null, category_id: goalStore.currentGoal.category_id ?? null,
color: goalStore.currentGoal.color, color: goalStore.currentGoal.color,
@@ -251,17 +294,79 @@ const statusLabel: Record<string, string> = {
<div class="detail-meta"> <div class="detail-meta">
<el-tag>{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }}</el-tag> <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.target_date">目标日期: {{ goalStore.currentGoal.target_date }}</span>
<span v-if="goalStore.currentGoal.category"> <span v-if="goalStore.currentGoal.category">
<el-icon :size="14"><Folder /></el-icon> <el-icon :size="14"><Folder /></el-icon>
{{ goalStore.currentGoal.category.name }} {{ goalStore.currentGoal.category.name }}
</span> </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>
<div class="detail-body"> <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"> <div class="section-header">
<h3>阶段 & 里程碑</h3> <h3>阶段 & 里程碑</h3>
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'milestone'">添加里程碑</el-button> <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; } .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> </style>

View File

@@ -115,7 +115,10 @@ const statusColor: Record<string, string> = {
<div class="progress-section"> <div class="progress-section">
<div class="progress-info"> <div class="progress-info">
<span class="progress-text">{{ goal.progress }}%</span> <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> </div>
<el-progress <el-progress
:percentage="goal.progress" :percentage="goal.progress"

View File

@@ -4,7 +4,7 @@ from app.models.tag import Tag, task_tags
from app.models.user_settings import UserSettings from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin, goal_tasks
from app.models.sync_settings import SyncSettings from app.models.sync_settings import SyncSettings
from app.models.certificate import Certificate, CertificateCategory from app.models.certificate import Certificate, CertificateCategory
@@ -12,7 +12,7 @@ __all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings", "Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin", "HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary", "AnniversaryCategory", "Anniversary",
"Goal", "GoalStep", "GoalReview", "goal_tasks", "Goal", "GoalStep", "GoalReview", "GoalCheckin", "goal_tasks",
"SyncSettings", "SyncSettings",
"Certificate", "CertificateCategory", "Certificate", "CertificateCategory",
] ]

View File

@@ -1,5 +1,5 @@
import uuid as _uuid import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, ForeignKey, Table, desc from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, Float, ForeignKey, Table, desc
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.utils.datetime import utcnow from app.utils.datetime import utcnow
@@ -23,7 +23,13 @@ class Goal(Base):
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
status = Column(String(20), default="active") # active/paused/completed/abandoned status = Column(String(20), default="active") # active/paused/completed/abandoned
progress = Column(Integer, default=0) # 0-100从里程碑自动计算 track_type = Column(String(20), default="milestone") # "milestone" | "cumulative"
progress = Column(Integer, default=0) # 0-100里程碑模式从里程碑自动计算累计模式从打卡汇总计算
target_value = Column(Float, nullable=True) # 累计模式:目标值(目标单位)
target_unit = Column(String(20), nullable=True) # 累计模式:目标单位,如 "g"、"kg"
input_unit = Column(String(20), nullable=True) # 累计模式:打卡输入单位,如 "kcal"、"次"
conversion_rate = Column(Float, default=1.0) # 累计模式:换算率(多少输入单位 = 1 目标单位)
current_value = Column(Float, default=0) # 累计模式:累计打卡值(输入单位)
target_date = Column(Date, nullable=True) target_date = Column(Date, nullable=True)
completed_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
@@ -47,6 +53,11 @@ class Goal(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by=lambda: desc(GoalReview.created_at), order_by=lambda: desc(GoalReview.created_at),
) )
checkins = relationship(
"GoalCheckin", back_populates="goal",
cascade="all, delete-orphan",
order_by=lambda: desc(GoalCheckin.checkin_date),
)
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals") tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
@@ -88,3 +99,21 @@ class GoalReview(Base):
# 关联关系 # 关联关系
goal = relationship("Goal", back_populates="reviews") goal = relationship("Goal", back_populates="reviews")
class GoalCheckin(Base):
"""目标累计打卡记录模型"""
__tablename__ = "goal_checkins"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
value = Column(Float, nullable=False)
note = Column(Text, nullable=True)
checkin_date = Column(Date, nullable=False)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="checkins")

View File

@@ -18,14 +18,14 @@ EXPORT_TABLES = [
"categories", "tags", "user_settings", "sync_settings", "categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories", "habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates", "goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins", "goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks", "task_tags", "goal_tasks",
] ]
# 导入时的清表顺序:子表先删(避免 FK 约束报错) # 导入时的清表顺序:子表先删(避免 FK 约束报错)
TRUNCATE_ORDER = [ TRUNCATE_ORDER = [
"task_tags", "goal_tasks", "task_tags", "goal_tasks",
"habit_checkins", "habit_checkins", "goal_checkins",
"goal_reviews", "goal_steps", "goal_reviews", "goal_steps",
"tasks", "habits", "anniversaries", "certificates", "tasks", "habits", "anniversaries", "certificates",
"goals", "categories", "tags", "goals", "categories", "tags",
@@ -38,7 +38,7 @@ INSERT_ORDER = [
"categories", "tags", "user_settings", "sync_settings", "categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories", "habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates", "goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins", "goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks", "task_tags", "goal_tasks",
] ]

View File

@@ -1,14 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List from typing import List
from app.database import get_db from app.database import get_db
from app.models.goal import Goal, GoalStep, GoalReview from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin
from app.models.task import Task from app.models.task import Task
from app.schemas.goal import ( from app.schemas.goal import (
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate, GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
GoalStepCreate, GoalStepUpdate, GoalStepResponse, GoalStepCreate, GoalStepUpdate, GoalStepResponse,
GoalReviewCreate, GoalReviewResponse, GoalReviewCreate, GoalReviewResponse,
GoalCheckinCreate, GoalCheckinUpdate, GoalCheckinResponse,
ReorderRequest, ReorderRequest,
) )
from app.schemas.common import DeleteResponse from app.schemas.common import DeleteResponse
@@ -20,7 +22,20 @@ router = APIRouter(prefix="/api/goals", tags=["目标"])
def recalc_progress(db: Session, goal_id: int): def recalc_progress(db: Session, goal_id: int):
"""根据里程碑完成比例重新计算目标进度""" """根据追踪类型重新计算目标进度。里程碑模式按步骤完成比例;累计模式按打卡值/换算率/目标值。"""
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if not goal:
return 0
if goal.track_type == "cumulative" and goal.target_value and goal.target_value > 0:
total = db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar()
goal.current_value = float(total)
progress_in_target = goal.current_value / goal.conversion_rate if goal.conversion_rate else goal.current_value
progress = int(progress_in_target / goal.target_value * 100)
return min(progress, 100)
total = db.query(GoalStep).filter( total = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id, GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone", GoalStep.step_type == "milestone",
@@ -89,6 +104,12 @@ def get_goals(
).count() ).count()
g.total_steps = total g.total_steps = total
g.completed_steps = completed g.completed_steps = completed
# 累计模式重新计算进度和当前值
if g.track_type == "cumulative":
g.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == g.id,
).scalar())
g.progress = recalc_progress(db, g.id)
result.append(g) result.append(g)
return result return result
@@ -115,7 +136,7 @@ def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
@router.get("/{goal_id}", response_model=GoalDetailResponse) @router.get("/{goal_id}", response_model=GoalDetailResponse)
def get_goal(goal_id: int, db: Session = Depends(get_db)): def get_goal(goal_id: int, db: Session = Depends(get_db)):
"""获取目标详情(含 steps 树、reviews、关联 tasks""" """获取目标详情(含 steps 树、reviews、关联 tasks、checkins"""
try: try:
goal = get_or_404(db, Goal, goal_id, "目标") goal = get_or_404(db, Goal, goal_id, "目标")
goal.total_steps = db.query(GoalStep).filter( goal.total_steps = db.query(GoalStep).filter(
@@ -127,6 +148,11 @@ def get_goal(goal_id: int, db: Session = Depends(get_db)):
GoalStep.step_type == "milestone", GoalStep.step_type == "milestone",
GoalStep.status == "completed", GoalStep.status == "completed",
).count() ).count()
if goal.track_type == "cumulative":
goal.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar())
goal.progress = recalc_progress(db, goal_id)
return goal return goal
except HTTPException: except HTTPException:
raise raise
@@ -430,3 +456,101 @@ def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
db.rollback() db.rollback()
logger.error(f"取消关联任务失败: {str(e)}") logger.error(f"取消关联任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="取消关联任务失败") raise HTTPException(status_code=500, detail="取消关联任务失败")
# ============ Checkins (累计打卡) ============
@router.get("/{goal_id}/checkins", response_model=List[GoalCheckinResponse])
def get_checkins(goal_id: int, db: Session = Depends(get_db)):
"""获取目标的打卡记录列表"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkins = db.query(GoalCheckin).filter(
GoalCheckin.goal_id == goal_id,
).order_by(GoalCheckin.checkin_date.desc(), GoalCheckin.created_at.desc()).all()
return checkins
except HTTPException:
raise
except Exception as e:
logger.error(f"获取打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取打卡记录失败")
@router.post("/{goal_id}/checkins", response_model=GoalCheckinResponse, status_code=201)
def create_checkin(goal_id: int, data: GoalCheckinCreate, db: Session = Depends(get_db)):
"""创建打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = GoalCheckin(goal_id=goal_id, **data.model_dump())
db.add(checkin)
db.commit()
db.refresh(checkin)
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"创建打卡记录成功: id={checkin.id}, goal_id={goal_id}, value={checkin.value}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建打卡记录失败")
@router.put("/{goal_id}/checkins/{checkin_id}", response_model=GoalCheckinResponse)
def update_checkin(goal_id: int, checkin_id: int, data: GoalCheckinUpdate, db: Session = Depends(get_db)):
"""修改打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(checkin, field, value)
db.commit()
db.refresh(checkin)
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"更新打卡记录成功: id={checkin_id}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新打卡记录失败")
@router.delete("/{goal_id}/checkins/{checkin_id}")
def delete_checkin(goal_id: int, checkin_id: int, db: Session = Depends(get_db)):
"""删除打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
db.delete(checkin)
db.commit()
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"删除打卡记录成功: id={checkin_id}")
return DeleteResponse(message="打卡记录删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除打卡记录失败")

View File

@@ -6,6 +6,37 @@ from app.schemas.category import CategoryResponse
from app.schemas.task import TaskResponse from app.schemas.task import TaskResponse
# ============ GoalCheckin Schemas ============
class GoalCheckinCreate(BaseModel):
value: float = Field(..., gt=0)
note: Optional[str] = None
checkin_date: date
class GoalCheckinUpdate(BaseModel):
value: Optional[float] = Field(None, gt=0)
note: Optional[str] = None
checkin_date: Optional[date] = None
@property
def clearable_fields(self) -> set:
return {"note"}
class GoalCheckinResponse(BaseModel):
id: int
uuid: Optional[str] = None
goal_id: int
value: float
note: Optional[str] = None
checkin_date: date
created_at: datetime
class Config:
from_attributes = True
# ============ GoalStep Schemas ============ # ============ GoalStep Schemas ============
class GoalStepBase(BaseModel): class GoalStepBase(BaseModel):
@@ -71,6 +102,11 @@ class GoalBase(BaseModel):
title: str = Field(..., max_length=200) title: str = Field(..., max_length=200)
description: Optional[str] = None description: Optional[str] = None
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$") status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
track_type: str = Field(default="milestone", pattern="^(milestone|cumulative)$")
target_value: Optional[float] = None
target_unit: Optional[str] = Field(None, max_length=20)
input_unit: Optional[str] = Field(None, max_length=20)
conversion_rate: float = Field(default=1.0)
target_date: Optional[date] = None target_date: Optional[date] = None
category_id: Optional[int] = None category_id: Optional[int] = None
color: str = Field(default="#FFB7C5", max_length=20) color: str = Field(default="#FFB7C5", max_length=20)
@@ -86,6 +122,11 @@ class GoalUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200) title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$") status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$")
track_type: Optional[str] = Field(None, pattern="^(milestone|cumulative)$")
target_value: Optional[float] = None
target_unit: Optional[str] = Field(None, max_length=20)
input_unit: Optional[str] = Field(None, max_length=20)
conversion_rate: Optional[float] = None
target_date: Optional[date] = None target_date: Optional[date] = None
category_id: Optional[int] = None category_id: Optional[int] = None
color: Optional[str] = Field(None, max_length=20) color: Optional[str] = Field(None, max_length=20)
@@ -94,13 +135,15 @@ class GoalUpdate(BaseModel):
@property @property
def clearable_fields(self) -> set: def clearable_fields(self) -> set:
return {"description", "target_date", "category_id"} return {"description", "target_date", "category_id",
"target_value", "target_unit", "input_unit"}
class GoalListResponse(GoalBase): class GoalListResponse(GoalBase):
id: int id: int
uuid: Optional[str] = None uuid: Optional[str] = None
progress: int progress: int
current_value: float = 0
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -115,6 +158,7 @@ class GoalListResponse(GoalBase):
class GoalDetailResponse(GoalListResponse): class GoalDetailResponse(GoalListResponse):
steps: List[GoalStepResponse] = [] steps: List[GoalStepResponse] = []
reviews: List[GoalReviewResponse] = [] reviews: List[GoalReviewResponse] = []
checkins: List[GoalCheckinResponse] = []
tasks: List[TaskResponse] = [] tasks: List[TaskResponse] = []

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-B7eroNyE.js"></script> <script type="module" crossorigin src="/assets/index-BX5HkU7A.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-D84OQMcE.js"> <link rel="modulepreload" crossorigin href="/assets/element-plus-CljBHM1G.js">
<link rel="stylesheet" crossorigin href="/assets/index-Z-X1qCVU.css"> <link rel="stylesheet" crossorigin href="/assets/index-O358hdvS.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>