sub_flow 节点引入工作流修复与首页执行工作流重构

- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
2026-08-15 18:49:56 +08:00
parent 3b71f144c4
commit 41655fa825
14 changed files with 1620 additions and 356 deletions
@@ -32,39 +32,23 @@
</el-tooltip>
</div>
<div class="mf-array-flat">
<!-- 联合数组enumValues 多模板全部模板平铺固定条数不可增删 -->
<template v-if="isVariantArray">
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
<div class="mf-variant-head">
<!-- 数组元素直接遍历模板enumValues/attrs值与勾选均落在模板上
保存只留 enumValues 一份即可完整回显不依赖 value 实例 -->
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
<template v-if="isObjectDef(item)">
<div v-if="isVariantArray" class="mf-variant-head">
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
</div>
<template v-if="isObjectDef(item)">
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
</div>
</template>
<!-- 同构数组保持现有平铺逻辑 -->
<template v-else>
<template v-for="(item, idx) in getArrayValue()" :key="idx">
<template v-if="isObjectDef(item)">
<div v-if="getArrayValue().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
</template>
</div>
</div>
@@ -177,7 +161,6 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
import { deepClone, normalizeArrayItem } from './modelParamUtils';
defineOptions({ name: 'ModelField' });
@@ -312,22 +295,26 @@ const arrayTemplates = computed<any[]>(() => {
return [];
});
const getArrayValue = (): any[] => {
if (!def.value) return [];
if (!Array.isArray(def.value.value)) {
def.value.value = [];
}
// value 为空时(后端仅定义结构未给内容)用模板补一条空结构,保证字段可展示、可填写
if (def.value.value.length === 0 && arrayTemplates.value.length > 0) {
def.value.value.push(normalizeArrayItem(deepClone(arrayTemplates.value[0])));
}
return def.value.value;
};
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
const setArrayPrimitive = (idx: number, val: any) => {
getArrayValue()[idx] = val ?? '';
// 数组渲染统一以模板(enumValues/attrs)为数据源:值与勾选均落在模板上,
// 不受 props 同步重建 localParams(深拷贝)影响,保存只留 enumValues 一份即可完整回显
const renderedArrayItems = (): any[] => {
const templates = arrayTemplates.value;
// 多模板(variant):懒填 type/role 标识值到模板(幂等),保证展示与保存有标识值
if (templates.length > 1) templates.forEach(autoFillVariant);
return templates;
};
// 原始值数组元素:值直接读写模板原始值对象上的 value
const getTemplatePrimitive = (item: any): any => {
if (!item || typeof item !== 'object') return '';
return item.value !== undefined ? item.value : item.defaultValue ?? '';
};
const setTemplatePrimitive = (item: any, v: any): void => {
if (!item || typeof item !== 'object') return;
item.value = v ?? '';
};
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
@@ -366,25 +353,6 @@ const autoFillVariant = (tpl: any): void => {
}
};
// 联合数组渲染条目:value 与 enumValues 模板按索引一一对应(懒补模板),保证固定条数
const getVariantItems = (): any[] => {
if (!isVariantArray.value || !def.value) return [];
const templates = Array.isArray(def.value.enumValues) ? def.value.enumValues : [];
if (!Array.isArray(def.value.value)) def.value.value = [];
templates.forEach((tpl: any, i: number) => {
const existing = def.value.value[i];
if (!existing || typeof existing !== 'object') {
const item = normalizeArrayItem(deepClone(tpl));
autoFillVariant(item);
def.value.value[i] = item;
}
});
if (def.value.value.length > templates.length) {
def.value.value = def.value.value.slice(0, templates.length);
}
return def.value.value;
};
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
</script>
@@ -202,6 +202,27 @@
</template>
</template>
</template>
<!-- 子流程配置(仅 sub_flow 节点):选择已有工作流引入其开始参数 -->
<template v-if="isSubFlowNode">
<el-divider content-position="left">子流程配置</el-divider>
<el-form-item label="引入工作流">
<el-button type="primary" plain @click="emit('openWorkflowSelector')" style="width: 100%">选择工作流</el-button>
<div v-if="subFlowConfig" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeWorkflow')">
{{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
</el-tag>
</div>
</el-form-item>
<template v-if="subFlowConfig">
<el-divider content-position="left">引入参数</el-divider>
<SubFlowParams
:fields="subFlowConfig.fields"
:upstream-nodes="upstreamNodes"
@update:fields="updateSubFlowFields"
/>
</template>
</template>
<!-- 自定义表单字段(formConfigOption,如 form 节点) -->
<template v-if="nodeConfig?.formConfigOption">
<el-divider content-position="left">自定义字段</el-divider>
@@ -255,6 +276,8 @@ import KeyValueEditor from './KeyValueEditor.vue';
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
import ModelParamsForm from './ModelParamsForm.vue';
import PromptEditor from './PromptEditor.vue';
import SubFlowParams from './SubFlowParams.vue';
import type { SubFlowField } from './subFlowTypes';
import { JsonEditor } from '/@/components/json-schema-editor';
interface NodeData {
@@ -269,6 +292,8 @@ interface NodeData {
patchLayout?: boolean;
isSaveFile?: boolean;
runFormFields?: any[];
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
subFlowConfig?: any;
}
interface NodeConfig {
@@ -306,6 +331,11 @@ const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__sta
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
// 子流程节点(sub_flow):是否当前选中节点为子流程
const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
// 子流程引入配置(编辑器态 subFlowConfig;未引入则为 null
const subFlowConfig = computed<any>(() => props.selectedNode?.data?.subFlowConfig || null);
// fieldType 可读标签
const fieldTypeLabel = (ft: string) => {
const map: Record<string, string> = {
@@ -330,6 +360,8 @@ const emit = defineEmits<{
(e: 'openSkillSelector'): void;
(e: 'removeSkill'): void;
(e: 'update:patchLayout', value: boolean): void;
(e: 'openWorkflowSelector'): void;
(e: 'removeWorkflow'): void;
}>();
// Schema 编辑器弹窗
@@ -433,6 +465,23 @@ const updateModelRequestParams = (params: Record<string, any> | null) => {
emit('update:selectedNode', updatedNode);
};
// 子流程引入参数就地变更(ModelField 已就地写共享引用):
// 仅刷新节点引用同步 VueFlow / 父组件状态;fields 为同一数组引用,避免与 SubFlowParams 深 watch 形成循环
const updateSubFlowFields = (fields: SubFlowField[]) => {
if (!props.selectedNode?.data || !props.selectedNode.data.subFlowConfig) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
subFlowConfig: {
...props.selectedNode.data.subFlowConfig,
fields,
},
},
};
emit('update:selectedNode', updatedNode);
};
const getFieldValue = (fieldName: string) => {
if (!props.selectedNode?.data?.formConfig) return '';
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
@@ -0,0 +1,53 @@
<template>
<div class="sub-flow-params">
<template v-if="fields?.length">
<!-- 薄封装直接复用 ModelField 渲染引入参数
ModelField 就地写 def.value / def.valueSource / def.runtimeShow
fields 元素为同一引用父组件读取同步无需额外同步 -->
<ModelField
v-for="f in fields"
:key="f.field"
:field-def="f"
:path="f.field"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-empty v-else description="该工作流无可引入的开始参数" :image-size="60" />
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue';
import ModelField from './ModelField.vue';
import type { SubFlowField } from './subFlowTypes';
defineOptions({ name: 'SubFlowParams' });
interface Props {
fields: SubFlowField[];
upstreamNodes?: any[];
}
interface Emits {
(e: 'update:fields', fields: SubFlowField[]): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// ModelField 就地修改 defvalue/valueSource/runtimeShow),deep watch 通知父级刷新节点引用
// (fields 元素为共享引用,父级无需复制数组,仅重新 emit update:selectedNode 即可)
watch(
() => props.fields,
(fields) => {
emit('update:fields', fields);
},
{ deep: true }
);
</script>
<style scoped lang="scss">
.sub-flow-params {
width: 100%;
}
</style>
@@ -0,0 +1,232 @@
<template>
<el-dialog v-model="visible" title="选择要引入的工作流" width="900px" :close-on-click-modal="false" @close="handleClose">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索工作流名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="workflow-list" v-loading="loading">
<el-empty v-if="!loading && workflowList.length === 0" description="暂无工作流数据" :image-size="100" />
<div v-else class="workflow-grid">
<div
v-for="workflow in workflowList"
:key="workflow.id"
class="workflow-card"
:class="{ selected: selectedWorkflow?.id === workflow.id }"
@click="handleSelectWorkflow(workflow)"
>
<div class="workflow-card-header">
<span class="workflow-badge">我的工作流</span>
<el-icon v-if="selectedWorkflow?.id === workflow.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="workflow-card-body">
<h3 class="workflow-name">{{ workflow.flowName || workflow.name || '未命名工作流' }}</h3>
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedWorkflow">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getSubFlowWorkflowList, type SubFlowWorkflowItem } from '/@/api/settings/workflow';
interface Props {
modelValue: boolean;
defaultWorkflow?: SubFlowWorkflowItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', workflow: SubFlowWorkflowItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultWorkflow: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const workflowList = ref<SubFlowWorkflowItem[]>([]);
const loading = ref(false);
const selectedWorkflow = ref<SubFlowWorkflowItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedWorkflow.value = props.defaultWorkflow || null;
pagination.pageNum = 1;
fetchWorkflowList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const fetchWorkflowList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
keyword: searchParams.keyword || undefined,
IsOwn: true,
};
const res = await getSubFlowWorkflowList(params);
const data = res.data || {};
// 响应结构多形态容错:扁平 list 或 listFlowUserRes 嵌套
const list = data.list ?? data.listFlowUserRes?.list ?? [];
workflowList.value = list || [];
pagination.total = data.total ?? data.listFlowUserRes?.total ?? workflowList.value.length;
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
// 名称字段兼容 flowName / name 双形态
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.name;
if (selName) {
const matched = workflowList.value.find((w) => (w.flowName || w.name) === selName);
if (matched) selectedWorkflow.value = matched;
}
}
} catch {
workflowList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchWorkflowList();
};
const handlePageChange = () => {
fetchWorkflowList();
};
const handleSelectWorkflow = (workflow: SubFlowWorkflowItem) => {
selectedWorkflow.value = workflow;
};
const handleConfirm = () => {
if (selectedWorkflow.value) {
emit('confirm', selectedWorkflow.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedWorkflow.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.workflow-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.workflow-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.workflow-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.workflow-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.workflow-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.workflow-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.workflow-badge {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.workflow-card-body {
flex: 1;
}
.workflow-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
@@ -128,9 +128,47 @@ export function stripReadonlyFields(params: any): any {
return params;
}
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
// enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
export function removeArrayValueInstances(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
if (typeof params.type === 'string') {
if (params.type === 'array') {
delete params.value;
if (params.attrs && typeof params.attrs === 'object') {
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
else removeArrayValueInstances(params.attrs);
}
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
removeArrayValueInstances(params.attrs);
}
return params;
}
// 字段定义容器 { key: fieldDef }:逐个字段递归
for (const key of Object.keys(params)) {
removeArrayValueInstances(params[key]);
}
return params;
}
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content"
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
function arrayTemplatesOf(def: any): any[] {
if (!def || typeof def !== 'object') return [];
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
return [];
}
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content"
export interface ExposedField {
path: string;
label: string;
@@ -139,13 +177,17 @@ export interface ExposedField {
required: boolean;
options?: any[];
value?: any;
defaultValue?: any; // 默认值:首页初始化/回显用
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
valueSource?: any; // 引用上游节点输出({ nodeId, field });有则首页只读展示
multiple?: boolean; // 多文件上传标记
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
}
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
// 路径文法(与 restoreRuntimeShow 共用):
// object 子字段 → 父路径 + ".attrs." + 子key
// array 实例元素 → 父路径 + ".value[i]"
// array 模板元素 → 父路径 + ".enumValues[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
@@ -161,14 +203,14 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
result.push(...collectExposedFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 仅遍历实例数组;元素为 { type:'object', attrs } 包装时才递归
if (Array.isArray(def.value)) {
def.value.forEach((item: any, i: number) => {
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
result.push(...collectExposedFields(item.attrs, `${path}.value[${i}].attrs`));
}
});
}
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
const templates = arrayTemplatesOf(def);
templates.forEach((tpl: any, i: number) => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${i}].attrs`));
}
});
} else if (def.runtimeShow === true) {
// 叶子且已勾选
result.push({
@@ -179,6 +221,10 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
required: !!def.required,
options: Array.isArray(def.options) ? def.options : undefined,
value: def.value,
defaultValue: def.defaultValue,
fieldConstraint: def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined,
valueSource: def.valueSource && typeof def.valueSource === 'object' ? def.valueSource : undefined,
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
});
}
}
@@ -208,14 +254,22 @@ function resolvePath(params: any, path: string): any {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
const m = seg.match(/^value\[(\d+)\]$/);
if (m) {
const idx = Number(m[1]);
const mv = seg.match(/^value\[(\d+)\]$/);
if (mv) {
const idx = Number(mv[1]);
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
cur = cur.value[idx];
} else {
cur = cur[seg];
continue;
}
const me = seg.match(/^enumValues\[(\d+)\]$/);
if (me) {
const idx = Number(me[1]);
const templates = arrayTemplatesOf(cur);
if (idx >= templates.length) return undefined;
cur = templates[idx];
continue;
}
cur = cur[seg];
}
}
return cur;
@@ -0,0 +1,58 @@
// 子流程节点(sub_flow)引入工作流的类型定义(workflow 独立实现,不依赖内容创作)
// ===== 编辑器态(node.data.subFlowConfig=====
export interface SubFlowField {
// = 目标工作流开始节点 runFormFields 的 field(后端匹配键,不可改)
field: string;
label: string;
// 控件类型:input/number/textarea/switch/select/upload/uploadMultiple
type: string;
fieldType: string;
required: boolean;
// 未引用且未勾选表单展示时的静态值(编辑器可填)
value?: any;
defaultValue?: any;
// upload {fileTypes,maxFileSize,maxFileCount} / number {minValue,maxValue}
fieldConstraint?: any;
// select 选项
options?: any[];
// 多文件上传标记
multiple?: boolean;
// 引用上级节点输出(主工作流上游);有值则编辑器内只读引用展示
valueSource?: { nodeId: string; field: string } | null;
// 编辑器内「表单展示」勾选(ModelField 用 runtimeShow
runtimeShow?: boolean;
}
export interface SubFlowConfig {
// 目标工作流 id(后端执行 sub_flow 时引入)
workflowId: string;
workflowName: string;
// 生成次数(sub_flow 执行并发数;未填为 0)
maxConcurrency?: number;
fields: SubFlowField[];
}
// ===== DSL 保存态(node.subConfigruntimeShow → isFormField=====
export interface SubFlowDslField {
field: string;
label: string;
type: string;
fieldType: string;
required: boolean;
value?: any;
defaultValue?: any;
fieldConstraint?: any;
options?: any[] | null;
multiple?: boolean;
valueSource?: { nodeId: string; field: string } | null;
// 勾选表单展示 → 聚合进主工作流开始节点(首页表单可填)
isFormField?: boolean;
}
export interface SubFlowConfigDsl {
workflowId: string;
workflowName: string;
maxConcurrency?: number;
fields: SubFlowDslField[];
}
+369 -23
View File
@@ -26,6 +26,8 @@
@open-skill-selector="showSkillSelector = true"
@remove-skill="handleRemoveSkill"
@update:patch-layout="handleTogglePatchLayout"
@open-workflow-selector="showWorkflowSelector = true"
@remove-workflow="handleRemoveWorkflow"
/>
<!-- 中间VueFlow 画布节点库在画布内 -->
@@ -84,6 +86,13 @@
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
<!-- 子流程工作流选择器 -->
<WorkflowSelector
v-model="showWorkflowSelector"
:default-workflow="selectedWorkflowData"
@confirm="handleWorkflowConfirm"
/>
</div>
</template>
@@ -103,13 +112,17 @@ import {
type WorkflowItem,
} from '/@/api/settings/creation';
import { checkIsSuperAdmin } from '/@/api/system/user';
import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
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,
isEqual,
deepClone,
removeArrayValueInstances,
type ExposedField,
} from './component/modelParamUtils';
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
@@ -119,8 +132,11 @@ import ModelSelector from './component/ModelSelector.vue';
import SkillSelector from './component/SkillSelector.vue';
import FlowNode from './component/FlowNode.vue';
// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点
interface RunFormField extends ExposedField {
// 开始节点「运行表单字段」:在暴露清单基础上标注来源节点field 为统一标识
// model 字段 = pathform 节点自定义字段 = 字段名),供首页按 field 渲染/写回
interface RunFormField extends Omit<ExposedField, 'path'> {
field: string;
path?: string;
nodeId: string;
nodeLabel: string;
}
@@ -157,6 +173,8 @@ interface NodeData {
isSaveFile?: boolean;
preTool?: string | null;
runFormFields?: RunFormField[]; // 仅开始节点使用
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
subFlowConfig?: SubFlowConfig | null;
}
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
@@ -239,6 +257,10 @@ const isSuperAdmin = ref(false);
const showSkillSelector = ref(false);
const selectedSkillData = ref<any>(null);
// 子流程工作流选择器相关状态
const showWorkflowSelector = ref(false);
const selectedWorkflowData = ref<any>(null);
const deleteSelectedNode = async () => {
if (!selectedNode.value?.data) return;
@@ -301,6 +323,9 @@ const onNodeClick = (event: { node: Node<NodeData, any, string> }) => {
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>) => {
@@ -423,6 +448,112 @@ const handleRemoveSkill = () => {
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;
@@ -457,14 +588,103 @@ const syncRunFormFields = () => {
const collected: RunFormField[] = [];
for (const n of nodes.value) {
if (isStartNode(n)) continue;
const params = n.data?.modelConfig?.modelRequestParams;
if (!params || typeof params !== 'object') continue;
for (const f of collectExposedFields(params)) {
collected.push({
...f,
nodeId: n.id,
nodeLabel: n.data?.label || n.id,
});
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: f.valueSource && typeof f.valueSource === 'object' ? 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,
});
}
}
}
@@ -558,8 +778,8 @@ const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[]
.map((k: string) => ({ field: k, label: k }));
}
if (nodeCode === START_NODE_CODE) {
// 开始节点:运行表单字段(被勾选的模型参数)
return (node.data?.runFormFields || []).map((f: any) => ({ field: f.path || '', label: f.label || f.path || '' }));
// 开始节点:运行表单字段(被勾选的模型参数 + form 自定义字段
return (node.data?.runFormFields || []).map((f: any) => ({ field: f.field || f.path || '', label: f.label || f.field || f.path || '' }));
}
return [];
};
@@ -753,7 +973,7 @@ const editWorkflow = async (workflow: WorkflowItem) => {
};
// 从 DSL 节点重建 formConfig(新格式:outputConfig;旧格式:formConfig 兜底)
const buildNodeFormConfigFromDsl = (n: any) => {
const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = []) => {
// form 节点:自定义字段(完整结构 {type,field,label,value,required},兼容命名对象 [{key:value}]
if (n.nodeCode === 'form' && Array.isArray(n.outputConfig)) {
return n.outputConfig.map((o: any) => {
@@ -805,7 +1025,10 @@ const buildNodeFormConfigFromDsl = (n: any) => {
});
return { ...def, value: out?.value ?? '', expand };
}
return { ...def, value: out?.value ?? '' };
// isFormField:true 字段不随节点 outputConfig 保存(值在开始节点运行表单),
// 从开始节点反查恢复,保证编辑器参数面板回显与运行表单一致
const startValue = startRunFields.find((r: any) => r && r.field === def.field)?.value;
return { ...def, value: out?.value ?? startValue ?? '' };
});
}
// 旧格式兜底
@@ -857,7 +1080,86 @@ const buildOutputConfig = (node: Node<NodeData>) => {
: {}),
}));
}
return null;
// 其它带 presetOption 的节点(如 sub_flow):isFormField:true 的字段已聚合进开始节点运行表单,
// 此处仅序列化 isFormField !== true 的节点配置参数,保证重开后编辑器参数面板回显
const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
if (defs.length === 0) return null;
const formConfig = Array.isArray(node.data?.formConfig) ? node.data.formConfig : [];
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 ?? '' };
});
};
// 子流程节点:编辑器态 subFlowConfig → DSL 保存态 subConfigruntimeShow → isFormField
const serializeSubFlowConfig = (config: SubFlowConfig | null | undefined, node?: Node<NodeData>, startNodeId = '') => {
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, fieldName }):
// - 勾选表单展示:值来自主工作流开始节点(首页表单),指向开始节点
// - 引用上级输出:值来自主工作流上游节点,统一转 fieldName
// - 其余:无引用(静态默认值)
valueSource: f.runtimeShow
? { nodeId: startNodeId, fieldName: f.field }
: f.valueSource && typeof f.valueSource === 'object'
? { nodeId: f.valueSource.nodeId, fieldName: f.valueSource.field }
: 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;
// 指向开始节点:值为表单展示(首页可填),编辑器不显示为引用(勾选由 isFormField 恢复)
if (vs && typeof vs === 'object' && vs.nodeId && vs.nodeId === startNodeId) return null;
// 引用上级:后端 fieldName 转回前端 field 结构
if (vs && typeof vs === 'object') return { nodeId: vs.nodeId, field: vs.fieldName ?? vs.field };
return null;
})(),
runtimeShow: Boolean(f.isFormField),
})),
};
};
// 从 DSL 构建模型配置:剔除只读字段 + 用 modelFormFields 还原勾选(幂等,兼容旧 DSL)
@@ -894,8 +1196,28 @@ 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);
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',
@@ -904,7 +1226,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
label: n.name || '',
nodeCode: n.nodeCode,
desc: n.desc || '',
formConfig: buildNodeFormConfigFromDsl(n),
formConfig,
modelConfig: buildModelConfigFromDsl(n),
skillName: n.skillName || null,
prompt: n.prompt || '',
@@ -912,6 +1234,8 @@ const loadWorkflowFromDsl = (dsl: any) => {
patchLayout: n.patchLayout || false,
isSaveFile: Boolean(n.isSaveFile),
preTool: n.preTool ?? null,
// 子流程节点:恢复引入的工作流配置(无 subConfig 时返回 null,兼容旧 DSL
...(n.nodeCode === 'sub_flow' ? { subFlowConfig: buildSubFlowConfigFromDsl(n.subConfig, startNodeId) } : {}),
// 开始节点运行表单字段:新格式存 outputConfig;旧 DSL 顶层 runFormFields 兜底兼容
...(isStart
? {
@@ -981,12 +1305,33 @@ const confirmSaveWorkflow = async () => {
}
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('、')},首页表单可能出现冲突`);
}
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;
return {
id: n.id,
nodeCode,
@@ -1000,7 +1345,8 @@ const confirmSaveWorkflow = async () => {
},
...(n.data?.preTool ? { preTool: n.data.preTool } : {}),
isSaveFile: Boolean(n.data?.isSaveFile),
subConfig: null,
// 子流程节点:序列化引入的工作流配置(含 workflowId 与 isFormField 标记);其余节点保持 null
subConfig: nodeCode === 'sub_flow' ? serializeSubFlowConfig(n.data?.subFlowConfig, n, startNode?.id || '') : null,
modelConfig: {
modelId: n.data?.modelConfig?.modelId || '',
// 模型名称/类型随工作流保存,供首页补全弹窗展示模型名与「同类型模型」过滤
@@ -1008,11 +1354,9 @@ const confirmSaveWorkflow = async () => {
...(n.data?.modelConfig?.modelType !== undefined && n.data.modelConfig.modelType !== null && n.data.modelConfig.modelType !== ''
? { modelType: n.data.modelConfig.modelType }
: {}),
modelRequestParams: n.data?.modelConfig?.modelRequestParams ?? null,
// 保存时实时收集勾选的「表单展示」字段(路径带实例索引)
...(n.data?.modelConfig?.modelRequestParams
? { modelFormFields: collectExposedFields(n.data.modelConfig.modelRequestParams) }
: {}),
modelRequestParams: savedModelRequestParams,
// 勾选的「表单展示」字段(路径带实例索引),已在上方基于完整结构收集
...(savedModelFormFields && savedModelFormFields.length > 0 ? { modelFormFields: savedModelFormFields } : {}),
// 模型返回参数随工作流保存,保证重开后仍可被下游引用
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
},
@@ -1021,6 +1365,8 @@ const confirmSaveWorkflow = async () => {
...(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,
};