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>
401 lines
14 KiB
Python
401 lines
14 KiB
Python
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="取消关联任务失败")
|