Преглед на файлове

feat: 添加webhook触发器配置

jiaxing.liao преди 1 месец
родител
ревизия
1332eb44c6

+ 76 - 76
apps/web/src/nodes/src/webhook/index.ts

@@ -1,83 +1,48 @@
-import { NodeConnectionTypes, type INodeType, type INodeDataBaseSchema } from '../../Interface'
+import {
+	NodeConnectionTypes,
+	type INodeDataBaseSchema,
+	type INodeType,
+	type NodeVariableType
+} from '../../Interface'
+import Setter from './setter.vue'
 
-export type WebhookData = INodeDataBaseSchema & {
-	/**
-	 * 异步模式
-	 */
-	async_mode: boolean
-
-	/**
-	 * webhook地址,系统自动获取
-	 */
-	webhook_url: string
+export type WebhookContentType =
+	| 'application/json'
+	| 'application/x-www-form-urlencoded'
+	| 'text/plain'
+	| 'application/octet-stream'
+	| 'multipart/form-data'
 
-	/**
-	 * webhook的调试地址,系统自动获取
-	 */
-	webhook_debug_url: string
-
-	/**
-	 * 内容类型
-	 * 可选值: application/json, application/x-www-form-urlencoded, text/plain, multipart/form-data
-	 */
-	content_type:
-		| 'application/json'
-		| 'application/x-www-form-urlencoded'
-		| 'text/plain'
-		| 'multipart/form-data'
+export type WebhookMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
 
-	/**
-	 * 请求头配置
-	 */
-	headers: Array<{
-		name: string
-		required: boolean
-	}>
-
-	/**
-	 * URL参数配置
-	 */
-	params: Array<{
-		name: string
-		type: 'string' | 'number' | 'boolean'
-		required: boolean
-	}>
+export type WebhookParam = {
+	name: string
+	type: Extract<NodeVariableType, 'string' | 'number' | 'boolean'>
+	required: boolean
+}
 
-	/**
-	 * body参数配置
-	 */
-	body: Array<{
-		name: string
-		type:
-			| 'string'
-			| 'number'
-			| 'boolean'
-			| 'object'
-			| 'array[string]'
-			| 'array[number]'
-			| 'array[boolean]'
-			| 'array[object]'
-		required: boolean
-	}>
+export type WebhookHeader = {
+	name: string
+	required: boolean
+}
 
-	/**
-	 * 响应内容类型
-	 * 可选值: application/json, application/x-www-form-urlencoded, text/plain, multipart/form-data
-	 */
-	response_content_type:
-		| 'application/json'
-		| 'application/x-www-form-urlencoded'
-		| 'text/plain'
-		| 'multipart/form-data'
+export type WebhookBodyParam = {
+	name: string
+	type: NodeVariableType
+	required: boolean
+}
 
-	/**
-	 * 响应内容
-	 */
+export type WebhookData = INodeDataBaseSchema & {
+	async_mode: boolean
+	method: WebhookMethod
+	webhook_url: string
+	webhook_debug_url: string
+	content_type: WebhookContentType
+	headers: WebhookHeader[]
+	params: WebhookParam[]
+	body: WebhookBodyParam[]
+	response_content_type: WebhookContentType
 	response_body: string
-
-	/**
-	 * 响应码
-	 */
 	response_status_code: number
 }
 
@@ -85,13 +50,13 @@ export const webhookNode: INodeType = {
 	version: ['1'],
 	displayName: 'Webhook触发',
 	name: 'trigger-webhook',
-	description: '可用于第三方系统http推送请求触发',
+	description: '通过 Webhook 接收第三方系统请求并触发工作流',
 	group: '开始',
 	icon: 'lucide:webhook',
 	iconColor: '#2e90fa',
 	inputs: [],
 	outputs: [NodeConnectionTypes.main],
-	// 业务数据
+	Setter,
 	schema: {
 		appAgentId: '',
 		parentId: '',
@@ -104,6 +69,41 @@ export const webhookNode: INodeType = {
 		selected: false,
 		nodeType: 'trigger-webhook',
 		zIndex: 1,
-		data: {}
+		data: {
+			title: 'Webhook触发',
+			type: 'trigger-webhook',
+			isInIteration: false,
+			iteration_id: '',
+			isInLoop: false,
+			loop_id: '',
+			variables: [],
+			retry_config: {
+				max_retries: 3,
+				retry_enabled: false,
+				retry_interval: 100
+			},
+			error_strategy: 'none',
+			fail_branch_node_id: '',
+			default_value: [],
+			output_can_alter: false,
+			outputs: [
+				{
+					name: 'payload._webhook_raw',
+					describe: 'Webhook 原始请求体',
+					type: 'object'
+				}
+			],
+			async_mode: false,
+			method: 'post',
+			webhook_url: '',
+			webhook_debug_url: '',
+			content_type: 'application/json',
+			headers: [],
+			params: [],
+			body: [],
+			response_content_type: 'application/json',
+			response_body: '{\n  "success": true\n}',
+			response_status_code: 200
+		}
 	}
 }

+ 695 - 0
apps/web/src/nodes/src/webhook/setter.vue

@@ -0,0 +1,695 @@
+<template>
+	<el-scrollbar class="w-full box-border p-12px">
+		<div class="webhook-setter">
+			<section class="section-block">
+				<div class="text-14px font-bold text-gray-700">WEBHOOK URL</div>
+				<div class="url-row">
+					<el-select v-model="formData.method" class="method-select">
+						<el-option
+							v-for="item in methodOptions"
+							:key="item.value"
+							:label="item.label"
+							:value="item.value"
+						/>
+					</el-select>
+
+					<el-input :model-value="formData.webhook_url" readonly class="url-input">
+						<template #append>
+							<IconButton
+								link
+								icon="lucide:copy"
+								@click="copyText(formData.webhook_url, 'Webhook URL')"
+							>
+							</IconButton>
+						</template>
+					</el-input>
+				</div>
+
+				<div class="debug-tip">
+					<div class="debug-tip__label">测试运行时,请始终使用此 URL</div>
+					<div
+						class="debug-tip__value cursor-pointer"
+						@click="copyText(formData.webhook_debug_url, '测试URL')"
+					>
+						{{ formData.webhook_debug_url || '--' }}
+					</div>
+				</div>
+			</section>
+
+			<section class="section-block">
+				<div class="field-title">内容类型</div>
+				<el-select v-model="formData.content_type" class="w-full">
+					<el-option
+						v-for="item in contentTypeOptions"
+						:key="item.value"
+						:label="item.label"
+						:value="item.value"
+					/>
+				</el-select>
+			</section>
+
+			<section class="section-block">
+				<div class="text-14px font-bold text-gray-700">QUERY PARAMETERS</div>
+				<div class="table-shell">
+					<el-table :data="queryRows" border row-class-name="param-table">
+						<el-table-column label="变量名">
+							<template #default="{ row, $index }">
+								<el-input
+									v-model="row.name"
+									placeholder="输入变量名..."
+									@focus="ensureExtraQueryRow($index)"
+								/>
+							</template>
+						</el-table-column>
+						<el-table-column label="类型" width="100">
+							<template #default="{ row }">
+								<el-select v-model="row.type" class="w-full">
+									<el-option
+										v-for="option in scalarTypeOptions"
+										:key="option.value"
+										:label="option.label"
+										:value="option.value"
+									/>
+								</el-select>
+							</template>
+						</el-table-column>
+						<el-table-column label="必填" width="88" align="center">
+							<template #default="{ row, $index }">
+								<div class="flex justify-between">
+									<el-checkbox v-model="row.required" />
+									<IconButton
+										v-if="queryRows.length > 1"
+										link
+										icon="lucide:trash-2"
+										class="delete-button"
+										@click="removeQueryRow($index)"
+									/>
+								</div>
+							</template>
+						</el-table-column>
+					</el-table>
+				</div>
+			</section>
+
+			<section class="section-block">
+				<div class="text-14px font-bold text-gray-700">HEADER PARAMETERS</div>
+				<div class="table-shell">
+					<el-table :data="headerRows" border row-class-name="param-table">
+						<el-table-column label="变量名">
+							<template #default="{ row, $index }">
+								<el-input
+									v-model="row.name"
+									placeholder="输入变量名..."
+									@focus="ensureExtraHeaderRow($index)"
+								/>
+							</template>
+						</el-table-column>
+						<el-table-column label="必填" width="88" align="center">
+							<template #default="{ row, $index }">
+								<div class="flex justify-between">
+									<el-checkbox v-model="row.required" />
+									<IconButton
+										v-if="headerRows.length > 1"
+										link
+										icon="lucide:trash-2"
+										class="delete-button"
+										@click="removeHeaderRow($index)"
+									/>
+								</div>
+							</template>
+						</el-table-column>
+					</el-table>
+				</div>
+			</section>
+
+			<section class="section-block">
+				<div class="text-14px font-bold text-gray-700">REQUEST BODY PARAMETERS</div>
+				<div class="table-shell">
+					<el-table :data="bodyRows" border row-class-name="param-table">
+						<el-table-column label="变量名">
+							<template #default="{ row, $index }">
+								<el-input
+									v-model="row.name"
+									placeholder="输入变量名..."
+									@focus="ensureExtraBodyRow($index)"
+								/>
+							</template>
+						</el-table-column>
+						<el-table-column label="类型" width="100">
+							<template #default="{ row }">
+								<el-select v-model="row.type" class="w-full">
+									<el-option
+										v-for="option in bodyTypeOptions"
+										:key="option.value"
+										:label="option.label"
+										:value="option.value"
+									/>
+								</el-select>
+							</template>
+						</el-table-column>
+						<el-table-column label="必填" width="88" align="center">
+							<template #default="{ row, $index }">
+								<div class="flex justify-between">
+									<el-checkbox v-model="row.required" />
+									<IconButton
+										v-if="bodyRows.length > 1"
+										link
+										icon="lucide:trash-2"
+										class="delete-button"
+										@click="removeBodyRow($index)"
+									/>
+								</div>
+							</template>
+						</el-table-column>
+					</el-table>
+				</div>
+			</section>
+
+			<section class="section-block section-block--response">
+				<div class="field-title">响应</div>
+
+				<div class="response-grid">
+					<div class="text-14px">状态码</div>
+					<el-input-number
+						v-model="formData.response_status_code"
+						:min="100"
+						:max="599"
+						:step="1"
+						class="status-input"
+					/>
+				</div>
+
+				<div class="field-stack">
+					<div class="text-14px">响应体</div>
+					<CodeEditor
+						v-model="formData.response_body"
+						:tools="false"
+						:copy-code="false"
+						:allow-change-language="false"
+						:language="responseEditorLanguage"
+						:height="180"
+						theme="vs-light"
+					/>
+				</div>
+			</section>
+
+			<section class="section-block mb-10">
+				<button type="button" class="output-toggle" @click="outputsExpanded = !outputsExpanded">
+					<span>输出变量</span>
+					<Icon :icon="outputsExpanded ? 'lucide:chevron-down' : 'lucide:chevron-right'" />
+				</button>
+
+				<div v-if="outputsExpanded" class="output-list">
+					<div v-for="output in formData.outputs" :key="output.name" class="output-item">
+						<span class="output-item__name">{{ output.name }}</span>
+						<span class="output-item__type">{{ output.type }}</span>
+					</div>
+				</div>
+			</section>
+		</div>
+	</el-scrollbar>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { cloneDeep, isEqual } from 'lodash-es'
+import { ElMessage } from 'element-plus'
+import { Icon, IconButton } from '@repo/ui'
+
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+import { VARIABLE_TYPE_OPTIONS } from '@/constant'
+import { useSetterModel } from '../_shared/useSetterModel'
+
+import type { NodeVariableType } from '../../Interface'
+import type {
+	WebhookBodyParam,
+	WebhookContentType,
+	WebhookData,
+	WebhookHeader,
+	WebhookMethod,
+	WebhookParam
+} from './index'
+
+interface Emits {
+	(e: 'update', value: WebhookData): void
+}
+
+const props = defineProps<{
+	data: WebhookData
+}>()
+
+const emit = defineEmits<Emits>()
+
+type QueryRow = WebhookParam
+type HeaderRow = WebhookHeader
+type BodyRow = WebhookBodyParam
+
+const methodOptions: Array<{ label: string; value: WebhookMethod }> = [
+	{ label: 'GET', value: 'get' },
+	{ label: 'POST', value: 'post' },
+	{ label: 'PUT', value: 'put' },
+	{ label: 'DELETE', value: 'delete' },
+	{ label: 'PATCH', value: 'patch' },
+	{ label: 'HEAD', value: 'head' }
+]
+
+const contentTypeOptions: Array<{ label: string; value: WebhookContentType }> = [
+	{ label: 'application/json', value: 'application/json' },
+	{
+		label: 'application/x-www-form-urlencoded',
+		value: 'application/x-www-form-urlencoded'
+	},
+	{ label: 'text/plain', value: 'text/plain' },
+	{ label: 'application/octet-stream', value: 'application/octet-stream' },
+	{ label: 'multipart/form-data', value: 'multipart/form-data' }
+]
+
+const scalarTypeOptions = VARIABLE_TYPE_OPTIONS.filter((item) =>
+	['string', 'number', 'boolean'].includes(item.value)
+) as Array<{ label: string; value: QueryRow['type'] }>
+
+const bodyTypeOptions = VARIABLE_TYPE_OPTIONS.filter((item) =>
+	[
+		'string',
+		'number',
+		'boolean',
+		'object',
+		'array[string]',
+		'array[number]',
+		'array[boolean]',
+		'array[object]'
+	].includes(item.value)
+) as Array<{ label: string; value: BodyRow['type'] }>
+
+const formData = useSetterModel<WebhookData>(props, emit)
+const outputsExpanded = ref(true)
+const queryRows = ref<QueryRow[]>([])
+const headerRows = ref<HeaderRow[]>([])
+const bodyRows = ref<BodyRow[]>([])
+
+const createEmptyQueryRow = (): QueryRow => ({
+	name: '',
+	type: 'string',
+	required: false
+})
+
+const createEmptyHeaderRow = (): HeaderRow => ({
+	name: '',
+	required: false
+})
+
+const createEmptyBodyRow = (): BodyRow => ({
+	name: '',
+	type: 'string',
+	required: false
+})
+
+const defaultOutputs = () => [
+	{
+		name: 'payload._webhook_raw',
+		describe: 'Webhook 原始请求体',
+		type: 'object' as NodeVariableType
+	}
+]
+
+const normalizeWebhookState = () => {
+	if (!formData.value.title) {
+		formData.value.title = 'Webhook触发'
+	}
+	if (!formData.value.type) {
+		formData.value.type = 'trigger-webhook'
+	}
+	if (!formData.value.variables) {
+		formData.value.variables = []
+	}
+	if (!formData.value.retry_config) {
+		formData.value.retry_config = {
+			max_retries: 3,
+			retry_enabled: false,
+			retry_interval: 100
+		}
+	}
+	if (!formData.value.error_strategy) {
+		formData.value.error_strategy = 'none'
+	}
+	if (!formData.value.fail_branch_node_id) {
+		formData.value.fail_branch_node_id = ''
+	}
+	if (!formData.value.default_value) {
+		formData.value.default_value = []
+	}
+	if (typeof formData.value.output_can_alter !== 'boolean') {
+		formData.value.output_can_alter = false
+	}
+	if (!Array.isArray(formData.value.outputs) || !formData.value.outputs.length) {
+		formData.value.outputs = defaultOutputs()
+	}
+	if (typeof formData.value.async_mode !== 'boolean') {
+		formData.value.async_mode = false
+	}
+	if (!formData.value.method) {
+		formData.value.method = 'post'
+	}
+	if (!formData.value.content_type) {
+		formData.value.content_type = 'application/json'
+	}
+	if (!formData.value.response_content_type) {
+		formData.value.response_content_type = 'application/json'
+	}
+	if (!Array.isArray(formData.value.headers)) {
+		formData.value.headers = []
+	}
+	if (!Array.isArray(formData.value.params)) {
+		formData.value.params = []
+	}
+	if (!Array.isArray(formData.value.body)) {
+		formData.value.body = []
+	}
+	if (typeof formData.value.response_body !== 'string') {
+		formData.value.response_body = '{\n  "success": true\n}'
+	}
+
+	const statusCode = Number(formData.value.response_status_code)
+	formData.value.response_status_code = Number.isFinite(statusCode)
+		? Math.min(599, Math.max(100, Math.trunc(statusCode)))
+		: 200
+
+	formData.value.webhook_url = `${formData.value.webhook_url || ''}`.trim()
+	formData.value.webhook_debug_url = `${formData.value.webhook_debug_url || ''}`.trim()
+}
+
+normalizeWebhookState()
+
+watch(
+	() => props.data,
+	() => {
+		normalizeWebhookState()
+		syncEditorRowsFromFormData()
+	},
+	{ deep: true }
+)
+
+const syncEditorRowsFromFormData = () => {
+	const nextQueryRows = formData.value.params.length
+		? cloneDeep(formData.value.params)
+		: [createEmptyQueryRow()]
+	if (!isEqual(nextQueryRows, queryRows.value)) {
+		queryRows.value = nextQueryRows
+	}
+
+	const nextHeaderRows = formData.value.headers.length
+		? cloneDeep(formData.value.headers)
+		: [createEmptyHeaderRow()]
+	if (!isEqual(nextHeaderRows, headerRows.value)) {
+		headerRows.value = nextHeaderRows
+	}
+
+	const nextBodyRows = formData.value.body.length
+		? cloneDeep(formData.value.body)
+		: [createEmptyBodyRow()]
+	if (!isEqual(nextBodyRows, bodyRows.value)) {
+		bodyRows.value = nextBodyRows
+	}
+}
+
+watch(
+	() => [formData.value.params, formData.value.headers, formData.value.body],
+	() => {
+		syncEditorRowsFromFormData()
+	},
+	{
+		deep: true,
+		immediate: true
+	}
+)
+
+watch(
+	() => formData.value.content_type,
+	(value) => {
+		formData.value.response_content_type = value
+	},
+	{ immediate: true }
+)
+
+watch(
+	queryRows,
+	(value) => {
+		const normalized = value
+			.map((item) => ({
+				name: item.name.trim(),
+				type: item.type,
+				required: Boolean(item.required)
+			}))
+			.filter((item) => item.name)
+
+		if (!isEqual(normalized, formData.value.params)) {
+			formData.value.params = normalized
+		}
+	},
+	{ deep: true }
+)
+
+watch(
+	headerRows,
+	(value) => {
+		const normalized = value
+			.map((item) => ({
+				name: item.name.trim(),
+				required: Boolean(item.required)
+			}))
+			.filter((item) => item.name)
+
+		if (!isEqual(normalized, formData.value.headers)) {
+			formData.value.headers = normalized
+		}
+	},
+	{ deep: true }
+)
+
+watch(
+	bodyRows,
+	(value) => {
+		const normalized = value
+			.map((item) => ({
+				name: item.name.trim(),
+				type: item.type,
+				required: Boolean(item.required)
+			}))
+			.filter((item) => item.name)
+
+		if (!isEqual(normalized, formData.value.body)) {
+			formData.value.body = normalized
+		}
+	},
+	{ deep: true }
+)
+
+const responseEditorLanguage = computed(() =>
+	formData.value.response_content_type === 'application/json' ? 'json' : 'javascript'
+)
+
+const ensureExtraQueryRow = (index: number) => {
+	if (index === queryRows.value.length - 1) {
+		queryRows.value.push(createEmptyQueryRow())
+	}
+}
+
+const ensureExtraHeaderRow = (index: number) => {
+	if (index === headerRows.value.length - 1) {
+		headerRows.value.push(createEmptyHeaderRow())
+	}
+}
+
+const ensureExtraBodyRow = (index: number) => {
+	if (index === bodyRows.value.length - 1) {
+		bodyRows.value.push(createEmptyBodyRow())
+	}
+}
+
+const removeQueryRow = (index: number) => {
+	queryRows.value.splice(index, 1)
+	if (!queryRows.value.length) {
+		queryRows.value.push(createEmptyQueryRow())
+	}
+}
+
+const removeHeaderRow = (index: number) => {
+	headerRows.value.splice(index, 1)
+	if (!headerRows.value.length) {
+		headerRows.value.push(createEmptyHeaderRow())
+	}
+}
+
+const removeBodyRow = (index: number) => {
+	bodyRows.value.splice(index, 1)
+	if (!bodyRows.value.length) {
+		bodyRows.value.push(createEmptyBodyRow())
+	}
+}
+
+const copyText = async (value: string, label: string) => {
+	const text = `${value || ''}`.trim()
+	if (!text) {
+		ElMessage.warning(`${label} 为空`)
+		return
+	}
+
+	try {
+		await navigator.clipboard.writeText(text)
+		ElMessage.success(`${label} 已复制`)
+	} catch (error) {
+		console.error(`copy ${label} failed`, error)
+		ElMessage.error(`${label} 复制失败`)
+	}
+}
+</script>
+
+<style scoped lang="less">
+.webhook-setter {
+	display: flex;
+	flex-direction: column;
+	gap: 20px;
+}
+
+.section-block {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.section-block--response {
+	padding-top: 4px;
+	border-top: 1px solid #eaecf0;
+}
+
+.field-title {
+	font-size: 14px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.field-title--minor {
+	font-size: 13px;
+	font-weight: 600;
+}
+
+.field-stack {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.url-row {
+	display: grid;
+	grid-template-columns: 108px minmax(0, 1fr);
+	gap: 12px;
+}
+
+.method-select,
+.url-input {
+	width: 100%;
+}
+
+.debug-tip {
+	padding-left: 12px;
+	border-left: 3px solid #eaecf0;
+}
+
+.debug-tip__label {
+	font-size: 12px;
+	color: #667085;
+}
+
+.debug-tip__value {
+	margin-top: 2px;
+	font-size: 14px;
+	color: #344054;
+	word-break: break-all;
+}
+
+.table-shell {
+	border-radius: 8px;
+	overflow: hidden;
+	border: 1px solid #dfe4ea;
+	background: #fff;
+}
+
+.delete-button {
+	color: #f04438;
+}
+
+.response-grid {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) 180px;
+	align-items: center;
+	gap: 16px;
+}
+
+.status-input {
+	width: 100%;
+}
+
+.output-toggle {
+	display: inline-flex;
+	align-items: center;
+	gap: 8px;
+	padding: 0;
+	border: 0;
+	background: transparent;
+	font-size: 14px;
+	font-weight: 700;
+	color: #344054;
+	cursor: pointer;
+}
+
+.output-list {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.output-item {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	font-size: 14px;
+	color: #475467;
+	word-break: break-all;
+}
+
+.output-item__name {
+	font-weight: 600;
+	color: #344054;
+}
+
+.output-item__type {
+	color: #667085;
+}
+
+:deep(.param-table) {
+	--el-table-border-color: #eef2f6;
+	--el-table-header-bg-color: #fff;
+	--el-table-tr-bg-color: #fff;
+	--el-table-row-hover-bg-color: #f8fbff;
+}
+
+:deep(.param-table th.el-table__cell) {
+	background: #fff;
+	color: #667085;
+	font-size: 12px;
+	font-weight: 600;
+}
+
+:deep(.param-table .cell) {
+	padding: 0 8px;
+}
+
+:deep(.param-table .el-table__inner-wrapper::before) {
+	display: none;
+}
+
+:deep(.param-table .el-input__wrapper),
+:deep(.param-table .el-select__wrapper) {
+	box-shadow: none;
+}
+</style>

+ 1 - 1
apps/web/src/router/index.ts

@@ -4,7 +4,7 @@ import 'nprogress/nprogress.css'
 
 const MainLayout = () => import('@/layouts/MainLayout.vue')
 const Dashboard = () => import('@/views/Dashboard.vue')
-const Editor = () => import('@/views/Editor.vue')
+const Editor = () => import('@/views/editor/Editor.vue')
 const Statistics = () => import('@/views/Statistics.vue')
 const Chat = () => import('@/views/Chat.vue')
 const Templates = () => import('@/views/TemplateDetail.vue')

+ 381 - 0
apps/web/src/views/editor/Editor.vue

@@ -0,0 +1,381 @@
+<template>
+	<div class="w-full h-full flex flex-col">
+		<div
+			class="h-60px shrink-0 border-b border-b-solid border-gray-200 flex items-center justify-between px-12px"
+		>
+			<div class="left flex items-center gap-4">
+				<el-breadcrumb separator="/" class="flex items-center">
+					<el-breadcrumb-item>Workspace</el-breadcrumb-item>
+					<el-breadcrumb-item>
+						<Input ref="inputRef" v-model="workflow.name" variant="borderless" />
+					</el-breadcrumb-item>
+				</el-breadcrumb>
+
+				<div class="flex gap-2" v-show="!showTagInput" @click="showTagInput = true">
+					<el-tag type="info" v-for="tag in workflow.tags" :key="tag" :disable-transitions="false">
+						{{ tag }}
+					</el-tag>
+				</div>
+
+				<el-input-tag
+					v-show="showTagInput"
+					v-model="workflow.tags"
+					placeholder="按回车键添加标签"
+					aria-label="按回车键添加标签"
+					:max="5"
+					@blur="showTagInput = false"
+				/>
+
+				<IconButton
+					v-if="!workflow.tags?.length && !showTagInput"
+					icon="iconoir:plus"
+					type="primary"
+					link
+					@click="showTagInput = true"
+				>
+					标签
+				</IconButton>
+			</div>
+
+			<div class="right flex items-center gap-2">
+				<el-button type="default" size="small">发布</el-button>
+				<IconButton icon="lucide:history" type="default" link></IconButton>
+				<el-dropdown placement="bottom-end" popper-class="w-120px">
+					<IconButton icon="fluent-mdl2:more" type="default" link></IconButton>
+					<template #dropdown>
+						<el-dropdown-item>描述</el-dropdown-item>
+						<el-dropdown-item>复用</el-dropdown-item>
+						<el-dropdown-item @click="handleRename">重命名</el-dropdown-item>
+						<el-dropdown-item divided @click="handleDelete">删除</el-dropdown-item>
+					</template>
+				</el-dropdown>
+			</div>
+		</div>
+
+		<el-splitter layout="vertical" class="flex-1">
+			<el-splitter-panel>
+				<NodeView :key="workflow.id" :workflow="workflow" :reload-workflow="loadAgentWorkflow" />
+			</el-splitter-panel>
+
+			<el-splitter-panel v-model:size.lazy="footerHeight" :min="32">
+				<EditorFooter @toggle="handleFooterToggle" />
+			</el-splitter-panel>
+		</el-splitter>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { inject, nextTick, onBeforeUnmount, ref, type CSSProperties, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { dayjs, ElMessage, ElMessageBox } from 'element-plus'
+import { agent } from '@repo/api-service'
+
+import EditorFooter from '@/features/editorFooter/index.vue'
+import NodeView from './NodeView.vue'
+import { nodeMap } from '@/nodes'
+import { IconButton, Input } from '@repo/ui'
+
+import type { IWorkflow } from '@repo/workflow'
+
+const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
+
+layout?.setMainStyle({
+	padding: '0px'
+})
+
+const route = useRoute()
+const router = useRouter()
+const footerHeight = ref(32)
+const showTagInput = ref(false)
+const inputRef = ref<InstanceType<typeof Input>>()
+const saveAgentTimer = ref<number | undefined>(undefined)
+const saveVarsTimer = ref<number | undefined>(undefined)
+const isHydrating = ref(false)
+const notifyTimestamps = new Map<string, number>()
+
+const id = route.params?.id as string
+const projectMap = JSON.parse(localStorage.getItem('workflow-map') || '{}') as Record<
+	string,
+	IWorkflow
+>
+
+const workflow = ref<IWorkflow>(
+	projectMap?.[id]
+		? projectMap[id]
+		: {
+				id,
+				name: 'workflow_1',
+				created: dayjs().format('MM 月DD 日'),
+				nodes: [],
+				edges: []
+			}
+)
+
+// 从后端节点数据中提取当前画布需要的节点类型。
+const normalizeNodeType = (node: any) => {
+	const sourceNodeType = node?.nodeType || node?.data?.nodeType || node?.data?.type || node?.type
+	return sourceNodeType || 'code'
+}
+
+// 将原始节点数据转换为画布组件可直接消费的节点结构。
+const toWorkflowNode = (node: any) => {
+	const normalizedNodeType = normalizeNodeType(node)
+	const schema = nodeMap[normalizedNodeType]?.schema
+	const position = node?.position || schema?.position || { x: 20, y: 30 }
+	const width = node?.width ?? schema?.width ?? 96
+	const height = node?.height ?? schema?.height ?? 96
+
+	return {
+		...schema,
+		...node,
+		id: node.id,
+		type: 'canvas-node',
+		position,
+		width,
+		height,
+		zIndex: node?.zIndex ?? schema?.zIndex ?? 1,
+		selected: false,
+		data: {
+			...(schema?.data || {}),
+			...(node?.data || {}),
+			id: node.id,
+			position,
+			width,
+			height,
+			nodeType: normalizedNodeType
+		}
+	}
+}
+
+// 将跨循环作用域的边重定向到父节点,确保外层画布能正确渲染。
+const normalizeEdgeEndpoints = (
+	edge: { source: string; target: string },
+	nodes: Array<{ id: string; parentId?: string }>
+) => {
+	const sourceNode = nodes.find((node) => node.id === edge.source)
+	const targetNode = nodes.find((node) => node.id === edge.target)
+
+	const sourceParentId = sourceNode?.parentId || ''
+	const targetParentId = targetNode?.parentId || ''
+
+	if (sourceParentId && sourceParentId !== targetParentId) {
+		return {
+			source: sourceParentId,
+			target: edge.target
+		}
+	}
+
+	if (targetParentId && targetParentId !== sourceParentId) {
+		return {
+			source: edge.source,
+			target: targetParentId
+		}
+	}
+
+	return edge
+}
+
+// 为边补齐兜底 id 和 handle,避免画布渲染时缺少必要字段。
+const toWorkflowEdge = (
+	edge: any,
+	index: number,
+	nodes: Array<{ id: string; parentId?: string }> = []
+) => {
+	if (!edge || typeof edge !== 'object' || !edge.source || !edge.target) {
+		return null
+	}
+
+	const normalizedEdge = normalizeEdgeEndpoints(edge, nodes)
+
+	return {
+		...edge,
+		...normalizedEdge,
+		sourceHandle: edge.sourceHandle === 'source' ? `${edge.source}-source` : edge.sourceHandle,
+		targetHandle: edge.targetHandle === 'target' ? `${edge.target}-target` : edge.targetHandle,
+		id: edge.id || `edge-${normalizedEdge.source}-${normalizedEdge.target}-${index}`,
+		type: 'canvas-edge',
+		data: edge.data || {}
+	}
+}
+
+// 自动保存频繁触发时,避免重复弹出相同成功提示。
+const notifySuccess = (key: string, message: string, cooldown = 1500) => {
+	const now = Date.now()
+	const last = notifyTimestamps.get(key) || 0
+	if (now - last < cooldown) return
+
+	notifyTimestamps.set(key, now)
+	ElMessage.success(message)
+}
+
+// 统一处理编辑页保存类接口的成功与失败提示。
+const handleApiResult = (response: any, successMessage?: string, errorMessage?: string) => {
+	if (response?.isSuccess) {
+		if (successMessage) {
+			notifySuccess(successMessage, successMessage)
+		}
+		return true
+	}
+	if (response?.code === 0 && response?.error) {
+		ElMessage.error(response.error)
+		return false
+	}
+	if (errorMessage) {
+		ElMessage.error(errorMessage)
+	}
+	return false
+}
+
+// 画布依赖归一化后的节点和边结构,因此在页面入口统一完成数据适配。
+const loadAgentWorkflow = async (agentId: string) => {
+	if (!agentId) return
+	isHydrating.value = true
+
+	try {
+		const response = await agent.postAgentGetAgentInfo({ id: agentId })
+		const result = response?.result
+		if (!response?.isSuccess || !result) {
+			throw new Error('获取智能体信息失败')
+		}
+
+		const normalizedNodes = (result.nodes || []).map(toWorkflowNode)
+		const normalizedEdges = (result.edges || [])
+			.map((edge: any, index: number) => toWorkflowEdge(edge, index, normalizedNodes))
+			.filter(Boolean)
+
+		workflow.value = {
+			...(result as unknown as IWorkflow),
+			nodes: normalizedNodes,
+			edges: normalizedEdges as IWorkflow['edges']
+		}
+		await nextTick()
+	} catch (error) {
+		console.error('loadAgentWorkflow error', error)
+		ElMessage.error('加载智能体流程失败')
+	} finally {
+		isHydrating.value = false
+	}
+}
+
+// 保存页面头部编辑的工作流基础信息。
+const saveAgentMeta = async () => {
+	if (!workflow.value?.id) return
+
+	try {
+		const response = await agent.postAgentDoEditAgent({
+			data: workflow.value
+		})
+		handleApiResult(response, '智能体已保存', '保存智能体失败')
+	} catch (error) {
+		console.error('saveAgentMeta error', error)
+		ElMessage.error('保存智能体失败')
+	}
+}
+
+// 保存不在节点画布内编辑的工作流变量。
+const saveAgentVariables = async () => {
+	if (!workflow.value?.id) return
+
+	try {
+		const response = await agent.postAgentDoSaveAgentVariables({
+			appAgentId: workflow.value.id,
+			conversation_variables: workflow.value.conversation_variables || [],
+			env_variables: (workflow.value.env_variables || []).map((item: any) => ({
+				name: item?.name || '',
+				value: item?.value ?? '',
+				type: item?.type || 'string'
+			}))
+		})
+		handleApiResult(response, '变量已保存', '保存变量失败')
+	} catch (error) {
+		console.error('saveAgentVariables error', error)
+		ElMessage.error('保存变量失败')
+	}
+}
+
+// 为基础信息保存增加防抖,减少输入过程中的重复请求。
+const scheduleSaveAgentMeta = () => {
+	if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
+	if (!workflow.value?.id) return
+
+	saveAgentTimer.value = window.setTimeout(() => {
+		saveAgentMeta()
+	}, 600)
+}
+
+// 为变量保存增加防抖,避免短时间内连续提交。
+const scheduleSaveAgentVariables = () => {
+	if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
+	if (!workflow.value?.id) return
+
+	saveVarsTimer.value = window.setTimeout(() => {
+		saveAgentVariables()
+	}, 600)
+}
+
+watch(
+	() => workflow.value,
+	(currentWorkflow) => {
+		projectMap[currentWorkflow.id] = currentWorkflow
+		localStorage.setItem('workflow-map', JSON.stringify(projectMap))
+	},
+	{ deep: true }
+)
+
+watch(
+	() => [workflow.value?.name, workflow.value?.description, workflow.value?.tags],
+	() => {
+		if (isHydrating.value) return
+		scheduleSaveAgentMeta()
+	},
+	{ deep: true }
+)
+
+watch(
+	() => [workflow.value?.conversation_variables, workflow.value?.env_variables],
+	() => {
+		if (isHydrating.value) return
+		scheduleSaveAgentVariables()
+	},
+	{ deep: true }
+)
+
+watch(
+	() => route.params?.id,
+	async (nextId) => {
+		if (nextId) {
+			await loadAgentWorkflow(nextId as string)
+		}
+	},
+	{ immediate: true }
+)
+
+// 根据底部面板开关状态调整面板高度。
+const handleFooterToggle = (open: boolean) => {
+	footerHeight.value = open ? 200 : 32
+}
+
+// 聚焦标题输入框,方便直接重命名当前工作流。
+const handleRename = () => {
+	inputRef.value?.focus()
+	inputRef.value?.select()
+}
+
+// 删除本地缓存中的当前工作流并退出编辑页。
+const handleDelete = () => {
+	ElMessageBox.confirm('确定要删除吗?', '提示', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'warning'
+	}).then(() => {
+		localStorage.removeItem(`project_${id}`)
+		router.push('/')
+	})
+}
+
+onBeforeUnmount(() => {
+	if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
+	if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)
+	layout?.setMainStyle({})
+})
+</script>

Файловите разлики са ограничени, защото са твърде много
+ 161 - 484
apps/web/src/views/Editor.vue


+ 6 - 4
packages/workflow/src/components/Canvas.vue

@@ -33,6 +33,8 @@ defineOptions({
 	inheritAttrs: false
 })
 
+type CreateNodeSource = { type: string } | string
+
 const emit = defineEmits<{
 	'update:node:size': [id: string, size: { width: number; height: number }]
 	'update:node:position': [id: string, position: XYPosition]
@@ -61,7 +63,7 @@ const emit = defineEmits<{
 	'copy:test:url': [id: string]
 	'delete:node': [id: string]
 	'replace:node': [id: string]
-	'create:node': [source: any]
+	'create:node': [source: CreateNodeSource]
 	'create:sticky': []
 	'delete:nodes': [ids: string[]]
 	'update:nodes:enabled': [ids: string[]]
@@ -86,7 +88,7 @@ const emit = defineEmits<{
 	/**
 	 * 连线节点添加
 	 */
-	'click:connection:add': [connection: Connection]
+	'click:connection:add': [connection: Connection, parentId?: string]
 }>()
 
 const props = withDefaults(
@@ -326,7 +328,7 @@ const onZoomToFit = () => {
 const onResetZoom = () => {
 	zoomTo(1)
 }
-const onAddNode = (value: { type: string } | string) => {
+const onAddNode = (value: CreateNodeSource) => {
 	emit('create:node', value)
 }
 
@@ -508,7 +510,7 @@ defineExpose({
 					@loop:child:run:node="emit('run:node', $event)"
 					@loop:child:delete:node="emit('delete:node', $event)"
 					@loop:child:delete:connection="emit('delete:connection', $event)"
-					@loop:child:click:connection:add="emit('click:connection:add', $event)"
+					@loop:child:click:connection:add="emit('click:connection:add', $event, nodeProps.id)"
 				/>
 			</slot>
 		</template>

+ 2 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -138,7 +138,8 @@ function onAddNode() {
 		<div class="loop-body flex-1 min-h-0 relative">
 			<div
 				ref="innerCanvasRef"
-				class="absolute top-16px right-16px bottom-16px left-16px z-1 rounded-8px border-1 border-dashed border-#d9d9d9 nopan overflow-hidden"
+				class="absolute top-16px right-16px bottom-16px left-16px z-1 rounded-8px border-1 border-dashed border-#d9d9d9 nodrag nopan overflow-hidden"
+				@mousedown.stop
 				@click.stop
 				@dblclick.stop
 			>