CMVR-IOT-UI/src/views/flow/nodes/common/serviceNode.vue
2025-11-17 17:32:33 +08:00

602 lines
16 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="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer
v-if="props.properties.outputType === 'json'"
:value="outputJsonData"
copyable
boxed
sort
theme="light"
/>
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="outputJsonData.imageUrl"
:preview-teleported="true"
show-progress
fit="fill"
/>
<IPlayer
v-if="props.properties.outputType === 'video'"
v-for="item in outputJsonData.videoUrl"
:videoUrl="item"
/>
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name"
:nodeDesc="props.properties.desc"
:zoom-state="nodeZoom"
@zoom="zoom"
/>
<div class="input__container" v-show="nodeZoom">
<div class="title">
<div class="left">
<div class="tag"></div>
<div class="text">输入</div>
</div>
<div class="right" v-if="properties.properties?.canAddFormItem">
<el-button
:disabled="flowStore.disableForm"
@click="addFormItem"
class="addFormItem"
>
<el-icon :size="20"><Plus /></el-icon>
</el-button>
</div>
</div>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicForm"
label-position="top"
label-width="auto"
:disabled="flowStore.disableForm"
>
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row>
<el-form-item
:label="index === 0 ? '参数名' : ''"
:prop="`nodeParams.${index}.name`"
:rules="[
{ required: true, message: '请输入参数名', trigger: 'blur' },
]"
>
<el-input
:disabled="property?.disabled || false"
v-model="property.name"
@keydown="handleInputKeydown"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
:label="index === 0 ? '参数值' : ''"
:prop="`nodeParams.${index}.type`"
>
<el-select
v-model="property.type"
@change="handleTypeChange(index)"
>
<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}.input`"
>
<el-input-number
v-if="property.componentType === 'number'"
v-model="property.input"
:min="0"
:controls="false"
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="property.input"
v-else-if="property.componentType === 'select'"
>
<el-option
v-for="item in property.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
@keydown="handleInputKeydown"
v-else
v-model="property.input"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.quote`"
>
<el-cascader
:ref="
(el) => {
if (el) cascaderRefs[index] = el;
}
"
v-model="property.quote"
:checkStrictly="true"
:options="quoteOptions"
placeholder="请选择"
@visible-change="
(visible) => visibleChange(visible, index, property.quote)
"
@change="(value) => cascaderChange(value, index)"
/>
</el-form-item>
</el-form-item>
</el-row>
</div>
</el-form>
</div>
</div>
<div class="output__container" v-show="nodeZoom">
<div v-if="props.properties?.outputParams?.length > 0">
<div class="title">
<div class="left">
<div class="tag"></div>
<div class="text">输出</div>
</div>
<div class="right">
<el-button
:disabled="flowStore.disableForm"
@click="addOutputFormItem"
class="addFormItem"
>
<el-icon :size="20"><Plus /></el-icon>
</el-button>
</div>
</div>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
label-position="top"
label-width="auto"
:rules="outputRules"
ref="outputFormRef"
>
<FormItemRecursive
formType="output"
:current-list="formData.outputParams"
prop-path="outputParams"
:depth="0"
:is-first-level="true"
:endDepth="2"
:parent-path="[]"
@delete-item="deleteTopLevelItem"
/>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from "vue";
import { getInput, initNodeZoom } from "@/utils/flow";
import { useFlowStore } from "@/store/modules/flow";
import { Plus } from "@element-plus/icons-vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import FormItemRecursive from "./FormItemRecursive.vue";
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
const flowStore = useFlowStore();
const formData = reactive({
nodeParams: [],
outputParams: [],
});
const rules = reactive({});
const quoteOptions = ref([]);
const handleTypeChange = (index) => {
if (formData.nodeParams[index].type === "input") {
formData.nodeParams[index].quote = "";
} else {
formData.nodeParams[index].input = "";
const option = getInput(props.model.id);
if (option) {
quoteOptions.value = option;
}
}
};
const cascaderRefs = ref([]);
const outputRules = reactive({});
const cascaderChange = (value, index) => {
const selectedOptions = cascaderRefs.value[index].getCheckedNodes(true);
formData.nodeParams[index].quote = value;
formData.nodeParams[index].quoteType = selectedOptions[0].data.type;
};
const addOutputFormItem = () => {
formData.outputParams.push({
name: "",
type: "",
desc: "",
children: [],
});
};
const dynamicForm = ref();
const outputFormRef = ref();
const setNodeProperties = async () => {
try {
const result = await dynamicForm.value.validate();
let flag = true;
if (outputFormRef.value) {
flag = await outputFormRef.value.validate();
}
if (result && flag) {
const data = toRaw(formData);
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
...data,
zoom: nodeZoom.value
});
emits("contentChange");
}
} catch {
// dynamicForm.value.clearValidate();
}
};
const nodeOperatingStatus = ref("NORMAL");
const inputJsonData = ref({});
const outputJsonData = ref({});
const visibleChange = (value, index, quote) => {
if (value) {
const option = getInput(props.model.id);
if (option) {
quoteOptions.value = option;
const currentValue = [...quote];
// 强制级联选择器重新处理选中值与选项的匹配
if (cascaderRefs.value[index]) {
// 等待DOM更新后再设置值确保新options已生效
setTimeout(() => {
// 如果当前有选中值,重新设置一次以触发重新匹配
if (currentValue.length) {
formData.nodeParams[index].quote = [];
// 确保响应式更新
setTimeout(() => {
formData.nodeParams[index].quote = currentValue;
}, 0);
}
// 手动触发级联选择器的重新渲染
// cascaderRefs.value[index].updatePopper();
}, 0);
}
}
}
};
const validateForm = async () => {
try {
const result = await dynamicForm.value.validate();
if (result) {
return { valid: true, message: "验证通过" };
} else {
return { valid: false, message: "验证失败" };
}
} catch {
return { valid: false, message: "验证失败" };
}
};
const addFormItem = () => {
formData.nodeParams.push({
name: "",
type: "",
desc: "",
required: false,
children: [],
});
emits("contentChange");
};
// 删除顶层表单项
const deleteTopLevelItem = (fullPath) => {
// 从顶层数据开始查找
let currentLevel = formData.outputParams;
// 遍历路径(除最后一个索引,因为最后一个是要删除的项)
for (let i = 0; i < fullPath.length - 1; i++) {
const index = fullPath[i];
// 进入下一层级
currentLevel = currentLevel[index].children;
}
// 最后一个索引是当前层级要删除的项
const lastIndex = fullPath[fullPath.length - 1];
currentLevel.splice(lastIndex, 1);
emits("contentChange");
};
const nodeZoom = ref(props?.properties?.zoom ?? true)
const zoom = (flag) => {
nodeZoom.value = flag
initNodeZoom(props.model.id, nodeZoom.value, '.node__box')
}
watch(
() => props.properties,
() => {
if (props.properties.nodeParams && props.properties.nodeParams.length > 0) {
formData.nodeParams = props.properties.nodeParams;
const option = getInput(props.model.id);
if (option) {
quoteOptions.value = option;
}
nextTick(() => {
initNodeZoom(props.model.id, props?.properties?.zoom ?? true, '.node__box', true)
})
}
if (
props.properties.outputParams &&
props.properties.outputParams.length > 0
) {
formData.outputParams = props.properties.outputParams;
}
},
{
immediate: true,
deep: true,
}
);
const handleInputKeydown = (e) => {
// 阻止事件冒泡到 Logic Flow 节点,避免被其事件拦截
e.stopPropagation();
// 可选:明确放行 Ctrl+V增强兼容性
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.returnValue = true; // 允许默认粘贴行为
}
};
onMounted(() => {
emitter.on("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
let inputData = {};
let output = {};
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");
}
});
emitter.on("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
});
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
});
defineExpose({
validateForm,
setNodeProperties,
});
</script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
.input__container {
width: 100%;
background-color: #fafbfc;
padding: 0 16px;
border-radius: 8px;
box-sizing: border-box;
.title {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.tag {
width: 3px;
height: 16px;
background: #1664ff;
border-radius: 0 4px 4px 0;
margin-right: 12px;
}
.text {
font-size: 16px;
font-weight: 700;
color: #0c0d0e;
}
}
.right {
.addFormItem {
border: none;
background: transparent;
cursor: pointer;
}
}
}
.form__container {
margin: 12px 0;
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
}
}
.output__container {
width: 100%;
background-color: #fafbfc;
padding: 0 16px;
border-radius: 8px;
box-sizing: border-box;
.title {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.tag {
width: 3px;
height: 16px;
background: #1664ff;
border-radius: 0 4px 4px 0;
margin-right: 12px;
}
.text {
font-size: 16px;
font-weight: 700;
color: #0c0d0e;
}
}
.right {
.addFormItem {
border: none;
background: transparent;
cursor: pointer;
}
}
}
.form__container {
margin: 12px 0;
}
}
:deep(.el-row) {
align-items: end;
.el-input {
--el-input-width: 148px;
}
.el-select {
--el-select-width: 148px;
}
.el-cascader {
--el-form-inline-content-width: 148px;
}
}
}
</style>