feat: add goal management module (long-term goals with phases, milestones, reviews)

Backend:
- Goal model: title, description, status (active/paused/completed/abandoned),
  progress (auto-computed from milestones), target_date, category, color, icon
- GoalStep model: unified phase/milestone with parent nesting
- GoalReview model: periodic reflection with rating
- goal_tasks M2M: link existing tasks to goals
- /api/goals CRUD + steps CRUD + reviews + task linking + status toggle
- Progress auto-calculated from milestone completion ratio

Frontend:
- GoalPage: card grid with progress bars, status filter
- GoalDetailPage: step tree (phases > milestones), reviews, linked tasks
- GoalDialog: create/edit form with color/icon picker
- Goal navigation in AppHeader
- useGoalStore: full Pinia store for all goal operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
祀梦
2026-05-17 16:34:39 +08:00
parent 0bca9e6654
commit 5af8cb5486
16 changed files with 1936 additions and 5 deletions

View File

@@ -59,7 +59,7 @@ def init_db():
"""初始化数据库表,自动补充新增的列"""
# 导入所有模型,确保 Base.metadata 包含全部表定义
from app.models import ( # noqa: F401
task, category, tag, user_settings, habit, anniversary,
task, category, tag, user_settings, habit, anniversary, goal,
)
Base.metadata.create_all(bind=engine)

View File

@@ -4,9 +4,11 @@ from app.models.tag import Tag, task_tags
from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
__all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary",
"Goal", "GoalStep", "GoalReview", "goal_tasks",
]

80
api/app/models/goal.py Normal file
View File

