1. 上游节点输出引用(核心功能)

- 新增 upstreamNodes computed:遍历当前选中节点的前驱链路,仅 model / http / form 三类节点被识别为有输出的节点
  - 新增 getNodeOutputFields,按节点类型提取可引用字段:
    - form → formConfig 的字段
    - http → response schema 的叶子字段
    - model → modelResponseBodyMapping(模型返回参数)的 key
    - 开始节点 → runFormFields(运行表单字段),从而让下游节点能引用"用户填的运行表单"作为输出

  2. 开始节点运行表单字段聚合
  - syncRunFormFields + 深监听:开始节点 formConfig 变化时自动聚合字段,存为 runFormFields,作为开始节点自身可被下游引用的输出

  3. 模型返回参数
  - handleModelConfirm 用 stripReadonlyFields 清理 requestBodyMapping 冗余字段,同时保存 modelResponseBodyMapping 供下游引用
  - 保存时 collectExposedFields 提取对外暴露字段存 modelFormFields

  4. 提示词 / 反向提示词
  - 保存 prompt、negativePrompt、runFormFields 到节点 DSL

  5. 其他
  - nodeTypes 用 markRaw 包裹 FlowNode,修复 Vue 警告
  - buildNodeFormConfigFromDsl 的 responseType 匹配改为兼容 key/value 两种取值
