diff --git a/WebUI/src/api/goals.ts b/WebUI/src/api/goals.ts new file mode 100644 index 0000000..ac863b1 --- /dev/null +++ b/WebUI/src/api/goals.ts @@ -0,0 +1,71 @@ +import { get, post, put, del, patch } from './request' +import type { + Goal, GoalDetail, GoalFormData, + GoalStep, GoalStepFormData, + GoalReview, GoalReviewFormData, +} from './types' + +// ============ Goals ============ + +export function getGoals(status?: string): Promise { + const params = status ? `?status=${status}` : '' + return get(`/goals${params}`) +} + +export function getGoal(id: number): Promise { + return get(`/goals/${id}`) +} + +export function createGoal(data: GoalFormData): Promise { + return post('/goals', data) +} + +export function updateGoal(id: number, data: Partial): Promise { + return put(`/goals/${id}`, data) +} + +export function deleteGoal(id: number): Promise<{ message: string }> { + return del<{ message: string }>(`/goals/${id}`) +} + +export function updateGoalStatus(id: number, status: string): Promise { + return patch(`/goals/${id}/status`, { status }) +} + +// ============ Steps ============ + +export function createStep(goalId: number, data: GoalStepFormData): Promise { + return post(`/goals/${goalId}/steps`, data) +} + +export function updateStep(goalId: number, stepId: number, data: Partial): Promise { + return put(`/goals/${goalId}/steps/${stepId}`, data) +} + +export function deleteStep(goalId: number, stepId: number): Promise<{ message: string }> { + return del<{ message: string }>(`/goals/${goalId}/steps/${stepId}`) +} + +export function toggleStep(goalId: number, stepId: number): Promise { + return patch(`/goals/${goalId}/steps/${stepId}/toggle`) +} + +// ============ Reviews ============ + +export function createReview(goalId: number, data: GoalReviewFormData): Promise { + return post(`/goals/${goalId}/reviews`, data) +} + +export function deleteReview(goalId: number, reviewId: number): Promise<{ message: string }> { + return del<{ message: string }>(`/goals/${goalId}/reviews/${reviewId}`) +} + +// ============ Task Linking ============ + +export function linkTask(goalId: number, taskId: number): Promise<{ message: string }> { + return post<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`) +} + +export function unlinkTask(goalId: number, taskId: number): Promise<{ message: string }> { + return del<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`) +} diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts index 1fa50dd..83401e9 100644 --- a/WebUI/src/api/types.ts +++ b/WebUI/src/api/types.ts @@ -187,3 +187,80 @@ export interface AnniversaryFormData { } +// ============ 目标相关 ============ + +export type GoalStatus = 'active' | 'paused' | 'completed' | 'abandoned' +export type StepType = 'phase' | 'milestone' +export type StepStatus = 'pending' | 'in_progress' | 'completed' + +export interface Goal { + id: number + title: string + description?: string | null + status: GoalStatus + progress: number + target_date?: string | null + completed_at?: string | null + category_id?: number | null + category?: Category | null + color: string + icon: string + sort_order: number + created_at: string + updated_at: string + total_steps: number + completed_steps: number +} + +export interface GoalDetail extends Goal { + steps: GoalStep[] + reviews: GoalReview[] + tasks: Task[] +} + +export interface GoalStep { + id: number + goal_id: number + parent_id?: number | null + title: string + step_type: StepType + status: StepStatus + target_date?: string | null + reached_at?: string | null + sort_order: number + created_at: string + children: GoalStep[] +} + +export interface GoalReview { + id: number + goal_id: number + content: string + rating?: number | null + created_at: string +} + +export interface GoalFormData { + title: string + description?: string | null + status: GoalStatus + target_date?: string | null + category_id?: number | null + color: string + icon: string + sort_order: number +} + +export interface GoalStepFormData { + title: string + step_type: StepType + status: StepStatus + target_date?: string | null + parent_id?: number | null + sort_order: number +} + +export interface GoalReviewFormData { + content: string + rating?: number | null +} diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue index a23d0a7..eb8e8d2 100644 --- a/WebUI/src/components/AppHeader.vue +++ b/WebUI/src/components/AppHeader.vue @@ -95,6 +95,14 @@ const currentRouteName = computed(() => route.name as string) 纪念日 +
diff --git a/WebUI/src/components/GoalDialog.vue b/WebUI/src/components/GoalDialog.vue new file mode 100644 index 0000000..9f04855 --- /dev/null +++ b/WebUI/src/components/GoalDialog.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index 7fc5784..a0be024 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -55,6 +55,18 @@ const routes: RouteRecordRaw[] = [ name: 'settings', component: () => import('@/views/SettingsView.vue'), meta: { title: '偏好设置', view: 'settings' } + }, + { + path: '/goals', + name: 'goals', + component: () => import('@/views/GoalPage.vue'), + meta: { title: '目标管理', view: 'goals' } + }, + { + path: '/goals/:id', + name: 'goalDetail', + component: () => import('@/views/GoalDetailPage.vue'), + meta: { title: '目标详情', view: 'goals' } } ] diff --git a/WebUI/src/stores/useGoalStore.ts b/WebUI/src/stores/useGoalStore.ts new file mode 100644 index 0000000..eb1d2e3 --- /dev/null +++ b/WebUI/src/stores/useGoalStore.ts @@ -0,0 +1,211 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Goal, GoalDetail, GoalStep, GoalReview, GoalFormData, GoalStepFormData, GoalReviewFormData } from '@/api/types' +import * as goalApi from '@/api/goals' + +export const useGoalStore = defineStore('goal', () => { + const goals = ref([]) + const currentGoal = ref(null) + const loading = ref(false) + const error = ref('') + + const activeGoals = computed(() => goals.value.filter(g => g.status === 'active')) + const pausedGoals = computed(() => goals.value.filter(g => g.status === 'paused')) + const completedGoals = computed(() => goals.value.filter(g => g.status === 'completed')) + + async function fetchGoals(status?: string) { + loading.value = true + error.value = '' + try { + goals.value = await goalApi.getGoals(status) + } catch (e: any) { + error.value = e?.response?.data?.detail || '获取目标列表失败' + } finally { + loading.value = false + } + } + + async function fetchGoal(id: number) { + loading.value = true + error.value = '' + try { + currentGoal.value = await goalApi.getGoal(id) + } catch (e: any) { + error.value = e?.response?.data?.detail || '获取目标详情失败' + } finally { + loading.value = false + } + } + + async function createGoal(data: GoalFormData): Promise { + loading.value = true + error.value = '' + try { + const goal = await goalApi.createGoal(data) + await fetchGoals() + return goal + } catch (e: any) { + error.value = e?.response?.data?.detail || '创建目标失败' + return null + } finally { + loading.value = false + } + } + + async function updateGoal(id: number, data: Partial): Promise { + loading.value = true + error.value = '' + try { + const goal = await goalApi.updateGoal(id, data) + if (currentGoal.value?.id === id) { + currentGoal.value = { ...currentGoal.value, ...goal } + } + await fetchGoals() + return goal + } catch (e: any) { + error.value = e?.response?.data?.detail || '更新目标失败' + return null + } finally { + loading.value = false + } + } + + async function deleteGoal(id: number): Promise { + loading.value = true + error.value = '' + try { + await goalApi.deleteGoal(id) + if (currentGoal.value?.id === id) { + currentGoal.value = null + } + await fetchGoals() + return true + } catch (e: any) { + error.value = e?.response?.data?.detail || '删除目标失败' + return false + } finally { + loading.value = false + } + } + + async function updateGoalStatus(id: number, status: string) { + try { + const goal = await goalApi.updateGoalStatus(id, status) + if (currentGoal.value?.id === id) { + currentGoal.value = { ...currentGoal.value, ...goal } + } + await fetchGoals() + } catch (e: any) { + error.value = e?.response?.data?.detail || '更新状态失败' + } + } + + // ============ Steps ============ + + async function createStep(goalId: number, data: GoalStepFormData): Promise { + try { + const step = await goalApi.createStep(goalId, data) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + await fetchGoals() + return step + } catch (e: any) { + error.value = e?.response?.data?.detail || '添加步骤失败' + return null + } + } + + async function updateStep(goalId: number, stepId: number, data: Partial) { + try { + await goalApi.updateStep(goalId, stepId, data) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + await fetchGoals() + } catch (e: any) { + error.value = e?.response?.data?.detail || '更新步骤失败' + } + } + + async function deleteStep(goalId: number, stepId: number) { + try { + await goalApi.deleteStep(goalId, stepId) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + await fetchGoals() + } catch (e: any) { + error.value = e?.response?.data?.detail || '删除步骤失败' + } + } + + async function toggleStep(goalId: number, stepId: number) { + try { + await goalApi.toggleStep(goalId, stepId) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + await fetchGoals() + } catch (e: any) { + error.value = e?.response?.data?.detail || '切换步骤状态失败' + } + } + + // ============ Reviews ============ + + async function createReview(goalId: number, data: GoalReviewFormData) { + try { + await goalApi.createReview(goalId, data) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + } catch (e: any) { + error.value = e?.response?.data?.detail || '创建复盘失败' + } + } + + async function deleteReview(goalId: number, reviewId: number) { + try { + await goalApi.deleteReview(goalId, reviewId) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + } catch (e: any) { + error.value = e?.response?.data?.detail || '删除复盘失败' + } + } + + // ============ Task Linking ============ + + async function linkTask(goalId: number, taskId: number) { + try { + await goalApi.linkTask(goalId, taskId) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + } catch (e: any) { + error.value = e?.response?.data?.detail || '关联任务失败' + } + } + + async function unlinkTask(goalId: number, taskId: number) { + try { + await goalApi.unlinkTask(goalId, taskId) + if (currentGoal.value?.id === goalId) { + await fetchGoal(goalId) + } + } catch (e: any) { + error.value = e?.response?.data?.detail || '取消关联失败' + } + } + + return { + goals, currentGoal, loading, error, + activeGoals, pausedGoals, completedGoals, + fetchGoals, fetchGoal, createGoal, updateGoal, deleteGoal, updateGoalStatus, + createStep, updateStep, deleteStep, toggleStep, + createReview, deleteReview, + linkTask, unlinkTask, + } +}) diff --git a/WebUI/src/views/GoalDetailPage.vue b/WebUI/src/views/GoalDetailPage.vue new file mode 100644 index 0000000..c909319 --- /dev/null +++ b/WebUI/src/views/GoalDetailPage.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/WebUI/src/views/GoalPage.vue b/WebUI/src/views/GoalPage.vue new file mode 100644 index 0000000..4193a1c --- /dev/null +++ b/WebUI/src/views/GoalPage.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/api/app/database.py b/api/app/database.py index c331347..2e6b27e 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -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) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index c086458..c36e730 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -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", ] diff --git a/api/app/models/goal.py b/api/app/models/goal.py new file mode 100644 index 0000000..f89a14e --- /dev/null +++ b/api/app/models/goal.py @@ -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") diff --git a/api/app/models/task.py b/api/app/models/task.py index cc82431..492d3ca 100644 --- a/api/app/models/task.py +++ b/api/app/models/task.py @@ -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") diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index 3afe881..56d9e06 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -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) diff --git a/api/app/routers/goals.py b/api/app/routers/goals.py new file mode 100644 index 0000000..f9957b2 --- /dev/null +++ b/api/app/routers/goals.py @@ -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="取消关联任务失败") diff --git a/api/app/schemas/goal.py b/api/app/schemas/goal.py new file mode 100644 index 0000000..98cd17b --- /dev/null +++ b/api/app/schemas/goal.py @@ -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)$") diff --git a/api/webui/index.html b/api/webui/index.html index 639cf9d..1969b98 100644 --- a/api/webui/index.html +++ b/api/webui/index.html @@ -5,10 +5,10 @@ webui - + - - + +