- NodeLibraryItem 增加 patchLayout 字段(节点库返回,仅 model 节点 true) - nodeConfigMap 聚合透传节点库 patchLayout,开关显示条件由 modelType 范围改为 nodeConfig.patchLayout - 移除不再使用的 isVideoModelSelected 判断 Co-Authored-By: Claude <noreply@anthropic.com>
1876 lines
68 KiB
Vue
1876 lines
68 KiB
Vue
<template>
|
||
<div class="workflow-canvas-page">
|
||
<!-- 工具栏 -->
|
||
<div class="toolbar">
|
||
<div class="toolbar-left">
|
||
<h2>工作流画布编辑器</h2>
|
||
<span class="info">节点: {{ nodes.length }} / 连线: {{ edges.length }}</span>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-button size="small" @click="createNewWorkflow">新建工作流</el-button>
|
||
<el-button size="small" type="danger" :disabled="!selectedNode" @click="deleteSelectedNode">删除选中</el-button>
|
||
<el-button size="small" type="primary" @click="saveWorkflowAction" :loading="saving">保存工作流</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区:三栏布局 -->
|
||
<div class="content">
|
||
<!-- 左侧:配置面板 -->
|
||
<NodeConfigPanel
|
||
:selected-node="selectedNode"
|
||
:node-config="currentNodeConfig"
|
||
:upstream-nodes="upstreamNodes"
|
||
@update:selected-node="updateSelectedNode"
|
||
@open-model-selector="showModelSelector = true"
|
||
@remove-model="handleRemoveModel"
|
||
@open-skill-selector="showSkillSelector = true"
|
||
@remove-skill="handleRemoveSkill"
|
||
@update:patch-layout="handleTogglePatchLayout"
|
||
@open-workflow-selector="showWorkflowSelector = true"
|
||
@remove-workflow="handleRemoveWorkflow"
|
||
/>
|
||
|
||
<!-- 中间:VueFlow 画布(节点库在画布内) -->
|
||
<div class="flow-wrapper">
|
||
<NodeLibraryPanel
|
||
:node-library-groups="nodeLibraryGroups"
|
||
:collapsed="nodeLibraryCollapsed"
|
||
@update:collapsed="nodeLibraryCollapsed = $event"
|
||
@add-node="addNodeFromLibrary"
|
||
/>
|
||
|
||
<!-- VueFlow 画布 -->
|
||
<VueFlow
|
||
v-model:nodes="nodes"
|
||
v-model:edges="edges"
|
||
:default-viewport="{ zoom: 1 }"
|
||
:nodes-connectable="true"
|
||
:edges-updatable="true"
|
||
:node-types="nodeTypes"
|
||
@connect="onConnect"
|
||
@node-click="onNodeClick"
|
||
>
|
||
<Background pattern-color="#cbd5e1" :gap="16" />
|
||
<Controls />
|
||
</VueFlow>
|
||
</div>
|
||
|
||
<!-- 右侧:工作流列表 -->
|
||
<WorkflowListPanel
|
||
:user-workflow-list="userWorkflowList"
|
||
:template-workflow-list="templateWorkflowList"
|
||
:current-editing-id="currentEditingWorkflowId"
|
||
:loading="workflowListLoading"
|
||
@edit="editWorkflow"
|
||
@delete="deleteWorkflowAction"
|
||
@create="createNewWorkflow"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 保存工作流对话框 -->
|
||
<SaveWorkflowDialog
|
||
v-model="saveDialogVisible"
|
||
:save-form="saveForm"
|
||
:current-editing-workflow-id="currentEditingWorkflowId"
|
||
:saving="saving"
|
||
@confirm="confirmSaveWorkflow"
|
||
/>
|
||
|
||
<!-- 模型选择器(管理员绘制模板时选系统模型无需填 API Key,直接选中) -->
|
||
<ModelSelector
|
||
v-model="showModelSelector"
|
||
:default-model="selectedModelData"
|
||
:system-model-require-key="!isSuperAdmin"
|
||
@confirm="handleModelConfirm"
|
||
/>
|
||
|
||
<!-- 技能选择器 -->
|
||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
|
||
|
||
<!-- 子流程工作流选择器 -->
|
||
<WorkflowSelector
|
||
v-model="showWorkflowSelector"
|
||
:default-workflow="selectedWorkflowData"
|
||
@confirm="handleWorkflowConfirm"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, markRaw } from 'vue';
|
||
import { VueFlow, useVueFlow } from '@vue-flow/core';
|
||
import { Background } from '@vue-flow/background';
|
||
import { Controls } from '@vue-flow/controls';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import type { Node, Edge, Connection } from '@vue-flow/core';
|
||
import {
|
||
getWorkflowList,
|
||
getWorkflowDetail,
|
||
saveWorkflow,
|
||
updateWorkflow,
|
||
deleteWorkflow,
|
||
type WorkflowItem,
|
||
} from '/@/api/settings/creation';
|
||
import { checkIsSuperAdmin } from '/@/api/system/user';
|
||
import { getNodeLibraryList, getSubFlowWorkflowDetail, type NodeLibraryGroup } from '/@/api/settings/workflow';
|
||
import NodeConfigPanel from './component/NodeConfigPanel.vue';
|
||
import WorkflowSelector from './component/WorkflowSelector.vue';
|
||
import type { SubFlowConfig, SubFlowField } from './component/subFlowTypes';
|
||
import {
|
||
stripReadonlyFields,
|
||
collectExposedFields,
|
||
restoreRuntimeShow,
|
||
attachFormFieldValueSources,
|
||
isEqual,
|
||
deepClone,
|
||
removeArrayValueInstances,
|
||
buildModelRequestParamsPath,
|
||
enrichResponseBodyMapping,
|
||
type ExposedField,
|
||
type ModelRequestParamsPathItem,
|
||
} from './component/modelParamUtils';
|
||
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
|
||
import WorkflowListPanel from './component/WorkflowListPanel.vue';
|
||
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
|
||
import ModelSelector from './component/ModelSelector.vue';
|
||
import SkillSelector from './component/SkillSelector.vue';
|
||
import FlowNode from './component/FlowNode.vue';
|
||
|
||
// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点;field 为统一标识
|
||
// (model 字段 = path;form 节点自定义字段 = 字段名),供首页按 field 渲染/写回
|
||
interface RunFormField extends Omit<ExposedField, 'path'> {
|
||
field: string;
|
||
path?: string;
|
||
nodeId: string;
|
||
nodeLabel: string;
|
||
}
|
||
|
||
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
|
||
interface UpstreamField {
|
||
field: string;
|
||
label: string;
|
||
// 输出字段类型:http 节点容器会额外产出 'object'/'array' 整体候选,标量或未标记视为叶子
|
||
fieldType?: 'scalar' | 'object' | 'array';
|
||
}
|
||
interface UpstreamNodeInfo {
|
||
id: string;
|
||
label: string;
|
||
nodeCode: string;
|
||
outputFields: UpstreamField[];
|
||
}
|
||
|
||
interface NodeData {
|
||
label?: string;
|
||
nodeCode?: string;
|
||
desc?: string;
|
||
formConfig?: any[];
|
||
modelConfig?: {
|
||
modelId?: string;
|
||
modelName?: string;
|
||
modelType?: string | number;
|
||
modelRequestParams?: any;
|
||
modelRequestParamsPath?: ModelRequestParamsPathItem[] | null;
|
||
modelFormFields?: ExposedField[] | null;
|
||
modelResponseBodyMapping?: any; // 模型返回参数(数组或对象两种结构),供下游引用
|
||
} | null;
|
||
skillName?: string;
|
||
prompt?: string;
|
||
negativePrompt?: string;
|
||
patchLayout?: boolean;
|
||
isSaveFile?: boolean;
|
||
preTool?: string | null;
|
||
postTool?: string | null;
|
||
runFormFields?: RunFormField[]; // 仅开始节点使用
|
||
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
|
||
subFlowConfig?: SubFlowConfig | null;
|
||
}
|
||
|
||
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
|
||
|
||
// 常量定义
|
||
const START_NODE_CODE = '__start__';
|
||
// 有输出参数、可作为下游引用来源的节点类型(模型/HTTP/表单)
|
||
const OUTPUT_NODE_CODES = ['model', 'http', 'form'];
|
||
const JUDGE_KEYWORDS = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'];
|
||
|
||
// 自定义节点类型:默认节点内置 DefaultNode 只有上下两个 Handle,自定义组件支持左右连接
|
||
const nodeTypes = { default: markRaw(FlowNode), input: markRaw(FlowNode) };
|
||
|
||
// 节点库相关状态
|
||
const nodeLibraryGroups = ref<NodeLibraryGroup[]>([]);
|
||
const nodeLibraryCollapsed = ref(false);
|
||
|
||
// 节点配置映射:nodeCode -> 节点配置
|
||
const nodeConfigMap = computed(() => {
|
||
const map = new Map<
|
||
string,
|
||
{
|
||
formConfig: any[];
|
||
preToolOption: any[];
|
||
postToolOption: any[];
|
||
isMultiParameter: boolean;
|
||
modelConfigOption: boolean;
|
||
formConfigOption: boolean;
|
||
skillOption: boolean;
|
||
promptOption: boolean;
|
||
negativePromptOption: boolean;
|
||
isSaveFileOption: boolean;
|
||
// 节点自带输出项(节点库 outputField):非空时该节点可作为下游引用来源
|
||
outputField: any[];
|
||
patchLayout: boolean;
|
||
}
|
||
>();
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
group.nodes.forEach((item) => {
|
||
map.set(item.key, {
|
||
formConfig: item.presetOption || [],
|
||
// 前置/后置方法配置定义(后端新增,仅 model 节点提供;值保存到节点顶层 preTool/postTool 字段)
|
||
preToolOption: item.preToolOption || [],
|
||
postToolOption: item.postToolOption || [],
|
||
isMultiParameter: item.isMultiParameter || false,
|
||
modelConfigOption: item.modelConfigOption || false,
|
||
formConfigOption: item.formConfigOption || false,
|
||
skillOption: item.skillOption || false,
|
||
promptOption: item.promptOption || false,
|
||
negativePromptOption: item.negativePromptOption || false,
|
||
isSaveFileOption: item.isSaveFileOption || false,
|
||
// 节点自带输出项:可被其它节点引用的字段(脚本转写等节点提供;空表示无输出)
|
||
outputField: item.outputField || [],
|
||
patchLayout: item.patchLayout || false,
|
||
});
|
||
});
|
||
});
|
||
return map;
|
||
});
|
||
|
||
// 当前选中节点的配置
|
||
const currentNodeConfig = computed(() => {
|
||
if (!selectedNode.value?.data?.nodeCode) return null;
|
||
return nodeConfigMap.value.get(selectedNode.value.data.nodeCode) || null;
|
||
});
|
||
|
||
// 工作流列表相关状态
|
||
const workflowListLoading = ref(false);
|
||
const userWorkflowList = ref<WorkflowItem[]>([]);
|
||
const templateWorkflowList = ref<WorkflowItem[]>([]);
|
||
const currentEditingWorkflowId = ref<string | null>(null);
|
||
|
||
// 保存对话框相关状态
|
||
const saveDialogVisible = ref(false);
|
||
const saving = ref(false);
|
||
const saveForm = ref({
|
||
flowName: '',
|
||
description: '',
|
||
});
|
||
|
||
const nodes = ref<Node<NodeData, any, string>[]>([]);
|
||
const edges = ref<Edge[]>([]);
|
||
const selectedNode = ref<Node<NodeData, any, string> | null>(null);
|
||
let nodeId = 0;
|
||
|
||
// 模型选择器相关状态
|
||
const showModelSelector = ref(false);
|
||
const selectedModelData = ref<any>(null);
|
||
// 当前用户是否为超级管理员(管理员绘制/编辑模板工作流时选系统模型无需填 API Key)
|
||
const isSuperAdmin = ref(false);
|
||
|
||
// 技能选择器相关状态
|
||
const showSkillSelector = ref(false);
|
||
const selectedSkillData = ref<any>(null);
|
||
|
||
// 子流程工作流选择器相关状态
|
||
const showWorkflowSelector = ref(false);
|
||
const selectedWorkflowData = ref<any>(null);
|
||
|
||
// 过滤对象(叶子 def / formConfig 条目等)上指向已删除节点的 valueSource 引用;返回是否有变化。
|
||
// 兼容单对象与数组两种 valueSource 形态;并递归 modelRequestParams 深树(对象 attrs / 数组模板 enumValues / value 实例)。
|
||
const filterValueSourceRefs = (obj: any, deletedNodeId: string): boolean => {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
let changed = false;
|
||
const vs = obj.valueSource;
|
||
if (vs && typeof vs === 'object') {
|
||
const arr = Array.isArray(vs) ? vs : [vs];
|
||
const keep = arr.filter((r: any) => r && r.nodeId !== deletedNodeId);
|
||
if (keep.length !== arr.length) {
|
||
if (keep.length === 0) delete obj.valueSource;
|
||
else obj.valueSource = keep;
|
||
changed = true;
|
||
}
|
||
}
|
||
if (obj.attrs && typeof obj.attrs === 'object' && !Array.isArray(obj.attrs)) {
|
||
if (filterValueSourceRefs(obj.attrs, deletedNodeId)) changed = true;
|
||
}
|
||
if (Array.isArray(obj.enumValues)) {
|
||
for (const tpl of obj.enumValues) {
|
||
if (filterValueSourceRefs(tpl, deletedNodeId)) changed = true;
|
||
}
|
||
}
|
||
if (Array.isArray(obj.value)) {
|
||
for (const v of obj.value) {
|
||
if (filterValueSourceRefs(v, deletedNodeId)) changed = true;
|
||
}
|
||
}
|
||
return changed;
|
||
};
|
||
|
||
// 清理数组中每项指向已删除节点的引用(formConfig / modelRequestParamsPath / subFlowConfig.fields)
|
||
const filterValueSourceInArray = (arr: any[] | null | undefined, deletedNodeId: string): boolean => {
|
||
if (!Array.isArray(arr)) return false;
|
||
let changed = false;
|
||
for (const item of arr) {
|
||
if (item && typeof item === 'object' && filterValueSourceRefs(item, deletedNodeId)) changed = true;
|
||
}
|
||
return changed;
|
||
};
|
||
|
||
const deleteSelectedNode = async () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
if (selectedNode.value.data.nodeCode === '__start__') {
|
||
ElMessage.warning('开始节点不能删除');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm('确定要删除选中的节点吗?', '删除确认', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
|
||
const deletedNodeId = selectedNode.value.id;
|
||
removeNodes([deletedNodeId]);
|
||
selectedNode.value = null;
|
||
|
||
// 清理其它节点指向被删节点的引用,避免保存脏 DSL(懒清理 watch 只在重新渲染时生效,这里主动清)
|
||
for (const node of nodes.value) {
|
||
if (node.id === deletedNodeId || !node.data) continue;
|
||
const data = node.data;
|
||
let changed = false;
|
||
if (filterValueSourceInArray(data.formConfig, deletedNodeId)) changed = true;
|
||
const mc = data.modelConfig;
|
||
if (mc) {
|
||
if (filterValueSourceInArray(mc.modelRequestParamsPath, deletedNodeId)) changed = true;
|
||
if (filterValueSourceRefs(mc.modelRequestParams, deletedNodeId)) changed = true;
|
||
}
|
||
if (data.subFlowConfig && filterValueSourceInArray(data.subFlowConfig.fields, deletedNodeId)) changed = true;
|
||
if (changed) {
|
||
// 同步 VueFlow 内部状态 + 触发节点重新渲染
|
||
updateNode(node.id, node);
|
||
const idx = nodes.value.findIndex((n) => n.id === node.id);
|
||
if (idx >= 0) nodes.value[idx] = { ...node, data: { ...data } };
|
||
}
|
||
}
|
||
|
||
ElMessage.success('节点已删除');
|
||
} catch {
|
||
// 用户取消删除
|
||
}
|
||
};
|
||
|
||
const onConnect = (connection: Connection) => {
|
||
const source = findNode(connection.source);
|
||
const target = findNode(connection.target);
|
||
|
||
if (!source?.data || !target?.data) return;
|
||
|
||
if (connection.source === connection.target) {
|
||
ElMessage.warning('不能将节点连接到自身');
|
||
return;
|
||
}
|
||
|
||
if (target.data.nodeCode === '__start__') {
|
||
ElMessage.warning('开始节点不能被连接');
|
||
return;
|
||
}
|
||
|
||
if (source.data.nodeCode === '__start__' && target.data.nodeCode === 'judge') {
|
||
ElMessage.warning('开始节点后不能接判断节点');
|
||
return;
|
||
}
|
||
|
||
// id 含 source/target/handle,同一对节点间不同 handle 的连线也互不冲突
|
||
addEdges([
|
||
{
|
||
id: `edge-${connection.source}-${connection.sourceHandle || 'source-bottom'}-${connection.target}-${connection.targetHandle || 'target-top'}`,
|
||
source: connection.source,
|
||
target: connection.target,
|
||
sourceHandle: connection.sourceHandle,
|
||
targetHandle: connection.targetHandle,
|
||
type: 'smoothstep',
|
||
animated: true,
|
||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||
},
|
||
]);
|
||
ElMessage.success('连接成功');
|
||
};
|
||
|
||
const onNodeClick = (event: { node: Node<NodeData, any, string> }) => {
|
||
selectedNode.value = event.node;
|
||
// 同步选择器预选项,避免展示上一个节点的模型/技能;回读后无 modelName 时仍按 modelId 高亮
|
||
const mc = event.node.data?.modelConfig;
|
||
selectedModelData.value = mc?.modelId ? { id: mc.modelId, modelName: mc.modelName || '', modelType: mc.modelType } : null;
|
||
const sk = event.node.data?.skillName;
|
||
selectedSkillData.value = sk ? { name: sk } : null;
|
||
// 子流程:回填预选工作流(仅部分信息,WorkflowSelector 按名称匹配当前页高亮)
|
||
const sc = event.node.data?.subFlowConfig;
|
||
selectedWorkflowData.value = sc?.workflowId ? { id: sc.workflowId, name: sc.workflowName || '' } : null;
|
||
};
|
||
|
||
const updateSelectedNode = (updatedNode: Node<NodeData, any, string>) => {
|
||
selectedNode.value = updatedNode;
|
||
// 使用 VueFlow 的 API 更新节点,确保内部状态同步
|
||
updateNode(updatedNode.id, updatedNode);
|
||
// 同步更新到 nodes 数组
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
};
|
||
|
||
// 模型选择确认
|
||
const handleModelConfirm = (model: any) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
modelConfig: {
|
||
modelId: model.id || '',
|
||
modelName: model.modelName,
|
||
modelType: model.modelType,
|
||
// 深拷贝模型 requestBodyMapping 作为参数模板(重选模型时覆盖旧参数)
|
||
// 剔除 isForm=false 的只读字段,使其不显示也不随工作流保存
|
||
modelRequestParams: stripReadonlyFields(JSON.parse(JSON.stringify(model.requestBodyMapping ?? null))),
|
||
// 保存模型返回参数(responseBodyMapping),作为该节点可被下游引用的输出项;
|
||
// 用模型 responseMapping 的中文描述填充 value,引用展示显示中文而非英文 key
|
||
modelResponseBodyMapping: enrichResponseBodyMapping(model.responseBodyMapping, model.responseMapping),
|
||
},
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedModelData.value = model;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('模型已选择');
|
||
};
|
||
|
||
// 移除模型
|
||
const handleRemoveModel = () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
modelConfig: undefined,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedModelData.value = null;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('模型已移除');
|
||
};
|
||
|
||
// 技能选择确认
|
||
const handleSkillConfirm = (skill: any) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
skillName: skill.name,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedSkillData.value = skill;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('技能已选择');
|
||
};
|
||
|
||
// 移除技能
|
||
const handleRemoveSkill = () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
skillName: undefined,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedSkillData.value = null;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('技能已移除');
|
||
};
|
||
|
||
// 子流程:选择已有工作流引入其开始参数
|
||
const handleWorkflowConfirm = async (workflow: any) => {
|
||
// 捕获当前选中节点:await 拉详情期间用户可能切换节点,防止写入错误节点
|
||
const targetNode = selectedNode.value;
|
||
if (!targetNode?.data) return;
|
||
|
||
// 防自引用:不能把当前正在编辑的工作流引入自身(更深层自引用环由后端执行期兜底)
|
||
if (workflow.id && currentEditingWorkflowId.value && String(workflow.id) === String(currentEditingWorkflowId.value)) {
|
||
ElMessage.warning('不能将当前工作流引入自身');
|
||
return;
|
||
}
|
||
|
||
let fields: SubFlowField[] = [];
|
||
try {
|
||
const res = await getSubFlowWorkflowDetail(workflow.id);
|
||
// 响应结构容错:详情可能直接返回 nodes/edges,也可能包在 flowContent 中
|
||
const detail = res.data?.flowContent || res.data || {};
|
||
const startNode = (detail.nodes || []).find(
|
||
(nd: any) => nd && String(nd.nodeCode || '').toLowerCase() === '__start__'
|
||
);
|
||
const outputs = Array.isArray(startNode?.outputConfig) ? startNode.outputConfig : [];
|
||
fields = outputs
|
||
.filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
|
||
.map((o: any) => {
|
||
const field = o.field !== undefined ? o.field : o.path;
|
||
const ft = String(o.fieldType || o.type || 'input');
|
||
const isUploadMultiple = ft === 'uploadMultiple';
|
||
return {
|
||
field,
|
||
label: o.label || String(field),
|
||
// 编辑器渲染归一到 upload(ModelField 控件识别),保留 multiple 供首页识别多文件上传
|
||
type: isUploadMultiple ? 'upload' : ft,
|
||
fieldType: isUploadMultiple ? 'upload' : ft,
|
||
required: Boolean(o.required),
|
||
value: o.value !== undefined ? o.value : o.defaultValue ?? '',
|
||
defaultValue: o.defaultValue,
|
||
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
|
||
options: Array.isArray(o.options) ? o.options : undefined,
|
||
multiple: isUploadMultiple || o.multiple || undefined,
|
||
// 导入时清空目标工作流自带的引用(其 nodeId 指向目标内部节点,在主工作流无意义)
|
||
valueSource: null,
|
||
runtimeShow: false,
|
||
};
|
||
});
|
||
} catch {
|
||
// 详情拉取失败:仅记空字段(错误已由全局拦截器提示)
|
||
}
|
||
|
||
// 拉详情期间若已切换选中节点,则中止写入,避免配置落到错误节点上
|
||
if (selectedNode.value !== targetNode) {
|
||
ElMessage.warning('已切换节点,请重新选择子流程节点后操作');
|
||
return;
|
||
}
|
||
|
||
const subFlowConfig: SubFlowConfig = {
|
||
workflowId: workflow.id || '',
|
||
workflowName: workflow.flowName || workflow.name || '',
|
||
fields,
|
||
};
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...targetNode,
|
||
data: {
|
||
...targetNode.data,
|
||
subFlowConfig,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedWorkflowData.value = workflow;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success(`已引入工作流:${workflow.flowName || workflow.name || workflow.id}`);
|
||
};
|
||
|
||
// 移除子流程引入的工作流
|
||
const handleRemoveWorkflow = () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
subFlowConfig: undefined,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedWorkflowData.value = null;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('已移除子流程工作流');
|
||
};
|
||
|
||
// 切换贴片布局
|
||
const handleTogglePatchLayout = (value: boolean) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
patchLayout: value,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
};
|
||
|
||
// 辅助函数:判断是否为开始节点
|
||
const isStartNode = (node: Node<NodeData, any, string>) => {
|
||
return node.data?.nodeCode === START_NODE_CODE;
|
||
};
|
||
|
||
// 聚合所有非开始节点勾选的「表单展示」字段,同步到开始节点的运行表单字段
|
||
const syncRunFormFields = () => {
|
||
const startNode = nodes.value.find((n) => isStartNode(n));
|
||
if (!startNode?.data) return;
|
||
|
||
const collected: RunFormField[] = [];
|
||
for (const n of nodes.value) {
|
||
if (isStartNode(n)) continue;
|
||
const nodeCode = n.data?.nodeCode || '';
|
||
if (nodeCode === 'model') {
|
||
const params = n.data?.modelConfig?.modelRequestParams;
|
||
if (!params || typeof params !== 'object') continue;
|
||
for (const f of collectExposedFields(params)) {
|
||
collected.push({
|
||
...f,
|
||
field: f.path,
|
||
nodeId: n.id,
|
||
nodeLabel: n.data?.label || n.id,
|
||
});
|
||
}
|
||
} else if (nodeCode === 'form') {
|
||
// form 节点:自定义表单字段即运行表单字段(无 path,field 为字段名)
|
||
const formFields = Array.isArray(n.data?.formConfig) ? n.data.formConfig : [];
|
||
for (const ff of formFields) {
|
||
if (!ff || typeof ff !== 'object') continue;
|
||
const fieldName = ff.field || ff.label || '';
|
||
if (!fieldName) continue;
|
||
const isUploadMultiple = ff.type === 'uploadMultiple';
|
||
collected.push({
|
||
field: fieldName,
|
||
label: ff.label || fieldName,
|
||
fieldType: ff.type || 'input',
|
||
type: ff.type || 'input',
|
||
required: Boolean(ff.required),
|
||
value: ff.value ?? '',
|
||
defaultValue: ff.defaultValue,
|
||
fieldConstraint: isUploadMultiple
|
||
? {
|
||
...(ff.fileTypes ? { fileTypes: ff.fileTypes } : {}),
|
||
...(ff.maxFileSize !== undefined && ff.maxFileSize !== null ? { maxFileSize: ff.maxFileSize } : {}),
|
||
...(ff.maxFileCount !== undefined && ff.maxFileCount !== null ? { maxFileCount: ff.maxFileCount } : {}),
|
||
}
|
||
: ff.fieldConstraint && typeof ff.fieldConstraint === 'object'
|
||
? ff.fieldConstraint
|
||
: undefined,
|
||
multiple: isUploadMultiple || undefined,
|
||
nodeId: n.id,
|
||
nodeLabel: n.data?.label || n.id,
|
||
});
|
||
}
|
||
} else {
|
||
// 子流程节点:引入工作流的开始参数中勾选「表单展示」的字段聚合进运行表单
|
||
if (nodeCode === 'sub_flow') {
|
||
const subFields = n.data?.subFlowConfig?.fields;
|
||
if (Array.isArray(subFields)) {
|
||
for (const f of subFields) {
|
||
if (!f || typeof f !== 'object' || f.runtimeShow !== true) continue;
|
||
const isNumeric = f.fieldType === 'number' || f.type === 'number' || f.type === 'inputNumber';
|
||
const isUploadMultiple = f.fieldType === 'uploadMultiple';
|
||
collected.push({
|
||
field: f.field,
|
||
label: f.label || f.field,
|
||
fieldType: isNumeric ? 'number' : f.type || 'input',
|
||
type: isNumeric ? 'number' : f.type || 'input',
|
||
required: Boolean(f.required),
|
||
value: f.value ?? f.defaultValue ?? '',
|
||
defaultValue: f.defaultValue,
|
||
fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
|
||
valueSource: Array.isArray(f.valueSource) ? f.valueSource : undefined,
|
||
options: Array.isArray(f.options) ? f.options : undefined,
|
||
multiple: isUploadMultiple || f.multiple || undefined,
|
||
nodeId: n.id,
|
||
nodeLabel: n.data?.label || n.id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 其它节点(如 sub_flow):presetOption 中标记 isFormField 的字段作为运行表单字段
|
||
const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
|
||
if (defs.length === 0) continue;
|
||
const formConfig = Array.isArray(n.data?.formConfig) ? n.data.formConfig : [];
|
||
for (const def of defs) {
|
||
if (!def || typeof def !== 'object' || def.isFormField !== true) continue;
|
||
const entry = formConfig.find((f: any) => f && f.field === def.field);
|
||
const isNumeric = def.constraint?.type === 'int' || def.constraint?.type === 'number' || def.type === 'number' || def.type === 'inputNumber';
|
||
collected.push({
|
||
field: def.field,
|
||
label: def.label || def.field,
|
||
fieldType: isNumeric ? 'number' : def.type || 'input',
|
||
type: isNumeric ? 'number' : def.type || 'input',
|
||
required: Boolean(def.required),
|
||
value: entry?.value ?? def.value ?? '',
|
||
defaultValue: def.value,
|
||
fieldConstraint:
|
||
def.constraint && typeof def.constraint === 'object'
|
||
? {
|
||
...(def.constraint.min !== undefined && def.constraint.min !== null ? { minValue: def.constraint.min } : {}),
|
||
...(def.constraint.max !== undefined && def.constraint.max !== null ? { maxValue: def.constraint.max } : {}),
|
||
}
|
||
: undefined,
|
||
nodeId: n.id,
|
||
nodeLabel: n.data?.label || n.id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 仅变更才写回,避免深监听死循环
|
||
if (isEqual(collected, startNode.data.runFormFields)) return;
|
||
startNode.data.runFormFields = collected;
|
||
updateNode(startNode.id, startNode);
|
||
const index = nodes.value.findIndex((n) => n.id === startNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = startNode;
|
||
}
|
||
};
|
||
|
||
// 节点变化(勾选/取消/删除/换模型)→ 自动重算开始节点运行表单字段
|
||
watch(
|
||
() => nodes.value,
|
||
() => {
|
||
syncRunFormFields();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 解析 schemaJson 值为 JSON 对象(兼容字符串/对象两种存储)
|
||
const parseSchema = (value: any): any => {
|
||
if (!value) return null;
|
||
if (typeof value === 'object' && !Array.isArray(value)) return value;
|
||
if (typeof value === 'string') {
|
||
const t = value.trim();
|
||
if (!t) return null;
|
||
try {
|
||
return JSON.parse(t);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 递归收集 http 节点 response schema 的输出字段(识别 JsonEditor 的 { type, attrs/value, label } 包裹结构):
|
||
// 产出叶子字段 + 对象/数组容器整体候选(下游可引用整个结构);
|
||
// field 用实际返回数据路径(不含 attrs 元数据,如 data.url 而非 data.attrs.url.value),
|
||
// label 优先用字段配置的中文名(label),无则兜底路径 key
|
||
const collectHttpOutputFields = (node: any, prefix = '', isRoot = true, suppressContainer = false): UpstreamField[] => {
|
||
const out: UpstreamField[] = [];
|
||
// JsonEditor 包裹结构:{ type: 'object'|'array'|'string'|..., attrs/value, label }
|
||
if (node && typeof node === 'object' && !Array.isArray(node) && typeof node.type === 'string') {
|
||
const jtype = node.type;
|
||
if (['object', 'array', 'string', 'number', 'boolean', 'null'].includes(jtype)) {
|
||
// 对象/数组容器:非根且非数组样本时,产出「整体」候选(引用整体 = 拿整个结构);标量不产整体
|
||
if (!isRoot && !suppressContainer && prefix && (jtype === 'object' || jtype === 'array')) {
|
||
out.push({ field: prefix, label: (typeof node.label === 'string' && node.label) || prefix, fieldType: jtype });
|
||
}
|
||
if (jtype === 'object') {
|
||
// 对象容器:子字段在 attrs 里,field 路径直接拼子字段名,不拼 attrs
|
||
const attrs = node.attrs;
|
||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||
for (const k of Object.keys(attrs)) {
|
||
out.push(...collectHttpOutputFields(attrs[k], prefix ? `${prefix}.${k}` : k, false, false));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
if (jtype === 'array') {
|
||
// 数组:取首元素样本作为结构(实际值为数组整体,field 不带索引);
|
||
// 首元素为标量时不产叶子(数组整体已代表该元素),元素为对象时不产对象整体避免与数组整体 field 重复
|
||
const arr = node.attrs;
|
||
if (Array.isArray(arr) && arr.length > 0) {
|
||
const el = arr[0];
|
||
if (el && typeof el === 'object' && !Array.isArray(el) && typeof el.type === 'string' && ['string', 'number', 'boolean', 'null'].includes(el.type)) {
|
||
return out;
|
||
}
|
||
return out.concat(collectHttpOutputFields(el, prefix, false, true));
|
||
}
|
||
return out;
|
||
}
|
||
// 标量叶子:label 为字段配置的中文名,无则兜底路径
|
||
out.push({ field: prefix, label: (typeof node.label === 'string' && node.label) || prefix, fieldType: 'scalar' });
|
||
return out;
|
||
}
|
||
}
|
||
// 普通对象(根/未包裹):递归子字段;子字段为原始标量时以路径兜底产出
|
||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||
for (const k of Object.keys(node)) {
|
||
const child = node[k];
|
||
const path = prefix ? `${prefix}.${k}` : k;
|
||
if (child && typeof child === 'object') out.push(...collectHttpOutputFields(child, path, false, false));
|
||
else out.push({ field: path, label: path, fieldType: 'scalar' });
|
||
}
|
||
return out;
|
||
}
|
||
// 数组根:取首元素结构样本
|
||
if (Array.isArray(node)) return node.length > 0 ? collectHttpOutputFields(node[0], prefix, isRoot, suppressContainer) : [];
|
||
return out;
|
||
};
|
||
|
||
// 取节点在设计时的可引用输出字段(运行时这些字段会产出实际值)
|
||
const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[] => {
|
||
const nodeCode = node.data?.nodeCode || '';
|
||
// 节点库自带输出项(outputField):非空时优先作为该节点的可引用输出(脚本转写等节点)
|
||
const ownOutput = nodeConfigMap.value.get(nodeCode)?.outputField || [];
|
||
if (ownOutput.length > 0) {
|
||
return ownOutput.map((o: any) => ({ field: o.field || o.label || '', label: o.label || o.field || '' }));
|
||
}
|
||
if (nodeCode === 'form') {
|
||
// form 节点:自定义表单字段即输出
|
||
return (node.data?.formConfig || []).map((f: any) => ({
|
||
field: f.field || f.label || '',
|
||
label: f.label || f.field || '',
|
||
}));
|
||
}
|
||
if (nodeCode === 'http') {
|
||
// http 节点:输出 = 结果返回结构(response schema)的叶子字段 + 对象/数组容器整体。
|
||
// 结果返回方式为主动拉取(responseType === 'pull')时,取主动拉取分支下配置的结果返回结构。
|
||
const formConfig = node.data?.formConfig || [];
|
||
const responseTypeEntry = formConfig.find((f: any) => f.field === 'responseType');
|
||
const isPull = !!responseTypeEntry && String(responseTypeEntry.value || '') === 'pull';
|
||
const responseField = isPull
|
||
? (responseTypeEntry?.expand || []).find((e: any) => e.field === 'response')
|
||
: formConfig.find((f: any) => f.field === 'response');
|
||
const schema = parseSchema(responseField?.value);
|
||
if (schema) return collectHttpOutputFields(schema);
|
||
return [];
|
||
}
|
||
if (nodeCode === 'model') {
|
||
// 模型节点:模型的返回参数(responseBodyMapping)即可被下游引用的输出;
|
||
// value 为保存时从模型 responseMapping 填充的中文描述,展示优先用中文,无则兜底 key
|
||
const resp = node.data?.modelConfig?.modelResponseBodyMapping;
|
||
// 数组结构:元素带 field/label
|
||
if (Array.isArray(resp)) {
|
||
return resp
|
||
.filter((it: any) => it && typeof it === 'object' && (it.field || it.label || it.key))
|
||
.map((it: any) => ({ field: it.field || it.label || it.key || '', label: it.label || it.field || it.key || '' }));
|
||
}
|
||
// 对象结构:{ key: 中文描述 }
|
||
if (resp && typeof resp === 'object') {
|
||
return Object.keys(resp)
|
||
.filter((k: any) => typeof k === 'string' && k.trim() !== '')
|
||
.map((k: string) => ({ field: k, label: (typeof resp[k] === 'string' && resp[k]) || k }));
|
||
}
|
||
return [];
|
||
}
|
||
if (nodeCode === START_NODE_CODE) {
|
||
// 开始节点:运行表单字段(被勾选的模型参数 + form 自定义字段)
|
||
return (node.data?.runFormFields || []).map((f: any) => ({ field: f.field || f.path || '', label: f.label || f.field || f.path || '' }));
|
||
}
|
||
return [];
|
||
};
|
||
|
||
// 当前选中节点的所有前驱链路节点(含各自可引用输出字段)
|
||
const upstreamNodes = computed<UpstreamNodeInfo[]>(() => {
|
||
const nodeId = selectedNode.value?.id;
|
||
if (!nodeId) return [];
|
||
|
||
const predsByTarget = new Map<string, string[]>();
|
||
edges.value.forEach((e) => {
|
||
if (!predsByTarget.has(e.target)) predsByTarget.set(e.target, []);
|
||
predsByTarget.get(e.target)!.push(e.source);
|
||
});
|
||
|
||
const result: UpstreamNodeInfo[] = [];
|
||
const visited = new Set<string>([nodeId]);
|
||
const queue: string[] = [nodeId];
|
||
while (queue.length) {
|
||
const cur = queue.shift()!;
|
||
for (const p of predsByTarget.get(cur) || []) {
|
||
if (visited.has(p)) continue;
|
||
visited.add(p);
|
||
const n = nodes.value.find((x) => x.id === p);
|
||
if (n) {
|
||
// 可作为下游引用来源:模型/HTTP/表单节点,或节点库声明了自带输出项(outputField)的节点(如脚本转写)
|
||
const hasOutput =
|
||
OUTPUT_NODE_CODES.includes(n.data?.nodeCode || '') ||
|
||
(nodeConfigMap.value.get(n.data?.nodeCode || '')?.outputField?.length ?? 0) > 0;
|
||
if (hasOutput) {
|
||
result.push({
|
||
id: n.id,
|
||
label: n.data?.label || n.id,
|
||
nodeCode: n.data?.nodeCode || '',
|
||
outputFields: getNodeOutputFields(n),
|
||
});
|
||
}
|
||
}
|
||
queue.push(p);
|
||
}
|
||
}
|
||
return result;
|
||
});
|
||
|
||
// 辅助函数:判断是否为判断节点
|
||
const isJudgeNode = (node: Node<NodeData, any, string>) => {
|
||
const nodeCode = (node.data?.nodeCode || '').toLowerCase();
|
||
const nodeName = (node.data?.label || '').toLowerCase();
|
||
return JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
|
||
};
|
||
|
||
// 约束校验函数
|
||
const validateFlowConstraints = () => {
|
||
const currentNodes = nodes.value;
|
||
const currentEdges = edges.value;
|
||
|
||
if (!currentNodes.length) return { ok: true };
|
||
|
||
// 创建节点映射
|
||
const nodeMap = new Map(currentNodes.map((n) => [n.id, n]));
|
||
|
||
// 检查是否有开始节点
|
||
const startNode = currentNodes.find((n) => isStartNode(n));
|
||
if (!startNode) {
|
||
return { ok: false, message: '工作流必须包含开始节点' };
|
||
}
|
||
|
||
// 自环校验(onConnect 已拦截,此处兜底历史/加载数据)
|
||
for (const edge of currentEdges) {
|
||
if (edge.source === edge.target) {
|
||
return { ok: false, message: '存在连接节点自身的连线,请先删除该连线' };
|
||
}
|
||
}
|
||
|
||
// model 节点必须已选模型,否则后端执行期才报错
|
||
const unconfiguredModel = currentNodes.find(
|
||
(n) => n.data?.nodeCode === 'model' && !(n.data?.modelConfig && n.data.modelConfig.modelId)
|
||
);
|
||
if (unconfiguredModel) {
|
||
const label = unconfiguredModel.data?.label || unconfiguredModel.id;
|
||
return { ok: false, message: `节点「${label}」尚未选择模型,请先配置` };
|
||
}
|
||
|
||
// 孤立节点(无任何连线、非开始节点):不参与执行,视为绘制遗漏
|
||
const isolatedNode = currentNodes.find((n) => {
|
||
if (isStartNode(n)) return false;
|
||
return !currentEdges.some((e) => e.source === n.id || e.target === n.id);
|
||
});
|
||
if (isolatedNode) {
|
||
const label = isolatedNode.data?.label || isolatedNode.id;
|
||
return { ok: false, message: `存在未连接的节点「${label}」,请先连接或删除` };
|
||
}
|
||
|
||
// 检查边的约束
|
||
for (const edge of currentEdges) {
|
||
const targetNode = nodeMap.get(edge.target);
|
||
|
||
// 开始节点不能被其他节点链接
|
||
if (targetNode && isStartNode(targetNode)) {
|
||
return { ok: false, message: '开始节点不能被其他节点链接' };
|
||
}
|
||
|
||
// 开始节点后不能接判断节点
|
||
const sourceNode = nodeMap.get(edge.source);
|
||
if (sourceNode && targetNode && isStartNode(sourceNode) && isJudgeNode(targetNode)) {
|
||
return { ok: false, message: '开始节点下一个节点不能是判断节点' };
|
||
}
|
||
}
|
||
|
||
// 检查结尾节点不能是判断节点
|
||
const hasOutEdge = new Set(currentEdges.map((e) => e.source));
|
||
const endNodes = currentNodes.filter((n) => !hasOutEdge.has(n.id));
|
||
if (endNodes.some((n) => isJudgeNode(n))) {
|
||
return { ok: false, message: '结尾节点不能是判断节点' };
|
||
}
|
||
|
||
return { ok: true };
|
||
};
|
||
|
||
// 获取节点库
|
||
const getNodeLibrary = async () => {
|
||
try {
|
||
const res = await getNodeLibraryList();
|
||
nodeLibraryGroups.value = res.data?.groups || [];
|
||
} catch {
|
||
nodeLibraryGroups.value = [];
|
||
ElMessage.error('节点库加载失败');
|
||
}
|
||
};
|
||
|
||
// 从节点库添加节点
|
||
const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
|
||
const existingNodes = getNodes.value;
|
||
let spawnX = 250;
|
||
let spawnY = 140;
|
||
|
||
if (existingNodes.length > 0) {
|
||
const lastNode = existingNodes[existingNodes.length - 1];
|
||
spawnX = (lastNode.position?.x || 250) + 180;
|
||
spawnY = lastNode.position?.y || 140;
|
||
|
||
if (spawnX > 800) {
|
||
spawnX = 250;
|
||
spawnY += 120;
|
||
}
|
||
}
|
||
|
||
addNodes([
|
||
{
|
||
id: `node-${++nodeId}`,
|
||
type: 'default',
|
||
position: { x: spawnX, y: spawnY },
|
||
data: { label: nodeName, nodeCode, desc: '', patchLayout: false },
|
||
},
|
||
]);
|
||
ElMessage.success(`已添加${nodeName}`);
|
||
};
|
||
|
||
// 获取工作流列表
|
||
const fetchWorkflowList = async () => {
|
||
workflowListLoading.value = true;
|
||
try {
|
||
const res = await getWorkflowList();
|
||
userWorkflowList.value = res.data?.listFlowUserRes?.list || [];
|
||
templateWorkflowList.value = res.data?.listFlowTemplateRes?.list || [];
|
||
} catch {
|
||
userWorkflowList.value = [];
|
||
templateWorkflowList.value = [];
|
||
} finally {
|
||
workflowListLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 构造默认开始节点(固定 id,供新建/清空画布后恢复开始节点)
|
||
const createStartNode = () => ({
|
||
id: 'start-node',
|
||
type: 'input',
|
||
position: { x: 200, y: 200 },
|
||
data: { label: '开始', nodeCode: '__start__', runFormFields: [] },
|
||
});
|
||
|
||
// 添加默认开始节点(onMounted 首次初始化用 addNodes,此时画布为空、无 v-model watch 竞争)
|
||
const addDefaultStartNode = () => {
|
||
addNodes([createStartNode()]);
|
||
};
|
||
|
||
// 新建工作流
|
||
const createNewWorkflow = () => {
|
||
currentEditingWorkflowId.value = null;
|
||
selectedNode.value = null;
|
||
nodeId = 0;
|
||
|
||
// 重置保存表单,避免新建后点保存还带着上次编辑/未保存的名称描述
|
||
saveForm.value.flowName = '';
|
||
saveForm.value.description = '';
|
||
|
||
// 直接整体赋值 v-model(nodes/edges),不再混用 removeNodes/addNodes 直接改 store,
|
||
// 避免 VueFlow 双向 watch(外部↔store)竞争导致开始节点丢失
|
||
nodes.value = [createStartNode()];
|
||
edges.value = [];
|
||
|
||
ElMessage.success('已清空画布,可以开始创建新工作流');
|
||
};
|
||
|
||
// 编辑工作流
|
||
const editWorkflow = async (workflow: WorkflowItem) => {
|
||
try {
|
||
const res = await getWorkflowDetail(workflow.id);
|
||
if (res.data?.flowContent) {
|
||
loadWorkflowFromDsl(res.data.flowContent);
|
||
const detail = res.data as any; // 后端详情可能返回 flowId 而非 id
|
||
currentEditingWorkflowId.value = detail.id || detail.flowId || null;
|
||
saveForm.value.flowName = res.data.flowName || res.data.flowTemplateName || '';
|
||
saveForm.value.description = res.data.description || '';
|
||
ElMessage.success('工作流已加载');
|
||
}
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
}
|
||
};
|
||
|
||
// 从 DSL 节点重建 formConfig(新格式:outputConfig;旧格式:formConfig 兜底)
|
||
const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = [], startNodeId = '') => {
|
||
// form 节点:自定义字段(完整结构 {type,field,label,value,required},兼容命名对象 [{key:value}])
|
||
if (n.nodeCode === 'form' && Array.isArray(n.outputConfig)) {
|
||
return n.outputConfig.map((o: any) => {
|
||
if (o && typeof o === 'object' && o.field !== undefined) {
|
||
const constraint = o.fieldConstraint || {};
|
||
return {
|
||
label: o.label || o.field,
|
||
field: o.field,
|
||
type: o.type || 'input',
|
||
value: o.value ?? '',
|
||
required: Boolean(o.required),
|
||
...(o.type === 'uploadMultiple'
|
||
? {
|
||
fileTypes: constraint.fileTypes || '',
|
||
maxFileSize: constraint.maxFileSize,
|
||
maxFileCount: constraint.maxFileCount,
|
||
}
|
||
: {}),
|
||
};
|
||
}
|
||
// 命名对象兼容:{key: value}
|
||
const key = Object.keys(o || {})[0] || '';
|
||
const value = o?.[key];
|
||
return {
|
||
label: key,
|
||
field: key,
|
||
type: typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'switch' : 'input',
|
||
value,
|
||
required: false,
|
||
};
|
||
});
|
||
}
|
||
// 非开始节点:优先按节点库 presetOption 定义回显参数。
|
||
// outputConfig 可能为 null/空数组(如 sub_flow 勾选参数全部聚合进开始节点,outputConfig 被过滤),
|
||
// 只要节点有 presetOption 定义就按定义构建,值从 outputConfig 或开始节点运行表单反查
|
||
if (n.nodeCode !== '__start__') {
|
||
const defs = nodeConfigMap.value.get(n.nodeCode)?.formConfig || [];
|
||
if (defs.length > 0) {
|
||
const outList = Array.isArray(n.outputConfig) ? n.outputConfig : [];
|
||
return defs.map((def: any) => {
|
||
const out = outList.find((o: any) => o.field === def.field);
|
||
if (def.field === 'responseType') {
|
||
// 恢复嵌套配置:定义取自 presetOption 选中选项的 config,值取自 options[0].config
|
||
const savedConfig = out?.options?.[0]?.config || [];
|
||
const selectedOpt = (def.options || []).find((o: any) => o.key === out?.value || o.value === out?.value);
|
||
const expandDefs = selectedOpt?.config || [];
|
||
const expand = expandDefs.map((cd: any) => {
|
||
const saved = savedConfig.find((c: any) => c.field === cd.field);
|
||
return { ...cd, value: saved?.value ?? '' };
|
||
});
|
||
return { ...def, value: out?.value ?? '', expand };
|
||
}
|
||
// isFormField:true 字段不随节点 outputConfig 保存(值在开始节点运行表单),
|
||
// 从开始节点反查恢复,保证编辑器参数面板回显与运行表单一致
|
||
const startValue = startRunFields.find((r: any) => r && r.field === def.field)?.value;
|
||
// 引用上级输出(如脚本转写的视频总时长):DSL valueSource 指向非开始节点时恢复为字段引用,
|
||
// 编辑器隐藏输入、显示引用来源(指向开始节点的 valueSource 是表单展示标记,由 isFormField 分支处理)
|
||
const refVs = Array.isArray(out?.valueSource)
|
||
? out.valueSource.filter((r: any) => r && r.nodeId && r.nodeId !== startNodeId && r.field)
|
||
: [];
|
||
return {
|
||
...def,
|
||
value: out?.value ?? startValue ?? '',
|
||
...(refVs.length ? { valueSource: refVs.map((r: any) => ({ nodeId: r.nodeId, field: r.field })) } : {}),
|
||
};
|
||
});
|
||
}
|
||
// 无 presetOption 定义:outputConfig 为数组时透传(form 节点已提前处理)
|
||
if (Array.isArray(n.outputConfig) && n.outputConfig.length > 0) return n.outputConfig;
|
||
}
|
||
// 旧格式兜底
|
||
if (Array.isArray(n.formConfig)) return n.formConfig;
|
||
return null;
|
||
};
|
||
|
||
// 引用来源 label 反查:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
|
||
const resolveRefLabel = (nodeId: string, field: string, outputIndex?: Map<string, { name: string; fields: Map<string, string> }>): string => {
|
||
const info = outputIndex?.get(nodeId);
|
||
if (!info) return field;
|
||
return info.fields.get(field) || field;
|
||
};
|
||
|
||
// 构建保存用的 outputConfig(HTTP/form 节点),其余返回 null(开始节点的运行表单字段由保存处单独写入)
|
||
const buildOutputConfig = (node: Node<NodeData>, startNodeId = '', outputIndex?: Map<string, { name: string; fields: Map<string, string> }>) => {
|
||
const nodeCode = node.data?.nodeCode || '';
|
||
if (nodeCode === 'http') {
|
||
const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
|
||
const formConfig = node.data?.formConfig || [];
|
||
return defs.map((def: any) => {
|
||
const entry = formConfig.find((f: any) => f.field === def.field);
|
||
const value = entry?.value ?? '';
|
||
// 勾选「表单展示」的字段:值来自开始节点表单,补 valueSource 标记(后端契约)
|
||
const valueSource = def.isFormField === true ? [{ nodeId: startNodeId, field: def.field, label: def.label || def.field }] : undefined;
|
||
if (def.field === 'responseType') {
|
||
const expand = entry?.expand || [];
|
||
return {
|
||
type: 'select',
|
||
field: 'responseType',
|
||
label: def.label,
|
||
value,
|
||
...(valueSource ? { valueSource } : {}),
|
||
options: expand.length
|
||
? [{ config: expand.map((e: any) => ({ type: e.type, field: e.field, label: e.label, value: e.value })) }]
|
||
: [],
|
||
};
|
||
}
|
||
return { type: def.type, field: def.field, label: def.label, value, ...(valueSource ? { valueSource } : {}) };
|
||
});
|
||
}
|
||
if (nodeCode === 'form') {
|
||
// form 节点 outputConfig 为自定义字段完整结构(含 type/required,便于完整回显)
|
||
// 全部字段均为运行表单字段(首页可填),保存时补 valueSource 指向开始节点(后端契约)
|
||
const fields = Array.isArray(node.data?.formConfig) ? node.data.formConfig : [];
|
||
return fields.map((f: any) => {
|
||
const fieldName = f.field || f.label || '';
|
||
return {
|
||
type: f.type || 'input',
|
||
// field 为不可变标识(下游引用 key),label 仅为展示名;field 优先,避免重命名 label 使引用失效
|
||
field: f.field || f.label || '',
|
||
label: f.label || f.field || '',
|
||
value: f.value ?? '',
|
||
required: Boolean(f.required),
|
||
...(fieldName ? { valueSource: [{ nodeId: startNodeId, field: fieldName, label: f.label || f.field || fieldName }] } : {}),
|
||
...(f.type === 'uploadMultiple'
|
||
? {
|
||
fieldConstraint: {
|
||
...(f.fileTypes ? { fileTypes: f.fileTypes } : {}),
|
||
...(f.maxFileSize !== undefined && f.maxFileSize !== null ? { maxFileSize: f.maxFileSize } : {}),
|
||
...(f.maxFileCount !== undefined && f.maxFileCount !== null ? { maxFileCount: f.maxFileCount } : {}),
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
});
|
||
}
|
||
// 其它带 presetOption 的节点:
|
||
// - sub_flow:isFormField:true 字段已聚合进开始节点并存入 subConfig(如 maxConcurrency),保持原过滤
|
||
// - 其它节点(数字人等):勾选「表单展示」的字段值来自开始节点表单,
|
||
// 序列化时补 valueSource 标记(后端契约);值不重复保存(回显从开始节点反查)
|
||
const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
|
||
if (defs.length === 0) return null;
|
||
const formConfig = Array.isArray(node.data?.formConfig) ? node.data.formConfig : [];
|
||
if (nodeCode === 'sub_flow') {
|
||
return defs
|
||
.filter((def: any) => def && typeof def === 'object' && def.isFormField !== true)
|
||
.map((def: any) => {
|
||
const entry = formConfig.find((f: any) => f && f.field === def.field);
|
||
return { type: def.type || 'input', field: def.field, label: def.label || def.field, value: entry?.value ?? '' };
|
||
});
|
||
}
|
||
return defs
|
||
.map((def: any) => {
|
||
const entry = formConfig.find((f: any) => f && f.field === def.field);
|
||
const isForm = Boolean(def && typeof def === 'object' && def.isFormField === true);
|
||
// 引用上级输出(如脚本转写的视频总时长):formConfig 条目挂 valueSource 数组;
|
||
// 值由上游节点运行时产出,序列化时不重复保存 value(回显从引用来源恢复)
|
||
const refVs = Array.isArray(entry?.valueSource) ? entry.valueSource.filter((r: any) => r && r.nodeId && r.field) : [];
|
||
const hasRef = refVs.length > 0;
|
||
return {
|
||
type: def.type || 'input',
|
||
field: def.field,
|
||
label: def.label || def.field,
|
||
// isFormField 字段值在开始节点运行表单、引用型字段值由上游节点产出,均不重复保存
|
||
...(isForm || hasRef ? {} : { value: entry?.value ?? '' }),
|
||
...(isForm
|
||
? { valueSource: [{ nodeId: startNodeId, field: def.field, label: def.label || def.field }] }
|
||
: hasRef
|
||
? { valueSource: refVs.map((r: any) => ({ nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field, outputIndex) })) }
|
||
: {}),
|
||
};
|
||
});
|
||
};
|
||
|
||
// 子流程节点:编辑器态 subFlowConfig → DSL 保存态 subConfig(runtimeShow → isFormField)
|
||
const serializeSubFlowConfig = (config: SubFlowConfig | null | undefined, node?: Node<NodeData>, startNodeId = '', outputIndex?: Map<string, { name: string; fields: Map<string, string> }>) => {
|
||
if (!config || !config.workflowId) return null;
|
||
// 生成次数(节点库 presetOption 的 maxConcurrency):勾选表单展示时聚合进开始节点表单,
|
||
// 此处同步保存到 subConfig,保证 sub_flow 节点自身也带该配置(后端执行时可读)
|
||
const formConfig = Array.isArray(node?.data?.formConfig) ? node.data.formConfig : [];
|
||
const mcEntry = formConfig.find((f: any) => f && f.field === 'maxConcurrency');
|
||
const mcValue = mcEntry?.value;
|
||
const maxConcurrency = mcValue !== undefined && mcValue !== null && mcValue !== '' ? Number(mcValue) : 0;
|
||
return {
|
||
workflowId: config.workflowId,
|
||
workflowName: config.workflowName || '',
|
||
maxConcurrency,
|
||
fields: (config.fields || []).map((f) => ({
|
||
field: f.field,
|
||
label: f.label || f.field,
|
||
type: f.type || 'input',
|
||
fieldType: f.fieldType || f.type || 'input',
|
||
required: Boolean(f.required),
|
||
value: f.value,
|
||
defaultValue: f.defaultValue,
|
||
fieldConstraint: f.fieldConstraint ?? null,
|
||
options: Array.isArray(f.options) ? f.options : null,
|
||
multiple: Boolean(f.multiple),
|
||
// 值来源契约(后端统一 { nodeId, field }):
|
||
// - 勾选表单展示:值来自主工作流开始节点(首页表单),指向开始节点
|
||
// - 引用上级输出:值来自主工作流上游节点,统一转 field
|
||
// - 其余:无引用(静态默认值)
|
||
valueSource: f.runtimeShow
|
||
? [{ nodeId: startNodeId, field: f.field, label: f.label || f.field }]
|
||
: Array.isArray(f.valueSource)
|
||
? f.valueSource.map((vs: any) => ({ nodeId: vs.nodeId, field: vs.field, label: resolveRefLabel(vs?.nodeId, vs?.field, outputIndex) }))
|
||
: null,
|
||
isFormField: Boolean(f.runtimeShow),
|
||
})),
|
||
};
|
||
};
|
||
|
||
// 子流程节点:DSL 保存态 subConfig → 编辑器态 subFlowConfig(isFormField → runtimeShow)
|
||
const buildSubFlowConfigFromDsl = (subConfig: any, startNodeId = '') => {
|
||
if (!subConfig || !subConfig.workflowId) return null;
|
||
return {
|
||
workflowId: subConfig.workflowId,
|
||
workflowName: subConfig.workflowName || '',
|
||
maxConcurrency: subConfig.maxConcurrency ?? 0,
|
||
fields: (Array.isArray(subConfig.fields) ? subConfig.fields : []).map((f: any) => ({
|
||
field: f.field,
|
||
label: f.label || f.field,
|
||
type: f.type || 'input',
|
||
fieldType: f.fieldType || f.type || 'input',
|
||
required: Boolean(f.required),
|
||
value: f.value !== undefined ? f.value : f.defaultValue ?? '',
|
||
defaultValue: f.defaultValue,
|
||
fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
|
||
options: Array.isArray(f.options) ? f.options : undefined,
|
||
multiple: Boolean(f.multiple) || f.fieldType === 'uploadMultiple',
|
||
valueSource: (() => {
|
||
const vs = f.valueSource;
|
||
// 统一数组:兼容旧 DSL 单对象
|
||
const arr = Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : [];
|
||
// 指向开始节点:值为表单展示(首页可填),编辑器不显示为引用(勾选由 isFormField 恢复)
|
||
const refs = arr.filter((r: any) => !(r && r.nodeId && r.nodeId === startNodeId));
|
||
// 引用上级:后端 field(兼容旧 DSL 的 fieldName)转回前端 field 结构
|
||
return refs.length ? refs.map((r: any) => ({ nodeId: r.nodeId, field: r.fieldName ?? r.field })) : null;
|
||
})(),
|
||
runtimeShow: Boolean(f.isFormField),
|
||
})),
|
||
};
|
||
};
|
||
|
||
// 从 DSL 构建模型配置:剔除只读字段 + 用 modelFormFields 还原勾选(幂等,兼容旧 DSL)
|
||
const buildModelConfigFromDsl = (n: any, startNodeId = '') => {
|
||
const modelRequestParams = stripReadonlyFields(n.modelConfig?.modelRequestParams ?? null);
|
||
const modelFormFields = n.modelConfig?.modelFormFields ?? null;
|
||
if (modelRequestParams && typeof modelRequestParams === 'object' && Array.isArray(modelFormFields)) {
|
||
restoreRuntimeShow(modelRequestParams, modelFormFields, startNodeId);
|
||
}
|
||
return {
|
||
modelId: n.modelConfig?.modelId || '',
|
||
// 回读 DSL 中保存的模型名称/类型(旧 DSL 无这些字段时为空,兼容)
|
||
modelName: n.modelConfig?.modelName || '',
|
||
modelType: n.modelConfig?.modelType ?? undefined,
|
||
modelRequestParams,
|
||
// 全部叶子字段扁平清单随 DSL 透传,编辑过程不消费,仅保证重开保存不丢
|
||
modelRequestParamsPath: n.modelConfig?.modelRequestParamsPath ?? null,
|
||
modelFormFields,
|
||
modelResponseBodyMapping: n.modelConfig?.modelResponseBodyMapping ?? null,
|
||
};
|
||
};
|
||
|
||
// 从 edge id 反向解析 handle:edge id 形如 edge-{source}-{sourceHandle}-{target}-{targetHandle},
|
||
// 后端保存时可能丢弃 sourceHandle/targetHandle,用 id 兜底还原,保证连线回显位置正确。
|
||
const parseEdgeHandleFromId = (id: string) => {
|
||
const sourceMatch = id.match(/^edge-.+-source-(right|bottom)-.+$/);
|
||
const targetMatch = id.match(/-target-(top|left)$/);
|
||
return {
|
||
sourceHandle: sourceMatch ? `source-${sourceMatch[1]}` : undefined,
|
||
targetHandle: targetMatch ? `target-${targetMatch[1]}` : undefined,
|
||
};
|
||
};
|
||
|
||
// 从 DSL 加载工作流
|
||
const loadWorkflowFromDsl = (dsl: any) => {
|
||
if (!dsl) return;
|
||
|
||
try {
|
||
// 开始节点 id 与其运行表单字段(outputConfig):
|
||
// - 运行表单供其它节点 isFormField:true 字段回显反查
|
||
// - 开始节点 id 用于识别 sub_flow 引入参数中"指向开始节点"的 valueSource(表单展示)
|
||
const startDslNode = (dsl.nodes || []).find((n: any) => n.nodeCode === '__start__');
|
||
const startNodeId = startDslNode?.id || '';
|
||
const startRunFields = startDslNode?.outputConfig || [];
|
||
const loadedNodes = (dsl.nodes || []).map((n: any) => {
|
||
const isStart = n.nodeCode === '__start__';
|
||
// 子流程节点:参数面板的生成次数为空时,用 subConfig.maxConcurrency 兜底恢复(兼容边界数据)
|
||
let formConfig = buildNodeFormConfigFromDsl(n, startRunFields, startNodeId);
|
||
if (n.nodeCode === 'sub_flow' && Array.isArray(formConfig) && formConfig.length > 0) {
|
||
const mcVal = n.subConfig?.maxConcurrency;
|
||
if (mcVal !== undefined && mcVal !== null && mcVal !== 0) {
|
||
const mcIdx = formConfig.findIndex((f: any) => f && f.field === 'maxConcurrency');
|
||
if (mcIdx >= 0) {
|
||
const cur = formConfig[mcIdx]?.value;
|
||
if (cur === '' || cur === undefined || cur === null) {
|
||
formConfig = formConfig.map((f: any, i: number) => (i === mcIdx ? { ...f, value: mcVal } : f));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
id: n.id,
|
||
type: isStart ? 'input' : 'default',
|
||
position: { x: n.config?.x || 220, y: n.config?.y || 140 },
|
||
data: {
|
||
label: n.name || '',
|
||
nodeCode: n.nodeCode,
|
||
desc: n.desc || '',
|
||
formConfig,
|
||
modelConfig: buildModelConfigFromDsl(n, startNodeId),
|
||
skillName: n.skillName || null,
|
||
prompt: n.prompt || '',
|
||
negativePrompt: n.negativePrompt || '',
|
||
patchLayout: n.patchLayout || false,
|
||
isSaveFile: Boolean(n.isSaveFile),
|
||
preTool: n.preTool ?? null,
|
||
postTool: n.postTool ?? null,
|
||
// 子流程节点:恢复引入的工作流配置(无 subConfig 时返回 null,兼容旧 DSL)
|
||
...(n.nodeCode === 'sub_flow' ? { subFlowConfig: buildSubFlowConfigFromDsl(n.subConfig, startNodeId) } : {}),
|
||
// 开始节点运行表单字段:新格式存 outputConfig;旧 DSL 顶层 runFormFields 兜底兼容
|
||
...(isStart
|
||
? {
|
||
runFormFields: Array.isArray(n.outputConfig)
|
||
? n.outputConfig
|
||
: Array.isArray(n.runFormFields)
|
||
? n.runFormFields
|
||
: [],
|
||
}
|
||
: {}),
|
||
},
|
||
};
|
||
});
|
||
|
||
const loadedEdges = (dsl.edges || []).map((e: any) => {
|
||
const fromId = parseEdgeHandleFromId(e.id || '');
|
||
return {
|
||
id: e.id,
|
||
source: e.from,
|
||
target: e.to,
|
||
sourceHandle: e.sourceHandle || fromId.sourceHandle || 'source-bottom',
|
||
targetHandle: e.targetHandle || fromId.targetHandle || 'target-top',
|
||
type: 'smoothstep',
|
||
animated: true,
|
||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||
};
|
||
});
|
||
|
||
nodes.value = loadedNodes;
|
||
edges.value = loadedEdges;
|
||
|
||
if (loadedNodes.length > 0) {
|
||
nodeId = Math.max(
|
||
...loadedNodes.map((n: any) => {
|
||
const match = n.id.match(/node-(\d+)/);
|
||
return match ? parseInt(match[1]) : 0;
|
||
})
|
||
);
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('工作流加载失败');
|
||
}
|
||
};
|
||
|
||
// 保存工作流
|
||
const saveWorkflowAction = () => {
|
||
if (nodes.value.length === 0) {
|
||
ElMessage.warning('画布为空,无法保存');
|
||
return;
|
||
}
|
||
|
||
// 保存前进行约束校验
|
||
const validateResult = validateFlowConstraints();
|
||
if (!validateResult.ok) {
|
||
ElMessage.warning(validateResult.message);
|
||
return;
|
||
}
|
||
|
||
saveDialogVisible.value = true;
|
||
};
|
||
|
||
// 确认保存工作流
|
||
const confirmSaveWorkflow = async () => {
|
||
if (!saveForm.value.flowName.trim()) {
|
||
ElMessage.warning('请输入工作流名称');
|
||
return;
|
||
}
|
||
|
||
const startNode = nodes.value.find((n) => isStartNode(n));
|
||
|
||
// 保存前检测聚合运行表单字段重名(不同节点同名 field 在首页表单会冲突;仅告警不阻断)
|
||
const runFields = Array.isArray(startNode?.data?.runFormFields) ? startNode.data.runFormFields : [];
|
||
const seenFields = new Set<string>();
|
||
const dupFields = runFields.filter((f: any) => {
|
||
if (!f || typeof f.field !== 'string') return false;
|
||
if (seenFields.has(f.field)) return true;
|
||
seenFields.add(f.field);
|
||
return false;
|
||
});
|
||
if (dupFields.length > 0) {
|
||
const dupNames = [...new Set(dupFields.map((f: any) => f.field))];
|
||
ElMessage.warning(`存在重名运行字段:${dupNames.join('、')},首页表单可能出现冲突`);
|
||
}
|
||
|
||
// 节点 id → 名称与输出字段(field → label)索引,供 modelRequestParamsPath 的 valueSource label 补全
|
||
const outputFieldsIndex = new Map<string, { name: string; fields: Map<string, string> }>();
|
||
nodes.value.forEach((n) => {
|
||
const fields = getNodeOutputFields(n);
|
||
outputFieldsIndex.set(n.id, {
|
||
name: n.data?.label || n.id,
|
||
fields: new Map(fields.map((f: any) => [f.field, f.label || f.field])),
|
||
});
|
||
});
|
||
|
||
const workflowDsl = {
|
||
version: '1.0.0',
|
||
startNodeId: startNode?.id || '',
|
||
nodes: nodes.value.map((n) => {
|
||
const gNode = n as any; // VueFlow 运行时会注入 dimensions(渲染尺寸)
|
||
const nodeCode = n.data?.nodeCode || 'unknown';
|
||
const rawModelParams = n.data?.modelConfig?.modelRequestParams ?? null;
|
||
// 先基于含实例值的完整结构收集勾选的「表单展示」字段
|
||
const savedModelFormFields = rawModelParams ? collectExposedFields(rawModelParams) : undefined;
|
||
// 数组字段的实例值(value)由前端从模板复制而来,与 enumValues 模板重复,
|
||
// 保存时不提交;deepClone 避免污染节点运行时状态
|
||
const savedModelRequestParams = rawModelParams ? removeArrayValueInstances(deepClone(rawModelParams)) : null;
|
||
// 勾选「表单展示」的字段:补 valueSource={nodeId:开始节点id, field:path}(后端契约,
|
||
// 标记字段值来自主工作流开始节点首页表单)
|
||
if (savedModelRequestParams && startNode?.id) {
|
||
attachFormFieldValueSources(savedModelRequestParams, savedModelFormFields, startNode.id);
|
||
}
|
||
return {
|
||
id: n.id,
|
||
nodeCode,
|
||
name: n.data?.label || '',
|
||
config: {
|
||
nodeCode,
|
||
x: n.position?.x || 0,
|
||
y: n.position?.y || 0,
|
||
width: gNode.dimensions?.width || gNode.width || 100,
|
||
height: gNode.dimensions?.height || gNode.height || 80,
|
||
},
|
||
...(n.data?.preTool ? { preTool: n.data.preTool } : {}),
|
||
...(n.data?.postTool ? { postTool: n.data.postTool } : {}),
|
||
isSaveFile: Boolean(n.data?.isSaveFile),
|
||
// 子流程节点:序列化引入的工作流配置(含 workflowId 与 isFormField 标记);其余节点保持 null
|
||
subConfig: nodeCode === 'sub_flow' ? serializeSubFlowConfig(n.data?.subFlowConfig, n, startNode?.id || '', outputFieldsIndex) : null,
|
||
modelConfig: {
|
||
modelId: n.data?.modelConfig?.modelId || '',
|
||
// 模型名称/类型随工作流保存,供首页补全弹窗展示模型名与「同类型模型」过滤
|
||
...(n.data?.modelConfig?.modelName ? { modelName: n.data.modelConfig.modelName } : {}),
|
||
...(n.data?.modelConfig?.modelType !== undefined && n.data.modelConfig.modelType !== null && n.data.modelConfig.modelType !== ''
|
||
? { modelType: n.data.modelConfig.modelType }
|
||
: {}),
|
||
modelRequestParams: savedModelRequestParams,
|
||
// 全部叶子字段扁平清单(path + 值 + 值来源),后端按 path 重组请求参数;基于编辑器原始嵌套树生成,保留用户配置的引用 valueSource
|
||
modelRequestParamsPath: rawModelParams ? buildModelRequestParamsPath(rawModelParams, startNode?.id || '', outputFieldsIndex) : null,
|
||
// 勾选的「表单展示」字段(路径带实例索引),已在上方基于完整结构收集
|
||
...(savedModelFormFields && savedModelFormFields.length > 0 ? { modelFormFields: savedModelFormFields } : {}),
|
||
// 模型返回参数随工作流保存,保证重开后仍可被下游引用
|
||
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
|
||
},
|
||
// 开始节点:运行表单字段存入 outputConfig(不再单独存顶层 runFormFields 字段)
|
||
outputConfig: isStartNode(n) ? (n.data?.runFormFields ?? []) : buildOutputConfig(n, startNode?.id || '', outputFieldsIndex),
|
||
...(n.data?.skillName ? { skillName: n.data.skillName } : {}),
|
||
...(n.data?.prompt ? { prompt: n.data.prompt } : {}),
|
||
...(n.data?.negativePrompt ? { negativePrompt: n.data.negativePrompt } : {}),
|
||
// 节点描述:与加载端 loadWorkflowFromDsl 的 n.desc 读取对齐,空描述不序列化
|
||
...(n.data?.desc ? { desc: n.data.desc } : {}),
|
||
...(n.data?.patchLayout ? { patchLayout: n.data.patchLayout } : {}),
|
||
outputResult: null,
|
||
};
|
||
}),
|
||
edges: edges.value.map((e) => ({
|
||
id: e.id,
|
||
from: e.source,
|
||
to: e.target,
|
||
sourceHandle: e.sourceHandle || undefined,
|
||
targetHandle: e.targetHandle || undefined,
|
||
})),
|
||
};
|
||
|
||
saving.value = true;
|
||
try {
|
||
if (currentEditingWorkflowId.value) {
|
||
await updateWorkflow({
|
||
id: currentEditingWorkflowId.value,
|
||
flowName: saveForm.value.flowName,
|
||
description: saveForm.value.description,
|
||
flowContent: workflowDsl,
|
||
});
|
||
ElMessage.success('工作流更新成功');
|
||
} else {
|
||
const res = await saveWorkflow({
|
||
flowName: saveForm.value.flowName,
|
||
description: saveForm.value.description,
|
||
flowContent: workflowDsl,
|
||
});
|
||
ElMessage.success('工作流保存成功');
|
||
// 新建成功:回填后端返回的 id,使后续「再点保存」走更新而非新建重复副本
|
||
const newData = (res as any)?.data;
|
||
const newId =
|
||
typeof newData === 'string'
|
||
? newData
|
||
: newData?.id ?? newData?.flowId ?? newData?.data?.id ?? newData?.data?.flowId ?? '';
|
||
if (newId) currentEditingWorkflowId.value = String(newId);
|
||
}
|
||
saveDialogVisible.value = false;
|
||
// 保存成功后保留 saveForm(名称/描述)与 currentEditingWorkflowId,便于连续编辑再保存;
|
||
// 新建/编辑入口(createNewWorkflow / editWorkflow)会重置这两者
|
||
await fetchWorkflowList();
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
// 删除工作流
|
||
const deleteWorkflowAction = async (workflow: WorkflowItem) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确定要删除工作流"${workflow.flowName}"吗?`, '删除确认', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
|
||
await deleteWorkflow(workflow.id);
|
||
ElMessage.success('工作流删除成功');
|
||
|
||
if (currentEditingWorkflowId.value === workflow.id) {
|
||
currentEditingWorkflowId.value = null;
|
||
saveForm.value.flowName = '';
|
||
saveForm.value.description = '';
|
||
// 删除的是当前编辑中的工作流时,恢复画布到默认开始节点,避免旧节点/连线残留。
|
||
// 整体赋值 v-model,与 createNewWorkflow 一致,避免直接改 store 触发 watch 竞争
|
||
selectedNode.value = null;
|
||
nodeId = 0;
|
||
nodes.value = [createStartNode()];
|
||
edges.value = [];
|
||
}
|
||
|
||
await fetchWorkflowList();
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
// 错误已由全局拦截器处理
|
||
}
|
||
}
|
||
};
|
||
|
||
// 初始化
|
||
onMounted(async () => {
|
||
await getNodeLibrary();
|
||
await fetchWorkflowList();
|
||
|
||
// 获取当前用户是否为超级管理员(管理员绘制模板时选系统模型无需填 API Key)
|
||
try {
|
||
const res: any = await checkIsSuperAdmin();
|
||
isSuperAdmin.value = res.data?.isSuperAdmin || false;
|
||
} catch {
|
||
isSuperAdmin.value = false;
|
||
}
|
||
|
||
// 添加默认开始节点(固定在页面中间偏左)
|
||
addDefaultStartNode();
|
||
nodeId = 0;
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.workflow-canvas-page {
|
||
height: calc(100vh - 100px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f3f5f9;
|
||
position: relative;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 14px 20px;
|
||
background: #ffffff;
|
||
border-bottom: 1px solid #e6eaf0;
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
position: relative;
|
||
z-index: 10;
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.info {
|
||
font-size: 12px;
|
||
color: #475569;
|
||
padding: 4px 10px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
|
||
:deep(.el-button) {
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
padding: 8px 14px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
}
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 340px 1fr 380px;
|
||
gap: 12px;
|
||
min-height: 0;
|
||
padding: 12px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
// 中间:画布区域
|
||
.flow-wrapper {
|
||
position: relative;
|
||
background: #ffffff;
|
||
border-radius: 10px;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||
border: 1px solid #e6eaf0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
.content {
|
||
grid-template-columns: 300px 1fr 340px;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.content {
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
@import '@vue-flow/core/dist/style.css';
|
||
@import '@vue-flow/core/dist/theme-default.css';
|
||
@import '@vue-flow/controls/dist/style.css';
|
||
|
||
/* 连接点样式优化 - 更大尺寸,hover 放大但不位移 */
|
||
.vue-flow__handle {
|
||
width: 14px !important;
|
||
height: 14px !important;
|
||
background: #3b82f6 !important;
|
||
border: 2px solid #fff !important;
|
||
opacity: 1 !important;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2) !important;
|
||
cursor: crosshair !important;
|
||
z-index: 10 !important;
|
||
transition: all 0.15s ease !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
.vue-flow__handle:hover {
|
||
width: 22px !important;
|
||
height: 22px !important;
|
||
margin: -4px !important;
|
||
background: #2563eb !important;
|
||
box-shadow: 0 3px 12px rgba(37, 99, 235, 0.6) !important;
|
||
}
|
||
|
||
/* 节点悬停时确保连接点可见 */
|
||
.vue-flow__node:hover .vue-flow__handle {
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
/* 连接线样式 */
|
||
.vue-flow__edge-path {
|
||
stroke-width: 2px;
|
||
}
|
||
|
||
.vue-flow__edge.selected .vue-flow__edge-path {
|
||
stroke: #ef4444 !important;
|
||
stroke-width: 3px;
|
||
}
|
||
|
||
/* 节点样式优化 */
|
||
.vue-flow__node {
|
||
cursor: move;
|
||
}
|
||
|
||
/* 覆盖 VueFlow 默认主题对节点外层的固定宽/padding/背景,让自定义 FlowNode 渐变背景完全贴合节点边缘 */
|
||
.vue-flow__node-default,
|
||
.vue-flow__node-input,
|
||
.vue-flow__node-output {
|
||
width: auto !important;
|
||
padding: 0 !important;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
border-radius: 0 !important;
|
||
}
|
||
|
||
.vue-flow__node:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||
}
|
||
|
||
/* 确保连接线在拖动时可见 */
|
||
.vue-flow__connection-path {
|
||
stroke: #3b82f6 !important;
|
||
stroke-width: 2px !important;
|
||
}
|
||
</style>
|