This commit is contained in:
2026-08-12 17:26:58 +08:00
parent 4e1cd284e9
commit 0e6381d2b3
9 changed files with 1661 additions and 50 deletions
@@ -0,0 +1,612 @@
<template>
<div class="model-field">
<!-- object递归渲染 attrs 子字段 -->
<template v-if="isObject">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><FolderOpened /></el-icon>
<span class="mf-block-name">{{ label }}</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="mf-block-body">
<ModelField
v-for="(childDef, childKey) in objectChildren"
:key="childKey"
:field-def="childDef"
:path="path ? `${path}.attrs.${childKey}` : childKey"
:upstream-nodes="upstreamNodes"
/>
</div>
</div>
</template>
<!-- array直接平铺展示元素子字段用户直接填写不提供添加/删除 -->
<template v-else-if="isArray">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><List /></el-icon>
<span class="mf-block-name">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</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">
<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" />
</template>
</template>
</div>
</div>
</template>
<!-- 叶子 fieldType 渲染控件 -->
<template v-else>
<div class="mf-leaf">
<div class="mf-leaf-label">
<span class="mf-label-text">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
<el-tooltip :content="path || label" placement="top">
<el-checkbox
size="small"
:model-value="def.runtimeShow ?? false"
:disabled="hasReference"
@change="toggleExpose"
class="mf-leaf-expose"
>表单展示</el-checkbox>
</el-tooltip>
<el-popover
v-if="canReference"
placement="bottom"
:width="260"
trigger="click"
popper-class="mf-ref-popover"
>
<template #reference>
<el-button
size="small"
text
type="primary"
class="mf-ref-btn"
title="引用上级节点输出"
:disabled="!!def.runtimeShow || !hasUpstream"
>
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="mf-ref-panel">
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
<div class="mf-ref-node-head">
<span class="mf-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
<div
v-for="of in node.outputFields"
:key="of.field"
class="mf-ref-field"
@click="setReference(node, of)"
>
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
</div>
<div v-if="hasReference" class="mf-ref-bound" :class="{ 'is-invalid': referenceInvalid }">
<span class="mf-ref-bound-text">已引用{{ referenceLabel }}</span>
<el-button size="small" text type="danger" @click="clearReference">清除</el-button>
</div>
<div v-else-if="!isUpload" class="mf-leaf-ctrl">
<el-input-number
v-if="isNumber"
:model-value="numberValue"
@change="setValue"
class="mf-ctrl"
:controls="false"
:placeholder="required ? '必填' : ''"
/>
<el-switch v-else-if="isSwitch" :model-value="switchValue" @change="setValue" class="mf-ctrl" />
<el-select
v-else-if="isSelect"
:model-value="def.value ?? ''"
@change="setSelectValue"
class="mf-ctrl"
clearable
:placeholder="required ? '必填' : ''"
>
<el-option v-for="opt in def.options" :key="opt.value" :label="opt.label ?? opt.value" :value="opt.value" />
</el-select>
<el-input
v-else-if="isTextarea"
:model-value="def.value ?? ''"
@input="setValue"
type="textarea"
:rows="2"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
<el-input
v-else
:model-value="def.value ?? ''"
@input="setValue"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
</div>
</div>
</template>
</div>
</template>
<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' });
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
const def = computed(() => props.fieldDef);
// ===== 基础元信息 =====
const label = computed(() => def.value?.label || def.value?.field || '');
const description = computed(() => def.value?.description || '');
const required = computed(() => !!def.value?.required);
const defType = computed(() => def.value?.type || 'string');
const fieldType = computed(() => def.value?.fieldType || 'string');
// ===== 类型判定 =====
const isObject = computed(() => defType.value === 'object');
const isArray = computed(() => defType.value === 'array');
const isLeaf = computed(() => !isObject.value && !isArray.value);
const objectChildren = computed<Record<string, any>>(() => {
if (!isObject.value) return {};
const attrs = def.value?.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) return attrs;
return {};
});
const isNumber = computed(() => isLeaf.value && (fieldType.value === 'number' || defType.value === 'number'));
const isSwitch = computed(() => isLeaf.value && (fieldType.value === 'switch' || defType.value === 'boolean'));
const isSelect = computed(() => isLeaf.value && fieldType.value === 'select' && Array.isArray(def.value?.options));
const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea');
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
// ===== 引用上级节点输出 =====
// 可引用:叶子 + 非开关 + 非固定选项下拉 + 非上传(开关/下拉值来自模型;上传类型不提供引用)
const canReference = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value && !isUpload.value);
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasReference = computed(() => !!def.value?.reference);
// 已引用时的展示文案:{节点label}.{字段label}
const referenceLabel = computed(() => {
const ref = def.value?.reference;
if (!ref) return '';
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
const nodeName = node?.label || ref.nodeId;
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
return `${nodeName}.${fieldLabel}`;
});
// 引用是否已失效:上游节点被删除,或其输出字段已不存在(换模型/换配置导致 schema 变化)
const referenceInvalid = computed(() => {
const ref = def.value?.reference;
if (!ref) return false;
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
if (!node) return true;
return !node.outputFields.some((f) => f.field === ref.field);
});
// 失效引用自动清理,避免保存脏数据(就地删除 reference,复用深 watch 上传链路)
watch(
referenceInvalid,
(invalid) => {
if (invalid && def.value) {
delete def.value.reference;
}
},
{ immediate: true }
);
// 选中前驱节点输出字段:就地写 def.reference,复用深 watch 上传链路
const setReference = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
if (!def.value) return;
def.value.reference = { nodeId: node.id, field: of.field };
};
const clearReference = () => {
if (!def.value) return;
delete def.value.reference;
};
// ===== 叶子值(兼容字符串/布尔/数字) =====
const numberValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
const n = Number(v);
return Number.isNaN(n) ? 0 : n;
});
const switchValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
return v === true || v === 'true';
});
const setValue = (val: any) => {
if (!def.value) return;
if (isSwitch.value) {
def.value.value = val === true || val === 'true';
} else if (isNumber.value) {
def.value.value = val === null || val === undefined || val === '' ? 0 : Number(val);
} else {
def.value.value = val ?? '';
}
};
const setSelectValue = (val: any) => {
if (!def.value) return;
def.value.value = val ?? '';
};
// 勾选「表单展示」:就地写 def.runtimeShow,复用深 watch 上传链路
const toggleExpose = (checked: any) => {
if (!def.value) return;
def.value.runtimeShow = !!checked;
};
// ===== array:平铺展示元素子字段,供用户直接填写;不提供添加/删除 =====
// 元素模板:优先 enumValues 首个,其次 attrs 数组,其次单个 attrs 对象
const arrayTemplates = computed<any[]>(() => {
if (!isArray.value || !def.value) return [];
if (Array.isArray(def.value.enumValues) && def.value.enumValues.length) return def.value.enumValues;
if (Array.isArray(def.value.attrs) && def.value.attrs.length) return def.value.attrs;
if (def.value.attrs && typeof def.value.attrs === 'object' && !Array.isArray(def.value.attrs)) {
return [{ type: 'object', attrs: def.value.attrs }];
}
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 ?? '';
};
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
const isVariantArray = computed(
() => isArray.value && Array.isArray(def.value?.enumValues) && (def.value?.enumValues?.length ?? 0) > 1
);
// 变体名推断:优先 type 字段值(如 text/image_url/video_url),其次 role 字段描述里的角色名,兜底"变体 N"
const templateLabel = (tpl: any, index: number): string => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
const typeField = tpl.attrs.type;
if (typeField) {
const t = typeField.value ?? typeField.defaultValue ?? '';
if (t) return String(t);
}
const roleField = tpl.attrs.role;
if (roleField && roleField.description) {
const m = String(roleField.description).match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) return m[1];
}
}
return `变体 ${index + 1}`;
};
// 自动填标识值:type 用 defaultValuerole 从 description 提取;只填空值,不覆盖用户已填
const autoFillVariant = (tpl: any): void => {
if (!tpl || typeof tpl !== 'object' || !tpl.attrs || typeof tpl.attrs !== 'object' || Array.isArray(tpl.attrs)) return;
const typeField = tpl.attrs.type;
if (typeField && (typeField.value === undefined || typeField.value === null || typeField.value === '')) {
if (typeField.defaultValue !== undefined && typeField.defaultValue !== null) typeField.value = typeField.defaultValue;
}
const roleField = tpl.attrs.role;
if (roleField && (roleField.value === undefined || roleField.value === null || roleField.value === '')) {
const m = String(roleField.description || '').match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) roleField.value = m[1];
}
};
// 联合数组渲染条目: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>
<style scoped lang="scss">
.model-field {
width: 100%;
// 嵌套层级不重复套卡片:内层块去边框背景,用左缩进区分层级
.model-field {
.mf-block,
.mf-leaf {
border: none;
background: transparent;
padding: 0 0 0 12px;
margin-bottom: 6px;
}
}
}
.mf-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fbfcfe;
.mf-block-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
.mf-block-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-block-body {
padding-left: 4px;
}
}
.mf-info {
cursor: help;
color: #94a3b8;
font-size: 13px;
flex-shrink: 0;
}
.mf-required {
color: #f56c6c;
margin-left: 2px;
}
.mf-array-flat {
.mf-array-flat-index {
display: inline-block;
font-size: 11px;
color: #94a3b8;
background: #eef2f7;
border-radius: 999px;
padding: 0 6px;
margin: 2px 0 6px;
}
:deep(.model-field) {
margin-bottom: 0;
}
}
.mf-variant {
margin-bottom: 8px;
padding-left: 10px;
border-left: 2px solid #e2e8f0;
.mf-variant-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
.mf-variant-name {
font-size: 12px;
font-weight: 600;
color: #475569;
}
}
}
.mf-leaf {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 10px;
background: #ffffff;
.mf-leaf-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #475569;
.mf-label-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-leaf-expose {
flex-shrink: 0;
margin-left: 8px;
:deep(.el-checkbox__label) {
font-size: 12px;
color: #475569;
font-weight: 400;
}
}
.mf-ref-btn {
flex-shrink: 0;
margin-left: 2px;
}
}
.mf-ref-bound {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 4px 8px;
.mf-ref-bound-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-invalid {
color: #dc2626;
background: #fef2f2;
}
}
.mf-leaf-ctrl {
.mf-ctrl {
width: 100%;
}
}
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
.mf-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.mf-ref-node {
& + .mf-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.mf-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.mf-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.mf-ref-field {
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
}
}
}
}
</style>