719 lines
20 KiB
Vue
719 lines
20 KiB
Vue
<template>
|
||
<div class="node__container" ref="switchRef" @mouseleave="setNodeProperties">
|
||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||
<template #input>
|
||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||
</template>
|
||
<template #output>
|
||
<div v-if="nodeOperatingStatus != 'FAILED'">
|
||
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
|
||
</div>
|
||
<div v-else>{{ errorInfoData }}</div>
|
||
</template>
|
||
</NodeState>
|
||
<NodeTitle
|
||
:icon="switchSvg"
|
||
:nodeId="props.model.id"
|
||
:nodeProperties="props.properties"
|
||
:nodeName="props.properties?.name || '分支'"
|
||
nodeDesc="连接多个下游分支,根据设定的条件按照顺序查找的方式来匹配运行的分支,如果匹配到某条件则只运行该条件对应的分支,否则继续匹配下一条件直至结束"
|
||
@setNodeName="setNodeName"
|
||
/>
|
||
<div class="condition_title_container">
|
||
<div class="left">
|
||
<div class="space"></div>
|
||
<div class="title">所有条件</div>
|
||
</div>
|
||
<div class="right">
|
||
<el-button
|
||
:disabled="flowStore.disableForm"
|
||
@click="addFormItem"
|
||
class="addFormItem"
|
||
>
|
||
<el-icon :size="20"><Plus /></el-icon>
|
||
</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
|
||
class="condition"
|
||
v-for="(params, index) in formData.nodeParams"
|
||
:key="params.id"
|
||
:id="params.id"
|
||
:data-branch-anchor-id="params.id"
|
||
>
|
||
<div class="title__container">
|
||
<div class="left">
|
||
<div class="space"></div>
|
||
<div class="title">{{ index === 0 ? "If" : "Else If" }}</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>
|
||
</template>
|
||
<script setup>
|
||
import { nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch } from "vue";
|
||
import NodeTitle from "../../components/NodeTitle.vue";
|
||
import NodeState from "../../components/NodeState.vue";
|
||
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";
|
||
|
||
const props = defineProps({
|
||
model: Object,
|
||
graphModel: Object,
|
||
properties: Object,
|
||
nowTime: Number,
|
||
});
|
||
|
||
const emits = defineEmits([
|
||
"addAnchor",
|
||
"removeAnchor",
|
||
"bindRef",
|
||
"changeAnchor",
|
||
"syncAnchors",
|
||
]);
|
||
|
||
const switchRef = ref();
|
||
const dynamicForm = ref();
|
||
const flowStore = useFlowStore();
|
||
const rules = reactive({});
|
||
const { onFlowEvent } = useFlowEvents();
|
||
|
||
const formData = reactive({
|
||
nodeParams: props.properties?.conditions || [],
|
||
});
|
||
|
||
const addFormItem = () => {
|
||
addCondition(0);
|
||
};
|
||
|
||
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([]);
|
||
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;
|
||
}
|
||
}
|
||
};
|
||
|
||
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();
|
||
});
|
||
};
|
||
|
||
const addListItem = (pIndex, cIndex) => {
|
||
formData.nodeParams[pIndex].list.push({
|
||
name: "",
|
||
nameQuote: "",
|
||
nameType: "input",
|
||
type: "input",
|
||
input: "",
|
||
quote: "",
|
||
});
|
||
nextTick(() => {
|
||
scheduleAnchorSync();
|
||
});
|
||
};
|
||
|
||
const removeListItem = (pIndex, cIndex) => {
|
||
if (cIndex === 0) {
|
||
return;
|
||
}
|
||
formData.nodeParams[pIndex].list.splice(cIndex, 1);
|
||
nextTick(() => {
|
||
scheduleAnchorSync();
|
||
});
|
||
};
|
||
|
||
let anchorSyncFrame = null;
|
||
let resizeObserver = null;
|
||
|
||
const syncAnchorPositions = () => {
|
||
const root = switchRef.value;
|
||
if (!root) return;
|
||
const rootRect = root.getBoundingClientRect();
|
||
const scale = root.offsetWidth > 0 ? rootRect.width / root.offsetWidth : 1;
|
||
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
id: element.dataset.branchAnchorId,
|
||
height: Math.round(((rect.top - rootRect.top + rect.height / 2) / scale) * 100) / 100,
|
||
};
|
||
});
|
||
emits("syncAnchors", positions);
|
||
};
|
||
|
||
const scheduleAnchorSync = () => {
|
||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||
anchorSyncFrame = requestAnimationFrame(() => {
|
||
anchorSyncFrame = null;
|
||
syncAnchorPositions();
|
||
});
|
||
};
|
||
|
||
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();
|
||
}
|
||
};
|
||
|
||
const setNodeName = (name) => {
|
||
const properties = lf.getProperties(props.model.id);
|
||
lf.setProperties(props.model.id, {
|
||
...properties,
|
||
name
|
||
});
|
||
emits("contentChange");
|
||
}
|
||
|
||
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);
|
||
|
||
const closePopover = () => {
|
||
if (nodeStateRef.value) {
|
||
nodeStateRef.value.closePopover();
|
||
}
|
||
}
|
||
|
||
const isEditing = ref(false)
|
||
const handleInputFocus = (e) => {
|
||
// 阻止事件冒泡到流程画布
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
}
|
||
|
||
|
||
const handleInputKeydown = (e) => {
|
||
// 阻止事件冒泡到流程节点,避免被画布快捷键拦截
|
||
e.stopPropagation();
|
||
// 可选:明确放行 Ctrl+V(增强兼容性)
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||
e.returnValue = true; // 允许默认粘贴行为
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
emits("addAnchor", 0, "".concat(props.model.id, "_else"));
|
||
|
||
emits("bindRef", dynamicForm.value);
|
||
nextTick(() => {
|
||
scheduleAnchorSync();
|
||
resizeObserver = new ResizeObserver(scheduleAnchorSync);
|
||
if (switchRef.value) resizeObserver.observe(switchRef.value);
|
||
});
|
||
|
||
onFlowEvent("changeNodeState", (data) => {
|
||
if (data.nodeId === props.model.id) {
|
||
nodeOperatingStatus.value = data.status;
|
||
if (data.endTime && data.startTime) {
|
||
runtimes.value = data.endTime - data.startTime;
|
||
}
|
||
let inputData = {};
|
||
let output = {};
|
||
errorInfoData.value = data?.message || ''
|
||
try {
|
||
inputData = JSON.parse(data.paramsIn) || {};
|
||
output = JSON.parse(data.paramsOut) || {};
|
||
} catch (error) {
|
||
inputData = data.paramsIn || {};
|
||
output = data.paramsOut || {};
|
||
}
|
||
inputJsonData.value = inputData;
|
||
outputJsonData.value = output;
|
||
|
||
emits("contentChange");
|
||
}
|
||
});
|
||
|
||
onFlowEvent("contentChange", (data) => {
|
||
if (data.id === props.model.id) {
|
||
nodeOperatingStatus.value = "NORMAL";
|
||
inputJsonData.value = {};
|
||
outputJsonData.value = {};
|
||
|
||
emits("contentChange");
|
||
}
|
||
});
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
resizeObserver?.disconnect();
|
||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||
});
|
||
|
||
defineExpose({ closePopover })
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
.node__container {
|
||
width: 100%;
|
||
height: auto;
|
||
background: #fff;
|
||
padding: 16px;
|
||
cursor: default;
|
||
border: 1px solid #dfe4ec;
|
||
border-left: 4px solid var(--node-accent, #b7791f);
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||
|
||
.condition_title_container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #e8ebf0;
|
||
|
||
.left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.space {
|
||
width: 3px;
|
||
height: 15px;
|
||
background: var(--node-accent, #b7791f);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.title {
|
||
margin-left: 8px;
|
||
color: #344054;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
}
|
||
|
||
.el-button {
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
border-color: #dfe4ec;
|
||
border-radius: 5px;
|
||
}
|
||
}
|
||
|
||
.condition {
|
||
width: 100%;
|
||
height: auto;
|
||
margin: 8px 0;
|
||
padding: 11px 12px 5px;
|
||
border: 1px solid #e4e8ef;
|
||
border-radius: 7px;
|
||
background-color: #f8fafc;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
</style>
|