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:
71
WebUI/src/api/goals.ts
Normal file
71
WebUI/src/api/goals.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
208
WebUI/src/components/GoalDialog.vue
Normal file
208
WebUI/src/components/GoalDialog.vue
Normal 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>
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
211
WebUI/src/stores/useGoalStore.ts
Normal file
211
WebUI/src/stores/useGoalStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
495
WebUI/src/views/GoalDetailPage.vue
Normal file
495
WebUI/src/views/GoalDetailPage.vue
Normal 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>
|
||||
246
WebUI/src/views/GoalPage.vue
Normal file
246
WebUI/src/views/GoalPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user