style: 调整分支节点样式

This commit is contained in:
zhanghao 2026-08-07 15:27:59 +08:00
parent 6c9ead2603
commit 92500c1c21
6 changed files with 634 additions and 672 deletions

View File

@ -8,7 +8,7 @@
custom-class="flow-params-drawer" custom-class="flow-params-drawer"
:title="props.data.properties?.name || '参数配置'" :title="props.data.properties?.name || '参数配置'"
class="flow-params-drawer" class="flow-params-drawer"
size="min(560px, 92vw)" :size="drawerSize"
append-to-body append-to-body
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@ -36,6 +36,7 @@ import HttpNodeParams from './params/HttpNodeParams.vue'
import SdAgentNodeParams from './params/SdAgentNodeParams.vue' import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
import RecognizeNodeParams from './params/RecognizeNodeParams.vue' import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
import DeviceUniversal from './params/device_universal.vue' import DeviceUniversal from './params/device_universal.vue'
import SwitchNodeParams from './params/SwitchNodeParams.vue'
const props = defineProps({ const props = defineProps({
drawer: Boolean, drawer: Boolean,
@ -45,6 +46,13 @@ const props = defineProps({
const emits = defineEmits(['close']) const emits = defineEmits(['close'])
const paramsComponentRef = ref(null) const paramsComponentRef = ref(null)
/**
* 分支条件包含左值运算符和右值三列仅为分支节点提供更宽的编辑空间
*/
const drawerSize = computed(() => (
props.data?.type === 'branch' ? 'min(920px, 96vw)' : 'min(560px, 92vw)'
))
// //
const currentComponent = computed(() => { const currentComponent = computed(() => {
const type = props.data?.type const type = props.data?.type
@ -55,6 +63,7 @@ const currentComponent = computed(() => {
if (type === 'sdAgent') return SdAgentNodeParams if (type === 'sdAgent') return SdAgentNodeParams
if (type === 'recognize') return RecognizeNodeParams if (type === 'recognize') return RecognizeNodeParams
if (type === 'device_universal') return DeviceUniversal if (type === 'device_universal') return DeviceUniversal
if (type === 'branch') return SwitchNodeParams
return null return null
}) })

View File

@ -0,0 +1,413 @@
<!--
* 文件说明分支节点专用参数组件独立负责条件组的新增删除引用选择校验和保存
* 作用范围仅用于 branch 类型节点不与通用节点参数表单共享业务逻辑
-->
<template>
<div class="switch-params">
<div class="switch-params__header">
<div>
<div class="switch-params__title">分支条件</div>
<div class="switch-params__description">条件按从上到下的顺序匹配每个条件对应节点上的一个输出锚点</div>
</div>
<el-button type="primary" :icon="Plus" @click="addCondition">添加条件</el-button>
</div>
<el-form ref="formRef" :model="formData" label-position="top">
<div v-for="(condition, conditionIndex) in formData.nodeParams" :key="condition.id" class="condition-card">
<div class="condition-card__header">
<div class="condition-card__identity">
<span>{{ conditionIndex === 0 ? "If" : "Else If" }}</span>
<el-input
v-model="condition.label"
maxlength="20"
show-word-limit
placeholder="请输入条件名称"
class="condition-name"
/>
</div>
<el-button
circle
plain
type="danger"
:icon="Minus"
:disabled="formData.nodeParams.length === 1"
@click="removeCondition(conditionIndex)"
/>
</div>
<el-form-item label="条件关系">
<el-radio-group v-model="condition.withOr">
<el-radio-button value="and">AND</el-radio-button>
<el-radio-button value="or">OR</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-for="(item, itemIndex) in condition.list" :key="itemIndex" class="condition-row">
<div class="condition-row__fields">
<el-form-item
label="引用变量"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.name${item.nameType === 'quote' ? 'Quote' : ''}`"
:rules="requiredRule(item.nameType === 'quote' ? '请选择引用变量' : '请输入变量值')"
>
<el-input v-if="item.nameType === 'input'" v-model="item.name" placeholder="请输入变量值">
<template #prepend>
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<div v-else class="reference-field">
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
<el-cascader
v-model="item.nameQuote"
:options="quoteOptions"
:show-all-levels="false"
placeholder="请选择"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'nameQuote')"
/>
</div>
</el-form-item>
<el-form-item
label="判断方式"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.condition`"
:rules="requiredRule('请选择判断方式')"
>
<el-select v-model="item.condition">
<el-option v-for="option in conditionOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item
label="比较值"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.${item.type === 'quote' ? 'quote' : 'input'}`"
:rules="requiresComparison(item.condition) ? requiredRule(item.type === 'quote' ? '请选择比较变量' : '请输入比较值') : []"
>
<el-input
v-if="item.type === 'input'"
v-model="item.input"
:disabled="!requiresComparison(item.condition)"
placeholder="请输入比较值"
>
<template #prepend>
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<el-input v-else model-value="" readonly :disabled="!requiresComparison(item.condition)" placeholder="请选择比较变量">
<template #prepend>
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
<template #append>
<el-cascader
v-model="item.quote"
:disabled="!requiresComparison(item.condition)"
:options="quoteOptions"
:show-all-levels="false"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'quote')"
/>
</template>
</el-input>
</el-form-item>
</div>
<div class="condition-row__actions">
<el-button circle :icon="Plus" @click="addConditionItem(conditionIndex)" />
<el-button
circle
:icon="Minus"
:disabled="condition.list.length === 1"
@click="removeConditionItem(conditionIndex, itemIndex)"
/>
</div>
</div>
</div>
</el-form>
<div class="else-tip"><strong>Else</strong><span>所有条件均不匹配时 Else 锚点继续执行</span></div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
import { Plus, Minus } from "@element-plus/icons-vue";
import { v4 as randomUUID } from "uuid";
import { getInput, removeSwitchEdge } from "@/utils/flow";
const props = defineProps({ data: Object });
const emit = defineEmits(["save-success", "save-error"]);
const formRef = ref(null);
const quoteOptions = ref([]);
const originalConditionIds = ref([]);
const formData = reactive({ nodeParams: [] });
const conditionOptions = [
{ label: "等于", value: "equal" },
{ label: "不等于", value: "notEqualTo" },
{ label: "为空", value: "null" },
{ label: "不为空", value: "notNull" },
{ label: "包含", value: "include" },
{ label: "不包含", value: "notInclude" },
{ label: "大于", value: "greaterThan" },
{ label: "大于等于", value: "greaterThanOrEqual" },
{ label: "小于", value: "lessThan" },
{ label: "小于等于", value: "lessThanOrEqual" },
];
/** 创建一条与原有数据结构兼容的判断项。 */
const createConditionItem = () => ({
name: "",
nameQuote: "",
nameType: "input",
condition: "",
type: "input",
input: "",
quote: "",
});
/** 创建具有稳定锚点 ID 的条件组。 */
const createCondition = (label = "") => ({
id: randomUUID().replace(/-/g, ""),
label,
withOr: "and",
list: [createConditionItem()],
});
/**
* 将旧版本可能保存的点号字符串统一转换为级联组件需要的路径数组
* @param {string|string[]} value 已保存的引用值
* @returns {string[]} 级联选择路径
*/
const normalizeQuotePath = (value) => {
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return value.split(".").filter(Boolean);
return [];
};
/** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */
const initData = () => {
// ID
quoteOptions.value = getInput(props.data.id) || [];
const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || [];
formData.nodeParams = JSON.parse(JSON.stringify(source));
if (!formData.nodeParams.length) formData.nodeParams.push(createCondition());
formData.nodeParams.forEach((condition, index) => {
if (!condition.label) condition.label = `条件 ${index + 1}`;
condition.list?.forEach((item) => {
if (item.nameType === "quote") item.nameQuote = normalizeQuotePath(item.nameQuote);
if (item.type === "quote") item.quote = normalizeQuotePath(item.quote);
});
});
originalConditionIds.value = formData.nodeParams.map((condition) => condition.id);
};
/** 返回 Element Plus 表单必填规则。 */
const requiredRule = (message) => [{ required: true, message, trigger: ["blur", "change"] }];
/** 判断当前运算符是否需要比较值。 */
const requiresComparison = (condition) => !["null", "notNull"].includes(condition);
/** 新增一个分支条件,保存后将生成同 ID 的节点锚点。 */
const addCondition = () => {
formData.nodeParams.push(createCondition(`条件 ${formData.nodeParams.length + 1}`));
};
/** 删除条件;实际连线清理延迟到保存成功时执行。 */
const removeCondition = (index) => {
if (formData.nodeParams.length > 1) formData.nodeParams.splice(index, 1);
};
/** 向指定条件组追加判断项。 */
const addConditionItem = (conditionIndex) => {
formData.nodeParams[conditionIndex].list.push(createConditionItem());
};
/** 删除指定判断项,同时保证每个条件至少保留一项。 */
const removeConditionItem = (conditionIndex, itemIndex) => {
const list = formData.nodeParams[conditionIndex].list;
if (list.length > 1) list.splice(itemIndex, 1);
};
/** 切换左值类型时清空另一种类型遗留的数据。 */
const resetNameValue = (item) => {
if (item.nameType === "input") item.nameQuote = "";
else item.name = "";
};
/** 切换比较值类型时清空另一种类型遗留的数据。 */
const resetCompareValue = (item) => {
if (item.type === "input") item.quote = "";
else item.input = "";
};
/**
* 打开引用选择器时获取当前节点可引用的上游输出并保留当前选值
* @param {boolean} visible 选择器是否打开
* @param {object} item 当前判断项
* @param {"nameQuote"|"quote"} field 当前引用字段
*/
const refreshQuoteOptions = (visible, item, field) => {
if (!visible) return;
quoteOptions.value = getInput(props.data.id) || [];
item[field] = item[field] || "";
};
/**
* 校验并保存条件先清理已删除条件的连线再写入条件和锚点集合
* 新条件锚点的位置由 switch.vue 渲染摘要后自动测量并补齐
*/
const validateAndSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
if (!valid) return;
try {
const conditions = JSON.parse(JSON.stringify(formData.nodeParams));
const activeIds = new Set(conditions.map((condition) => condition.id));
const removedIds = originalConditionIds.value.filter((id) => !activeIds.has(id));
removedIds.forEach((id) => removeSwitchEdge(props.data.id, id));
const properties = window.lf.getProperties(props.data.id);
const anchor = (properties.anchor || []).filter((item) => (
item.id === `${props.data.id}_else` || activeIds.has(item.id)
));
window.lf.setProperties(props.data.id, {
...properties,
nodeParams: conditions,
anchor,
});
emit("save-success");
} catch {
emit("save-error");
}
};
watch(() => props.data, initData, { immediate: true, deep: true });
defineExpose({ validateAndSave });
</script>
<style lang="scss" scoped>
.switch-params {
padding-top: 16px;
}
.switch-params__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.switch-params__title {
color: #1f2937;
font-size: 16px;
font-weight: 650;
}
.switch-params__description {
margin-top: 4px;
color: #7a8494;
font-size: 12px;
line-height: 1.6;
}
.condition-card {
margin-bottom: 14px;
padding: 14px;
border: 1px solid #e4e8ef;
border-radius: 8px;
background: #f8fafc;
}
.condition-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #3978c5;
font-size: 13px;
font-weight: 650;
}
.condition-card__identity {
display: flex;
align-items: center;
gap: 10px;
}
.condition-name {
width: 240px;
}
.condition-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed #dfe4ec;
}
.condition-row + .condition-row {
margin-top: 10px;
}
.condition-row__fields {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(120px, 0.8fr) minmax(0, 1.4fr);
flex: 1;
gap: 10px;
}
.condition-row__actions {
display: flex;
padding-bottom: 18px;
}
.value-type {
width: 74px;
}
.reference-field {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
width: 100%;
gap: 8px;
}
.else-tip {
display: flex;
gap: 12px;
padding: 12px 14px;
border: 1px solid #f0d9a7;
border-radius: 8px;
background: #fffaf0;
color: #7a8494;
font-size: 12px;
strong {
color: #b7791f;
}
}
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-input-group__append) {
width: 120px;
padding: 0;
}
:deep(.el-input-group__append .el-cascader) {
width: 100%;
}
</style>

View File

@ -354,7 +354,7 @@ const drop = (e) => {
} }
if ( if (
!["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(newNode.type) !["selectArea", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
) { ) {
showParamsDrawer.value = true; showParamsDrawer.value = true;
paramsDrawerData.value = newNode; paramsDrawerData.value = newNode;
@ -383,7 +383,7 @@ const handleConnect = (connection) => {
*/ */
const handleNodeDoubleClick = ({ node }) => { const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id); const data = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (!data || ["selectArea", "branch", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return; if (!data || ["selectArea", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return;
showParamsDrawer.value = true; showParamsDrawer.value = true;
paramsDrawerData.value = data; paramsDrawerData.value = data;
}; };
@ -649,8 +649,8 @@ const initFlowData = {
properties: { properties: {
name: "Start", name: "Start",
action: "start", action: "start",
nodeParams: [ inputParams: [
{ name: "robotId", type: "input", input: "", disabled: true } { name: "robotId", type: "string", input: "", disabled: true }
], ],
}, },
}, },

View File

@ -1,281 +1,63 @@
<!-- <!--
* 文件说明条件分支节点配置条件组和动态分支锚点 * 文件说明条件分支节点展示已保存的条件摘要并同步每个条件对应的画布锚点
* 作用范围仅服务于流程设计器模块 * 作用范围仅服务于流程设计器模块条件编辑由 SwitchNodeParams 组件独立负责
--> -->
<template> <template>
<div class="node__container" ref="switchRef" @mouseleave="setNodeProperties"> <div class="node__container" ref="switchRef">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input> <template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" /> <JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template> </template>
<template #output> <template #output>
<div v-if="nodeOperatingStatus != 'FAILED'"> <div v-if="nodeOperatingStatus !== 'FAILED'">
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" /> <JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
</div> </div>
<div v-else>{{ errorInfoData }}</div> <div v-else>{{ errorInfoData }}</div>
</template> </template>
</NodeState> </NodeState>
<NodeTitle <NodeTitle
:icon="switchSvg" :icon="switchSvg"
:nodeId="props.model.id" :nodeId="props.model.id"
:nodeProperties="props.properties" :nodeProperties="props.properties"
:nodeName="props.properties?.name || '分支'" :nodeName="props.properties?.name || '分支'"
nodeDesc="连接多个下游分支,根据设定的条件按照顺序查找的方式来匹配运行的分支,如果匹配到某条件则只运行该条件对应的分支,否则继续匹配下一条件直至结束" nodeDesc="双击节点配置分支条件;流程将按顺序匹配条件,并只运行首个匹配条件对应的分支"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
<div class="condition_title_container">
<div class="left"> <div class="condition-summary">
<div class="space"></div> <div class="condition-summary__title">
<div class="title">所有条件</div> <span>分支条件</span>
<span class="condition-summary__count">{{ conditions.length }} </span>
</div> </div>
<div class="right">
<el-button <div
:disabled="flowStore.disableForm" v-for="(condition, index) in conditions"
@click="addFormItem" :id="condition.id"
class="addFormItem" :key="condition.id"
> :data-branch-anchor-id="condition.id"
<el-icon :size="20"><Plus /></el-icon> class="condition-summary__item"
</el-button>
</div>
</div>
<div>
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicForm"
label-position="top"
label-width="auto"
:disabled="flowStore.disableForm"
> >
<div <span class="condition-summary__order">{{ condition.label || `条件 ${index + 1}` }}</span>
class="condition" <span class="condition-summary__text">{{ conditionSummary(condition) }}</span>
v-for="(params, index) in formData.nodeParams" </div>
:key="params.id" <div
:id="params.id" :id="`${props.model.id}_else`"
:data-branch-anchor-id="params.id" :data-branch-anchor-id="`${props.model.id}_else`"
> class="condition-summary__item condition-summary__item--else"
<div class="title__container"> >
<div class="left"> <span class="condition-summary__order">Else</span>
<div class="space"></div> <span class="condition-summary__text">以上条件均不满足时执行</span>
<div class="title">{{ index === 0 ? "If" : "Else If" }}</div> </div>
</div>
<div class="right">
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="deleteFormItem(params.id, index)"
>
<el-icon :size="16"><Minus /></el-icon>
</el-button>
</div>
</div>
<div class="withOr">
<div class="title">条件</div>
<div>
<el-select
v-model="params.withOr"
placeholder="Select"
style="width: 240px"
@focus="handleInputFocus"
@click.stop
@mousedown.stop
>
<el-option key="and" label="AND" value="and" />
<el-option key="or" label="OR" value="or" />
</el-select>
</div>
</div>
<div class="form__container">
<el-row v-for="(property, pIndex) in params.list" :key="`${params.id}-${pIndex}`">
<el-form-item
:label="pIndex === 0 ? '引用变量' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.nameType`"
>
<el-select
v-model="property.nameType"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
@change="handleTypeChange(index, pIndex)"
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.nameType === 'input'"
:rules="[
{
required: true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.name`"
>
<el-input
v-model="property.name"
placeholder="请输入"
clearable
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
<el-form-item
v-if="property.nameType === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.nameQuote`"
>
<el-cascader
v-model="property.nameQuote"
:options="quoteOptions"
placeholder="请选择"
:key="nameQuoteKey"
@visible-change="(val) => visibleChange(val, 'nameQuote', index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
</el-form-item>
<el-form-item
:label="pIndex === 0 ? '选择条件' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.condition`"
:rules="[
{ required: true, message: '请输入变量名', trigger: 'blur' },
]"
>
<el-select
v-model="property.condition"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
>
<el-option label="等于" value="equal" />
<el-option label="不等于" value="notEqualTo" />
<el-option label="为空" value="null" />
<el-option label="不为空" value="notNull" />
<el-option label="包含" value="include" />
<el-option label="不包含" value="notInclude" />
<el-option label="大于" value="greaterThan" />
<el-option label="大于等于" value="greaterThanOrEqual" />
<el-option label="小于" value="lessThan" />
<el-option label="小于等于" value="lessThanOrEqual" />
</el-select>
</el-form-item>
<el-form-item
:label="pIndex === 0 ? '比较值' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.type`"
>
<el-select
v-model="property.type"
@change="handleTypeChange(index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.type === 'input'"
:rules="[
{
required: true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.input`"
>
<el-input
v-model="property.input"
placeholder="请输入"
clearable
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.quote`"
>
<el-cascader
v-model="property.quote"
:options="quoteOptions"
placeholder="请选择"
@visible-change="(val) => visibleChange(val, 'quote', index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
</el-form-item>
<el-form-item :label="pIndex === 0 ? '操作' : ''">
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="addListItem(index)"
>
<el-icon :size="12"><Plus /></el-icon>
</el-button>
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="removeListItem(index, pIndex)"
>
<el-icon :size="12"><Minus /></el-icon>
</el-button>
</el-form-item>
</el-row>
</div>
</div>
</el-form>
</div>
<div
class="else"
:id="''.concat(props.model.id, '_else')"
:data-branch-anchor-id="''.concat(props.model.id, '_else')"
>
<div class="space"></div>
<div class="title">Else</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch } from "vue"; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import switchSvg from "../../icon/switch.svg"; import switchSvg from "../../icon/switch.svg";
import { v4 as randomUUID } from "uuid";
import { useFlowStore } from "@/store/modules/flow";
import { Plus, Minus } from "@element-plus/icons-vue";
import { getInput, removeSwitchEdge } from "@/utils/flow";
import { useFlowEvents } from "../../composables/useFlowEvents"; import { useFlowEvents } from "../../composables/useFlowEvents";
const props = defineProps({ const props = defineProps({
@ -285,152 +67,100 @@ const props = defineProps({
nowTime: Number, nowTime: Number,
}); });
const emits = defineEmits([ const emits = defineEmits(["contentChange", "syncAnchors"]);
"addAnchor",
"removeAnchor",
"bindRef",
"changeAnchor",
"syncAnchors",
]);
const switchRef = ref();
const dynamicForm = ref();
const flowStore = useFlowStore();
const rules = reactive({});
const { onFlowEvent } = useFlowEvents(); const { onFlowEvent } = useFlowEvents();
const switchRef = ref(null);
const nodeStateRef = ref(null);
const conditions = computed(() => props.properties?.nodeParams || props.properties?.conditions || []);
const formData = reactive({ const nodeOperatingStatus = ref("NORMAL");
nodeParams: props.properties?.conditions || [], const runtimes = ref(0);
}); const inputJsonData = ref({});
const outputJsonData = ref({});
/** const errorInfoData = ref("");
* 新增 addFormItem 对应的数据或交互作用范围仅限当前组件或模块
*/
const addFormItem = () => {
addCondition(0);
};
/**
* 删除 deleteFormItem 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} id 目标元素 ID
* @param {*} index 目标项索引
*/
const deleteFormItem = (id, index) => {
if (index === 0) {
return;
}
formData.nodeParams.splice(index, 1);
emits("removeAnchor", id);
removeSwitchEdge(props.model.id, id);
nextTick(() => {
scheduleAnchorSync();
});
};
const quoteOptions = ref([]);
/**
* 处理 handleTypeChange 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} pIndex 条件组索引
* @param {*} index 目标项索引
*/
const handleTypeChange = (pIndex, index) => {
if (formData.nodeParams[pIndex].list[index].type === "input") {
formData.nodeParams[pIndex].list[index].quote = "";
} else {
formData.nodeParams[pIndex].list[index].input = "";
const option = getInput(props.model.id, false);
if (option) {
quoteOptions.value = option;
}
}
};
/**
* 新增 addCondition 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} height 锚点纵向位置
*/
const addCondition = (height) => {
const id = randomUUID().replace(/-/g, "");
formData.nodeParams.push({
id,
withOr: "and",
list: [
{
name: "",
nameQuote: "",
nameType: "input",
type: "input",
input: "",
quote: "",
},
],
});
emits("addAnchor", height, id);
nextTick(() => {
scheduleAnchorSync();
});
};
/**
* 新增 addListItem 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} pIndex 条件组索引
* @param {*} cIndex 条件项索引
*/
const addListItem = (pIndex, cIndex) => {
formData.nodeParams[pIndex].list.push({
name: "",
nameQuote: "",
nameType: "input",
type: "input",
input: "",
quote: "",
});
nextTick(() => {
scheduleAnchorSync();
});
};
/**
* 移除 removeListItem 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} pIndex 条件组索引
* @param {*} cIndex 条件项索引
*/
const removeListItem = (pIndex, cIndex) => {
if (cIndex === 0) {
return;
}
formData.nodeParams[pIndex].list.splice(cIndex, 1);
nextTick(() => {
scheduleAnchorSync();
});
};
let anchorSyncFrame = null; let anchorSyncFrame = null;
let resizeObserver = null; let resizeObserver = null;
const conditionTextMap = {
equal: "等于",
notEqualTo: "不等于",
null: "为空",
notNull: "不为空",
include: "包含",
notInclude: "不包含",
greaterThan: "大于",
greaterThanOrEqual: "大于等于",
lessThan: "小于",
lessThanOrEqual: "小于等于",
};
/** /**
* 同步 syncAnchorPositions 对应的数据或交互作用范围仅限当前组件或模块 * 生成画布卡片中的完整条件说明多个判断项按配置的 AND/OR 关系连接
* @param {object} condition 当前条件组
* @returns {string} 可直接展示的条件表达式
*/
const conditionSummary = (condition) => {
if (!condition.list?.length) return "未配置判断";
const relation = condition.withOr === "or" ? " OR " : " AND ";
return condition.list.map((item) => {
const left = item.nameType === "quote"
? formatQuote(item.nameQuote)
: String(item.name || "未填写变量");
const operator = conditionTextMap[item.condition] || "未选择判断";
if (["null", "notNull"].includes(item.condition)) return `${left} ${operator}`;
const right = item.type === "quote"
? formatQuote(item.quote)
: String(item.input ?? "未填写比较值");
return `${left} ${operator} ${right}`;
}).join(relation);
};
/**
* 将级联引用路径格式化为易读文本
* @param {string|string[]} quote 引用路径
* @returns {string} 点号连接的引用名称
*/
const formatQuote = (quote) => {
const path = Array.isArray(quote)
? quote.filter(Boolean)
: String(quote || "").split(".").filter(Boolean);
if (!path.length) return "未选择引用";
const [nodeId, direction, ...fieldPath] = path;
const graphNode = window.lf?.getGraphData().nodes.find((node) => node.id === nodeId);
const nodeName = graphNode?.properties?.name || graphNode?.name || nodeId;
const directionName = direction === "input"
? "输入"
: direction === "output" ? "输出" : direction;
return [nodeName, directionName, ...fieldPath].filter(Boolean).join(".");
};
/**
* 根据摘要项的实际纵向中心同步分支锚点范围仅限当前分支节点
*/ */
const syncAnchorPositions = () => { const syncAnchorPositions = () => {
//
//
const root = switchRef.value; const root = switchRef.value;
if (!root) return; if (!root) return;
const rootRect = root.getBoundingClientRect(); // Handle top FlowNode 使 switch
const scale = root.offsetWidth > 0 ? rootRect.width / root.offsetWidth : 1; // padding rootRect.top
const positioningParent = root.closest(".flow-node-shell");
if (!positioningParent) return;
const parentRect = positioningParent.getBoundingClientRect();
const scale = positioningParent.offsetWidth > 0
? parentRect.width / positioningParent.offsetWidth
: 1;
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => { const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return { return {
id: element.dataset.branchAnchorId, id: element.dataset.branchAnchorId,
height: Math.round(((rect.top - rootRect.top + rect.height / 2) / scale) * 100) / 100, height: Math.round(((rect.top - parentRect.top + rect.height / 2) / scale) * 100) / 100,
}; };
}); });
emits("syncAnchors", positions); emits("syncAnchors", positions);
}; };
/** /**
* 调度 scheduleAnchorSync 对应的数据或交互作用范围仅限当前组件或模块 * 将同一帧内的多次尺寸变化合并为一次锚点同步范围仅限当前分支节点
*/ */
const scheduleAnchorSync = () => { const scheduleAnchorSync = () => {
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame); if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
@ -441,124 +171,29 @@ const scheduleAnchorSync = () => {
}; };
/** /**
* 设置 setNodeProperties 对应的数据或交互作用范围仅限当前组件或模块 * 保存节点名称范围仅限当前分支节点
*/ * @param {string} name 新的节点名称
const setNodeProperties = async () => {
try {
const valid = await dynamicForm.value.validate();
if (valid) {
const data = toRaw(formData);
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
...data,
});
emits("contentChange");
}
} catch (error) {
dynamicForm.value.clearValidate();
}
};
/**
* 设置 setNodeName 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} name 名称或事件名称
*/ */
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = window.lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { window.lf.setProperties(props.model.id, { ...properties, name });
...properties,
name
});
emits("contentChange"); emits("contentChange");
}
/**
* 处理 visibleChange 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} value 待处理的新值
* @param {*} type 目标类型
* @param {*} index 目标项索引
* @param {*} pIndex 条件组索引
*/
const visibleChange = async (value, type, index, pIndex) => {
if (value) {
const option = getInput(props.model.id);
if (option) {
quoteOptions.value = option;
const currentValue = formData.nodeParams[index].list[pIndex][type]
if (currentValue) {
formData.nodeParams[index].list[pIndex][type] = null
await nextTick();
formData.nodeParams[index].list[pIndex][type] = currentValue;
}
}
}
}; };
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const errorInfoData = ref('');
watch(
() => props.properties,
() => {
if (props.properties.nodeParams && props.properties.nodeParams.length > 0) {
formData.nodeParams = props.properties.nodeParams;
} else {
if (formData.nodeParams.length === 0) {
addCondition(100);
}
}
nextTick(scheduleAnchorSync);
},
{
immediate: true,
deep: true,
}
);
const nodeStateRef = ref(null);
/** /**
* 关闭 closePopover 对应的数据或交互作用范围仅限当前组件或模块 * 关闭节点运行状态浮层范围仅限当前分支节点
*/ */
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { nodeStateRef.value?.closePopover();
nodeStateRef.value.closePopover(); };
}
}
const isEditing = ref(false) watch(
/** () => conditions.value.map((condition) => condition.id),
* 处理 handleInputFocus 对应的数据或交互作用范围仅限当前组件或模块 () => nextTick(scheduleAnchorSync),
* @param {*} e 触发操作的浏览器事件 { immediate: true },
*/ );
const handleInputFocus = (e) => {
//
e.stopPropagation()
e.preventDefault()
}
/**
* 处理 handleInputKeydown 对应的数据或交互作用范围仅限当前组件或模块
* @param {*} e 触发操作的浏览器事件
*/
const handleInputKeydown = (e) => {
//
e.stopPropagation();
// Ctrl+V
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.returnValue = true; //
}
}
onMounted(() => { onMounted(() => {
emits("addAnchor", 0, "".concat(props.model.id, "_else"));
emits("bindRef", dynamicForm.value);
nextTick(() => { nextTick(() => {
scheduleAnchorSync(); scheduleAnchorSync();
resizeObserver = new ResizeObserver(scheduleAnchorSync); resizeObserver = new ResizeObserver(scheduleAnchorSync);
@ -566,36 +201,26 @@ onMounted(() => {
}); });
onFlowEvent("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId !== props.model.id) return;
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
runtimes.value = data.endTime - data.startTime; errorInfoData.value = data?.message || "";
} try {
let inputData = {}; inputJsonData.value = JSON.parse(data.paramsIn) || {};
let output = {}; outputJsonData.value = JSON.parse(data.paramsOut) || {};
errorInfoData.value = data?.message || '' } catch {
try { inputJsonData.value = data.paramsIn || {};
inputData = JSON.parse(data.paramsIn) || {}; outputJsonData.value = data.paramsOut || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
} }
emits("contentChange");
}); });
onFlowEvent("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id !== props.model.id) return;
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
outputJsonData.value = {}; outputJsonData.value = {};
emits("contentChange");
emits("contentChange");
}
}); });
}); });
@ -604,179 +229,91 @@ onUnmounted(() => {
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame); if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
}); });
defineExpose({ closePopover }) defineExpose({ closePopover });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.node__container { .node__container {
width: 100%; width: 100%;
height: auto; min-height: 180px;
background: #fff;
padding: 16px; padding: 16px;
cursor: default;
border: 1px solid #dfe4ec; border: 1px solid #dfe4ec;
border-left: 4px solid var(--node-accent, #b7791f); border-left: 4px solid var(--node-accent, #b7791f);
border-radius: 8px; border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09); box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
position: relative;
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.16s ease, box-shadow 0.16s ease; cursor: default;
}
.condition_title_container { .condition-summary {
display: flex; margin-top: 14px;
align-items: center; padding-top: 12px;
justify-content: space-between; border-top: 1px solid #e8ebf0;
margin-top: 14px; }
padding-top: 12px;
border-top: 1px solid #e8ebf0;
.left { .condition-summary__title,
display: flex; .condition-summary__item {
align-items: center; display: flex;
align-items: center;
justify-content: space-between;
}
.space { .condition-summary__title {
width: 3px; margin-bottom: 8px;
height: 15px; color: #344054;
background: var(--node-accent, #b7791f); font-size: 13px;
border-radius: 3px; font-weight: 650;
} }
.title { .condition-summary__count {
margin-left: 8px; color: #98a2b3;
color: #344054; font-size: 12px;
font-size: 13px; font-weight: 400;
font-weight: 650; }
}
}
.el-button { .condition-summary__item {
width: 28px; min-height: 44px;
height: 28px; margin-top: 6px;
padding: 0; padding: 0 10px;
border-color: #dfe4ec; border: 1px solid #e4e8ef;
border-radius: 5px; border-radius: 6px;
} background: #f8fafc;
} position: relative;
.condition { &::after {
width: 100%; content: "";
height: auto; width: 17px;
margin: 8px 0; position: absolute;
padding: 11px 12px 5px; top: 50%;
border: 1px solid #e4e8ef; right: -18px;
border-radius: 7px; border-top: 1px solid var(--node-accent, #b7791f);
background-color: #f8fafc; pointer-events: none;
box-sizing: border-box;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.title__container {
display: flex;
align-items: center;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.space {
width: 3px;
height: 14px;
background: #3978c5;
border-radius: 3px;
}
.title {
margin-left: 8px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
}
.el-button {
width: 26px;
height: 26px;
padding: 0;
border-radius: 5px;
}
}
.withOr {
display: flex;
align-items: center;
gap: 10px;
margin: 9px 0 7px;
.title {
color: #7a8494;
font-size: 12px;
}
:deep(.el-select) { width: 120px !important; }
}
:deep(.form__container) {
.el-form-item {
margin-right: 12px;
margin-bottom: 8px;
}
.el-input {
width: 105px;
}
.el-select {
width: 105px;
}
.el-cascader {
width: 105px;
}
}
}
.else {
display: flex;
align-items: center;
min-height: 40px;
padding: 8px 12px;
border: 1px solid #e4e8ef;
border-radius: 7px;
background-color: #f8fafc;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.space {
width: 3px;
height: 14px;
background: #697386;
border-radius: 3px;
}
.title {
margin-left: 8px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
} }
} }
.condition-summary__item--else {
background: #fffaf0;
}
.condition-summary__order {
width: 96px;
flex: 0 0 96px;
color: #3978c5;
font-size: 12px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.condition-summary__text {
flex: 1;
color: #7a8494;
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: right;
white-space: normal;
}
</style> </style>

View File

@ -16,7 +16,7 @@
:is-valid-connection="isValidLoopConnection" :is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--target" class="flow-handle flow-handle--target"
/> />
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup && flowType !== 'branch' }"> <div class="flow-node-content" :class="{ 'flow-node-card': !isGroup }">
<component <component
:is="nodeComponent" :is="nodeComponent"
ref="componentRef" ref="componentRef"
@ -132,6 +132,7 @@ const branchAnchors = computed(() => {
return true; return true;
}); });
}); });
const model = computed(() => window.lf?.getNodeModelById(props.id)); const model = computed(() => window.lf?.getNodeModelById(props.id));
/** /**
@ -375,4 +376,5 @@ onMounted(() => bindComponent(componentRef.value));
&--branch:hover { transform: translate(50%, -50%) scale(1.18); } &--branch:hover { transform: translate(50%, -50%) scale(1.18); }
} }
</style> </style>

View File

@ -50,12 +50,13 @@ const groupChildExtent = () => ({
* @param {*} node 当前流程节点 * @param {*} node 当前流程节点
*/ */
const nodeSize = (node) => { const nodeSize = (node) => {
// 分支节点采用统一的标准卡片尺寸,忽略旧流程数据中遗留的大尺寸记录。
if (node.type === "branch") return { width: 372, height: 180 };
const width = Number(node.properties?.width || node.width); const width = Number(node.properties?.width || node.width);
const height = Number(node.properties?.height || node.height); const height = Number(node.properties?.height || node.height);
if (Number.isFinite(width) && Number.isFinite(height)) { if (Number.isFinite(width) && Number.isFinite(height)) {
return { width, height }; return { width, height };
} }
if (node.type === "branch") return { width: 748, height: 420 };
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 }; if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) { if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
return { width: 328, height: 120 }; return { width: 328, height: 120 };