CMVR-IOT-UI/src/views/flow/components/NodeTitle.vue
lixiaolong 8e2afcbaa6 feat(flow): 添加单节点执行机器人选择记忆功能
- 引入 watch 监听器和相关变量用于缓存单节点机器人选择
- 实现按节点ID缓存机器人选择,新节点默认沿用最近一次选择
- 在单节点执行前后记录当前机器人选择到缓存中
- 修改NodeTitle组件传递节点ID用于按节点恢复机器人选择
- 添加试运行参数缓存功能,支持按流程缓存整套参数
- 实现参数层级缓存值恢复,兼容参数结构变化
- 优化试运行关闭和确认时的数据缓存处理逻辑
2026-08-07 11:28:01 +08:00

316 lines
8.0 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-title">
<div class="node-title__main">
<div class="node-title__identity">
<div class="node-title__icon">
<img v-if="props.icon" :src="props.icon" alt="" />
<el-icon v-else><Operation /></el-icon>
</div>
<div class="node-title__text">
<div v-if="!isEditing" class="node-title__name-row">
<span class="node-title__name" :title="localName">{{ localName }}</span>
<span v-if="typeLabel" class="node-title__type">{{ typeLabel }}</span>
<el-tooltip content="重命名" placement="top">
<button
type="button"
class="node-icon-button node-title__edit nodrag nopan"
aria-label="重命名节点"
@mousedown.stop
@click.stop="startEditing"
>
<el-icon><EditPen /></el-icon>
</button>
</el-tooltip>
</div>
<div v-else class="node-title__editor nodrag nopan" @mousedown.stop @click.stop>
<el-input
ref="nameInputRef"
v-model="localName"
maxlength="40"
@keydown.enter.prevent="confirmNodeName"
@keydown.esc.prevent="cancelNodeName"
/>
<button type="button" class="node-icon-button is-confirm" aria-label="确认名称" @click="confirmNodeName">
<el-icon><Select /></el-icon>
</button>
<button type="button" class="node-icon-button" aria-label="取消重命名" @click="cancelNodeName">
<el-icon><CloseBold /></el-icon>
</button>
</div>
</div>
</div>
<div class="node-title__actions nodrag nopan" @mousedown.stop>
<el-tooltip v-if="canExecute" content="执行当前节点" placement="top">
<button type="button" class="node-icon-button is-run" aria-label="执行当前节点" @click.stop="execute">
<el-icon><VideoPlay /></el-icon>
</button>
</el-tooltip>
<el-tooltip :content="isSelectArea ? '解散分组' : '删除节点'" placement="top">
<button
type="button"
class="node-icon-button is-danger"
:aria-label="isSelectArea ? '解散分组' : '删除节点'"
@click.stop="deleteNode"
>
<el-icon><FolderRemove v-if="isSelectArea" /><Delete v-else /></el-icon>
</button>
</el-tooltip>
</div>
</div>
<div v-if="props.nodeDesc" class="node-title__description" :title="props.nodeDesc">
{{ props.nodeDesc }}
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { CloseBold, Delete, EditPen, FolderRemove, Operation, Select, VideoPlay } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { emitter } from "@/utils/eventBus";
const props = defineProps({
icon: { type: String, default: "" },
nodeId: { type: String, default: "" },
nodeProperties: { type: Object, default: () => ({}) },
nodeDesc: { type: String, default: "" },
nodeName: { type: String, default: "" },
nodeType: { type: String, default: "" },
});
const emits = defineEmits(["setNodeName"]);
const isEditing = ref(false);
const localName = ref("");
const nameInputRef = ref();
const isSelectArea = computed(() => props.nodeType === "selectArea");
const flowContextActions = new Set([
"NONE",
"START",
"END",
"SUB_START",
"SUB_END",
"START_LOOP",
"STOP_LOOP",
"BRANCH",
"GET_CURRENT_OBJECT",
]);
const canExecute = computed(() => {
if (isSelectArea.value) return false;
const action = String(props.nodeProperties?.action || "").toUpperCase();
return Boolean(action) && !flowContextActions.has(action);
});
const typeLabel = computed(() => {
if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return "";
return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType;
});
const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
watch(sourceName, (value) => {
if (!isEditing.value) localName.value = value;
});
const startEditing = async () => {
isEditing.value = true;
await nextTick();
nameInputRef.value?.focus?.();
nameInputRef.value?.select?.();
};
const confirmNodeName = () => {
const name = localName.value.trim();
if (!name) return;
localName.value = name;
emits("setNodeName", name);
isEditing.value = false;
};
const cancelNodeName = () => {
localName.value = sourceName();
isEditing.value = false;
};
const deleteNode = async () => {
try {
await ElMessageBox.confirm(
isSelectArea.value
? "解散后内部节点和连线会保留,是否继续?"
: "删除后相关连线也会一并移除,是否继续?",
isSelectArea.value ? "解散分组" : "删除节点",
{
confirmButtonText: isSelectArea.value ? "解散" : "删除",
cancelButtonText: "取消",
confirmButtonClass: "el-button--danger",
type: "warning",
});
if (isSelectArea.value) window.lf?.ungroup(props.nodeId);
else window.lf?.deleteNode(props.nodeId);
emitter.emit("deleteNode", props.nodeId);
} catch (error) {
// Cancelled by the user.
}
};
const execute = () => {
document.dispatchEvent(new CustomEvent("singleNodeExecution", {
bubbles: false,
cancelable: false,
// 携带节点 ID供单节点执行弹窗按节点恢复上次选择的机器人。
detail: { ...props.nodeProperties, nodeId: props.nodeId },
}));
};
onMounted(() => {
localName.value = sourceName();
});
</script>
<style lang="scss" scoped>
.node-title { min-width: 0; }
.node-title__main {
display: flex;
min-height: 38px;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.node-title__identity {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 10px;
}
.node-title__icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 7px;
background: #f1f5f9;
color: var(--node-accent, #475569);
img {
width: 22px;
height: 22px;
object-fit: contain;
}
}
.node-title__text { min-width: 0; flex: 1; }
.node-title__name-row {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.node-title__name {
overflow: hidden;
color: #1f2937;
font-size: 15px;
font-weight: 650;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-title__type {
flex: 0 0 auto;
padding: 1px 6px;
border: 1px solid #dfe4ec;
border-radius: 4px;
background: #f8fafc;
color: #697386;
font-size: 10px;
font-weight: 600;
}
.node-title__edit {
width: 24px;
height: 24px;
flex: 0 0 24px;
opacity: 0;
}
.node-title__actions {
display: flex;
flex: 0 0 auto;
gap: 4px;
opacity: 0;
transition: opacity 0.16s ease;
}
.node-title:hover,
.node-title:focus-within {
.node-title__actions,
.node-title__edit { opacity: 1; }
}
.node-icon-button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #7b8494;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: #dfe4ec;
background: #f8fafc;
color: #344054;
}
&.is-run:hover {
border-color: #b7dfc7;
background: #effaf3;
color: #168447;
}
&.is-danger:hover {
border-color: #f2c5c2;
background: #fff3f2;
color: #d93025;
}
&.is-confirm { color: #168447; }
}
.node-title__editor {
display: grid;
grid-template-columns: minmax(100px, 1fr) 28px 28px;
gap: 4px;
align-items: center;
}
.node-title__description {
display: -webkit-box;
overflow: hidden;
margin-top: 8px;
color: #7a8494;
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (hover: none) {
.node-title__actions,
.node-title__edit { opacity: 1; }
}
</style>