From 4ce7de48c490a0918a5540fa30379eff593df79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=80=E6=A2=A6?= <3501646051@qq.com> Date: Mon, 18 May 2026 23:00:24 +0800 Subject: [PATCH] feat: add cumulative checkin tracking mode for goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- WebUI/src/api/goals.ts | 19 ++++ WebUI/src/api/types.ts | 29 +++++ WebUI/src/components/GoalDialog.vue | 56 ++++++++++ WebUI/src/stores/useGoalStore.ts | 52 ++++++++- WebUI/src/views/GoalDetailPage.vue | 163 +++++++++++++++++++++++++++- WebUI/src/views/GoalPage.vue | 5 +- api/app/models/__init__.py | 4 +- api/app/models/goal.py | 33 +++++- api/app/routers/backup.py | 6 +- api/app/routers/goals.py | 130 +++++++++++++++++++++- api/app/schemas/goal.py | 46 +++++++- api/webui/index.html | 6 +- 12 files changed, 529 insertions(+), 20 deletions(-) diff --git a/WebUI/src/api/goals.ts b/WebUI/src/api/goals.ts index 7d2ec6b..c6f73f8 100644 --- a/WebUI/src/api/goals.ts +++ b/WebUI/src/api/goals.ts @@ -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 { + return get(`/goals/${goalId}/checkins`) +} + +export function createCheckin(goalId: number, data: GoalCheckinFormData): Promise { + return post(`/goals/${goalId}/checkins`, data) +} + +export function updateCheckin(goalId: number, checkinId: number, data: Partial): Promise { + return put(`/goals/${goalId}/checkins/${checkinId}`, data) +} + +export function deleteCheckin(goalId: number, checkinId: number): Promise<{ message: string }> { + return del<{ message: string }>(`/goals/${goalId}/checkins/${checkinId}`) +} diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts index 51233e2..a03114a 100644 --- a/WebUI/src/api/types.ts +++ b/WebUI/src/api/types.ts @@ -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 { diff --git a/WebUI/src/components/GoalDialog.vue b/WebUI/src/components/GoalDialog.vue index 2bc6c87..cba4fb7 100644 --- a/WebUI/src/components/GoalDialog.vue +++ b/WebUI/src/components/GoalDialog.vue @@ -23,6 +23,11 @@ const form = ref({ 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', + + + 里程碑模式 + 累计打卡模式 + + + + + @@ -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; diff --git a/WebUI/src/stores/useGoalStore.ts b/WebUI/src/stores/useGoalStore.ts index eb1d2e3..275d464 100644 --- a/WebUI/src/stores/useGoalStore.ts +++ b/WebUI/src/stores/useGoalStore.ts @@ -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 { + try { + return await goalApi.getCheckins(goalId) + } catch (e: any) { + error.value = e?.response?.data?.detail || '获取打卡记录失败' + return [] + } + } + + async function createCheckin(goalId: number, data: GoalCheckinFormData): Promise { + 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) { + 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, } }) diff --git a/WebUI/src/views/GoalDetailPage.vue b/WebUI/src/views/GoalDetailPage.vue index 109d64c..0f7b1b4 100644 --- a/WebUI/src/views/GoalDetailPage.vue +++ b/WebUI/src/views/GoalDetailPage.vue @@ -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(null) +const checkinFormVisible = ref(false) +const checkinValue = ref(0) +const checkinNote = ref('') +const checkinDate = ref(new Date().toISOString().slice(0, 10)) + const editDialogVisible = ref(false) const linkTaskVisible = ref(false) const selectedTaskId = ref(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 = {
{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }} + + {{ formatCheckinProgress(goalStore.currentGoal) }} + 目标日期: {{ goalStore.currentGoal.target_date }} {{ goalStore.currentGoal.category.name }} - {{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成 + {{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成
- -
+ +
+
+

每日打卡

+ 记录打卡 +
+ +
+ + + + + + + + + + + + + + + + + +
+ 提交 + 取消 +
+
+ +
+
+ 累计 + {{ goalStore.currentGoal.current_value.toFixed(1) }} {{ goalStore.currentGoal.input_unit || '' }} +
+
+ 折算 + {{ (goalStore.currentGoal.current_value / (goalStore.currentGoal.conversion_rate || 1)).toFixed(1) }} {{ goalStore.currentGoal.target_unit || '' }} +
+
+ 目标 + {{ goalStore.currentGoal.target_value }} {{ goalStore.currentGoal.target_unit || '' }} +
+
+ +
还没有打卡记录,点击上方按钮开始记录
+
+
+ +{{ checkin.value }} {{ goalStore.currentGoal.input_unit || '' }} + {{ checkin.note }} +
+
+ {{ checkin.checkin_date }} + +
+
+
+ + +

阶段 & 里程碑

添加里程碑 @@ -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; } + } +} diff --git a/WebUI/src/views/GoalPage.vue b/WebUI/src/views/GoalPage.vue index 4193a1c..f5e6901 100644 --- a/WebUI/src/views/GoalPage.vue +++ b/WebUI/src/views/GoalPage.vue @@ -115,7 +115,10 @@ const statusColor: Record = {
{{ goal.progress }}% - {{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑 + + {{ (goal.current_value / (goal.conversion_rate || 1)).toFixed(1) }}/{{ goal.target_value }} {{ goal.target_unit || '' }} + + {{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑
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( GoalStep.goal_id == goal_id, GoalStep.step_type == "milestone", @@ -89,6 +104,12 @@ def get_goals( ).count() g.total_steps = total 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) return result @@ -115,7 +136,7 @@ def create_goal(data: GoalCreate, db: Session = Depends(get_db)): @router.get("/{goal_id}", response_model=GoalDetailResponse) def get_goal(goal_id: int, db: Session = Depends(get_db)): - """获取目标详情(含 steps 树、reviews、关联 tasks)""" + """获取目标详情(含 steps 树、reviews、关联 tasks、checkins)""" try: goal = get_or_404(db, Goal, goal_id, "目标") 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.status == "completed", ).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 except HTTPException: raise @@ -430,3 +456,101 @@ def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)): db.rollback() logger.error(f"取消关联任务失败: {str(e)}") 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="删除打卡记录失败") diff --git a/api/app/schemas/goal.py b/api/app/schemas/goal.py index ca66948..45eaa9f 100644 --- a/api/app/schemas/goal.py +++ b/api/app/schemas/goal.py @@ -6,6 +6,37 @@ from app.schemas.category import CategoryResponse 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 ============ class GoalStepBase(BaseModel): @@ -71,6 +102,11 @@ class GoalBase(BaseModel): title: str = Field(..., max_length=200) description: Optional[str] = None 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 category_id: Optional[int] = None color: str = Field(default="#FFB7C5", max_length=20) @@ -86,6 +122,11 @@ class GoalUpdate(BaseModel): title: Optional[str] = Field(None, max_length=200) description: Optional[str] = None 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 category_id: Optional[int] = None color: Optional[str] = Field(None, max_length=20) @@ -94,13 +135,15 @@ class GoalUpdate(BaseModel): @property 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): id: int uuid: Optional[str] = None progress: int + current_value: float = 0 completed_at: Optional[datetime] = None created_at: datetime updated_at: datetime @@ -115,6 +158,7 @@ class GoalListResponse(GoalBase): class GoalDetailResponse(GoalListResponse): steps: List[GoalStepResponse] = [] reviews: List[GoalReviewResponse] = [] + checkins: List[GoalCheckinResponse] = [] tasks: List[TaskResponse] = [] diff --git a/api/webui/index.html b/api/webui/index.html index a992534..eb68fec 100644 --- a/api/webui/index.html +++ b/api/webui/index.html @@ -5,10 +5,10 @@ webui - + - - + +