feat: add education-training and platform-engineering team templates
- Add 2 new team templates: 教育培训团队 (4 roles) and 天工平台工程团队 (5 roles) - Fix orchestrator to support multi-template workflow types (remove hardcoded PM planner) - Add _resolve_planner_role() to auto-detect planner based on team.config.workflow - Add frontend buttons and API clients for new templates - Merge 14 preset roles from 3 template families in get_preset_roles() - Add creation guide and platform engineering usage docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
146
frontend/src/api/teams.ts
Normal file
146
frontend/src/api/teams.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import api from './index'
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
team_id: string
|
||||
agent_id: string
|
||||
role: string
|
||||
position: number
|
||||
is_lead: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
workspace_id: string | null
|
||||
user_id: string
|
||||
is_template: boolean
|
||||
config: any
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
members?: TeamMember[]
|
||||
}
|
||||
|
||||
export interface PresetRole {
|
||||
label: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PhaseResult {
|
||||
phase: number
|
||||
name: string
|
||||
role: string
|
||||
agent_name: string | null
|
||||
output: string
|
||||
success: boolean
|
||||
iterations?: number
|
||||
tool_calls?: number
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
team_id: string
|
||||
team_name: string
|
||||
project_description: string
|
||||
started_at: string
|
||||
completed_at?: string
|
||||
plan: any
|
||||
phases: PhaseResult[]
|
||||
qa_review: any
|
||||
final_deliverable: string
|
||||
success: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
/** 获取预置角色定义 */
|
||||
export function getPresetRoles() {
|
||||
return api.get('/api/v1/teams/preset-roles')
|
||||
}
|
||||
|
||||
/** 列出团队 */
|
||||
export function listTeams(params?: { workspace_id?: string }) {
|
||||
return api.get('/api/v1/teams', { params })
|
||||
}
|
||||
|
||||
/** 获取团队详情 */
|
||||
export function getTeam(teamId: string) {
|
||||
return api.get(`/api/v1/teams/${teamId}`)
|
||||
}
|
||||
|
||||
/** 创建团队 */
|
||||
export function createTeam(data: {
|
||||
name: string
|
||||
description?: string
|
||||
workspace_id?: string
|
||||
config?: any
|
||||
}) {
|
||||
return api.post('/api/v1/teams', data)
|
||||
}
|
||||
|
||||
/** 更新团队 */
|
||||
export function updateTeam(teamId: string, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
config?: any
|
||||
status?: string
|
||||
}) {
|
||||
return api.put(`/api/v1/teams/${teamId}`, data)
|
||||
}
|
||||
|
||||
/** 删除团队 */
|
||||
export function deleteTeam(teamId: string) {
|
||||
return api.delete(`/api/v1/teams/${teamId}`)
|
||||
}
|
||||
|
||||
/** 一键创建软件公司虚拟团队模板 */
|
||||
export function createSoftwareCompanyTemplate(workspaceId?: string) {
|
||||
return api.post('/api/v1/teams/template/software-company', null, {
|
||||
params: workspaceId ? { workspace_id: workspaceId } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 一键创建教育培训团队模板 */
|
||||
export function createEducationTrainingTemplate(workspaceId?: string) {
|
||||
return api.post('/api/v1/teams/template/education-training', null, {
|
||||
params: workspaceId ? { workspace_id: workspaceId } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 一键创建天工平台工程团队模板 */
|
||||
export function createPlatformEngineeringTemplate(workspaceId?: string) {
|
||||
return api.post('/api/v1/teams/template/platform-engineering', null, {
|
||||
params: workspaceId ? { workspace_id: workspaceId } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 添加团队成员 */
|
||||
export function addMember(teamId: string, data: {
|
||||
agent_id: string
|
||||
role: string
|
||||
position?: number
|
||||
is_lead?: boolean
|
||||
}) {
|
||||
return api.post(`/api/v1/teams/${teamId}/members`, data)
|
||||
}
|
||||
|
||||
/** 移除团队成员 */
|
||||
export function removeMember(teamId: string, memberId: string) {
|
||||
return api.delete(`/api/v1/teams/${teamId}/members/${memberId}`)
|
||||
}
|
||||
|
||||
/** 列出团队成员 */
|
||||
export function listMembers(teamId: string) {
|
||||
return api.get(`/api/v1/teams/${teamId}/members`)
|
||||
}
|
||||
|
||||
/** 执行团队项目 */
|
||||
export function executeProject(teamId: string, projectDescription: string, autoApproveFiles = true) {
|
||||
return api.post(`/api/v1/teams/${teamId}/execute`, {
|
||||
project_description: projectDescription,
|
||||
auto_approve_files: autoApproveFiles,
|
||||
}, { timeout: 600000 }) // 10 min timeout for long executions
|
||||
}
|
||||
970
frontend/src/views/TeamBuilder.vue
Normal file
970
frontend/src/views/TeamBuilder.vue
Normal file
@@ -0,0 +1,970 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="team-builder">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="teamName"
|
||||
placeholder="团队名称"
|
||||
style="width: 260px"
|
||||
@change="onTeamNameChange"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSaveTeam" :loading="saving">
|
||||
<el-icon><Check /></el-icon> 保存团队
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleCreateTemplate" :loading="creatingTemplate">
|
||||
<el-icon><MagicStick /></el-icon> 软件公司模板
|
||||
</el-button>
|
||||
<el-button type="warning" @click="handleCreateEducationTemplate" :loading="creatingEduTemplate">
|
||||
<el-icon><MagicStick /></el-icon> 教育培训模板
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleCreatePlatformTemplate" :loading="creatingPlatformTemplate">
|
||||
<el-icon><MagicStick /></el-icon> 天工平台工程团队
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="selectedTeamId" placeholder="加载已有团队" clearable style="width: 220px" @change="handleLoadTeam">
|
||||
<el-option v-for="t in teams" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
<el-button @click="loadTeams" :loading="loadingTeams">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体区域 -->
|
||||
<div class="main-area">
|
||||
<!-- 左侧:可用 Agent 列表 -->
|
||||
<div class="left-panel">
|
||||
<div class="panel-title">可用 Agent</div>
|
||||
<el-input v-model="agentSearch" placeholder="搜索Agent..." size="small" style="margin-bottom: 8px" clearable />
|
||||
<div class="agent-list">
|
||||
<div
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
class="agent-item"
|
||||
:class="{ active: selectedAgent?.id === agent.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(agent)"
|
||||
@click="selectedAgent = agent"
|
||||
>
|
||||
<span class="agent-name">{{ agent.name }}</span>
|
||||
<el-tag size="small" type="info">{{ agent.agent_type || 'specialist' }}</el-tag>
|
||||
</div>
|
||||
<el-empty v-if="filteredAgents.length === 0" description="暂无可用Agent" :image-size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:角色槽位 -->
|
||||
<div class="center-panel">
|
||||
<div class="panel-title">团队角色</div>
|
||||
<div class="role-slots">
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.key"
|
||||
class="role-card"
|
||||
:class="{
|
||||
filled: roleSlots[role.key],
|
||||
'drag-over': dragOverRole === role.key,
|
||||
}"
|
||||
@dragover.prevent="dragOverRole = role.key"
|
||||
@dragleave="dragOverRole = null"
|
||||
@drop="onDrop(role.key)"
|
||||
>
|
||||
<div class="role-header">
|
||||
<span class="role-icon">{{ role.icon }}</span>
|
||||
<span class="role-label">{{ role.label }}</span>
|
||||
<el-tag v-if="roleSlots[role.key]?.is_lead" size="small" type="danger">Leader</el-tag>
|
||||
</div>
|
||||
<div class="role-desc">{{ role.description }}</div>
|
||||
<div v-if="roleSlots[role.key]" class="role-assigned">
|
||||
<el-tag type="success" closable @close="removeFromSlot(role.key)">
|
||||
{{ roleSlots[role.key].name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="role-empty">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>拖拽 Agent 到此处</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:Agent 详情 -->
|
||||
<div class="right-panel" v-if="selectedAgent">
|
||||
<div class="panel-title">Agent 配置</div>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="名称">{{ selectedAgent.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ selectedAgent.agent_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ selectedAgent.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ selectedAgent.description || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top: 12px">
|
||||
<el-button size="small" @click="router.push(`/agents/${selectedAgent.id}/config`)">
|
||||
编辑 Agent
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:项目输入 + 执行 -->
|
||||
<div class="bottom-area">
|
||||
<div class="project-input">
|
||||
<el-input
|
||||
v-model="projectDescription"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入项目描述,例如:做一个支持用户注册登录的个人博客系统,包含文章发布、评论功能..."
|
||||
/>
|
||||
<el-switch
|
||||
v-model="autoApproveFiles"
|
||||
active-text="自动写入文件"
|
||||
style="margin-left: 12px"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleExecute('sync')"
|
||||
:loading="executing"
|
||||
:disabled="!projectDescription || !hasAnyRole"
|
||||
style="margin-left: 12px"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon> 执行项目
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
@click="handleExecute('stream')"
|
||||
:loading="executingStream"
|
||||
:disabled="!projectDescription || !hasAnyRole"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
<el-icon><Connection /></el-icon> 流式执行
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 执行结果 -->
|
||||
<div v-if="executing || executingStream || executeResult || streamEvents.length" class="result-area">
|
||||
<div class="result-header">
|
||||
<h3>执行结果</h3>
|
||||
<el-button v-if="executeResult || streamEvents.length" size="small" @click="executeResult = null; streamEvents = []; streamDeliverable = ''; streamProjectPath = ''; streamProjectFiles = []">清空</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 流式事件 -->
|
||||
<div v-if="streamEvents.length" class="stream-events">
|
||||
<div v-for="(evt, i) in streamEvents" :key="i" class="stream-event" :class="'event-' + evt.type">
|
||||
<template v-if="evt.type === 'plan_start'">
|
||||
<el-alert type="info" :closable="false">
|
||||
PM 开始规划 — 团队: {{ evt.team_name }} ({{ evt.member_count }} 名成员)
|
||||
</el-alert>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'plan_done'">
|
||||
<el-alert :type="evt.success ? 'success' : 'error'" :closable="false">
|
||||
PM 规划完成 — {{ evt.plan?.phases?.length || 0 }} 个阶段
|
||||
</el-alert>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'phase_start'">
|
||||
<div class="phase-start">
|
||||
<el-icon :size="18"><Loading /></el-icon>
|
||||
阶段 {{ evt.phase }}: {{ evt.name }} ({{ evt.role }}) — Agent: {{ evt.agent }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'phase_done'">
|
||||
<el-collapse>
|
||||
<el-collapse-item>
|
||||
<template #title>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<el-tag :type="evt.success ? 'success' : 'danger'" size="small">阶段{{ evt.phase }}</el-tag>
|
||||
<span>{{ evt.name }}</span>
|
||||
<span style="color:#909399;font-size:12px">({{ evt.role }})</span>
|
||||
<span v-if="evt.iterations" style="color:#909399;font-size:12px">{{ evt.iterations }}次迭代</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="output-content" v-html="renderMarkdown(evt.output || '')" />
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'qa_done'">
|
||||
<el-collapse>
|
||||
<el-collapse-item>
|
||||
<template #title>
|
||||
<el-tag :type="evt.success ? 'success' : 'info'" size="small">QA审查</el-tag>
|
||||
</template>
|
||||
<div class="output-content" v-html="renderMarkdown(evt.output || '')" />
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'final'">
|
||||
<el-alert type="success" :closable="false">
|
||||
项目交付完成!共 {{ evt.phase_count }} 个阶段
|
||||
</el-alert>
|
||||
</template>
|
||||
<template v-else-if="evt.type === 'error'">
|
||||
<el-alert type="error" :closable="false">{{ evt.content }}</el-alert>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流式完成后显示最终交付物 -->
|
||||
<div v-if="streamDeliverable" style="margin-top:12px">
|
||||
<el-divider />
|
||||
<h4 style="margin:0 0 8px 0">最终交付物</h4>
|
||||
<div class="output-content" v-html="renderMarkdown(streamDeliverable)" />
|
||||
</div>
|
||||
|
||||
<!-- 流式项目文件路径 -->
|
||||
<div v-if="streamProjectPath" style="margin-top:12px">
|
||||
<el-divider />
|
||||
<h4 style="margin:0 0 8px 0">项目文件</h4>
|
||||
<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span style="font-weight:600;white-space:nowrap">保存路径:</span>
|
||||
<el-input :model-value="streamProjectPath" readonly size="small" style="flex:1;min-width:300px" />
|
||||
<el-button size="small" @click="copyPath(streamProjectPath)">复制</el-button>
|
||||
</div>
|
||||
<div v-if="streamProjectFiles.length">
|
||||
<span style="font-weight:600;font-size:13px">产出文件 ({{ streamProjectFiles.length }}):</span>
|
||||
<ul style="margin-top:4px;padding-left:20px">
|
||||
<li v-for="f in streamProjectFiles" :key="f" style="font-size:13px;color:#409eff;margin-bottom:4px;word-break:break-all">{{ f }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<el-empty v-else description="暂无产出文件" :image-size="48" />
|
||||
</div>
|
||||
|
||||
<!-- 同步结果 -->
|
||||
<div v-if="executeResult" class="sync-result">
|
||||
<el-tabs>
|
||||
<el-tab-pane label="项目计划">
|
||||
<pre class="plan-json">{{ JSON.stringify(executeResult.plan, null, 2) }}</pre>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-for="p in executeResult.phases" :key="p.phase" :label="'阶段' + p.phase + ': ' + p.name">
|
||||
<div class="phase-output">
|
||||
<el-tag :type="p.success ? 'success' : 'danger'" style="margin-bottom: 8px">
|
||||
{{ p.role }} — {{ p.agent_name }}
|
||||
</el-tag>
|
||||
<div class="output-content" v-html="renderMarkdown(p.output)" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="executeResult.qa_review" label="QA审查">
|
||||
<pre class="plan-json">{{ JSON.stringify(executeResult.qa_review, null, 2) }}</pre>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="最终交付物">
|
||||
<div class="output-content" v-html="renderMarkdown(executeResult.final_deliverable)" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="executeResult.project_path" label="项目文件">
|
||||
<div style="margin-bottom:12px">
|
||||
<span style="font-weight:600;margin-right:8px">保存路径:</span>
|
||||
<el-input
|
||||
:model-value="executeResult.project_path"
|
||||
readonly
|
||||
size="small"
|
||||
style="width:500px;max-width:100%"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="copyPath(executeResult.project_path)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div v-if="executeResult.files?.length">
|
||||
<span style="font-weight:600">产出文件 ({{ executeResult.files.length }}):</span>
|
||||
<ul style="margin-top:8px;padding-left:20px">
|
||||
<li v-for="f in executeResult.files" :key="f" style="font-size:13px;color:#409eff;margin-bottom:4px;word-break:break-all">{{ f }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<el-empty v-else description="暂无产出文件" :image-size="48" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import {
|
||||
Check, MagicStick, Refresh, Plus, VideoPlay, Connection, Loading,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
listTeams, getTeam, createTeam, updateTeam, createSoftwareCompanyTemplate,
|
||||
createEducationTrainingTemplate, createPlatformEngineeringTemplate, addMember, removeMember, executeProject, getPresetRoles,
|
||||
} from '@/api/teams'
|
||||
import api from '@/api'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
agent_type?: string
|
||||
status?: string
|
||||
workflow_config?: any
|
||||
}
|
||||
|
||||
interface RoleSlot {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 团队
|
||||
const teamName = ref('我的软件团队')
|
||||
const selectedTeamId = ref<string | null>(null)
|
||||
const currentTeamId = ref<string | null>(null)
|
||||
const teams = ref<any[]>([])
|
||||
const saving = ref(false)
|
||||
const creatingTemplate = ref(false)
|
||||
const creatingEduTemplate = ref(false)
|
||||
const creatingPlatformTemplate = ref(false)
|
||||
const loadingTeams = ref(false)
|
||||
|
||||
// Agent
|
||||
const agents = ref<Agent[]>([])
|
||||
const agentSearch = ref('')
|
||||
const selectedAgent = ref<Agent | null>(null)
|
||||
|
||||
// 角色槽位
|
||||
const roles = ref<RoleSlot[]>([])
|
||||
const roleSlots = ref<Record<string, any>>({})
|
||||
|
||||
// 拖拽
|
||||
const dragOverRole = ref<string | null>(null)
|
||||
let draggingAgent: Agent | null = null
|
||||
|
||||
// 项目执行
|
||||
const projectDescription = ref('')
|
||||
const executing = ref(false)
|
||||
const executingStream = ref(false)
|
||||
const executeResult = ref<any>(null)
|
||||
const streamEvents = ref<any[]>([])
|
||||
const streamDeliverable = ref<string>('')
|
||||
const streamProjectPath = ref<string>('')
|
||||
const streamProjectFiles = ref<string[]>([])
|
||||
const autoApproveFiles = ref(true)
|
||||
|
||||
const hasAnyRole = computed(() => Object.values(roleSlots.value).some(v => !!v))
|
||||
|
||||
// 过滤 Agent
|
||||
const filteredAgents = computed(() => {
|
||||
if (!agentSearch.value) return agents.value
|
||||
const kw = agentSearch.value.toLowerCase()
|
||||
return agents.value.filter(a => a.name.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
// 加载预置角色
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await getPresetRoles()
|
||||
const roleMap = res.data?.roles || {}
|
||||
roles.value = Object.entries(roleMap).map(([key, info]: [string, any]) => ({
|
||||
key,
|
||||
label: info.label,
|
||||
icon: info.icon,
|
||||
description: info.description,
|
||||
}))
|
||||
} catch (e) {
|
||||
// 使用默认角色
|
||||
roles.value = [
|
||||
{ key: 'pm', label: '项目经理', icon: '📋', description: '分析需求、分解任务、制定计划、协调团队' },
|
||||
{ key: 'designer', label: 'UI/UX设计师', icon: '🎨', description: '设计用户流程、线框图、组件层级、视觉规范' },
|
||||
{ key: 'developer', label: '开发工程师', icon: '💻', description: '编写后端API、前端组件、业务逻辑、数据库设计' },
|
||||
{ key: 'qa', label: '测试工程师', icon: '🔍', description: '审查交付物、发现缺陷、验证验收标准、质量把关' },
|
||||
{ key: 'devops', label: 'DevOps工程师', icon: '🚀', description: '部署配置、CI/CD、环境管理、健康检查' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Agent 列表
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const res = await api.get('/api/v1/agents', { params: { page_size: 200 } })
|
||||
agents.value = (Array.isArray(res.data) ? res.data : (res.data?.data || res.data?.items || [])) as Agent[]
|
||||
} catch (e) {
|
||||
console.error('加载 Agent 列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载团队列表
|
||||
async function loadTeams() {
|
||||
loadingTeams.value = true
|
||||
try {
|
||||
const res = await listTeams()
|
||||
teams.value = (res.data?.data || []) as any[]
|
||||
} catch (e) {
|
||||
console.error('加载团队列表失败:', e)
|
||||
} finally {
|
||||
loadingTeams.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载团队详情
|
||||
async function handleLoadTeam(teamId: string | null) {
|
||||
if (!teamId) {
|
||||
resetSlots()
|
||||
currentTeamId.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getTeam(teamId)
|
||||
const team = res.data?.data
|
||||
if (team) {
|
||||
teamName.value = team.name
|
||||
currentTeamId.value = team.id
|
||||
const slots: Record<string, any> = {}
|
||||
if (team.members) {
|
||||
for (const m of team.members) {
|
||||
// 找到对应的 Agent
|
||||
const agent = agents.value.find(a => a.id === m.agent_id)
|
||||
if (agent) {
|
||||
slots[m.role] = { ...agent, is_lead: m.is_lead, member_id: m.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
roleSlots.value = slots
|
||||
ElMessage.success(`已加载团队: ${team.name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载团队失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存团队
|
||||
async function handleSaveTeam() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = { name: teamName.value }
|
||||
if (currentTeamId.value) {
|
||||
await updateTeam(currentTeamId.value, payload)
|
||||
} else {
|
||||
const res = await createTeam(payload)
|
||||
currentTeamId.value = (res.data as any)?.data?.id
|
||||
}
|
||||
|
||||
// 同步成员
|
||||
if (currentTeamId.value) {
|
||||
for (const [role, agent] of Object.entries(roleSlots.value)) {
|
||||
if (agent) {
|
||||
await addMember(currentTeamId.value, {
|
||||
agent_id: (agent as any).id,
|
||||
role,
|
||||
position: roles.value.findIndex(r => r.key === role),
|
||||
is_lead: (agent as any).is_lead || false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ElMessage.success('团队已保存')
|
||||
await loadTeams()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键创建软件公司模板
|
||||
async function handleCreateTemplate() {
|
||||
creatingTemplate.value = true
|
||||
try {
|
||||
const res = await createSoftwareCompanyTemplate()
|
||||
const team = (res.data as any)?.data
|
||||
if (team) {
|
||||
currentTeamId.value = team.id
|
||||
teamName.value = team.name
|
||||
// 重新加载 agents(因为创建了新 agent)
|
||||
await loadAgents()
|
||||
// 映射成员到槽位
|
||||
const slots: Record<string, any> = {}
|
||||
if (team.members) {
|
||||
for (const m of team.members) {
|
||||
const agent = agents.value.find(a => a.id === m.agent_id)
|
||||
if (agent) {
|
||||
slots[m.role] = { ...agent, is_lead: m.is_lead, member_id: m.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
roleSlots.value = slots
|
||||
ElMessage.success('软件公司虚拟团队创建成功!')
|
||||
}
|
||||
await loadTeams()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建模板失败')
|
||||
} finally {
|
||||
creatingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键创建教育培训模板
|
||||
async function handleCreateEducationTemplate() {
|
||||
creatingEduTemplate.value = true
|
||||
try {
|
||||
const res = await createEducationTrainingTemplate()
|
||||
const team = (res.data as any)?.data
|
||||
if (team) {
|
||||
currentTeamId.value = team.id
|
||||
teamName.value = team.name
|
||||
await loadAgents()
|
||||
const slots: Record<string, any> = {}
|
||||
if (team.members) {
|
||||
for (const m of team.members) {
|
||||
const agent = agents.value.find(a => a.id === m.agent_id)
|
||||
if (agent) {
|
||||
slots[m.role] = { ...agent, is_lead: m.is_lead, member_id: m.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
roleSlots.value = slots
|
||||
ElMessage.success('教育培训团队创建成功!')
|
||||
}
|
||||
await loadTeams()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建模板失败')
|
||||
} finally {
|
||||
creatingEduTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键创建天工平台工程团队模板
|
||||
async function handleCreatePlatformTemplate() {
|
||||
creatingPlatformTemplate.value = true
|
||||
try {
|
||||
const res = await createPlatformEngineeringTemplate()
|
||||
const team = (res.data as any)?.data
|
||||
if (team) {
|
||||
currentTeamId.value = team.id
|
||||
teamName.value = team.name
|
||||
await loadAgents()
|
||||
const slots: Record<string, any> = {}
|
||||
if (team.members) {
|
||||
for (const m of team.members) {
|
||||
const agent = agents.value.find(a => a.id === m.agent_id)
|
||||
if (agent) {
|
||||
slots[m.role] = { ...agent, is_lead: m.is_lead, member_id: m.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
roleSlots.value = slots
|
||||
ElMessage.success('天工平台工程团队创建成功!')
|
||||
}
|
||||
await loadTeams()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建模板失败')
|
||||
} finally {
|
||||
creatingPlatformTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽
|
||||
function onDragStart(agent: Agent) {
|
||||
draggingAgent = agent
|
||||
}
|
||||
|
||||
function onDrop(role: string) {
|
||||
if (draggingAgent) {
|
||||
roleSlots.value[role] = draggingAgent
|
||||
dragOverRole.value = null
|
||||
draggingAgent = null
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromSlot(role: string) {
|
||||
delete roleSlots.value[role]
|
||||
}
|
||||
|
||||
function resetSlots() {
|
||||
roleSlots.value = {}
|
||||
}
|
||||
|
||||
// 执行项目
|
||||
async function handleExecute(mode: 'sync' | 'stream') {
|
||||
if (!currentTeamId.value) {
|
||||
ElMessage.warning('请先保存团队')
|
||||
return
|
||||
}
|
||||
if (!projectDescription.value) {
|
||||
ElMessage.warning('请输入项目描述')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'sync') {
|
||||
executing.value = true
|
||||
executeResult.value = null
|
||||
streamDeliverable.value = ''
|
||||
streamProjectPath.value = ''
|
||||
streamProjectFiles.value = []
|
||||
try {
|
||||
const res = await executeProject(currentTeamId.value, projectDescription.value, autoApproveFiles.value)
|
||||
executeResult.value = (res.data as any)?.data
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '执行失败')
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
} else {
|
||||
executingStream.value = true
|
||||
streamEvents.value = []
|
||||
streamDeliverable.value = ''
|
||||
streamProjectPath.value = ''
|
||||
streamProjectFiles.value = []
|
||||
executeResult.value = null
|
||||
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const baseUrl = (api.defaults.baseURL || '').replace(/\/+$/, '')
|
||||
const url = `${baseUrl}/api/v1/teams/${currentTeamId.value}/execute/stream`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ project_description: projectDescription.value, auto_approve_files: autoApproveFiles.value }),
|
||||
})
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('无法读取流')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const evt = JSON.parse(line.slice(6))
|
||||
streamEvents.value.push(evt)
|
||||
if (evt.type === 'final') {
|
||||
if (evt.deliverable) streamDeliverable.value = evt.deliverable
|
||||
if (evt.project_path) streamProjectPath.value = evt.project_path
|
||||
if (evt.files) streamProjectFiles.value = evt.files
|
||||
}
|
||||
if (evt.type === 'plan_start' && evt.project_path) {
|
||||
streamProjectPath.value = evt.project_path
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
streamEvents.value.push({ type: 'error', content: e.message })
|
||||
} finally {
|
||||
executingStream.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown 渲染
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
try {
|
||||
return marked.parse(text) as string
|
||||
} catch {
|
||||
return text.replace(/\n/g, '<br>')
|
||||
}
|
||||
}
|
||||
|
||||
function onTeamNameChange() {
|
||||
// 仅更新本地状态
|
||||
}
|
||||
|
||||
async function copyPath(path: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
ElMessage.success('路径已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadAgents()
|
||||
loadTeams()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.team-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 240px;
|
||||
min-width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.agent-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-item:hover {
|
||||
background: #ecf5ff;
|
||||
border-color: #c6e2ff;
|
||||
}
|
||||
|
||||
.agent-item.active {
|
||||
background: #d9ecff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.role-slots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
border: 2px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-height: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.role-card.filled {
|
||||
border-color: #67c23a;
|
||||
border-style: solid;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.role-card.drag-over {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.role-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-assigned {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
width: 260px;
|
||||
min-width: 220px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bottom-area {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.project-input {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-input .el-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stream-events {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sync-result {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plan-json {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.phase-output {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.output-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.output-content :deep(pre) {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.output-content :deep(code) {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.phase-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user