Files
admin-ui/src/views/settings/workflow/index.vue
T
2910410219andClaude e2a5480bf9 工作流节点库 patchLayout 驱动贴片布局开关显示(对齐内容创作)
- NodeLibraryItem 增加 patchLayout 字段(节点库返回,仅 model 节点 true)
- nodeConfigMap 聚合透传节点库 patchLayout,开关显示条件由 modelType 范围改为 nodeConfig.patchLayout
- 移除不再使用的 isVideoModelSelected 判断

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:01:38 +08:00

1876 lines
68 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 字段 = pathform 节点自定义字段 = 字段名),供首页按 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),
// 编辑器渲染归一到 uploadModelField 控件识别),保留 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-modelnodes/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;
};
// 构建保存用的 outputConfigHTTP/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_flowisFormField: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 保存态 subConfigruntimeShow → 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 → 编辑器态 subFlowConfigisFormField → 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 反向解析 handleedge 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>