Przeglądaj źródła

feat: 新增连线节点弹窗选择

jiaxing.liao 4 dni temu
rodzic
commit
9aacdeabbe

+ 4 - 0
apps/web/components.d.ts

@@ -14,6 +14,7 @@ declare module 'vue' {
   export interface GlobalComponents {
     AutoFocusPlugin: typeof import('./src/components/PromptEditor/plugins/AutoFocusPlugin.vue')['default']
     ElAside: typeof import('element-plus/es')['ElAside']
+    ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
@@ -28,6 +29,7 @@ declare module 'vue' {
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+    ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
@@ -78,6 +80,7 @@ declare module 'vue' {
 declare global {
   const AutoFocusPlugin: typeof import('./src/components/PromptEditor/plugins/AutoFocusPlugin.vue')['default']
   const ElAside: typeof import('element-plus/es')['ElAside']
+  const ElAvatar: typeof import('element-plus/es')['ElAvatar']
   const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
   const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
   const ElButton: typeof import('element-plus/es')['ElButton']
@@ -92,6 +95,7 @@ declare global {
   const ElDropdown: typeof import('element-plus/es')['ElDropdown']
   const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
   const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+  const ElEmpty: typeof import('element-plus/es')['ElEmpty']
   const ElForm: typeof import('element-plus/es')['ElForm']
   const ElFormItem: typeof import('element-plus/es')['ElFormItem']
   const ElIcon: typeof import('element-plus/es')['ElIcon']

+ 8 - 0
apps/web/src/components/Sidebar/index.vue

@@ -76,6 +76,14 @@
 				<span v-if="!collapsed" class="label">流程设计</span>
 			</el-menu-item>
 
+			<el-menu-item index="/management">
+				<el-tooltip v-if="collapsed" content="智能体" placement="right">
+					<span><SvgIcon name="platForm" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="platForm" />
+				<span v-if="!collapsed" class="label">智能体</span>
+			</el-menu-item>
+
 			<el-menu-item index="/execution">
 				<el-tooltip v-if="collapsed" content="执行" placement="right">
 					<span><SvgIcon name="play" /></span>

+ 6 - 0
apps/web/src/router/index.ts

@@ -16,6 +16,7 @@ const LogStream = () => import('@/views/LogStream.vue')
 const ModelLog = () => import('@/views/ModelLog.vue')
 const WorkflowOrchestration = () => import('@/views/WorkflowOrchestration.vue')
 const WorkflowExecution = () => import('@/views/WorkflowExecution.vue')
+const FlowManagement = () => import('@/views/FlowManagement.vue')
 
 const routes = [
 	{
@@ -43,6 +44,11 @@ const routes = [
 				name: 'WorkflowExecution',
 				component: WorkflowExecution
 			},
+			{
+				path: 'management',
+				name: 'FlowManagement',
+				component: FlowManagement
+			},
 			{
 				path: 'chat',
 				name: 'Chat',

+ 67 - 32
apps/web/src/views/Editor.vue

@@ -48,7 +48,7 @@
 		</div>
 		<el-splitter layout="vertical" class="flex-1">
 			<el-splitter-panel>
-				<div class="h-full w-full" @drop="onDrop">
+				<div class="h-full w-full" ref="workflowWrapperRef" @drop="onDrop">
 					<Workflow
 						ref="workflowRef"
 						:id="workflow?.id"
@@ -84,6 +84,16 @@
 					@update:node:data="hangleUpdateNodeData"
 					v-model:visible="setterVisible"
 				/>
+				<el-popover
+					:visible="showNodeLibary"
+					width="360px"
+					virtual-triggering
+					:show-arrow="false"
+					:append-to="workflowWrapperRef"
+					:virtual-ref="libaryRefferenceRef"
+				>
+					<NodeLibary @add-node="handleNodeCreate" @mouseleave="onHideNodeLibary" />
+				</el-popover>
 			</el-splitter-panel>
 
 			<el-splitter-panel v-model:size.lazy="footerHeight" :min="32">
@@ -109,9 +119,9 @@ import { nodeMap } from '@/nodes'
 import { Workflow, useDragAndDrop } from '@repo/workflow'
 
 import { dayjs, ElMessage, ElMessageBox } from 'element-plus'
+import { useRunnerStore } from '@/store/modules/runner.store'
 
 import type { IWorkflow, XYPosition, Connection, ConnectStartEvent } from '@repo/workflow'
-import { useRunnerStore } from '@/store/modules/runner.store'
 
 const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
 
@@ -124,6 +134,15 @@ const route = useRoute()
 const router = useRouter()
 const id = route.params?.id as string
 const runnerStore = useRunnerStore()
+const workflowWrapperRef = ref<HTMLElement>()
+const showNodeLibary = ref(false)
+const libaryRefferenceRef = ref<HTMLElement>()
+const peddingHandlePayload = ref<{
+	handle: ConnectStartEvent
+	position: XYPosition
+	event?: MouseEvent
+	parentId?: string
+}>()
 
 const projectMap = JSON.parse(localStorage.getItem(`workflow-map`) || '{}') as Record<
 	string,
@@ -502,42 +521,44 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 	}
 
 	const nodeToAdd = nodeMap[value.type]?.schema
-
-	// 获取当前画布的中心点
 	const viewport = workflowRef.value?.getVueFlow()?.viewport
-	let centerX = 0
-	let centerY = 0
-	if (viewport) {
-		// 计算当前中心点坐标(相对于画布)
-		centerX = (-viewport.value.x + window.innerWidth / 2) / viewport.value.zoom
-		centerY = (-viewport.value.y + window.innerHeight / 2) / viewport.value.zoom
-	}
 
 	// 如果存在对应节点则添加
 	if (nodeToAdd) {
-		const position = value.position || { x: centerX, y: centerY } || nodeToAdd.position
-
-		const newNode = {
+		const newNodeParam = {
 			...nodeToAdd,
-			position,
-			type: 'canvas-node',
-			data: {
-				...nodeToAdd,
-				position
+			appAgentId: workflow.value?.id || '',
+			position: value.position
+		}
+
+		// 获取当前画布的中心点
+		if (newNodeParam.position && viewport) {
+			newNodeParam.position = {
+				// 计算当前中心点坐标(相对于画布)
+				x: (-viewport.value.x + window.innerWidth / 2) / viewport.value.zoom,
+				y: (-viewport.value.y + window.innerHeight / 2) / viewport.value.zoom
 			}
 		}
 
+		// 需要连接前一个节点
+		if (peddingHandlePayload.value) {
+			const { position, handle, parentId } = peddingHandlePayload.value
+			newNodeParam.position = position
+			newNodeParam.prevNodeId = handle.nodeId
+			newNodeParam.parentId = parentId
+			if (handle.handleId?.includes('_')) {
+				newNodeParam.nodeHandleId = handle.handleId
+			}
+		}
+
+		if (!newNodeParam.position) {
+			newNodeParam.position = nodeToAdd.position
+		}
+
+		onHideNodeLibary()
+
 		agent
-			.postAgentDoNewAgentNode({
-				appAgentId: workflow.value.id,
-				position: newNode.position,
-				width: newNode.width,
-				height: newNode.height,
-				selected: !!newNode.selected,
-				nodeType: newNode.data?.nodeType as any,
-				zIndex: newNode.zIndex ?? 1,
-				parentId: newNode.parentId || ''
-			})
+			.postAgentDoNewAgentNode(newNodeParam)
 			.then(async (response) => {
 				if (handleApiResult(response, '节点已添加', '新增节点失败')) {
 					await loadAgentWorkflow(workflow.value.id)
@@ -770,12 +791,26 @@ const handleChangeEnvVars = async (
 /**
  * 连线取消时
  */
-const onConnectionOpenNodeLibary = (event: {
+const onConnectionOpenNodeLibary = async (payload: {
 	handle: ConnectStartEvent
 	position: XYPosition
-	e: MouseEvent
+	event: MouseEvent
+	parentId?: string
 }) => {
-	// TODO: 在这个对应位置(可以需要转化成实际坐标)打开节点弹窗,选中节点在对应位置添加节点
+	await nextTick()
+	const { handle } = payload
+	libaryRefferenceRef.value = handle.event?.target as HTMLElement
+	showNodeLibary.value = true
+	peddingHandlePayload.value = payload
+}
+
+/**
+ * 节点库隐藏时
+ */
+const onHideNodeLibary = () => {
+	peddingHandlePayload.value = undefined
+	libaryRefferenceRef.value = undefined
+	showNodeLibary.value = false
 }
 
 onBeforeUnmount(() => {

+ 687 - 0
apps/web/src/views/FlowManagement.vue

@@ -0,0 +1,687 @@
+<template>
+	<div class="management-container">
+		<div class="page-header">
+			<div class="header-copy">
+				<h1>智能体管理</h1>
+			</div>
+		</div>
+
+		<div class="stats-grid">
+			<div class="stat-card">
+				<div class="stat-icon coral">
+					<SvgIcon name="platForm" size="20" />
+				</div>
+				<div>
+					<div class="stat-value">{{ pageData.totalCount }}</div>
+					<div class="stat-label">智能体总数</div>
+				</div>
+			</div>
+			<div class="stat-card">
+				<div class="stat-icon blue">
+					<SvgIcon name="workflow" size="20" />
+				</div>
+				<div>
+					<div class="stat-value">{{ agents.length }}</div>
+					<div class="stat-label">当前页数量</div>
+				</div>
+			</div>
+			<div class="stat-card">
+				<div class="stat-icon green">
+					<SvgIcon name="service" size="20" />
+				</div>
+				<div>
+					<div class="stat-value">{{ totalConversationVariables }}</div>
+					<div class="stat-label">会话变量数</div>
+				</div>
+			</div>
+			<div class="stat-card">
+				<div class="stat-icon orange">
+					<SvgIcon name="setting" size="20" />
+				</div>
+				<div>
+					<div class="stat-value">{{ totalEnvVariables }}</div>
+					<div class="stat-label">环境变量数</div>
+				</div>
+			</div>
+		</div>
+
+		<div class="panel">
+			<div class="toolbar">
+				<el-input
+					v-model="keyword"
+					placeholder="搜索当前页的智能体名称或 ID"
+					clearable
+					class="search-input"
+				>
+					<template #prefix>
+						<el-icon><Search /></el-icon>
+					</template>
+				</el-input>
+
+				<div class="toolbar-meta">
+					<el-button type="primary" link :loading="loading" @click="loadAgents(pageIndex)">
+						<el-icon><RefreshRight /></el-icon>
+					</el-button>
+
+					<span class="meta-pill"
+						>第 {{ pageData.currentPage || 1 }} / {{ pageData.totalPages || 1 }} 页</span
+					>
+					<span class="meta-pill">{{ pageData.pageSize }} 条/页</span>
+				</div>
+			</div>
+
+			<div v-loading="loading" class="card-grid">
+				<div
+					v-for="row in filteredAgents"
+					:key="row.id"
+					class="agent-card"
+					@click="handleRowClick(row)"
+				>
+					<div class="cover">
+						<img
+							v-if="row.profilePhoto"
+							:src="row.profilePhoto"
+							:alt="row.name"
+							class="cover-image"
+						/>
+						<div v-else class="cover-fallback">
+							<div class="fallback-monogram">{{ row.name?.slice(0, 1) || 'A' }}</div>
+						</div>
+
+						<div class="cover-gradient"></div>
+
+						<div class="cover-top">
+							<span class="cover-badge">智能体</span>
+							<span class="cover-badge subtle">{{ row.env_variables.length }} 个环境变量</span>
+						</div>
+					</div>
+
+					<div class="cover-bottom">
+						<el-avatar :size="56" :src="row.profilePhoto || undefined" class="cover-avatar">
+							{{ row.name?.slice(0, 1) || 'A' }}
+						</el-avatar>
+					</div>
+
+					<div class="card-body">
+						<div class="card-head">
+							<div class="card-title-wrap">
+								<div class="agent-name" :title="row.name || fallbackText.unnamed">
+									{{ row.name || fallbackText.unnamed }}
+								</div>
+								<div class="agent-id" :title="row.id">{{ row.id }}</div>
+							</div>
+						</div>
+
+						<div class="card-footer">
+							<el-button type="primary" @click.stop="openAgent(row.id)">打开</el-button>
+						</div>
+					</div>
+				</div>
+
+				<el-empty
+					v-if="!filteredAgents.length && !loading"
+					:description="keyword ? emptyText.filtered : emptyText.default"
+					class="empty-state"
+				/>
+			</div>
+
+			<div class="pagination">
+				<el-pagination
+					background
+					layout="prev, pager, next, total"
+					:current-page="pageIndex"
+					:page-size="pageData.pageSize || 10"
+					:total="pageData.totalCount"
+					@current-change="handlePageChange"
+				/>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { RefreshRight, Search } from '@element-plus/icons-vue'
+import { agent } from '@repo/api-service'
+
+import SvgIcon from '@/components/SvgIcon/index.vue'
+
+type AgentListResponse = Awaited<ReturnType<typeof agent.postAgentGetAgentList>>
+type AgentListResult = NonNullable<AgentListResponse['result']>
+type AgentListItem = AgentListResult['model'][number]
+
+const createEmptyPageData = (): AgentListResult => ({
+	currentPage: 1,
+	hasNextPage: false,
+	hasPreviousPage: false,
+	model: [],
+	pageSize: 10,
+	totalCount: 0,
+	totalPages: 0
+})
+
+const fallbackText = {
+	unnamed: '未命名智能体',
+	notConfiguredConversation: '未配置会话变量',
+	notConfiguredEnv: '未配置环境变量'
+} as const
+
+const emptyText = {
+	default: '暂无智能体数据',
+	filtered: '当前筛选条件下暂无数据'
+} as const
+
+const separator = '、'
+
+const router = useRouter()
+const loading = ref(false)
+const keyword = ref('')
+const pageIndex = ref(1)
+const pageData = ref<AgentListResult>(createEmptyPageData())
+
+const agents = computed(() => pageData.value.model)
+
+const filteredAgents = computed(() => {
+	const searchValue = keyword.value.trim().toLowerCase()
+
+	if (!searchValue) {
+		return agents.value
+	}
+
+	return agents.value.filter((item) => {
+		const name = item.name.toLowerCase()
+		const id = item.id.toLowerCase()
+		return name.includes(searchValue) || id.includes(searchValue)
+	})
+})
+
+const totalConversationVariables = computed(() =>
+	agents.value.reduce((total, item) => total + item.conversation_variables.length, 0)
+)
+
+const totalEnvVariables = computed(() =>
+	agents.value.reduce((total, item) => total + item.env_variables.length, 0)
+)
+
+const formatConversationVariables = (variables: AgentListItem['conversation_variables']) => {
+	if (!variables.length) {
+		return fallbackText.notConfiguredConversation
+	}
+
+	return variables.slice(0, 4).join(separator) + (variables.length > 4 ? '...' : '')
+}
+
+const formatEnvVariables = (variables: AgentListItem['env_variables']) => {
+	if (!variables.length) {
+		return fallbackText.notConfiguredEnv
+	}
+
+	const labels = variables.slice(0, 3).map((item) => item.name || item.type)
+	return labels.join(separator) + (variables.length > 3 ? '...' : '')
+}
+
+const openAgent = (id: string) => {
+	router.push(`/workflow/${id}`)
+}
+
+const handleRowClick = (row: AgentListItem) => {
+	openAgent(row.id)
+}
+
+const loadAgents = async (targetPage = 1) => {
+	loading.value = true
+
+	try {
+		const response = await agent.postAgentGetAgentList({
+			pageIndex: targetPage
+		})
+
+		if (!response.isSuccess || !response.result) {
+			throw new Error('获取智能体列表失败')
+		}
+
+		pageData.value = response.result
+		pageIndex.value = response.result.currentPage || targetPage
+	} catch (error) {
+		console.error('loadAgents error', error)
+		ElMessage.error('加载智能体列表失败')
+		pageData.value = createEmptyPageData()
+	} finally {
+		loading.value = false
+	}
+}
+
+const handlePageChange = (nextPage: number) => {
+	pageIndex.value = nextPage
+	loadAgents(nextPage)
+}
+
+onMounted(() => {
+	loadAgents(pageIndex.value)
+})
+</script>
+
+<style lang="less" scoped>
+.management-container {
+	max-width: 1320px;
+	margin: 0 auto;
+	padding: 28px 24px 32px;
+}
+
+.page-header {
+	display: flex;
+	align-items: flex-end;
+	justify-content: space-between;
+	gap: 20px;
+	margin-bottom: 24px;
+}
+
+.header-copy {
+	h1 {
+		margin: 6px 0 8px;
+		font-size: 30px;
+		line-height: 1.1;
+		color: var(--text-primary);
+	}
+
+	p {
+		margin: 0;
+		max-width: 720px;
+		font-size: 14px;
+		line-height: 1.7;
+		color: var(--text-secondary);
+	}
+}
+
+.eyebrow {
+	display: inline-flex;
+	align-items: center;
+	padding: 6px 10px;
+	border-radius: 999px;
+	background: rgba(255, 107, 107, 0.1);
+	color: var(--el-color-primary);
+	font-size: 12px;
+	font-weight: 700;
+	letter-spacing: 0.08em;
+	text-transform: uppercase;
+}
+
+.stats-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+	gap: 16px;
+	margin-bottom: 22px;
+}
+
+.stat-card {
+	display: flex;
+	align-items: center;
+	gap: 14px;
+	padding: 18px;
+	border: 1px solid var(--border-light);
+	border-radius: 18px;
+	background:
+		linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(245, 247, 250, 0.8)), var(--bg-base);
+	box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
+}
+
+.stat-icon {
+	width: 46px;
+	height: 46px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	border-radius: 14px;
+
+	&.coral {
+		background: #fff1f0;
+		color: #ff6b6b;
+	}
+
+	&.blue {
+		background: #eef4ff;
+		color: #3b82f6;
+	}
+
+	&.green {
+		background: #ecfdf3;
+		color: #10b981;
+	}
+
+	&.orange {
+		background: #fff7ed;
+		color: #f97316;
+	}
+}
+
+.stat-value {
+	font-size: 24px;
+	font-weight: 700;
+	color: var(--text-primary);
+}
+
+.stat-label {
+	margin-top: 2px;
+	font-size: 13px;
+	color: var(--text-secondary);
+}
+
+.panel {
+	padding: 22px;
+	border: 1px solid var(--border-light);
+	border-radius: 22px;
+	background: var(--bg-base);
+	box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
+}
+
+.toolbar {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 16px;
+	margin-bottom: 20px;
+}
+
+.search-input {
+	max-width: 340px;
+}
+
+.toolbar-meta {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+
+.meta-pill {
+	display: inline-flex;
+	align-items: center;
+	padding: 8px 12px;
+	border: 1px solid var(--border-light);
+	border-radius: 999px;
+	background: var(--bg-container);
+	font-size: 12px;
+	color: var(--text-secondary);
+}
+
+.card-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+	gap: 20px;
+	min-height: 240px;
+}
+
+.agent-card {
+	display: flex;
+	flex-direction: column;
+	min-height: 100%;
+	overflow: hidden;
+	border: 1px solid var(--border-light);
+	border-radius: 22px;
+	background: var(--bg-base);
+	box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
+	cursor: pointer;
+	position: relative;
+	transition:
+		transform 0.18s ease,
+		box-shadow 0.18s ease,
+		border-color 0.18s ease;
+
+	&:hover {
+		transform: translateY(-6px);
+		border-color: rgba(255, 107, 107, 0.24);
+		box-shadow: 0 20px 46px rgba(15, 23, 42, 0.1);
+	}
+}
+
+.cover {
+	position: relative;
+	height: 188px;
+	overflow: hidden;
+	background:
+		radial-gradient(circle at top left, rgba(255, 107, 107, 0.28), transparent 38%),
+		radial-gradient(circle at right bottom, rgba(59, 130, 246, 0.24), transparent 34%),
+		linear-gradient(135deg, #f8fafc, #eef2ff);
+}
+
+.cover-image,
+.cover-fallback {
+	width: 100%;
+	height: 100%;
+}
+
+.cover-image {
+	display: block;
+	object-fit: cover;
+}
+
+.cover-fallback {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.fallback-monogram {
+	width: 84px;
+	height: 84px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	border-radius: 24px;
+	background: rgba(255, 255, 255, 0.7);
+	backdrop-filter: blur(10px);
+	font-size: 38px;
+	font-weight: 800;
+	color: #ff6b6b;
+}
+
+.cover-gradient {
+	position: absolute;
+	inset: 0;
+	background: linear-gradient(180deg, rgba(10, 10, 10, 0.02), rgba(10, 10, 10, 0.5));
+}
+
+.cover-top {
+	position: absolute;
+	top: 14px;
+	left: 14px;
+	right: 14px;
+	display: flex;
+	justify-content: space-between;
+	gap: 8px;
+}
+
+.cover-badge {
+	display: inline-flex;
+	align-items: center;
+	padding: 6px 10px;
+	border-radius: 999px;
+	background: rgba(255, 255, 255, 0.92);
+	color: #1f2937;
+	font-size: 12px;
+	font-weight: 600;
+	backdrop-filter: blur(10px);
+
+	&.subtle {
+		background: rgba(15, 23, 42, 0.58);
+		color: #fff;
+	}
+}
+
+.cover-bottom {
+	position: absolute;
+	left: 18px;
+	top: 160px;
+}
+
+.cover-avatar {
+	border: 4px solid var(--bg-base);
+	box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
+}
+
+.card-body {
+	display: flex;
+	flex: 1;
+	flex-direction: column;
+	padding: 40px 18px 18px;
+}
+
+.card-head {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.card-title-wrap {
+	min-width: 0;
+}
+
+.agent-name {
+	overflow: hidden;
+	font-size: 18px;
+	font-weight: 700;
+	line-height: 1.3;
+	color: var(--text-primary);
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.agent-id {
+	margin-top: 6px;
+	overflow: hidden;
+	font-size: 12px;
+	color: var(--text-secondary);
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.enter-link {
+	padding-right: 0;
+	padding-left: 0;
+}
+
+.metric-row {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 10px;
+	margin-top: 18px;
+}
+
+.metric-card {
+	padding: 12px 14px;
+	border: 1px solid var(--border-light);
+	border-radius: 16px;
+	background: var(--bg-container);
+}
+
+.metric-label {
+	font-size: 12px;
+	color: var(--text-tertiary);
+}
+
+.metric-value {
+	margin-top: 6px;
+	font-size: 20px;
+	font-weight: 700;
+	line-height: 1;
+	color: var(--text-primary);
+}
+
+.detail-panel {
+	display: grid;
+	gap: 12px;
+	margin-top: 16px;
+	padding: 14px;
+	border-radius: 18px;
+	background: linear-gradient(180deg, var(--bg-container), rgba(245, 247, 250, 0.4));
+}
+
+.detail-item {
+	padding-bottom: 12px;
+	border-bottom: 1px solid var(--border-light);
+
+	&:last-child {
+		padding-bottom: 0;
+		border-bottom: 0;
+	}
+}
+
+.detail-label {
+	font-size: 12px;
+	color: var(--text-tertiary);
+}
+
+.detail-value {
+	margin-top: 6px;
+	font-size: 13px;
+	line-height: 1.65;
+	color: var(--text-secondary);
+	word-break: break-word;
+}
+
+.card-footer {
+	display: flex;
+	justify-content: flex-end;
+	margin-top: auto;
+	padding-top: 18px;
+}
+
+.empty-state {
+	grid-column: 1 / -1;
+	padding: 30px 0 18px;
+}
+
+.pagination {
+	display: flex;
+	justify-content: flex-end;
+	margin-top: 20px;
+}
+
+@media (max-width: 960px) {
+	.page-header,
+	.toolbar {
+		flex-direction: column;
+		align-items: stretch;
+	}
+
+	.search-input {
+		max-width: none;
+	}
+
+	.toolbar-meta,
+	.pagination {
+		justify-content: flex-start;
+	}
+}
+
+@media (max-width: 640px) {
+	.management-container {
+		padding: 18px 14px 24px;
+	}
+
+	.panel {
+		padding: 16px;
+		border-radius: 18px;
+	}
+
+	.card-grid {
+		grid-template-columns: 1fr;
+	}
+
+	.cover {
+		height: 172px;
+	}
+
+	.card-head {
+		flex-direction: column;
+	}
+
+	.enter-link,
+	.card-footer :deep(.el-button) {
+		width: 100%;
+	}
+}
+</style>

+ 7 - 2
packages/api-service/agent.openapi.json

@@ -2712,11 +2712,16 @@
 									},
 									"parentId": {
 										"type": "string"
+									},
+									"prevNodeId": {
+										"type": "string"
+									},
+									"nodeHandleId": {
+										"type": "string"
 									}
 								},
 								"required": [
 									"appAgentId",
-									"parentId",
 									"position",
 									"width",
 									"height",
@@ -3455,7 +3460,7 @@
 										"type": "integer"
 									}
 								},
-								"required": ["appAgentId", "source", "sourceHandle", "target", "zIndex"]
+								"required": ["appAgentId", "source", "target", "zIndex"]
 							},
 							"examples": {
 								"1": {

+ 4 - 2
packages/api-service/servers/api/agent.ts

@@ -86,7 +86,9 @@ export async function postAgentDoNewAgentNode(
     selected: boolean
     nodeType: string
     zIndex: number
-    parentId: string
+    parentId?: string
+    prevNodeId?: string
+    nodeHandleId?: string
   },
   options?: { [key: string]: any }
 ) {
@@ -108,7 +110,7 @@ export async function postAgentDoNewEdge(
   body: {
     appAgentId: string
     source: string
-    sourceHandle: string
+    sourceHandle?: string
     target: string
     zIndex: number
   },

+ 9 - 2
packages/workflow/src/components/Canvas.vue

@@ -68,7 +68,12 @@ const emit = defineEmits<{
 	'create:connection': [connection: Connection]
 	'create:connection:end': [connection: Connection, event?: MouseEvent]
 	'create:connection:cancelled': [
-		payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }
+		payload: {
+			handle: ConnectStartEvent
+			position: XYPosition
+			event?: MouseEvent
+			parentId?: string
+		}
 	]
 	'click:connection:add': [connection: Connection]
 }>()
@@ -416,7 +421,9 @@ defineExpose({
 						})
 					"
 					@add-inner-edge="emit('create:connection:end', $event)"
-					@create-connection-cancelled="emit('create:connection:cancelled', $event)"
+					@create-connection-cancelled="
+						(p) => emit('create:connection:cancelled', { ...p, parentId: nodeProps.id })
+					"
 					@delete="onDeleteNode"
 					@run="onRunNode"
 				/>

+ 9 - 5
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -32,10 +32,14 @@ const childrenEdges = computed(() =>
 const emit = defineEmits<{
 	update: [parameters: Record<string, unknown>]
 	move: [position: XYPosition]
-	'click:node:add': [payload: { nodeId: string; handle: string; position: XYPosition; event?: MouseEvent }]
+	'click:node:add': [
+		payload: { nodeId: string; handle: string; position: XYPosition; event?: MouseEvent }
+	]
 	'add-inner-node': [parentId: string]
 	'add-inner-edge': [connection: Connection]
-	'create:connection:cancelled': [payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }]
+	'create:connection:cancelled': [
+		payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }
+	]
 }>()
 
 const nodeData = computed(() => node.props.value.node?.data ?? node.props.value.data)
@@ -92,7 +96,7 @@ function onAddEdge(connection: Connection) {
 
 	<div
 		:class="nodeClass"
-		class="w-full h-full box-border node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fafafa overflow-hidden flex flex-col"
+		class="w-full h-full box-border node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fff overflow-hidden flex flex-col"
 	>
 		<!-- 标题栏 -->
 		<div
@@ -110,9 +114,9 @@ function onAddEdge(connection: Connection) {
 		<!-- 内部画布区域:虚线网格 + 占位内容 -->
 		<div class="loop-body flex-1 min-h-0 relative">
 			<div
-				class="absolute top-16px right-40px bottom-16px left-16px rounded-8px border-1 border-dashed border-#d9d9d9 bg-[repeating-linear-gradient(to_right,#e8e8e8_0,transparent_1px),repeating-linear-gradient(to_bottom,#e8e8e8_0,transparent_1px)] bg-[length:12px_12px]"
+				class="absolute top-16px right-16px bottom-16px left-16px rounded-8px border-1 border-dashed border-#d9d9d9 bg-[repeating-linear-gradient(to_right,#e8e8e8_0,transparent_1px),repeating-linear-gradient(to_bottom,#e8e8e8_0,transparent_1px)] bg-[length:12px_12px]"
 			/>
-			<div class="absolute top-16px right-40px bottom-16px left-16px z-1">
+			<div class="absolute top-16px right-16px bottom-16px left-16px z-1">
 				<Canvas
 					:id="node.props.value.id"
 					:nodes="childrenNodes"