feat: add Digital Employee Factory frontend, Goal scheduling, and Phase 4 UI

- New pages: DigitalEmployeeFactory.vue (goal grid + create dialog), GoalDetail.vue (task tree + progress)
- New store: goal.ts (Pinia store with full Goal/Task API bindings)
- Router: add /digital-employees and /goals/:id routes
- MainLayout: add "数字员工工厂" nav menu item with UserFilled icon
- Schedule model: add schedule_type, goal_id, goal_config for Goal-type cron scheduling
- Schedule service: add _create_execution_for_goal_schedule for periodic Goal creation
- Migration 014: add schedule_type/goal_id/goal_config columns to agent_schedules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-08 22:09:52 +08:00
parent d0b55f2b16
commit dca6020730
8 changed files with 767 additions and 3 deletions

View File

@@ -0,0 +1,30 @@
"""add schedule_type, goal_id, goal_config to agent_schedules
Revision ID: 014_add_schedule_goal_fields
Revises: 013_add_goals_tasks
Create Date: 2026-05-08
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import CHAR
revision = "014_add_schedule_goal_fields"
down_revision = "013_add_goals_tasks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("agent_schedules", sa.Column("schedule_type", sa.String(20), default="agent", comment="调度类型: agent / goal"))
op.add_column("agent_schedules", sa.Column("goal_id", CHAR(36), sa.ForeignKey("goals.id"), nullable=True, comment="关联 Goal ID"))
op.add_column("agent_schedules", sa.Column("goal_config", sa.JSON, nullable=True, comment="Goal 调度配置"))
op.create_index("ix_agent_schedules_goal_id", "agent_schedules", ["goal_id"])
op.alter_column("agent_schedules", "agent_id", existing_type=CHAR(36), nullable=True)
def downgrade() -> None:
op.drop_index("ix_agent_schedules_goal_id", table_name="agent_schedules")
op.drop_column("agent_schedules", "goal_config")
op.drop_column("agent_schedules", "goal_id")
op.drop_column("agent_schedules", "schedule_type")
op.alter_column("agent_schedules", "agent_id", existing_type=CHAR(36), nullable=False)

View File

@@ -1,7 +1,7 @@
"""Agent 定时任务表:按 cron 表达式周期执行 Agent"""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, Boolean
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, Boolean, JSON
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
@@ -11,7 +11,10 @@ class AgentSchedule(Base):
__tablename__ = "agent_schedules"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()))
agent_id = Column(CHAR(36), ForeignKey("agents.id"), nullable=False, index=True, comment="关联 Agent ID")
agent_id = Column(CHAR(36), ForeignKey("agents.id"), nullable=True, index=True, comment="关联 Agent IDschedule_type=agent 时必填)")
schedule_type = Column(String(20), default="agent", comment="调度类型: agent / goal")
goal_id = Column(CHAR(36), ForeignKey("goals.id"), nullable=True, index=True, comment="关联 Goal IDschedule_type=goal 时必填)")
goal_config = Column(JSON, nullable=True, comment="Goal 调度配置: {title, description, priority}")
name = Column(String(100), nullable=False, comment="任务名称")
cron_expression = Column(String(100), nullable=False, comment="cron 表达式,如 0 9 * * *")
input_message = Column(Text, nullable=False, comment="定时执行时发送的消息内容")

View File

@@ -47,6 +47,46 @@ def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz:
return next_utc.replace(tzinfo=None)
def _create_execution_for_goal_schedule(db: Session, schedule) -> Optional[str]:
"""为 Goal 类型定时任务创建 Goal 并投递执行。
Args:
db: 数据库会话
schedule: AgentSchedule ORM 对象 (schedule_type == "goal")
Returns:
创建的 goal_id失败返回 None
"""
from app.services.goal_service import create_goal, update_goal
from app.tasks.goal_tasks import execute_goal_task
gc = schedule.goal_config or {}
title = gc.get("title", schedule.input_message or schedule.name)
description = gc.get("description", "")
priority = gc.get("priority", 5)
main_agent_id = schedule.agent_id or gc.get("main_agent_id")
try:
goal = create_goal(
db=db,
creator_id=schedule.user_id,
title=title,
description=description,
priority=priority,
main_agent_id=main_agent_id,
)
task = execute_goal_task.delay(str(goal.id))
update_goal(db, str(goal.id), status="active")
logger.info(
"Goal 定时任务 %s 已投递: goal_id=%s celery_task=%s",
schedule.id, goal.id, task.id,
)
return str(goal.id)
except Exception as e:
logger.error("Goal 定时任务 %s 投递失败: %s", schedule.id, e)
return None
def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
"""为定时任务创建 Execution 记录并投递 Celery 任务。
@@ -60,6 +100,10 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
from app.models.execution import Execution
from app.models.agent import Agent
# Goal 类型调度:创建 Goal 并投递执行
if getattr(schedule, "schedule_type", "agent") == "goal" and schedule.goal_id:
return _create_execution_for_goal_schedule(db, schedule)
agent = db.query(Agent).filter(Agent.id == schedule.agent_id).first()
if not agent:
logger.warning("定时任务 %s 关联的 Agent %s 不存在", schedule.id, schedule.agent_id)

