sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
@@ -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 就地修改 def(value/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.subConfig,runtimeShow → 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[];
|
||||
}
|
||||
@@ -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 字段 = path;form 节点自定义字段 = 字段名),供首页按 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),
|
||||
// 编辑器渲染归一到 upload(ModelField 控件识别),保留 multiple 供首页识别多文件上传
|
||||
type: isUploadMultiple ? 'upload' : ft,
|
||||
fieldType: isUploadMultiple ? 'upload' : ft,
|
||||
required: Boolean(o.required),
|
||||
value: o.value !== undefined ? o.value : o.defaultValue ?? '',
|
||||
defaultValue: o.defaultValue,
|
||||
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
|
||||
options: Array.isArray(o.options) ? o.options : undefined,
|
||||
multiple: isUploadMultiple || o.multiple || undefined,
|
||||
// 导入时清空目标工作流自带的引用(其 nodeId 指向目标内部节点,在主工作流无意义)
|
||||
valueSource: null,
|
||||
runtimeShow: false,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// 详情拉取失败:仅记空字段(错误已由全局拦截器提示)
|
||||
}
|
||||
|
||||
// 拉详情期间若已切换选中节点,则中止写入,避免配置落到错误节点上
|
||||
if (selectedNode.value !== targetNode) {
|
||||
ElMessage.warning('已切换节点,请重新选择子流程节点后操作');
|
||||
return;
|
||||
}
|
||||
|
||||
const subFlowConfig: SubFlowConfig = {
|
||||
workflowId: workflow.id || '',
|
||||
workflowName: workflow.flowName || workflow.name || '',
|
||||
fields,
|
||||
};
|
||||
|
||||
const updatedNode: Node<NodeData> = {
|
||||
...targetNode,
|
||||
data: {
|
||||
...targetNode.data,
|
||||
subFlowConfig,
|
||||
},
|
||||
};
|
||||
|
||||
selectedNode.value = updatedNode;
|
||||
selectedWorkflowData.value = workflow;
|
||||
// 同步更新到 VueFlow 内部状态
|
||||
updateNode(updatedNode.id, updatedNode);
|
||||
|
||||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||||
if (index >= 0) {
|
||||
nodes.value[index] = updatedNode;
|
||||
}
|
||||
|
||||
ElMessage.success(`已引入工作流:${workflow.flowName || workflow.name || workflow.id}`);
|
||||
};
|
||||
|
||||
// 移除子流程引入的工作流
|
||||
const handleRemoveWorkflow = () => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
|
||||
const updatedNode: Node<NodeData> = {
|
||||
...selectedNode.value,
|
||||
data: {
|
||||
...selectedNode.value.data,
|
||||
subFlowConfig: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
selectedNode.value = updatedNode;
|
||||
selectedWorkflowData.value = null;
|
||||
// 同步更新到 VueFlow 内部状态
|
||||
updateNode(updatedNode.id, updatedNode);
|
||||
|
||||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||||
if (index >= 0) {
|
||||
nodes.value[index] = updatedNode;
|
||||
}
|
||||
|
||||
ElMessage.success('已移除子流程工作流');
|
||||
};
|
||||
|
||||
// 切换贴片布局
|
||||
const handleTogglePatchLayout = (value: boolean) => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
@@ -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 保存态 subConfig(runtimeShow → 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 → 编辑器态 subFlowConfig(isFormField → runtimeShow)
|
||||
const buildSubFlowConfigFromDsl = (subConfig: any, startNodeId = '') => {
|
||||
if (!subConfig || !subConfig.workflowId) return null;
|
||||
return {
|
||||
workflowId: subConfig.workflowId,
|
||||
workflowName: subConfig.workflowName || '',
|
||||
maxConcurrency: subConfig.maxConcurrency ?? 0,
|
||||
fields: (Array.isArray(subConfig.fields) ? subConfig.fields : []).map((f: any) => ({
|
||||
field: f.field,
|
||||
label: f.label || f.field,
|
||||
type: f.type || 'input',
|
||||
fieldType: f.fieldType || f.type || 'input',
|
||||
required: Boolean(f.required),
|
||||
value: f.value !== undefined ? f.value : f.defaultValue ?? '',
|
||||
defaultValue: f.defaultValue,
|
||||
fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
|
||||
options: Array.isArray(f.options) ? f.options : undefined,
|
||||
multiple: Boolean(f.multiple) || f.fieldType === 'uploadMultiple',
|
||||
valueSource: (() => {
|
||||
const vs = f.valueSource;
|
||||
// 指向开始节点:值为表单展示(首页可填),编辑器不显示为引用(勾选由 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user