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

71
WebUI/src/api/goals.ts Normal file
View File

@@ -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<Goal[]> {
const params = status ? `?status=${status}` : ''
return get<Goal[]>(`/goals${params}`)
}
export function getGoal(id: number): Promise<GoalDetail> {
return get<GoalDetail>(`/goals/${id}`)
}
export function createGoal(data: GoalFormData): Promise<GoalDetail> {
return post<GoalDetail>('/goals', data)
}
export function updateGoal(id: number, data: Partial<GoalFormData>): Promise<GoalDetail> {
return put<GoalDetail>(`/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<GoalDetail> {
return patch<GoalDetail>(`/goals/${id}/status`, { status })
}
// ============ Steps ============
export function createStep(goalId: number, data: GoalStepFormData): Promise<GoalStep> {
return post<GoalStep>(`/goals/${goalId}/steps`, data)
}
export function updateStep(goalId: number, stepId: number, data: Partial<GoalStepFormData>): Promise<GoalStep> {
return put<GoalStep>(`/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<GoalStep> {
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
}
// ============ Reviews ============
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {
return post<GoalReview>(`/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}`)
}

View File

@@ -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
}

View File

@@ -95,6 +95,14 @@ const currentRouteName = computed(() => route.name as string)
<el-icon><Cherry /></el-icon>
<span>纪念日</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'goals' || currentRouteName === 'goalDetail' }"
@click="router.push('/goals')"
>
<el-icon><Aim /></el-icon>
<span>目标</span>
</button>
</nav>
<div class="header-right">

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import type { Goal, GoalFormData } from '@/api/types'
const props = defineProps<{
visible: boolean
editingGoal?: Goal | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const goalStore = useGoalStore()
const categoryStore = useCategoryStore()
const form = ref<GoalFormData>({
title: '',
description: null,
status: 'active',
target_date: null,
category_id: null,
color: '#FFB7C5',
icon: 'flag',
sort_order: 0,
})
const isEditing = ref(false)
const saving = ref(false)
watch(() => props.visible, (val) => {
if (val) {
categoryStore.fetchCategories()
if (props.editingGoal) {
isEditing.value = true
form.value = {
title: props.editingGoal.title,
description: props.editingGoal.description ?? null,
status: props.editingGoal.status,
target_date: props.editingGoal.target_date ?? null,
category_id: props.editingGoal.category_id ?? null,
color: props.editingGoal.color,
icon: props.editingGoal.icon,
sort_order: props.editingGoal.sort_order,
}
} else {
isEditing.value = false
form.value = {
title: '',
description: null,
status: 'active',
target_date: null,
category_id: null,
color: '#FFB7C5',
icon: 'flag',
sort_order: 0,
}
}
}
})
async function handleSave() {
if (!form.value.title.trim()) return
saving.value = true
try {
if (isEditing.value && props.editingGoal) {
await goalStore.updateGoal(props.editingGoal.id, form.value)
} else {
await goalStore.createGoal(form.value)
}
emit('saved')
} finally {
saving.value = false
}
}
const colorOptions = [
'#FFB7C5', '#FF6B81', '#FFA502', '#7BED9F', '#70A1FF', '#5352ED', '#A29BFE', '#FF7979',
]
const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny', 'moon']
</script>
<template>
<el-dialog
:model-value="visible"
:title="isEditing ? '编辑目标' : '新建目标'"
width="520px"
@close="emit('close')"
destroy-on-close
>
<el-form :model="form" label-position="top">
<el-form-item label="目标名称" required>
<el-input v-model="form.title" maxlength="200" placeholder="例如:学好 Rust 开发" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="描述一下这个目标..." />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%">
<el-option label="进行中" value="active" />
<el-option label="暂停" value="paused" />
<el-option label="已完成" value="completed" />
<el-option label="已放弃" value="abandoned" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标日期">
<el-date-picker v-model="form.target_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分类">
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="颜色">
<div class="color-picker">
<button
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ active: form.color === c }"
:style="{ background: c }"
@click.prevent="form.color = c"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="图标">
<div class="icon-picker">
<button
v-for="icon in iconOptions" :key="icon"
class="icon-btn"
:class="{ active: form.icon === icon }"
@click.prevent="form.icon = icon"
>
<el-icon :size="20"><component :is="icon" /></el-icon>
</button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.color-picker {
display: flex;
gap: 6px;
align-items: center;
padding-top: 4px;
.color-dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
&:hover { transform: scale(1.15); }
&.active { border-color: #333; transform: scale(1.1); }
}
}
.icon-picker {
display: flex;
gap: 8px;
.icon-btn {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
color: #666;
&:hover { border-color: var(--primary); color: var(--primary); }
&.active { background: var(--primary); color: white; border-color: var(--primary); }
}
}
</style>