@@ -0,0 +1,80 @@
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, Table, desc
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
# 目标-任务关联表(多对多)
goal_tasks = Table(
"goal_tasks",
Base.metadata,
Column("goal_id", Integer, ForeignKey("goals.id"), primary_key=True),
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
)
class Goal(Base):
"""长期目标模型"""
__tablename__ = "goals"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
status = Column(String(20), default="active") # active/paused/completed/abandoned
progress = Column(Integer, default=0) # 0-100从里程碑自动计算
target_date = Column(Date, nullable=True)
completed_at = Column(DateTime, nullable=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
category = relationship("Category")
steps = relationship(
"GoalStep", back_populates="goal",
cascade="all, delete-orphan",
order_by="GoalStep.sort_order",
)
reviews = relationship(
"GoalReview", back_populates="goal",
cascade="all, delete-orphan",
order_by=lambda: desc(GoalReview.created_at),
)
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
class GoalStep(Base):
"""目标阶段/里程碑模型step_type 区分类型)"""
__tablename__ = "goal_steps"
id = Column(Integer, primary_key=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
title = Column(String(200), nullable=False)
step_type = Column(String(20), nullable=False) # "phase" | "milestone"
status = Column(String(20), default="pending") # pending/in_progress/completed
target_date = Column(Date, nullable=True)
reached_at = Column(DateTime, nullable=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="steps")
parent = relationship("GoalStep", remote_side=[id], backref="children")
class GoalReview(Base):
"""目标复盘记录模型"""
__tablename__ = "goal_reviews"
id = Column(Integer, primary_key=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
content = Column(Text, nullable=False)
rating = Column(Integer, nullable=True) # 1-5 自评
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="reviews")

View File

@@ -21,3 +21,4 @@ class Task(Base):
# 关联关系
category = relationship("Category", back_populates="tasks")
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
goals = relationship("Goal", secondary="goal_tasks", back_populates="tasks")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals
api_router = APIRouter()
@@ -10,3 +10,4 @@ api_router.include_router(tags.router)
api_router.include_router(user_settings.router)
api_router.include_router(habits.router)
api_router.include_router(anniversaries.router)
api_router.include_router(goals.router)

400
api/app/routers/goals.py Normal file
View File

@@ -0,0 +1,400 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.models.goal import Goal, GoalStep, GoalReview
from app.models.task import Task
from app.schemas.goal import (
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
GoalReviewCreate, GoalReviewResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow, today
from app.utils.logger import logger
router = APIRouter(prefix="/api/goals", tags=["目标"])
def recalc_progress(db: Session, goal_id: int):
"""根据里程碑完成比例重新计算目标进度"""
total = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
).count()
if total == 0:
return 0
completed = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
return int(completed / total * 100)
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
"""将扁平的 step 列表转为树形结构phase 包含子 milestone"""
step_map = {}
roots = []
for s in steps:
step_map[s.id] = {
"id": s.id,
"goal_id": s.goal_id,
"parent_id": s.parent_id,
"title": s.title,
"step_type": s.step_type,
"status": s.status,
"target_date": s.target_date,
"reached_at": s.reached_at,
"sort_order": s.sort_order,
"created_at": s.created_at,
"children": [],
}
for s in steps:
node = step_map[s.id]
if s.parent_id and s.parent_id in step_map:
step_map[s.parent_id]["children"].append(node)
else:
roots.append(node)
return roots
# ============ Goals CRUD ============
@router.get("", response_model=List[GoalListResponse])
def get_goals(
status: str | None = None,
db: Session = Depends(get_db),
):
"""获取所有目标"""
try:
query = db.query(Goal)
if status:
query = query.filter(Goal.status == status)
goals = query.order_by(Goal.sort_order, Goal.created_at.desc()).all()
result = []
for g in goals:
total = db.query(GoalStep).filter(
GoalStep.goal_id == g.id,
GoalStep.step_type == "milestone",
).count()
completed = db.query(GoalStep).filter(
GoalStep.goal_id == g.id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
g.total_steps = total
g.completed_steps = completed
result.append(g)
return result
except Exception as e:
logger.error(f"获取目标列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取目标列表失败")
@router.post("", response_model=GoalDetailResponse, status_code=201)
def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
"""创建目标"""
try:
goal = Goal(**data.model_dump())
db.add(goal)
db.commit()
db.refresh(goal)
logger.info(f"创建目标成功: id={goal.id}, title={goal.title}")
return goal
except Exception as e:
db.rollback()
logger.error(f"创建目标失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建目标失败")
@router.get("/{goal_id}", response_model=GoalDetailResponse)
def get_goal(goal_id: int, db: Session = Depends(get_db)):
"""获取目标详情(含 steps 树、reviews、关联 tasks"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
goal.total_steps = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
).count()
goal.completed_steps = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
return goal
except HTTPException:
raise
except Exception as e:
logger.error(f"获取目标详情失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取目标详情失败")
@router.put("/{goal_id}", response_model=GoalDetailResponse)
def update_goal(goal_id: int, data: GoalUpdate, db: Session = Depends(get_db)):
"""更新目标"""
try:
goal = get_or_404(db, Goal, goal_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(goal, field, value)
goal.updated_at = utcnow()
db.commit()
db.refresh(goal)
logger.info(f"更新目标成功: id={goal_id}")
return goal
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新目标失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新目标失败")
@router.delete("/{goal_id}")
def delete_goal(goal_id: int, db: Session = Depends(get_db)):
"""删除目标(级联删除 steps + reviews"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
db.delete(goal)
db.commit()
logger.info(f"删除目标成功: id={goal_id}")
return DeleteResponse(message="目标删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除目标失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除目标失败")
@router.patch("/{goal_id}/status", response_model=GoalDetailResponse)
def update_goal_status(goal_id: int, data: GoalStatusUpdate, db: Session = Depends(get_db)):
"""更新目标状态"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
goal.status = data.status
if data.status == "completed":
goal.completed_at = utcnow()
goal.progress = 100
else:
goal.completed_at = None
goal.updated_at = utcnow()
db.commit()
db.refresh(goal)
logger.info(f"更新目标状态成功: id={goal_id}, status={data.status}")
return goal
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新目标状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新目标状态失败")
# ============ Steps ============
@router.post("/{goal_id}/steps", response_model=GoalStepResponse, status_code=201)
def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db)):
"""添加阶段/里程碑"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = GoalStep(goal_id=goal_id, **data.model_dump())
db.add(step)
db.commit()
db.refresh(step)
# 重算进度
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"添加{data.step_type}成功: id={step.id}, goal_id={goal_id}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"添加步骤失败: {str(e)}")
raise HTTPException(status_code=500, detail="添加步骤失败")
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
"""更新阶段/里程碑"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_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(step, field, value)
db.commit()
db.refresh(step)
# 重算进度
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={step_id}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新步骤失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新步骤失败")
@router.delete("/{goal_id}/steps/{step_id}")
def delete_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
"""删除阶段/里程碑(级联删除子 step"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_id, "步骤")
db.delete(step)
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={step_id}")
return DeleteResponse(message="步骤删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除步骤失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除步骤失败")
@router.patch("/{goal_id}/steps/{step_id}/toggle", response_model=GoalStepResponse)
def toggle_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
"""切换步骤状态 (pending → in_progress → completed → pending)"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_id, "步骤")
cycle = {"pending": "in_progress", "in_progress": "completed", "completed": "pending"}
step.status = cycle.get(step.status, "pending")
if step.status == "completed":
step.reached_at = utcnow()
else:
step.reached_at = None
db.commit()
db.refresh(step)
# 重算进度
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={step_id}, status={step.status}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换步骤状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换步骤状态失败")
# ============ Reviews ============
@router.post("/{goal_id}/reviews", response_model=GoalReviewResponse, status_code=201)
def create_review(goal_id: int, data: GoalReviewCreate, db: Session = Depends(get_db)):
"""创建复盘记录"""
try:
get_or_404(db, Goal, goal_id, "目标")
review = GoalReview(goal_id=goal_id, **data.model_dump())
db.add(review)
db.commit()
db.refresh(review)
logger.info(f"创建复盘成功: goal_id={goal_id}")
return review
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建复盘失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建复盘失败")
@router.delete("/{goal_id}/reviews/{review_id}")
def delete_review(goal_id: int, review_id: int, db: Session = Depends(get_db)):
"""删除复盘记录"""
try:
get_or_404(db, Goal, goal_id, "目标")
review = get_or_404(db, GoalReview, review_id, "复盘记录")
db.delete(review)
db.commit()
logger.info(f"删除复盘成功: id={review_id}")
return DeleteResponse(message="复盘记录删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除复盘失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除复盘失败")
# ============ Task Linking ============
@router.post("/{goal_id}/tasks/{task_id}")
def link_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
"""关联任务到目标"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
task = get_or_404(db, Task, task_id, "任务")
if task not in goal.tasks:
goal.tasks.append(task)
db.commit()
logger.info(f"关联任务成功: goal_id={goal_id}, task_id={task_id}")
return {"message": "关联成功"}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"关联任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="关联任务失败")
@router.delete("/{goal_id}/tasks/{task_id}")
def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
"""取消关联任务"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
task = get_or_404(db, Task, task_id, "任务")
if task in goal.tasks:
goal.tasks.remove(task)
db.commit()
logger.info(f"取消关联任务成功: goal_id={goal_id}, task_id={task_id}")
return DeleteResponse(message="取消关联成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"取消关联任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="取消关联任务失败")

119
api/app/schemas/goal.py Normal file
View File

@@ -0,0 +1,119 @@
from pydantic import BaseModel, Field, field_validator
from datetime import datetime, date
from typing import Optional, List
from app.schemas.category import CategoryResponse
from app.schemas.task import TaskResponse
# ============ GoalStep Schemas ============
class GoalStepBase(BaseModel):
title: str = Field(..., max_length=200)
step_type: str = Field(..., pattern="^(phase|milestone)$")
status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
target_date: Optional[date] = None
parent_id: Optional[int] = None
sort_order: int = Field(default=0)
class GoalStepCreate(GoalStepBase):
pass
class GoalStepUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
step_type: Optional[str] = Field(None, pattern="^(phase|milestone)$")
status: Optional[str] = Field(None, pattern="^(pending|in_progress|completed)$")
target_date: Optional[date] = None
parent_id: Optional[int] = None
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"target_date", "parent_id"}
class GoalStepResponse(GoalStepBase):
id: int
goal_id: int
reached_at: Optional[datetime] = None
created_at: datetime
children: List["GoalStepResponse"] = []
class Config:
from_attributes = True
# ============ GoalReview Schemas ============
class GoalReviewCreate(BaseModel):
content: str = Field(..., min_length=1)
rating: Optional[int] = Field(None, ge=1, le=5)
class GoalReviewResponse(BaseModel):
id: int
goal_id: int
content: str
rating: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# ============ Goal Schemas ============
class GoalBase(BaseModel):
title: str = Field(..., max_length=200)
description: Optional[str] = None
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
target_date: Optional[date] = None
category_id: Optional[int] = None
color: str = Field(default="#FFB7C5", max_length=20)
icon: str = Field(default="flag", max_length=50)
sort_order: int = Field(default=0)
class GoalCreate(GoalBase):
pass
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)$")
target_date: Optional[date] = None
category_id: Optional[int] = None
color: Optional[str] = Field(None, max_length=20)
icon: Optional[str] = Field(None, max_length=50)
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"description", "target_date", "category_id"}
class GoalListResponse(GoalBase):
id: int
progress: int
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
category: Optional[CategoryResponse] = None
total_steps: int = 0
completed_steps: int = 0
class Config:
from_attributes = True
class GoalDetailResponse(GoalListResponse):
steps: List[GoalStepResponse] = []
reviews: List[GoalReviewResponse] = []
tasks: List[TaskResponse] = []
class GoalStatusUpdate(BaseModel):
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")