- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
581 lines
17 KiB
Vue
581 lines
17 KiB
Vue
<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 用 defaultValue,role 从 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>
|