Files
admin-ui/src/views/settings/workflow/component/ModelField.vue
T
2910410219 41655fa825 sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
2026-08-15 18:49:56 +08:00

581 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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/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>
<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>
</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="hasValueSource"
@change="toggleExpose"
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>
</div>
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<span class="mf-ref-bound-text">已引用{{ valueSourceLabel }}</span>
<el-button size="small" text type="danger" @click="clearValueSource">清除</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';
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 canValueSource = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value);
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasValueSource = computed(() => !!def.value?.valueSource);
// 已引用时的展示文案:{节点label}.{字段label}
const valueSourceLabel = computed(() => {
const ref = def.value?.valueSource;
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 valueSourceInvalid = computed(() => {
const ref = def.value?.valueSource;
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);
});
// 失效引用自动清理,避免保存脏数据(就地删除 valueSource,复用深 watch 上传链路)
watch(
valueSourceInvalid,
(invalid) => {
if (invalid && def.value) {
delete def.value.valueSource;
}
},
{ 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;
};
// ===== 叶子值(兼容字符串/布尔/数字) =====
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 isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
// 数组渲染统一以模板(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 多模板)→ 全部模板平铺,固定条数,不可增删 =====
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];
}
};
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>