CMVR-IOT-UI/src/views/flow/components/NodeTitle.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>