View File

@@ -39,6 +39,10 @@
<el-icon><Share /></el-icon>
<span>Agent协作</span>
</el-menu-item>
<el-menu-item index="digital-employees" @click="router.push('/digital-employees')">
<el-icon><UserFilled /></el-icon>
<span>数字员工工厂</span>
</el-menu-item>
<el-menu-item index="agent-schedules" @click="router.push('/agent-schedules')">
<el-icon><Clock /></el-icon>
<span>定时任务</span>
@@ -106,7 +110,7 @@
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock, Share, ChatLineSquare, Shop } from '@element-plus/icons-vue'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock, Share, ChatLineSquare, Shop, UserFilled } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -131,6 +135,7 @@ const activeMenu = computed(() => {
if (route.path === '/alert-rules') return 'alert-rules'
if (route.path === '/agent-chat' || route.path.startsWith('/agent-chat/')) return 'agent-chat'
if (route.path === '/agent-schedules') return 'agent-schedules'
if (route.path === '/digital-employees' || route.path.startsWith('/goals/')) return 'digital-employees'
return 'workflows'
})
@@ -166,6 +171,8 @@ const handleMenuSelect = (key: string) => {
router.push('/alert-rules')
} else if (key === 'agent-schedules') {
router.push('/agent-schedules')
} else if (key === 'digital-employees') {
router.push('/digital-employees')
}
}

View File

@@ -153,6 +153,18 @@ const router = createRouter({
name: 'agent-market',
component: () => import('@/views/AgentMarket.vue'),
meta: { requiresAuth: true }
},
{
path: '/digital-employees',
name: 'digital-employees',
component: () => import('@/views/DigitalEmployeeFactory.vue'),
meta: { requiresAuth: true }
},
{
path: '/goals/:id',
name: 'goal-detail',
component: () => import('@/views/GoalDetail.vue'),
meta: { requiresAuth: true }
}
]
})

