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> <el-icon><Cherry /></el-icon>
<span>纪念日</span> <span>纪念日</span>
</button> </button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'goals' || currentRouteName === 'goalDetail' }"
@click="router.push('/goals')"
>
<el-icon><Aim /></el-icon>
<span>目标</span>
</button>
</nav> </nav>
<div class="header-right"> <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', name: 'settings',
component: () => import('@/views/SettingsView.vue'), component: () => import('@/views/SettingsView.vue'),
meta: { title: '偏好设置', view: 'settings' } 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 包含全部表定义 # 导入所有模型,确保 Base.metadata 包含全部表定义
from app.models import ( # noqa: F401 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) 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.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
__all__ = [ __all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings", "Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin", "HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary", "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") category = relationship("Category", back_populates="tasks")
tags = relationship("Tag", secondary="task_tags", 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 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() api_router = APIRouter()
@@ -10,3 +10,4 @@ api_router.include_router(tags.router)
api_router.include_router(user_settings.router) api_router.include_router(user_settings.router)
api_router.include_router(habits.router) api_router.include_router(habits.router)
api_router.include_router(anniversaries.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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <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/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js"> <link rel="modulepreload" crossorigin href="/assets/element-plus-C7J9BJ23.js">
<link rel="stylesheet" crossorigin href="/assets/index-BI3KBgCV.css"> <link rel="stylesheet" crossorigin href="/assets/index-DXzKHHP4.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>