CMVR-IOT-UI/src/views/flow/components/NodeTitle.vue
zhanghao d733ea8525 feat: 增强流资源管理和节点执行组件
- 增加了检测流资源中引用循环的功能,以防止循环依赖。
- 改进了流引用的验证,以确保节点输出中不存在循环依赖。
- 增强了资源定义清理功能,以便在执行前移除过时的设备ID。
- 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。
- 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。
- 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。
- 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。
- 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。
- 更新了各种参数组件,以简化输入处理并提升用户体验。
- 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
2026-08-21 15:01:16 +08:00

340 lines
8.8 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",
"SUB_FLOW",
"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;
});
/**
* 处理 sourceName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
watch(sourceName, (value) => {
if (!isEditing.value) localName.value = value;
});
/**
* 处理 startEditing 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const startEditing = async () => {
isEditing.value = true;
await nextTick();
nameInputRef.value?.focus?.();
nameInputRef.value?.select?.();
};
/**
* 确认 confirmNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmNodeName = () => {
const name = localName.value.trim();
if (!name) return;
localName.value = name;
emits("setNodeName", name);
isEditing.value = false;
};
/**
* 处理 cancelNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const cancelNodeName = () => {
localName.value = sourceName();
isEditing.value = false;
};
/**
* 删除 deleteNode 对应的数据或交互;作用范围仅限当前组件或模块。
*/
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.
}
};
/**
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/
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>