195
frontend/src/stores/goal.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Goal/Task 状态管理 — 数字员工工厂
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
export interface Goal {
id: string
title: string
description?: string
status: string
priority: number
progress: number
plan?: any
autonomy_config?: any
creator_id: string
main_agent_id?: string
parent_goal_id?: string
started_at?: string
completed_at?: string
deadline?: string
created_at: string
updated_at: string
}
export interface Task {
id: string
goal_id: string
title: string
description?: string
status: string
priority: number
task_config?: any
parent_task_id?: string
depends_on?: string[]
result?: any
error_message?: string
execution_id?: string
assigned_agent_id?: string
assigned_agent_name?: string
requires_approval: boolean
approver_id?: string
approval_status?: string
started_at?: string
completed_at?: string
deadline?: string
created_at: string
updated_at: string
}
export interface GoalTaskTree {
goal: Record<string, any>
tasks: any[]
}
export const useGoalStore = defineStore('goal', () => {
const goals = ref<Goal[]>([])
const currentGoal = ref<Goal | null>(null)
const taskTree = ref<GoalTaskTree | null>(null)
const tasks = ref<Task[]>([])
const loading = ref(false)
// ─── Goals ───
const fetchGoals = async (options?: { status?: string; skip?: number; limit?: number }) => {
loading.value = true
try {
const params: any = {}
if (options?.status) params.status = options.status
if (options?.skip !== undefined) params.skip = options.skip
if (options?.limit !== undefined) params.limit = options.limit
const response = await api.get('/api/v1/goals', { params })
goals.value = response.data
return response.data
} finally {
loading.value = false
}
}
const fetchGoal = async (goalId: string) => {
loading.value = true
try {
const response = await api.get(`/api/v1/goals/${goalId}`)
currentGoal.value = response.data
return response.data
} finally {
loading.value = false
}
}
const createGoal = async (data: Partial<Goal> & { title: string }) => {
const response = await api.post('/api/v1/goals', data)
goals.value.unshift(response.data)
return response.data
}
const updateGoal = async (goalId: string, data: Partial<Goal>) => {
const response = await api.put(`/api/v1/goals/${goalId}`, data)
const idx = goals.value.findIndex(g => g.id === goalId)
if (idx >= 0) goals.value[idx] = response.data
if (currentGoal.value?.id === goalId) currentGoal.value = response.data
return response.data
}
const deleteGoal = async (goalId: string) => {
await api.delete(`/api/v1/goals/${goalId}`)
goals.value = goals.value.filter(g => g.id !== goalId)
if (currentGoal.value?.id === goalId) currentGoal.value = null
}
const decomposeGoal = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/decompose`)
return response.data
}
const startGoal = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/start`)
if (currentGoal.value?.id === goalId) currentGoal.value = response.data
return response.data
}
const pauseGoal = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/pause`)
if (currentGoal.value?.id === goalId) currentGoal.value = response.data
return response.data
}
const resumeGoal = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/resume`)
if (currentGoal.value?.id === goalId) currentGoal.value = response.data
return response.data
}
const executeGoalAsync = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/execute-async`)
return response.data
}
const replanGoal = async (goalId: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/replan`)
return response.data
}
// ─── Tasks ───
const fetchTaskTree = async (goalId: string) => {
const response = await api.get(`/api/v1/goals/${goalId}/tasks`)
taskTree.value = response.data
return response.data
}
const fetchTasks = async (options?: { goal_id?: string; status?: string; limit?: number }) => {
const params: any = {}
if (options?.goal_id) params.goal_id = options.goal_id
if (options?.status) params.status = options.status
if (options?.limit) params.limit = options.limit
const response = await api.get('/api/v1/tasks', { params })
tasks.value = response.data
return response.data
}
const fetchTask = async (taskId: string) => {
const response = await api.get(`/api/v1/tasks/${taskId}`)
return response.data
}
const updateTask = async (taskId: string, data: Partial<Task>) => {
const response = await api.put(`/api/v1/tasks/${taskId}`, data)
return response.data
}
const approveTask = async (taskId: string) => {
const response = await api.post(`/api/v1/tasks/${taskId}/approve`)
return response.data
}
const rejectTask = async (taskId: string) => {
const response = await api.post(`/api/v1/tasks/${taskId}/reject`)
return response.data
}
const checkTaskDeps = async (taskId: string) => {
const response = await api.get(`/api/v1/tasks/${taskId}/check-dependencies`)
return response.data
}
return {
goals, currentGoal, taskTree, tasks, loading,
fetchGoals, fetchGoal, createGoal, updateGoal, deleteGoal,
decomposeGoal, startGoal, pauseGoal, resumeGoal, executeGoalAsync, replanGoal,
fetchTaskTree, fetchTasks, fetchTask, updateTask,
approveTask, rejectTask, checkTaskDeps,
}
})

View File

