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:
30
backend/alembic/versions/014_add_schedule_goal_fields.py
Normal file
30
backend/alembic/versions/014_add_schedule_goal_fields.py
Normal 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)
|
||||
@@ -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 ID(schedule_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 ID(schedule_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="定时执行时发送的消息内容")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
195
frontend/src/stores/goal.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
210
frontend/src/views/DigitalEmployeeFactory.vue
Normal file
210
frontend/src/views/DigitalEmployeeFactory.vue
Normal 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>
|
||||
263
frontend/src/views/GoalDetail.vue
Normal file
263
frontend/src/views/GoalDetail.vue
Normal 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>
|
||||
Reference in New Issue
Block a user