- 引入 watch 监听器和相关变量用于缓存单节点机器人选择 - 实现按节点ID缓存机器人选择,新节点默认沿用最近一次选择 - 在单节点执行前后记录当前机器人选择到缓存中 - 修改NodeTitle组件传递节点ID用于按节点恢复机器人选择 - 添加试运行参数缓存功能,支持按流程缓存整套参数 - 实现参数层级缓存值恢复,兼容参数结构变化 - 优化试运行关闭和确认时的数据缓存处理逻辑
316 lines
8.0 KiB
Vue
316 lines
8.0 KiB
Vue
<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>
|