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,
|
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}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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="删除打卡记录失败")
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user