feat(ui): agent teams page with team management, kanban board and role-aware member views

This commit is contained in:
matevip 2026-07-24 17:38:44 +08:00
parent 5e380a1b3c
commit bc867f0cbb
8 changed files with 1725 additions and 0 deletions

View File

@ -806,6 +806,112 @@ export const cronJobApi = {
http.get('/cron-jobs/active-runs', { params: { conversationId } }),
}
// ==================== Agent Teams ====================
// All ids are strings end-to-end (global Long→String Jackson config) — never
// coerce them to number, Snowflake ids exceed Number.MAX_SAFE_INTEGER.
export interface AgentTeam {
id: string
name: string
description: string | null
leadAgentId: string
status: string
settings: string | null
createTime?: string
}
export interface TeamVO {
team: AgentTeam
leadName: string | null
leadIcon?: string | null
memberCount: number
}
export interface TeamMemberVO {
agentId: string
name: string
role: 'lead' | 'member' | 'reviewer'
icon?: string | null
}
export interface TeamTask {
id: string
teamId: string
taskNumber: number
subject: string
description: string | null
status: string
priority: number
assigneeAgentId: string | null
ownerAgentId: string | null
blockedBy: string | null
requireApproval: boolean | null
progressPercent: number | null
progressStep: string | null
result: string | null
reason: string | null
dispatchCount: number
conversationId: string | null
leadConversationId: string | null
createTime?: string
updateTime?: string
}
export interface TeamTaskVO {
task: TeamTask
assigneeName: string | null
ownerName: string | null
}
export interface TeamTaskComment {
id: string
taskId: string
authorType: string
authorId: string
commentType: string
content: string
createTime?: string
}
export const teamApi = {
list: () => http.get('/teams'),
get: (id: string) => http.get(`/teams/${id}`),
create: (data: {
name: string
description?: string
leadAgentId: string
memberAgentIds: string[]
}) => http.post('/teams', data),
update: (id: string, data: { name?: string; description?: string; settings?: string }) =>
http.put(`/teams/${id}`, data),
delete: (id: string) => http.delete(`/teams/${id}`),
addMember: (id: string, agentId: string, role: string) =>
http.post(`/teams/${id}/members`, { agentId, role }),
removeMember: (id: string, agentId: string) => http.delete(`/teams/${id}/members/${agentId}`),
listTasks: (id: string, status?: string[]) =>
http.get(`/teams/${id}/tasks`, { params: status?.length ? { status: status.join(',') } : {} }),
getTask: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}`),
createTask: (
id: string,
data: {
subject: string
description?: string
assigneeAgentId: string
priority?: number
blockedBy?: string[]
requireApproval?: boolean
},
) => http.post(`/teams/${id}/tasks`, data),
approveTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/approve`),
rejectTask: (id: string, taskId: string, reason?: string) =>
http.post(`/teams/${id}/tasks/${taskId}/reject`, { reason }),
retryTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/retry`),
cancelTask: (id: string, taskId: string, reason?: string) =>
http.post(`/teams/${id}/tasks/${taskId}/cancel`, { reason }),
commentTask: (id: string, taskId: string, content: string) =>
http.post(`/teams/${id}/tasks/${taskId}/comments`, { content }),
}
// ==================== Wiki Knowledge Base ====================
// One row in the cross-KB failure center. ids are strings (global Long→String
// Jackson config) to avoid Snowflake precision loss.

View File

@ -5,6 +5,61 @@ export default {
router: {
chunkLoadFailed: 'Failed to load page resources. Check your network and try again.',
},
teams: {
kicker: 'Multi-Agent Collaboration',
title: 'Agent Teams',
subtitle: 'Multi-agent teams: the lead orchestrates, members execute',
create: 'Create Team',
empty: 'No teams yet — create a lead-orchestrated multi-agent team',
memberCount: '{count} members',
back: 'Back',
board: 'Task Board',
members: 'Members',
addMember: 'Add Member',
memberName: 'Member',
role: 'Role',
name: 'Name',
description: 'Description',
lead: 'Lead',
membersField: 'Members',
createIncomplete: 'Name, lead and at least one member are required',
pickHint: 'Click to select / deselect; the lead cannot double as a member',
roles: {
lead: 'Lead',
member: 'Member',
reviewer: 'Reviewer',
},
noCandidates: 'No agents left to add (all are already on the team)',
deleteConfirm: 'Delete this team? Historical tasks are kept but no longer dispatched.',
cancelConfirm: 'Cancel this task?',
assignee: 'Assignee',
taskDescription: 'Description',
result: 'Result',
comments: 'Comments',
commentPlaceholder: 'Write a comment…',
approve: 'Approve',
approved: 'Approved',
reject: 'Reject',
rejectReason: 'Rejection reason (the lead will be notified)',
retry: 'Retry',
column: {
todo: 'To Do',
in_progress: 'In Progress',
in_review: 'In Review',
completed: 'Completed',
closed: 'Closed',
},
status: {
pending: 'Pending',
blocked: 'Blocked',
in_progress: 'In Progress',
in_review: 'In Review',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
stale: 'Stale',
},
},
common: {
save: 'Save',
saving: 'Saving...',
@ -538,6 +593,7 @@ export default {
dashboard: 'Dashboard',
chat: 'Chat',
control: 'Control',
teams: 'Teams',
channels: 'Channels',
sessions: 'Sessions',
agent: 'Agent',

View File

@ -5,6 +5,61 @@ export default {
router: {
chunkLoadFailed: '页面资源加载失败,请检查网络后重试',
},
teams: {
kicker: '多 Agent 协作',
title: 'Agent 团队',
subtitle: 'Lead 编排、成员执行的多 Agent 协作团队',
create: '创建团队',
empty: '还没有团队,创建一个由 Lead 编排的多 Agent 团队',
memberCount: '{count} 名成员',
back: '返回',
board: '任务板',
members: '成员',
addMember: '添加成员',
memberName: '成员',
role: '角色',
name: '名称',
description: '描述',
lead: 'Lead',
membersField: '成员',
createIncomplete: '请填写名称、Lead 和至少一名成员',
pickHint: '点击选择 / 取消Lead 不能同时作为成员',
roles: {
lead: '负责人',
member: '成员',
reviewer: '审核员',
},
noCandidates: '没有可添加的 Agent都已在团队中',
deleteConfirm: '确定删除该团队?历史任务将保留但不再派发。',
cancelConfirm: '确定取消该任务?',
assignee: '执行人',
taskDescription: '任务说明',
result: '执行结果',
comments: '评论',
commentPlaceholder: '写下评论…',
approve: '批准',
approved: '已批准',
reject: '驳回',
rejectReason: '驳回原因(将通知 Lead',
retry: '重试',
column: {
todo: '待处理',
in_progress: '进行中',
in_review: '待审核',
completed: '已完成',
closed: '已终止',
},
status: {
pending: '待处理',
blocked: '等待依赖',
in_progress: '进行中',
in_review: '待审核',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
stale: '已过期',
},
},
common: {
save: '保存',
saving: '保存中...',
@ -538,6 +593,7 @@ export default {
dashboard: '仪表盘',
chat: '对话',
control: '控制台',
teams: '团队',
channels: '渠道',
sessions: '会话',
core: '核心',

View File

@ -47,6 +47,12 @@ const router = createRouter({
component: () => import('@/views/AgentCreateWizard.vue'),
meta: { title: 'Create Agent', requiredCapability: 'manage:agents' },
},
{
path: 'teams',
name: 'Teams',
component: () => import('@/views/Teams.vue'),
meta: { title: 'Teams', requiredCapability: 'manage:agents' },
},
{
// Live runtime view folded into the Agents page as a sub-view.
// Kept as a redirect so old links / bookmarks still resolve.

View File

@ -0,0 +1,103 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { teamApi } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskVO, TeamVO } from '@/api/index'
/**
* Agent-team domain state: team list, the currently opened team (members +
* task board), and board polling. Ids stay strings for their entire lifecycle
* (Snowflake precision convention).
*/
export const useTeamStore = defineStore('team', () => {
const teams = ref<TeamVO[]>([])
const loading = ref(false)
const currentTeam = ref<TeamVO | null>(null)
const members = ref<TeamMemberVO[]>([])
const tasks = ref<TeamTaskVO[]>([])
const boardLoading = ref(false)
/** Statuses that mean the board is still moving and worth polling. */
const ACTIVE_STATUSES = ['pending', 'in_progress', 'in_review', 'blocked']
const hasActiveTasks = computed(() =>
tasks.value.some((t) => ACTIVE_STATUSES.includes(t.task.status)),
)
async function fetchTeams() {
loading.value = true
try {
const res: any = await teamApi.list()
teams.value = res.data || []
} catch (e) {
console.error('Failed to fetch teams', e)
} finally {
loading.value = false
}
}
async function openTeam(teamId: string) {
const res: any = await teamApi.get(teamId)
currentTeam.value = res.data?.team || null
members.value = res.data?.members || []
await fetchTasks(teamId)
}
function closeTeam() {
currentTeam.value = null
members.value = []
tasks.value = []
}
async function fetchTasks(teamId: string) {
boardLoading.value = true
try {
const res: any = await teamApi.listTasks(teamId)
tasks.value = res.data || []
} catch (e) {
console.error('Failed to fetch team tasks', e)
} finally {
boardLoading.value = false
}
}
async function createTeam(data: {
name: string
description?: string
leadAgentId: string
memberAgentIds: string[]
}) {
await teamApi.create(data)
await fetchTeams()
}
async function deleteTeam(teamId: string) {
await teamApi.delete(teamId)
if (currentTeam.value?.team.id === teamId) {
closeTeam()
}
await fetchTeams()
}
return {
teams,
loading,
currentTeam,
members,
tasks,
boardLoading,
hasActiveTasks,
fetchTeams,
openTeam,
closeTeam,
fetchTasks,
createTeam,
deleteTeam,
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useTeamStore, import.meta.hot))
}
export type { TeamMemberVO, TeamTaskComment, TeamTaskVO, TeamVO }

View File

@ -11,7 +11,9 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es/components/alert/index')['ElAlert']
ElButton: typeof import('element-plus/es/components/button/index')['ElButton']
ElCard: typeof import('element-plus/es/components/card/index')['ElCard']
ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker']
ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog']
@ -20,17 +22,22 @@ declare module 'vue' {
ElDropdownItem: typeof import('element-plus/es/components/dropdown/index')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es/components/empty/index')['ElEmpty']
ElForm: typeof import('element-plus/es/components/form/index')['ElForm']
ElFormItem: typeof import('element-plus/es/components/form/index')['ElFormItem']
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer']
ElInput: typeof import('element-plus/es/components/input/index')['ElInput']
ElOption: typeof import('element-plus/es/components/select/index')['ElOption']
ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination']
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
ElProgress: typeof import('element-plus/es/components/progress/index')['ElProgress']
ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect']
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane']
ElTabs: typeof import('element-plus/es/components/tabs/index')['ElTabs']
ElTag: typeof import('element-plus/es/components/tag/index')['ElTag']
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

File diff suppressed because it is too large Load Diff

View File

@ -480,6 +480,12 @@ const navGroups = computed(() => [
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
requiredCapability: 'manage:agents',
},
{
path: '/teams',
label: t('nav.teams'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
requiredCapability: 'manage:agents',
},
{
path: '/wiki',
label: t('nav.wiki'),