@@ -0,0 +1,210 @@
<template>
<div class="digital-employee-factory">
<div class="page-header">
<div>
<h2>数字员工工厂</h2>
<p class="subtitle">创建目标 Main Agent 自主分解任务调度执行追踪进度</p>
</div>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
创建目标
</el-button>
</div>
<!-- 目标卡片列表 -->
<div v-if="goals.length === 0 && !loading" class="empty-state">
<el-empty description="还没有任何目标">
<el-button type="primary" @click="showCreateDialog = true">创建第一个目标</el-button>
</el-empty>
</div>
<div v-else class="goal-grid">
<el-card
v-for="goal in goals"
:key="goal.id"
class="goal-card"
:shadow="'hover'"
@click="router.push(`/goals/${goal.id}`)"
>
<template #header>
<div class="goal-card-header">
<el-tag
:type="statusTagType(goal.status)"
size="small"
>{{ statusLabel(goal.status) }}</el-tag>
<span class="goal-priority">P{{ goal.priority }}</span>
</div>
</template>
<h3>{{ goal.title }}</h3>
<p class="goal-desc">{{ goal.description || '无描述' }}</p>
<el-progress
:percentage="Math.round(goal.progress * 100)"
:status="goal.progress >= 1 ? 'success' : undefined"
:stroke-width="8"
/>
<div class="goal-meta">
<span>截止: {{ goal.deadline ? new Date(goal.deadline).toLocaleDateString() : '无期限' }}</span>
<span>创建: {{ new Date(goal.created_at).toLocaleDateString() }}</span>
</div>
</el-card>
</div>
<!-- 创建目标对话框 -->
<el-dialog v-model="showCreateDialog" title="创建新目标" width="550px">
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="90px">
<el-form-item label="目标标题" prop="title">
<el-input v-model="createForm.title" placeholder="例如帮我调研竞品AI平台功能差异" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="详细描述你希望数字员工完成的任务..." />
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="createForm.priority">
<el-option v-for="p in [1,2,3,4,5,6,7,8,9,10]" :key="p" :label="`P${p}`" :value="p" />
</el-select>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="createForm.deadline" type="datetime" placeholder="选择截止日期" />
</el-form-item>
<el-form-item label="Main Agent">
<el-select v-model="createForm.main_agent_id" placeholder="选择管理此目标的 Agent" clearable>
<el-option v-for="a in mainAgents" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="creating">创建并开始</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/goal'
import type { Goal, Task } from '@/stores/goal'
import api from '@/api'
const router = useRouter()
const goalStore = useGoalStore()
const goals = ref<Goal[]>([])
const loading = ref(false)
const showCreateDialog = ref(false)
const creating = ref(false)
const createFormRef = ref()
const mainAgents = ref<any[]>([])
const createForm = reactive({
title: '',
description: '',
priority: 5,
deadline: null as any,
main_agent_id: '',
})
const createRules = {
title: [{ required: true, message: '请输入目标标题', trigger: 'blur' }],
}
const statusTagType = (s: string) => {
const m: Record<string, any> = { active: 'success', paused: 'warning', completed: '', failed: 'danger', cancelled: 'info' }
return m[s] || 'info'
}
const statusLabel = (s: string) => {
const m: Record<string, string> = { active: '进行中', paused: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' }
return m[s] || s
}
const loadGoals = async () => {
loading.value = true
try {
goals.value = await goalStore.fetchGoals()
} finally {
loading.value = false
}
}
const loadMainAgents = async () => {
try {
const resp = await api.get('/api/v1/agents', { params: { agent_type: 'main' } })
mainAgents.value = resp.data || []
// 如果没找到 main agent尝试获取所有 agents
if (mainAgents.value.length === 0) {
const resp2 = await api.get('/api/v1/agents', { params: { limit: 20 } })
mainAgents.value = resp2.data || []
}
} catch { /* ignore */ }
}
const handleCreate = async () => {
if (!createFormRef.value) return
await createFormRef.value.validate(async (valid: boolean) => {
if (valid) {
creating.value = true
try {
const goal = await goalStore.createGoal({
title: createForm.title,
description: createForm.description,
priority: createForm.priority,
deadline: createForm.deadline ? new Date(createForm.deadline).toISOString() : undefined,
main_agent_id: createForm.main_agent_id || undefined,
})
ElMessage.success('目标已创建')
showCreateDialog.value = false
// 自动分解 + 异步执行
try {
await goalStore.decomposeGoal(goal.id)
ElMessage.info('任务分解完成,正在启动执行...')
} catch { /* 分解可能失败,不影响使用 */ }
try {
await goalStore.executeGoalAsync(goal.id)
ElMessage.success('目标已提交执行')
} catch { /* Decomposition may fail, proceed anyway */ }
await loadGoals()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建失败')
} finally {
creating.value = false
}
}
})
}
onMounted(() => {
loadGoals()
loadMainAgents()
})
</script>
<style scoped>
.digital-employee-factory {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.page-header h2 { margin: 0 0 4px 0; }
.subtitle { color: #909399; font-size: 14px; margin: 0; }
.empty-state { margin-top: 80px; }
.goal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.goal-card { cursor: pointer; transition: transform 0.15s; }
.goal-card:hover { transform: translateY(-2px); }
.goal-card-header { display: flex; justify-content: space-between; align-items: center; }
.goal-priority { font-weight: bold; color: #909399; font-size: 13px; }
.goal-card h3 { margin: 8px 0; font-size: 16px; }
.goal-desc { color: #606266; font-size: 13px; margin: 4px 0 12px; min-height: 20px; }
.goal-meta { display: flex; justify-content: space-between; margin-top: 10px; font-size: 12px; color: #c0c4cc; }
</style>

View File

@@ -0,0 +1,263 @@
<template>
<div class="goal-detail" v-loading="loading">
<!-- 顶部栏 -->
<div class="detail-header">
<el-button @click="router.back()" :icon="ArrowLeft" text>返回</el-button>
<div class="header-info">
<h2>{{ goal?.title }}</h2>
<el-tag :type="statusTagType(goal?.status)" size="small">{{ statusLabel(goal?.status) }}</el-tag>
</div>
<div class="header-actions">
<el-button v-if="goal?.status === 'active'" @click="handlePause" type="warning" plain>暂停</el-button>
<el-button v-if="goal?.status === 'paused'" @click="handleResume" type="success" plain>恢复</el-button>
<el-button v-if="goal?.status === 'active' || goal?.status === 'paused'" @click="handleReplan" plain>重新规划</el-button>
<el-button @click="handleDelete" type="danger" plain :disabled="deleting">删除</el-button>
</div>
</div>
<!-- 进度条 -->
<el-card class="progress-card">
<el-progress
:percentage="Math.round((goal?.progress || 0) * 100)"
:status="goal?.progress >= 1 ? 'success' : undefined"
:stroke-width="12"
/>
<div class="progress-meta">
<span>优先级: P{{ goal?.priority }}</span>
<span>截止: {{ goal?.deadline ? new Date(goal.deadline).toLocaleDateString() : '无期限' }}</span>
<span>创建: {{ goal?.created_at ? new Date(goal.created_at).toLocaleDateString() : '-' }}</span>
</div>
</el-card>
<!-- 目标描述 -->
<el-card v-if="goal?.description" class="desc-card">
<template #header><span>目标描述</span></template>
<p>{{ goal.description }}</p>
</el-card>
<!-- 任务树 -->
<el-card class="task-tree-card">
<template #header>
<div class="card-header-row">
<span>任务列表 ({{ tasks.length }})</span>
<el-button size="small" @click="loadTaskTree" :loading="loadingTasks">刷新</el-button>
</div>
</template>
<div v-if="tasks.length === 0 && !loadingTasks" class="empty-tasks">
<el-empty description="尚未分解任务" :image-size="80" />
</div>
<div v-else class="task-list">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ 'task-has-deps': task.depends_on?.length }"
>
<div class="task-main">
<div class="task-left">
<el-tag :type="taskStatusType(task.status)" size="small" class="task-status-tag">
{{ taskStatusLabel(task.status) }}
</el-tag>
<span class="task-title">{{ task.title }}</span>
</div>
<div class="task-right">
<span v-if="task.assigned_agent_name" class="task-agent">
<el-icon><User /></el-icon> {{ task.assigned_agent_name }}
</span>
<span v-if="task.depends_on?.length" class="task-deps-badge">
依赖 {{ task.depends_on.length }}
</span>
<span class="task-priority">P{{ task.priority }}</span>
</div>
</div>
<div v-if="task.description" class="task-desc">{{ task.description }}</div>
<div v-if="task.error_message" class="task-error">
<el-icon><WarningFilled /></el-icon> {{ task.error_message }}
</div>
<div v-if="task.result" class="task-result">
<el-collapse>
<el-collapse-item title="执行结果">
<pre>{{ formatResult(task.result) }}</pre>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, User, WarningFilled } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/goal'
const route = useRoute()
const router = useRouter()
const goalStore = useGoalStore()
const goalId = ref(route.params.id as string)
const goal = ref<any>(null)
const tasks = ref<any[]>([])
const loading = ref(false)
const loadingTasks = ref(false)
const deleting = ref(false)
const statusTagType = (s?: string) => {
const m: Record<string, any> = { active: 'success', paused: 'warning', completed: '', failed: 'danger', cancelled: 'info' }
return m[s || ''] || 'info'
}
const statusLabel = (s?: string) => {
const m: Record<string, string> = { active: '进行中', paused: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' }
return m[s || ''] || s
}
const taskStatusType = (s: string) => {
const m: Record<string, any> = { pending: 'info', in_progress: 'warning', completed: 'success', failed: 'danger', cancelled: 'info', awaiting_approval: '' }
return m[s] || 'info'
}
const taskStatusLabel = (s: string) => {
const m: Record<string, string> = { pending: '待执行', in_progress: '执行中', completed: '已完成', failed: '失败', cancelled: '已取消', awaiting_approval: '待审批' }
return m[s] || s
}
const formatResult = (r: any) => {
if (typeof r === 'string') return r
try { return JSON.stringify(r, null, 2) } catch { return String(r) }
}
const loadGoal = async () => {
loading.value = true
try {
goal.value = await goalStore.fetchGoal(goalId.value)
} finally {
loading.value = false
}
}
const loadTaskTree = async () => {
loadingTasks.value = true
try {
const tree = await goalStore.fetchTaskTree(goalId.value)
tasks.value = tree?.tasks || []
} finally {
loadingTasks.value = false
}
}
const handlePause = async () => {
try {
await goalStore.pauseGoal(goalId.value)
ElMessage.success('目标已暂停')
await loadGoal()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '暂停失败')
}
}
const handleResume = async () => {
try {
await goalStore.resumeGoal(goalId.value)
ElMessage.success('目标已恢复')
await loadGoal()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '恢复失败')
}
}
const handleReplan = async () => {
try {
await ElMessageBox.confirm('重新规划将清除未完成的任务并重新分解,确定继续?', '确认')
await goalStore.replanGoal(goalId.value)
ElMessage.success('重新规划完成')
await loadGoal()
await loadTaskTree()
} catch { /* cancelled */ }
}
const handleDelete = async () => {
try {
await ElMessageBox.confirm('确定删除此目标及其所有任务?', '确认删除', { type: 'warning' })
deleting.value = true
await goalStore.deleteGoal(goalId.value)
ElMessage.success('目标已删除')
router.back()
} catch (e: any) {
if (e !== 'cancel' && e !== 'close') {
ElMessage.error(e.response?.data?.detail || '删除失败')
}
} finally {
deleting.value = false
}
}
onMounted(() => {
loadGoal()
loadTaskTree()
})
</script>
<style scoped>
.goal-detail {
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-info {
display: flex;
align-items: center;
gap: 12px;
}
.header-info h2 { margin: 0; font-size: 20px; }
.header-actions { display: flex; gap: 8px; }
.progress-card { margin-bottom: 16px; }
.progress-meta {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 13px;
color: #909399;
}
.desc-card { margin-bottom: 16px; }
.desc-card p { color: #606266; font-size: 14px; margin: 0; }
.task-tree-card { margin-bottom: 16px; }
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-tasks { padding: 20px 0; }
.task-list { display: flex; flex-direction: column; gap: 8px; }
.task-item {
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
transition: background 0.15s;
}
.task-item:hover { background: #f5f7fa; }
.task-has-deps { border-left: 3px solid #e6a23c; }
.task-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.task-left { display: flex; align-items: center; gap: 10px; }
.task-title { font-weight: 500; font-size: 14px; }
.task-right { display: flex; align-items: center; gap: 12px; font-size: 12px; color: #909399; }
.task-agent { display: flex; align-items: center; gap: 4px; }
.task-deps-badge { color: #e6a23c; font-weight: 500; }
.task-priority { font-weight: bold; }
.task-desc { margin-top: 6px; font-size: 13px; color: #606266; }
.task-error { margin-top: 6px; font-size: 13px; color: #f56c6c; display: flex; align-items: center; gap: 4px; }
.task-result { margin-top: 8px; }
.task-result pre { font-size: 12px; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; }
</style>