首页工作流引用功能收口:Schema 移除对象/数组引用,模型参数引用与表单展示同级

- FieldConfigDialog(json-schema-editor):删除对象/数组字段的「值来源」引用入口,
  标量字段的默认值引用保留
- ModelField:leaf 分支引用按钮对所有叶子参数生效(含数组元素内字段),
  与「表单展示」勾选同级;对象/数组字段本身不提供引用
- modelParamUtils:buildModelRequestParamsPath 恢复所有叶子保留 valueSource,
  保存链路与编辑器语义一致

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:21:06 +08:00
co-authored by Claude
parent f7b3531392
commit b6208b37e4
5 changed files with 237 additions and 72 deletions
@@ -65,7 +65,9 @@
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
{{ of.label || of.field }}
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
@@ -206,7 +208,9 @@
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
{{ of.label || of.field }}
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
@@ -305,7 +309,7 @@ interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
interface FieldFormData {
@@ -584,6 +588,9 @@ function handleClosed() { resetForm(); }
padding: 2px 0 2px 8px;
}
.fcd-ref-field {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #475569;
padding: 4px 8px;
@@ -593,6 +600,24 @@ function handleClosed() { resetForm(); }
background: #eff6ff;
color: #3b82f6;
}
.fcd-ref-type {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: #64748b;
background: #f1f5f9;
border-radius: 3px;
padding: 2px 4px;
}
.fcd-ref-field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
@@ -0,0 +1,170 @@
<template>
<!-- 引用上级节点输出按钮 + 候选面板叶子字段 + 对象/数组整体 -->
<el-popover
v-if="canValueSource"
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="!!fieldDef?.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}-${of.fieldType || 'scalar'}`"
class="mf-ref-field"
@click="setValueSource(node, of)"
>
<span v-if="of.fieldType === 'object'" class="mf-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="mf-ref-type" title="引用数组整体">[] 数组</span>
<span class="mf-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Link } from '@element-plus/icons-vue';
defineOptions({ name: 'MfReferenceButton' });
// 前驱节点的可引用输出字段(fieldTypescalar 叶子 / object、array 整体)
interface UpstreamField {
field: string;
label: string;
fieldType?: 'scalar' | 'object' | 'array';
}
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: UpstreamField[];
}
const props = defineProps<{ fieldDef: any; upstreamNodes?: UpstreamNodeInfo[] }>();
// 可引用:非开关、非固定选项下拉(开关/下拉值来自模型);叶子与对象/数组整体均可引用
const canValueSource = computed(() => {
const ft = props.fieldDef?.fieldType || 'string';
const dt = props.fieldDef?.type || 'string';
return ft !== 'switch' && ft !== 'select' && dt !== 'switch';
});
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
// 选中前驱节点输出字段:就地写 fieldDef.valueSource,复用父组件深 watch 上传链路
const setValueSource = (node: UpstreamNodeInfo, of: UpstreamField) => {
if (!props.fieldDef) return;
props.fieldDef.valueSource = { nodeId: node.id, field: of.field };
};
</script>
<style scoped lang="scss">
.mf-ref-btn {
flex-shrink: 0;
margin-left: 2px;
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
max-height: 40vh;
overflow-y: auto;
.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 {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
.mf-ref-type {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: #64748b;
background: #f1f5f9;
border-radius: 3px;
padding: 2px 4px;
}
.mf-ref-field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
</style>
@@ -71,52 +71,14 @@
class="mf-leaf-expose"
>表单展示</el-checkbox>
</el-tooltip>
<el-popover
v-if="canValueSource"
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="setValueSource(node, of)"
>
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
<!-- 引用上级输出与表单展示勾选同级所有叶子参数含数组元素内字段都可引用 -->
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" />
</div>
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<span class="mf-ref-bound-text" :title="valueSourceLabel">已引用{{ valueSourceLabel }}</span>
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
</div>
<div v-else-if="!isUpload" class="mf-leaf-ctrl">
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
<el-input-number
v-if="isNumber"
:model-value="numberValue"
@@ -160,7 +122,8 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
import MfReferenceButton from './MfReferenceButton.vue';
defineOptions({ name: 'ModelField' });
@@ -169,7 +132,7 @@ interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
@@ -203,9 +166,7 @@ const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea'
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
// ===== 引用上级节点输出 =====
// 引用:叶子 + 非开关 + 非固定选项下拉(开关/下拉值来自模型;上传类型同样支持引用上级输出
const canValueSource = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value);
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
// 引用按钮与候选面板在 MfReferenceButton 中统一渲染(叶子与对象/数组整体均可引用
const hasValueSource = computed(() => !!def.value?.valueSource);
// 已引用时的展示文案:{节点label}.{字段label}
const valueSourceLabel = computed(() => {
@@ -237,12 +198,6 @@ watch(
{ immediate: true }
);
// 选中前驱节点输出字段:就地写 def.valueSource,复用深 watch 上传链路
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
if (!def.value) return;
def.value.valueSource = { nodeId: node.id, field: of.field };
};
const clearValueSource = () => {
if (!def.value) return;
delete def.value.valueSource;
@@ -288,6 +288,8 @@ function collectAllLeafFields(params: any, prefix = ''): { path: string; def: an
}
// 生成 modelRequestParamsPath 扁平清单
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
// 与编辑器「引用上级输出与表单展示同级」语义一致
export function buildModelRequestParamsPath(params: any, startNodeId = ''): ModelRequestParamsPathItem[] {
const fields = collectAllLeafFields(params);
return fields.map(({ path, def }) => {
+30 -17
View File
@@ -149,6 +149,8 @@ interface RunFormField extends Omit<ExposedField, 'path'> {
interface UpstreamField {
field: string;
label: string;
// 输出字段类型:http 节点容器会额外产出 'object'/'array' 整体候选,标量或未标记视为叶子
fieldType?: 'scalar' | 'object' | 'array';
}
interface UpstreamNodeInfo {
id: string;
@@ -729,50 +731,61 @@ const parseSchema = (value: any): any => {
return null;
};
// 递归收集 http 节点 response schema 的叶子输出字段(识别 JsonEditor 的 { type, attrs/value, label } 包裹结构):
// 递归收集 http 节点 response schema 的输出字段(识别 JsonEditor 的 { type, attrs/value, label } 包裹结构):
// 产出叶子字段 + 对象/数组容器整体候选(下游可引用整个结构);
// field 用实际返回数据路径(不含 attrs 元数据,如 data.url 而非 data.attrs.url.value),
// label 优先用字段配置的中文名(label),无则兜底路径 key
const collectHttpLeafFields = (node: any, prefix = ''): UpstreamField[] => {
const collectHttpOutputFields = (node: any, prefix = '', isRoot = true, suppressContainer = false): UpstreamField[] => {
const out: UpstreamField[] = [];
// JsonEditor 包裹结构:{ type: 'object'|'array'|'string'|..., attrs/value, label }
if (node && typeof node === 'object' && !Array.isArray(node) && typeof node.type === 'string') {
const jtype = node.type;
if (['object', 'array', 'string', 'number', 'boolean', 'null'].includes(jtype)) {
// 对象/数组容器:非根且非数组样本时,产出「整体」候选(引用整体 = 拿整个结构);标量不产整体
if (!isRoot && !suppressContainer && prefix && (jtype === 'object' || jtype === 'array')) {
out.push({ field: prefix, label: (typeof node.label === 'string' && node.label) || prefix, fieldType: jtype });
}
if (jtype === 'object') {
// 对象容器:子字段在 attrs 里,field 路径直接拼子字段名,不拼 attrs
const attrs = node.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
const out: UpstreamField[] = [];
for (const k of Object.keys(attrs)) {
out.push(...collectHttpLeafFields(attrs[k], prefix ? `${prefix}.${k}` : k));
out.push(...collectHttpOutputFields(attrs[k], prefix ? `${prefix}.${k}` : k, false, false));
}
return out;
}
return [];
return out;
}
if (jtype === 'array') {
// 数组:取首元素样本作为结构(实际值为数组整体,field 不带索引)
// 数组:取首元素样本作为结构(实际值为数组整体,field 不带索引)
// 首元素为标量时不产叶子(数组整体已代表该元素),元素为对象时不产对象整体避免与数组整体 field 重复
const arr = node.attrs;
if (Array.isArray(arr) && arr.length > 0) return collectHttpLeafFields(arr[0], prefix);
return [];
if (Array.isArray(arr) && arr.length > 0) {
const el = arr[0];
if (el && typeof el === 'object' && !Array.isArray(el) && typeof el.type === 'string' && ['string', 'number', 'boolean', 'null'].includes(el.type)) {
return out;
}
return out.concat(collectHttpOutputFields(el, prefix, false, true));
}
return out;
}
// 标量叶子:label 为字段配置的中文名,无则兜底路径
return [{ field: prefix, label: (typeof node.label === 'string' && node.label) || prefix }];
out.push({ field: prefix, label: (typeof node.label === 'string' && node.label) || prefix, fieldType: 'scalar' });
return out;
}
}
// 普通对象(根/未包裹):递归子字段;子字段为原始标量时以路径兜底产出
if (node && typeof node === 'object' && !Array.isArray(node)) {
const out: UpstreamField[] = [];
for (const k of Object.keys(node)) {
const child = node[k];
const path = prefix ? `${prefix}.${k}` : k;
if (child && typeof child === 'object') out.push(...collectHttpLeafFields(child, path));
else out.push({ field: path, label: path });
if (child && typeof child === 'object') out.push(...collectHttpOutputFields(child, path, false, false));
else out.push({ field: path, label: path, fieldType: 'scalar' });
}
return out;
}
// 数组根:取首元素结构样本
if (Array.isArray(node)) return node.length > 0 ? collectHttpLeafFields(node[0], prefix) : [];
return [];
if (Array.isArray(node)) return node.length > 0 ? collectHttpOutputFields(node[0], prefix, isRoot, suppressContainer) : [];
return out;
};
// 取节点在设计时的可引用输出字段(运行时这些字段会产出实际值)
@@ -786,7 +799,7 @@ const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[]
}));
}
if (nodeCode === 'http') {
// http 节点:输出 = 结果返回结构(response schema)的所有叶子字段。
// http 节点:输出 = 结果返回结构(response schema)的叶子字段 + 对象/数组容器整体
// 结果返回方式为主动拉取(responseType === 'pull')时,取主动拉取分支下配置的结果返回结构。
const formConfig = node.data?.formConfig || [];
const responseTypeEntry = formConfig.find((f: any) => f.field === 'responseType');
@@ -795,7 +808,7 @@ const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[]
? (responseTypeEntry?.expand || []).find((e: any) => e.field === 'response')
: formConfig.find((f: any) => f.field === 'response');
const schema = parseSchema(responseField?.value);
if (schema) return collectHttpLeafFields(schema);
if (schema) return collectHttpOutputFields(schema);
return [];
}
if (nodeCode === 'model') {