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

@@ -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>