View File

@@ -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' }
}
]

View File

@@ -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<Goal[]>([])
const currentGoal = ref<GoalDetail | null>(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<GoalDetail | null> {
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<GoalFormData>): Promise<GoalDetail | null> {
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<boolean> {
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<GoalStep | null> {
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<GoalStepFormData>) {
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,
}
})

View File

@@ -0,0 +1,495 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useTaskStore } from '@/stores/useTaskStore'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal, GoalStep } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const goalStore = useGoalStore()
const taskStore = useTaskStore()
const goalId = computed(() => Number(route.params.id))
const stepFormVisible = ref(false)
const stepParentId = ref<number | null>(null)
const stepTitle = ref('')
const stepType = ref<'phase' | 'milestone'>('milestone')
const reviewFormVisible = ref(false)
const reviewContent = ref('')
const reviewRating = ref<number | null>(null)
const editDialogVisible = ref(false)
const linkTaskVisible = ref(false)
const selectedTaskId = ref<number | null>(null)
onMounted(async () => {
await goalStore.fetchGoal(goalId.value)
await taskStore.fetchTasks()
})
function goBack() {
router.push('/goals')
}
const stepLabel: Record<string, string> = {
pending: '待开始',
in_progress: '进行中',
completed: '已完成',
}
const stepColor: Record<string, string> = {
pending: '#909399',
in_progress: '#E6A23C',
completed: '#67C23A',
}
async function handleToggleStep(stepId: number) {
await goalStore.toggleStep(goalId.value, stepId)
}
async function handleDeleteStep(stepId: number) {
try {
await ElMessageBox.confirm('确定删除此步骤吗?', '删除确认', { type: 'warning' })
await goalStore.deleteStep(goalId.value, stepId)
} catch {}
}
async function handleAddStep() {
if (!stepTitle.value.trim()) return
await goalStore.createStep(goalId.value, {
title: stepTitle.value,
step_type: stepType.value,
status: 'pending',
parent_id: stepParentId.value,
sort_order: 0,
})
stepTitle.value = ''
stepParentId.value = null
stepFormVisible.value = false
}
async function handleAddReview() {
if (!reviewContent.value.trim()) return
await goalStore.createReview(goalId.value, {
content: reviewContent.value,
rating: reviewRating.value,
})
reviewContent.value = ''
reviewRating.value = null
reviewFormVisible.value = false
}
async function handleDeleteReview(reviewId: number) {
try {
await ElMessageBox.confirm('确定删除此复盘记录吗?', '删除确认', { type: 'warning' })
await goalStore.deleteReview(goalId.value, reviewId)
} catch {}
}
async function handleLinkTask() {
if (!selectedTaskId.value) return
await goalStore.linkTask(goalId.value, selectedTaskId.value)
selectedTaskId.value = null
linkTaskVisible.value = false
}
async function handleUnlinkTask(taskId: number) {
await goalStore.unlinkTask(goalId.value, taskId)
}
const editableGoal = computed(() => goalStore.currentGoal ? {
id: goalStore.currentGoal.id,
title: goalStore.currentGoal.title,
description: goalStore.currentGoal.description ?? null,
status: goalStore.currentGoal.status,
target_date: goalStore.currentGoal.target_date ?? null,
category_id: goalStore.currentGoal.category_id ?? null,
color: goalStore.currentGoal.color,
icon: goalStore.currentGoal.icon,
sort_order: goalStore.currentGoal.sort_order,
} as Goal : null)
const unlinkedTasks = computed(() => {
if (!goalStore.currentGoal) return []
const linkedIds = new Set(goalStore.currentGoal.tasks.map(t => t.id))
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
})
const statusLabel: Record<string, string> = {
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
}
</script>
<template>
<div class="goal-detail" v-if="goalStore.currentGoal">
<div class="detail-header">
<el-button text :icon="ArrowLeft" @click="goBack">返回</el-button>
<div class="header-actions">
<el-button @click="editDialogVisible = true">编辑目标</el-button>
</div>
</div>
<div class="goal-hero" :style="{ borderColor: goalStore.currentGoal.color }">
<div class="hero-left">
<el-icon :size="36" :color="goalStore.currentGoal.color">
<component :is="goalStore.currentGoal.icon" />
</el-icon>
<div>
<h1>{{ goalStore.currentGoal.title }}</h1>
<p v-if="goalStore.currentGoal.description" class="hero-desc">{{ goalStore.currentGoal.description }}</p>
</div>
</div>
<div class="hero-right">
<div class="big-progress">{{ goalStore.currentGoal.progress }}%</div>
<el-progress
:percentage="goalStore.currentGoal.progress"
:color="goalStore.currentGoal.color"
:stroke-width="10"
:show-text="false"
style="width:140px"
/>
</div>
</div>
<div class="detail-meta">
<el-tag>{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }}</el-tag>
<span v-if="goalStore.currentGoal.target_date">目标日期: {{ goalStore.currentGoal.target_date }}</span>
<span v-if="goalStore.currentGoal.category">
<el-icon :size="14"><Folder /></el-icon>
{{ goalStore.currentGoal.category.name }}
</span>
<span>{{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成</span>
</div>
<div class="detail-body">
<!-- 阶段 & 里程碑 -->
<section class="steps-section">
<div class="section-header">
<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 = 'phase'">添加阶段</el-button>
</div>
<div v-if="stepFormVisible" class="step-form">
<el-input v-model="stepTitle" placeholder="步骤名称" size="small" style="flex:1" @keyup.enter="handleAddStep" />
<el-select v-model="stepType" size="small" style="width:100px">
<el-option label="里程碑" value="milestone" />
<el-option label="阶段" value="phase" />
</el-select>
<el-select v-if="stepType === 'milestone'" v-model="stepParentId" size="small" style="width:140px" clearable placeholder="所属阶段(可选)">
<el-option
v-for="s in goalStore.currentGoal.steps.filter((s: GoalStep) => s.step_type === 'phase')"
:key="s.id" :label="s.title" :value="s.id"
/>
</el-select>
<el-button size="small" type="primary" @click="handleAddStep">添加</el-button>
<el-button size="small" @click="stepFormVisible = false">取消</el-button>
</div>
<div v-if="goalStore.currentGoal.steps.length === 0" class="empty-hint">还没有阶段或里程碑点击上方按钮添加</div>
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
<!-- Phase -->
<div v-if="step.step_type === 'phase'" class="phase-node">
<div class="step-row" :class="step.status">
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[step.status] }"
@click="handleToggleStep(step.id)"
>
<el-icon :size="18">
<CircleCheck v-if="step.status === 'completed'" />
<Loading v-else-if="step.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ step.title }}</span>
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[step.status] }}
</el-tag>
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
</div>
<!-- child milestones -->
<div v-for="child in step.children" :key="child.id" class="child-milestone">
<div class="step-row" :class="child.status">
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[child.status] }"
@click="handleToggleStep(child.id)"
>
<el-icon :size="16">
<CircleCheck v-if="child.status === 'completed'" />
<Loading v-else-if="child.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ child.title }}</span>
<el-tag size="small" :type="child.status === 'completed' ? 'success' : child.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[child.status] }}
</el-tag>
<span v-if="child.target_date" class="step-date">{{ child.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(child.id)" />
</div>
</div>
</div>
<!-- Standalone milestone (no parent phase) -->
<div v-else class="step-row standalone" :class="step.status">
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[step.status] }"
@click="handleToggleStep(step.id)"
>
<el-icon :size="18">
<CircleCheck v-if="step.status === 'completed'" />
<Loading v-else-if="step.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ step.title }}</span>
<el-tag size="small" type="info" effect="plain">里程碑</el-tag>
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[step.status] }}
</el-tag>
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
</div>
</div>
</section>
<!-- 关联任务 -->
<section class="tasks-section">
<div class="section-header">
<h3>关联任务</h3>
<el-button size="small" :icon="Plus" @click="linkTaskVisible = true">关联任务</el-button>
</div>
<div v-if="linkTaskVisible" class="link-form">
<el-select v-model="selectedTaskId" placeholder="选择要关联的任务" size="small" style="flex:1" filterable>
<el-option v-for="t in unlinkedTasks" :key="t.id" :label="t.title" :value="t.id" />
</el-select>
<el-button size="small" type="primary" @click="handleLinkTask" :disabled="!selectedTaskId">关联</el-button>
<el-button size="small" @click="linkTaskVisible = false">取消</el-button>
</div>
<div v-if="goalStore.currentGoal.tasks.length === 0" class="empty-hint">暂无关联任务</div>
<div v-for="task in goalStore.currentGoal.tasks" :key="task.id" class="linked-task">
<span class="task-title">{{ task.title }}</span>
<span v-if="task.is_completed" class="done-badge">已完成</span>
<span v-else class="pending-badge">进行中</span>
<el-button text size="small" type="danger" @click="handleUnlinkTask(task.id)">取消关联</el-button>
</div>
</section>
<!-- 复盘记录 -->
<section class="reviews-section">
<div class="section-header">
<h3>复盘记录</h3>
<el-button size="small" :icon="Plus" @click="reviewFormVisible = true">添加复盘</el-button>
</div>
<div v-if="reviewFormVisible" class="review-form">
<el-input
v-model="reviewContent"
type="textarea"
:rows="3"
placeholder="记录本次复盘的思考和收获..."
/>
<div class="review-form-actions">
<span>自评:</span>
<el-rate v-model="reviewRating" :max="5" show-score />
<el-button size="small" type="primary" @click="handleAddReview" :disabled="!reviewContent.trim()">提交</el-button>
<el-button size="small" @click="reviewFormVisible = false">取消</el-button>
</div>
</div>
<div v-if="goalStore.currentGoal.reviews.length === 0" class="empty-hint">暂无复盘记录</div>
<div v-for="review in goalStore.currentGoal.reviews" :key="review.id" class="review-card">
<div class="review-meta">
<span class="review-date">{{ review.created_at.slice(0, 10) }}</span>
<el-rate v-if="review.rating" :model-value="review.rating" :max="5" disabled show-score size="small" />
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteReview(review.id)" />
</div>
<p class="review-content">{{ review.content }}</p>
</div>
</section>
</div>
<GoalDialog
:visible="editDialogVisible"
:editing-goal="editableGoal"
@close="editDialogVisible = false"
@saved="editDialogVisible = false"
/>
</div>
<div v-else-if="goalStore.loading" class="loading-state">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
</template>
<style scoped lang="scss">
.goal-detail {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.detail-header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.goal-hero {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border: 1px solid #eee;
border-left: 5px solid #FFB7C5;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
.hero-left {
display: flex;
align-items: center;
gap: 16px;
h1 { margin: 0; font-size: 22px; }
.hero-desc { margin: 6px 0 0; color: #999; font-size: 14px; }
}
.hero-right {
text-align: center;
.big-progress { font-size: 32px; font-weight: 800; color: #333; margin-bottom: 8px; }
}
}
.detail-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
font-size: 13px;
color: #999;
}
.detail-body {
display: flex;
flex-direction: column;
gap: 28px;
}
section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
h3 { margin: 0; font-size: 16px; flex:1; }
}
.empty-hint {
color: #ccc;
font-size: 13px;
padding: 16px 0;
text-align: center;
}
// Steps
.step-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
transition: background 0.15s;
&:hover { background: #fafafa; }
.toggle-btn { padding: 4px; }
.step-title { flex: 1; font-size: 14px; }
.step-date { font-size: 12px; color: #bbb; }
}
.phase-node {
margin-bottom: 8px;
.phase-node { padding-left: 32px; }
}
.child-milestone {
padding-left: 32px;
}
.standalone {
padding: 8px 4px;
}
.step-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
// Reviews
.review-form {
margin-bottom: 16px;
.review-form-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
}
.review-card {
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
&:last-child { border-bottom: none; }
.review-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
.review-date { font-size: 12px; color: #999; }
}
.review-content { font-size: 14px; line-height: 1.6; color: #555; margin: 0; }
}
// Tasks
.link-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.linked-task {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
.task-title { flex: 1; font-size: 14px; }
.done-badge { font-size: 12px; color: #67C23A; }
.pending-badge { font-size: 12px; color: #E6A23C; }
}
.loading-state { text-align: center; padding: 80px; }
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal } from '@/api/types'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const goalStore = useGoalStore()
const dialogVisible = ref(false)
const editingGoal = ref<Goal | null>(null)
const statusFilter = ref<string>('')
onMounted(async () => {
await goalStore.fetchGoals()
})
function openCreate() {
editingGoal.value = null
dialogVisible.value = true
}
function openEdit(goal: Goal) {
editingGoal.value = goal
dialogVisible.value = true
}
async function handleDelete(goal: Goal) {
try {
await ElMessageBox.confirm(`确定要删除目标「${goal.title}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await goalStore.deleteGoal(goal.id)
} catch {}
}
function goDetail(id: number) {
router.push(`/goals/${id}`)
}
function onSaved() {
dialogVisible.value = false
}
const filteredGoals = ref<Goal[]>([])
function applyFilter() {
goalStore.fetchGoals(statusFilter.value || undefined)
}
const statusLabel: Record<string, string> = {
active: '进行中',
paused: '已暂停',
completed: '已完成',
abandoned: '已放弃',
}
const statusColor: Record<string, string> = {
active: '#67C23A',
paused: '#E6A23C',
completed: '#409EFF',
abandoned: '#909399',
}
</script>
<template>
<div class="goal-page">
<div class="page-header">
<h2>目标管理</h2>
<div class="header-actions">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width:140px" @change="applyFilter">
<el-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" />
<el-option label="已完成" value="completed" />
<el-option label="已放弃" value="abandoned" />
</el-select>
<el-button type="primary" :icon="Plus" @click="openCreate">新建目标</el-button>
</div>
</div>
<div v-if="goalStore.loading" class="loading-state">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
<div v-else-if="goalStore.goals.length === 0" class="empty-state">
<el-icon :size="64" color="#ccc"><Flag /></el-icon>
<p>还没有目标</p>
<p class="hint">设定一个长期目标拆解成阶段和里程碑来追踪进度</p>
<el-button type="primary" @click="openCreate">创建第一个目标</el-button>
</div>
<div v-else class="goal-grid">
<div
v-for="goal in goalStore.goals"
:key="goal.id"
class="goal-card"
:style="{ borderLeftColor: goal.color }"
@click="goDetail(goal.id)"
>
<div class="card-top">
<el-icon :size="24" :color="goal.color"><component :is="goal.icon" /></el-icon>
<div class="card-actions" @click.stop>
<el-button text size="small" @click="openEdit(goal)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete(goal)">删除</el-button>
</div>
</div>
<h3 class="card-title">{{ goal.title }}</h3>
<p v-if="goal.description" class="card-desc">{{ goal.description }}</p>
<div class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ goal.progress }}%</span>
<span class="progress-steps">{{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑</span>
</div>
<el-progress
:percentage="goal.progress"
:color="goal.color"
:stroke-width="8"
:show-text="false"
/>
</div>
<div class="card-footer">
<el-tag :color="statusColor[goal.status]" effect="dark" size="small">
{{ statusLabel[goal.status] || goal.status }}
</el-tag>
<span v-if="goal.target_date" class="target-date">目标日期: {{ goal.target_date }}</span>
<span v-if="goal.category" class="category-badge">
<el-icon :size="14"><Folder /></el-icon>
{{ goal.category.name }}
</span>
</div>
</div>
</div>
<GoalDialog :visible="dialogVisible" :editing-goal="editingGoal" @close="dialogVisible = false" @saved="onSaved" />
</div>
</template>
<style scoped lang="scss">
.goal-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 { margin: 0; font-size: 22px; }
.header-actions { display: flex; gap: 12px; }
}
.loading-state, .empty-state {
text-align: center;
padding: 80px 0;
color: #999;
.hint { font-size: 13px; color: #bbb; margin-top: 8px; }
}
.goal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
.goal-card {
background: white;
border-radius: 12px;
padding: 20px;
border-left: 4px solid #FFB7C5;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
}
.goal-card:hover .card-actions { opacity: 1; }
.card-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 13px;
color: #999;
margin: 0 0 16px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.progress-section {
margin-bottom: 14px;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
.progress-text { font-weight: 700; color: #333; }
.progress-steps { color: #999; }
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #999;
.category-badge {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
}
</style>

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)$")

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BMiBC4ZK.js"></script>
<script type="module" crossorigin src="/assets/index-DHLFfahW.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js">
<link rel="stylesheet" crossorigin href="/assets/index-BI3KBgCV.css">
<link rel="modulepreload" crossorigin href="/assets/element-plus-C7J9BJ23.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXzKHHP4.css">
</head>
<body>
<div id="app"></div>