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>