知你客服

This commit is contained in:
rjb
2026-03-06 22:31:41 +08:00
parent 171a6edf94
commit 9d3198f6bc
31 changed files with 6579 additions and 80 deletions

View File

@@ -98,6 +98,70 @@
</div>
</el-card>
<!-- 工具调用可视化 -->
<el-card v-if="toolCalls.length > 0" class="tool-calls-card" shadow="never">
<template #header>
<div class="card-header">
<span>🔧 工具调用</span>
<el-tag type="info" size="small">{{ toolCalls.length }} 个工具调用</el-tag>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="(toolCall, index) in toolCalls"
:key="index"
:timestamp="formatTime(toolCall.timestamp)"
:type="toolCall.status === 'success' ? 'success' : toolCall.status === 'failed' ? 'danger' : 'primary'"
:icon="toolCall.status === 'success' ? 'CircleCheck' : toolCall.status === 'failed' ? 'CircleClose' : 'Loading'"
>
<div class="tool-call-content">
<div class="tool-call-header">
<el-tag :type="toolCall.status === 'success' ? 'success' : toolCall.status === 'failed' ? 'danger' : 'info'" size="small">
{{ toolCall.tool_name }}
</el-tag>
<span v-if="toolCall.duration" class="tool-call-duration">
耗时: {{ toolCall.duration }}ms
</span>
</div>
<!-- 工具参数 -->
<div v-if="toolCall.tool_args" class="tool-call-section">
<div class="section-title">📥 参数</div>
<el-collapse>
<el-collapse-item title="查看参数" :name="`args-${index}`">
<pre class="json-viewer-small">{{ formatJSON(toolCall.tool_args) }}</pre>
</el-collapse-item>
</el-collapse>
</div>
<!-- 工具结果 -->
<div v-if="toolCall.tool_result" class="tool-call-section">
<div class="section-title">
{{ toolCall.status === 'success' ? '✅ 结果' : '❌ 错误' }}
</div>
<el-collapse>
<el-collapse-item title="查看结果" :name="`result-${index}`">
<pre class="json-viewer-small" :class="{ 'error-result': toolCall.status === 'failed' }">
{{ formatToolResult(toolCall.tool_result, toolCall.tool_result_length) }}
</pre>
</el-collapse-item>
</el-collapse>
</div>
<!-- 错误信息 -->
<div v-if="toolCall.error" class="tool-call-error">
<el-alert
type="error"
:title="toolCall.error"
:closable="false"
show-icon
/>
</div>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
<!-- 执行时间线 -->
<el-card class="timeline-card" shadow="never">
<template #header>
@@ -228,6 +292,98 @@ const statusType = computed(() => {
return 'info'
})
// 提取工具调用信息
const toolCalls = computed(() => {
const calls: Array<{
tool_name: string
tool_call_id?: string
tool_args?: any
tool_result?: string
tool_result_length?: number
status: 'success' | 'failed' | 'requested'
timestamp: string
duration?: number
error?: string
}> = []
nodeLogs.value.forEach(log => {
// 检查是否是工具调用相关的日志
if (log.data) {
const data = log.data
// 工具调用请求
if (data.tool_name && data.status === 'requested') {
calls.push({
tool_name: data.tool_name,
tool_call_id: data.tool_call_id,
tool_args: data.tool_args,
status: 'requested',
timestamp: log.timestamp
})
}
// 工具调用成功
if (data.tool_name && data.status === 'success') {
const existingCall = calls.find(c =>
c.tool_name === data.tool_name &&
c.tool_call_id === data.tool_call_id &&
c.status === 'requested'
)
if (existingCall) {
existingCall.status = 'success'
existingCall.tool_result = data.tool_result
existingCall.tool_result_length = data.tool_result_length
existingCall.duration = log.duration || data.duration
} else {
calls.push({
tool_name: data.tool_name,
tool_call_id: data.tool_call_id,
tool_args: data.tool_args,
tool_result: data.tool_result,
tool_result_length: data.tool_result_length,
status: 'success',
timestamp: log.timestamp,
duration: log.duration || data.duration
})
}
}
// 工具调用失败
if (data.tool_name && data.status === 'failed') {
const existingCall = calls.find(c =>
c.tool_name === data.tool_name &&
c.tool_call_id === data.tool_call_id &&
c.status === 'requested'
)
if (existingCall) {
existingCall.status = 'failed'
existingCall.error = data.error
existingCall.duration = log.duration || data.duration
} else {
calls.push({
tool_name: data.tool_name,
tool_call_id: data.tool_call_id,
tool_args: data.tool_args,
error: data.error,
status: 'failed',
timestamp: log.timestamp,
duration: log.duration || data.duration
})
}
}
}
})
// 按时间排序
calls.sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
})
return calls
})
// 监听props变化加载日志
watch([() => props.visible, () => props.executionId, () => props.nodeId],
([newVisible, newExecutionId, newNodeId]) => {
@@ -336,6 +492,30 @@ const getLogIcon = (level: string) => {
}
}
// 格式化工具结果
const formatToolResult = (result: string, length?: number) => {
if (!result) return ''
// 如果结果太长,显示预览
if (length && length > 500) {
try {
// 尝试解析JSON
const parsed = JSON.parse(result)
const formatted = JSON.stringify(parsed, null, 2)
return formatted.substring(0, 1000) + '\n\n... (结果已截断,完整结果长度: ' + length + ' 字符)'
} catch {
return result.substring(0, 1000) + '\n\n... (结果已截断,完整结果长度: ' + length + ' 字符)'
}
}
try {
const parsed = JSON.parse(result)
return JSON.stringify(parsed, null, 2)
} catch {
return result
}
}
// 复制到剪贴板
const copyToClipboard = async (data: any) => {
try {
@@ -442,4 +622,46 @@ const copyToClipboard = async (data: any) => {
.log-data {
margin-top: 8px;
}
.tool-calls-card {
margin-bottom: 16px;
}
.tool-call-content {
padding-left: 8px;
}
.tool-call-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.tool-call-duration {
color: #909399;
font-size: 12px;
}
.tool-call-section {
margin-top: 12px;
margin-bottom: 8px;
}
.section-title {
font-size: 13px;
font-weight: 500;
color: #606266;
margin-bottom: 8px;
}
.tool-call-error {
margin-top: 12px;
}
.error-result {
background: #fef0f0;
border-color: #fde2e2;
color: #f56c6c;
}
</style>

View File

@@ -284,7 +284,71 @@
<!-- 右侧配置面板 -->
<div class="config-panel" v-if="selectedNode">
<h3>节点配置</h3>
<!-- 配置面板头部 -->
<div class="config-panel-header">
<div class="config-panel-title">
<h3>节点配置</h3>
</div>
<div class="config-panel-actions">
<!-- 测试该节点 -->
<el-tooltip content="测试该节点" placement="bottom">
<el-button
type="primary"
link
size="small"
@click="handleTestNode"
:loading="testingNode"
:disabled="!selectedNode || !!testInputError"
class="config-action-btn"
>
<el-icon><VideoPlay /></el-icon>
</el-button>
</el-tooltip>
<!-- 更多操作 -->
<el-dropdown trigger="click" @command="handleConfigMoreAction">
<el-tooltip content="更多" placement="bottom">
<el-button
type="primary"
link
size="small"
class="config-action-btn"
>
<el-icon><MoreFilled /></el-icon>
</el-button>
</el-tooltip>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="save">
<el-icon><Check /></el-icon>
保存配置
</el-dropdown-item>
<el-dropdown-item command="duplicate" divided>
<el-icon><DocumentCopy /></el-icon>
复制节点
</el-dropdown-item>
<el-dropdown-item command="delete">
<el-icon><Delete /></el-icon>
删除节点
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 关闭面板 -->
<el-tooltip content="关闭" placement="bottom">
<el-button
type="primary"
link
size="small"
@click="closeConfigPanel"
class="config-action-btn"
>
<el-icon><Close /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<!-- 配置标签页 -->
<el-tabs v-model="configActiveTab" type="border-card">
@@ -301,6 +365,199 @@
<el-input v-model="selectedNode.data.label" />
</el-form-item>
<!-- 输入变量配置 -->
<el-divider />
<el-collapse v-model="inputOutputCollapse" style="margin-bottom: 16px;">
<el-collapse-item name="input">
<template #title>
<div style="display: flex; align-items: center; width: 100%;">
<span style="flex: 1;">输入</span>
<el-icon style="margin-right: 8px;"><InfoFilled /></el-icon>
<el-button
type="primary"
link
size="small"
@click.stop="addInputVariable"
style="margin-right: 8px;"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
<div v-if="!inputVariables.length" style="text-align: center; padding: 20px; color: #909399;">
<el-icon style="font-size: 32px; margin-bottom: 8px;"><Box /></el-icon>
<div>暂未配置输入变量</div>
</div>
<el-table
v-else
:data="inputVariables"
border
size="small"
style="width: 100%;"
>
<el-table-column label="变量名" width="150">
<template #default="{ row, $index }">
<el-input
v-model="row.name"
placeholder="变量名"
size="small"
@blur="updateInputVariable($index)"
/>
</template>
</el-table-column>
<el-table-column label="变量值" min-width="200">
<template #default="{ row, $index }">
<div style="display: flex; gap: 8px; align-items: center;">
<el-select
v-model="row.type"
size="small"
style="width: 100px;"
@change="updateInputVariable($index)"
>
<el-option label="str." value="string" />
<el-option label="int." value="integer" />
<el-option label="float." value="number" />
<el-option label="bool." value="boolean" />
<el-option label="object" value="object" />
<el-option label="array" value="array" />
</el-select>
<el-input
v-model="row.value"
placeholder="输入或引用参数值"
size="small"
style="flex: 1;"
@blur="updateInputVariable($index)"
>
<template #suffix>
<el-icon
style="cursor: pointer;"
@click="showVariableSelector($index, 'input')"
>
<Aim />
</el-icon>
</template>
</el-input>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button
type="danger"
link
size="small"
@click="removeInputVariable($index)"
>
<el-icon><Minus /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">
提示可以使用&#123;&#123;变量名&#125;&#125;&#123;&#123;变量名.子变量名&#125;&#125;&#123;&#123;变量名[数组索引]&#125;&#125;的方式引用输入参数中的变量
</div>
</el-collapse-item>
<!-- 输出变量配置 -->
<el-collapse-item name="output">
<template #title>
<div style="display: flex; align-items: center; width: 100%;">
<span style="flex: 1;">输出</span>
<el-icon style="margin-right: 8px;"><InfoFilled /></el-icon>
<div style="margin-right: 8px; font-size: 12px; color: #909399;">
输出格式
<el-select
v-model="selectedNode.data.output_format"
size="small"
style="width: 100px; margin-left: 4px;"
@click.stop
>
<el-option label="JSON" value="json" />
<el-option label="Text" value="text" />
</el-select>
</div>
<el-button
type="primary"
link
size="small"
@click.stop="addOutputVariable"
style="margin-right: 8px;"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
<div v-if="!outputVariables.length" style="text-align: center; padding: 20px; color: #909399;">
<el-icon style="font-size: 32px; margin-bottom: 8px;"><Box /></el-icon>
<div>暂未配置输出变量</div>
</div>
<el-table
v-else
:data="outputVariables"
border
size="small"
style="width: 100%;"
>
<el-table-column label="变量名" width="150">
<template #default="{ row, $index }">
<el-input
v-model="row.name"
placeholder="变量名"
size="small"
@blur="updateOutputVariable($index)"
/>
</template>
</el-table-column>
<el-table-column label="变量类型" min-width="200">
<template #default="{ row, $index }">
<div style="display: flex; gap: 8px; align-items: center;">
<el-select
v-model="row.type"
size="small"
style="flex: 1;"
@change="updateOutputVariable($index)"
>
<el-option label="str. String" value="string" />
<el-option label="int. Integer" value="integer" />
<el-option label="float. Number" value="number" />
<el-option label="bool. Boolean" value="boolean" />
<el-option label="object. Object" value="object" />
<el-option label="Array&lt;Object&gt;" value="array" />
<el-option label="Array&lt;String&gt;" value="string[]" />
<el-option label="Array&lt;Number&gt;" value="number[]" />
</el-select>
<el-icon
style="cursor: pointer; font-size: 16px;"
@click="expandOutputVariable($index)"
>
<ArrowDown v-if="expandedOutputVariableIndex !== $index" />
<ArrowUp v-else />
</el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button
type="danger"
link
size="small"
@click="removeOutputVariable($index)"
>
<el-icon><Minus /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="expandedOutputVariableIndex !== null && expandedOutputVariableIndex >= 0 && outputVariables && outputVariables[expandedOutputVariableIndex]" style="margin-top: 12px; padding: 12px; background: #f5f7fa; border-radius: 4px;">
<div style="font-size: 12px; color: #606266; margin-bottom: 8px;">
<strong>{{ outputVariables[expandedOutputVariableIndex]?.name }}</strong> 结构预览
</div>
<pre style="font-size: 11px; color: #909399; margin: 0; white-space: pre-wrap;">{{ JSON.stringify(getOutputVariableStructure(expandedOutputVariableIndex), null, 2) }}</pre>
</div>
</el-collapse-item>
</el-collapse>
<!-- 快速模板 & 变量插入 -->
<el-divider />
<div class="quick-actions">
@@ -1462,13 +1719,6 @@
</el-form-item>
</template>
<el-form-item>
<div class="config-actions">
<el-button type="primary" @click="handleSaveNode">保存配置</el-button>
<el-button @click="handleCopyNode">复制节点</el-button>
<el-button type="danger" @click="handleDeleteNode">删除节点</el-button>
</div>
</el-form-item>
</el-form>
</el-tab-pane>
@@ -2098,9 +2348,9 @@
<!-- 配置模式选择 -->
<el-radio-group v-model="configAssistantMode" style="width: 100%; margin-bottom: 15px;">
<el-radio-button label="simple">简单模式</el-radio-button>
<el-radio-button label="template">模板模式</el-radio-button>
<el-radio-button label="wizard">向导模式</el-radio-button>
<el-radio-button value="simple">简单模式</el-radio-button>
<el-radio-button value="template">模板模式</el-radio-button>
<el-radio-button value="wizard">向导模式</el-radio-button>
</el-radio-group>
<!-- 简单模式 -->
@@ -2461,7 +2711,7 @@
type="success"
@click="handleTestNode"
:loading="testingNode"
:disabled="!selectedNode || testInputError"
:disabled="!selectedNode || !!testInputError"
style="width: 100%"
>
<el-icon><VideoPlay /></el-icon>
@@ -2648,7 +2898,7 @@ import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document, Search, Timer, Box, Edit, Picture, Download, Delete, CircleCheck, CircleClose, Aim, ArrowLeft, ArrowRight, Refresh, Loading, Star, ChatDotRound, Connection as ConnectionIcon, Setting, DataAnalysis, Link, Upload, UploadFilled, DocumentAdd, QuestionFilled } from '@element-plus/icons-vue'
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, ArrowUp, Sort, Grid, Operation, Document, Search, Timer, Box, Edit, Picture, Download, Delete, CircleCheck, CircleClose, Aim, ArrowLeft, ArrowRight, Refresh, Loading, Star, ChatDotRound, Connection as ConnectionIcon, Setting, DataAnalysis, Link, Upload, UploadFilled, DocumentAdd, QuestionFilled, Plus, Minus, MoreFilled, Close } from '@element-plus/icons-vue'
import { useWorkflowStore } from '@/stores/workflow'
import api from '@/api'
import type { WorkflowNode, WorkflowEdge } from '@/types'
@@ -3723,6 +3973,159 @@ const getVarTypeTag = (type: string) => {
// 变量面板展开状态
const variablePanelActive = ref<string[]>([])
// 输入输出变量管理
const inputOutputCollapse = ref<string[]>([])
const expandedOutputVariableIndex = ref<number | null>(null)
// 输入变量列表
const inputVariables = computed(() => {
if (!selectedNode.value || !selectedNode.value.data) return []
if (!selectedNode.value.data.input_variables) {
selectedNode.value.data.input_variables = []
}
return selectedNode.value.data.input_variables
})
// 输出变量列表
const outputVariables = computed(() => {
if (!selectedNode.value || !selectedNode.value.data) return []
if (!selectedNode.value.data.output_variables) {
selectedNode.value.data.output_variables = []
}
return selectedNode.value.data.output_variables
})
// 添加输入变量
const addInputVariable = () => {
if (!selectedNode.value) return
if (!selectedNode.value.data.input_variables) {
selectedNode.value.data.input_variables = []
}
selectedNode.value.data.input_variables.push({
name: `input_${selectedNode.value.data.input_variables.length + 1}`,
type: 'string',
value: ''
})
}
// 删除输入变量
const removeInputVariable = (index: number) => {
if (!selectedNode.value || !selectedNode.value.data) return
if (!selectedNode.value.data.input_variables) {
selectedNode.value.data.input_variables = []
return
}
if (index >= 0 && index < selectedNode.value.data.input_variables.length) {
selectedNode.value.data.input_variables.splice(index, 1)
}
}
// 更新输入变量
const updateInputVariable = (index: number) => {
// 变量已通过 v-model 自动更新,这里可以添加验证逻辑
if (selectedNode.value && selectedNode.value.data && selectedNode.value.data.input_variables) {
if (index >= 0 && index < selectedNode.value.data.input_variables.length) {
const variable = selectedNode.value.data.input_variables[index]
if (variable && !variable.name) {
ElMessage.warning('变量名不能为空')
}
}
}
}
// 添加输出变量
const addOutputVariable = () => {
if (!selectedNode.value || !selectedNode.value.data) return
if (!selectedNode.value.data.output_variables) {
selectedNode.value.data.output_variables = []
}
selectedNode.value.data.output_variables.push({
name: `output_${selectedNode.value.data.output_variables.length + 1}`,
type: 'string'
})
}
// 删除输出变量
const removeOutputVariable = (index: number) => {
if (!selectedNode.value || !selectedNode.value.data) return
if (!selectedNode.value.data.output_variables) {
selectedNode.value.data.output_variables = []
return
}
if (index >= 0 && index < selectedNode.value.data.output_variables.length) {
selectedNode.value.data.output_variables.splice(index, 1)
if (expandedOutputVariableIndex.value === index) {
expandedOutputVariableIndex.value = null
} else if (expandedOutputVariableIndex.value !== null && expandedOutputVariableIndex.value > index) {
expandedOutputVariableIndex.value = expandedOutputVariableIndex.value - 1
}
}
}
// 更新输出变量
const updateOutputVariable = (index: number) => {
// 变量已通过 v-model 自动更新,这里可以添加验证逻辑
if (selectedNode.value && selectedNode.value.data && selectedNode.value.data.output_variables) {
if (index >= 0 && index < selectedNode.value.data.output_variables.length) {
const variable = selectedNode.value.data.output_variables[index]
if (variable && !variable.name) {
ElMessage.warning('变量名不能为空')
}
}
}
}
// 展开/收起输出变量结构
const expandOutputVariable = (index: number) => {
if (!selectedNode.value || !selectedNode.value.data) return
if (!selectedNode.value.data.output_variables) {
selectedNode.value.data.output_variables = []
return
}
if (index >= 0 && index < selectedNode.value.data.output_variables.length) {
if (expandedOutputVariableIndex.value === index) {
expandedOutputVariableIndex.value = null
} else {
expandedOutputVariableIndex.value = index
}
}
}
// 获取输出变量结构预览
const getOutputVariableStructure = (index: number) => {
if (!selectedNode.value || !selectedNode.value.data || !selectedNode.value.data.output_variables) return {}
if (index < 0 || index >= selectedNode.value.data.output_variables.length) return {}
const variable = selectedNode.value.data.output_variables[index]
if (!variable) return {}
// 根据类型生成示例结构
const type = variable.type
switch (type) {
case 'string':
return { [variable.name]: 'string value' }
case 'integer':
return { [variable.name]: 0 }
case 'number':
return { [variable.name]: 0.0 }
case 'boolean':
return { [variable.name]: true }
case 'object':
return { [variable.name]: { key: 'value' } }
case 'array':
case 'string[]':
case 'number[]':
return { [variable.name]: [] }
default:
return { [variable.name]: null }
}
}
// 显示变量选择器(用于输入变量的值)
const showVariableSelector = (index: number, type: 'input' | 'output') => {
// TODO: 实现变量选择器弹窗
ElMessage.info('变量选择器功能开发中...')
}
// 变量自动补全相关
const promptTextareaRef = ref<any>(null)
const autocompleteDropdownRef = ref<HTMLElement | null>(null)
@@ -5565,11 +5968,39 @@ watch(nodeTestInput, (value) => {
}
try {
JSON.parse(value)
const parsed = JSON.parse(value)
// 验证解析后的结果必须是对象
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
testInputError.value = '输入必须是JSON对象'
return
}
testInputError.value = ''
} catch (error: any) {
testInputError.value = 'JSON格式错误: ' + error.message
}
}, { immediate: true })
// 当节点切换时,重置测试输入错误
watch(selectedNode, () => {
testInputError.value = ''
// 重新验证当前输入
if (nodeTestInput.value) {
const value = nodeTestInput.value
if (value.trim() === '') {
testInputError.value = ''
return
}
try {
const parsed = JSON.parse(value)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
testInputError.value = '输入必须是JSON对象'
return
}
testInputError.value = ''
} catch (error: any) {
testInputError.value = 'JSON格式错误: ' + error.message
}
}
})
// 测试用例存储
@@ -5921,6 +6352,45 @@ const handleSaveNode = async () => {
}
}
// 关闭配置面板
const closeConfigPanel = () => {
selectedNode.value = null
selectedEdge.value = null
}
// 处理配置面板更多操作
const handleConfigMoreAction = (command: string) => {
if (!selectedNode.value) return
switch (command) {
case 'save':
// 保存配置
handleSaveNode()
break
case 'duplicate':
// 复制节点
handleCopyNode()
break
case 'delete':
// 删除节点
ElMessageBox.confirm(
`确定要删除节点 "${selectedNode.value.data?.label || selectedNode.value.id}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
handleDeleteNode()
closeConfigPanel()
}).catch(() => {
// 取消删除
})
break
}
}
// 测试节点
const handleTestNode = async () => {
if (!selectedNode.value) {
@@ -6660,24 +7130,71 @@ const handleAutoLayout = async () => {
// 布局参数
const nodeWidth = 200
const nodeHeight = 80
const horizontalSpacing = 280 // 水平间距(增大间距,避免节点重叠
const verticalSpacing = 180 // 垂直间距(增大间距,使布局更清晰
const horizontalSpacing = 320 // 水平间距(节点之间的水平距离
const verticalSpacing = 150 // 垂直间距(用于有分支的情况
const startX = 100
const startY = 100
const startY = 200
// 计算每层的布局(水平居中
layers.forEach((layer, layerIndex) => {
const layerY = startY + layerIndex * verticalSpacing
const layerWidth = (layer.length - 1) * horizontalSpacing
const layerStartX = startX - layerWidth / 2
layer.forEach((nodeId, nodeIndex) => {
const nodeX = layerStartX + nodeIndex * horizontalSpacing
updateNode(nodeId, {
position: { x: nodeX, y: layerY }
// 检查是否是简单的线性工作流(每层只有一个节点
let isLinearWorkflow = true
layers.forEach(layer => {
if (layer.length > 1) {
isLinearWorkflow = false
}
})
// 如果每层只有一个节点,使用水平线性布局(从左到右,所有节点在同一水平线)
if (isLinearWorkflow && layers.length > 1) {
// 水平线性布局:所有节点水平排列在同一水平线上
layers.forEach((layer, layerIndex) => {
layer.forEach((nodeId) => {
const nodeX = startX + layerIndex * horizontalSpacing
const nodeY = startY // 所有节点在同一水平线上
updateNode(nodeId, {
position: { x: nodeX, y: nodeY }
})
})
})
})
} else {
// 层次布局:有分支的工作流
// 优化策略:尽量让单节点层水平排列,多节点层才垂直排列
let baseY = startY
let currentX = startX
let consecutiveSingleNodeLayers = 0
layers.forEach((layer, layerIndex) => {
if (layer.length === 1) {
// 单节点层:水平排列
consecutiveSingleNodeLayers++
const nodeId = layer[0]
const nodeX = currentX
// 如果连续多个单节点层,保持水平对齐
const nodeY = baseY + (consecutiveSingleNodeLayers > 3 ? 20 : 0) // 如果连续太多,稍微下移
updateNode(nodeId, {
position: { x: nodeX, y: nodeY }
})
currentX += horizontalSpacing
} else {
// 多节点层水平居中排列使用新的Y坐标
consecutiveSingleNodeLayers = 0
baseY += verticalSpacing
currentX = startX // 重置X位置
const layerWidth = (layer.length - 1) * horizontalSpacing
const layerStartX = startX
layer.forEach((nodeId, nodeIndex) => {
const nodeX = layerStartX + nodeIndex * horizontalSpacing
updateNode(nodeId, {
position: { x: nodeX, y: baseY }
})
})
// 更新currentX为下一层的起始位置
currentX = layerStartX + layerWidth + horizontalSpacing
}
})
}
// 自动调整视口,使所有节点可见
await nextTick()
@@ -7574,16 +8091,63 @@ onUnmounted(() => {
min-width: 380px;
background: #fff;
border-left: 1px solid #ddd;
padding: 15px 20px 15px 15px; /* 右侧预留滚动条空间,避免按钮被遮挡 */
padding: 0;
overflow-y: auto;
overflow-x: hidden;
flex-shrink: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.config-panel h3 {
margin: 0 0 15px 0;
.config-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px 15px 15px;
border-bottom: 1px solid #e4e7ed;
background: #fff;
position: sticky;
top: 0;
z-index: 10;
}
.config-panel-title {
flex: 1;
}
.config-panel-title h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.config-panel-actions {
display: flex;
align-items: center;
gap: 4px;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 2px;
background: #fff;
}
.config-action-btn {
padding: 6px 8px;
min-width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.config-action-btn:hover {
background: #f5f7fa;
border-radius: 3px;
}
.config-panel :deep(.el-tabs) {
padding: 15px 20px 15px 15px;
}
.node-test-section {