300 lines
7.6 KiB
Vue
300 lines
7.6 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 canExecute = computed(() => Boolean(props.nodeType) && !["NONE", "selectArea"].includes(props.nodeType));
|
|
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,
|
|
detail: { ...props.nodeProperties },
|
|
}));
|
|
};
|
|
|
|
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>
|