add:增加vue-flow组件

This commit is contained in:
liangdong
2026-01-10 12:57:14 +08:00
parent 2a76877bdb
commit 23a7285e29
7 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ref } from "vue";
import { VueFlow, useVueFlow, getRectOfNodes } from "@vue-flow/core";
import sopNode from "./sopNode.vue";
const { updateNode, setViewport, nodes: flowNodes } = useVueFlow();
const nodes = ref([
{
id: "1",
type: "my-custom", // 对应插槽名 #node-my-custom
position: { x: 0, y: 0 },
label: "完成项目文档",
data: { title: "开发任务", isFinished: true, value: "", isActive: false }, // 传入自定义数据
},
{
id: "2",
type: "my-custom",
position: { x: 160 + 64, y: 0 },
label: "代码上线评审",
selected: true,
data: {
title: "代码上线评审",
isFinished: false,
value: "",
isActive: true,
},
},
{
id: "3",
type: "my-custom",
position: { x: 320 + 128, y: 0 },
label: "代码发布",
data: { title: "代码发布", value: "", isFinished: false, isActive: false },
},
{
id: "3-top",
type: "my-custom",
label: "任务完成",
position: { x: 448 + 192, y: -100 },
data: { title: "任务完成" },
}, // 向上偏
{
id: "3-bottom",
type: "my-custom",
label: "任务未完成",
position: { x: 448 + 192, y: 100 },
data: { title: "任务未完成" },
}, // 向下偏
]);
const edges = ref([
{ id: "e1-2", source: "1", target: "2", },
{ id: "e2-3", source: "2", target: "3", },
{ id: "e3-3t", source: "3", target: "3-top", },
{ id: "e3-3b", source: "3", target: "3-bottom", },
]);
const PADDING = 20;
const canvasRect = computed(() => {
// 过滤掉还没有尺寸信息的节点
const validNodes = flowNodes.value.filter((n) => n.dimensions.width > 0);
if (validNodes.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
return getRectOfNodes(validNodes);
});
const translateExtent = computed(() => {
const rect = canvasRect.value;
if (rect.width === 0)
return [
[0, 0],
[0, 0],
];
const result = [
[rect.x - PADDING, rect.y - PADDING],
[rect.x + rect.width + PADDING, rect.y + rect.height + PADDING],
];
return result;
});
const canvasStyle = computed(() => {
const bounds = canvasRect.value;
return {
// 宽度 = 节点矩形宽度 + 左右边距
width: `${bounds.width + PADDING * 2}px`,
// 高度 = 节点矩形高度 + 上下边距
height: `${bounds.height + PADDING * 2}px`,
position: "relative",
};
});
// 新增节点
const addNode = () => {
const HORIZONTAL_GAP = 40; // 缩短间距,让主干紧贴汇合点
const MAX_WIDTH = 160;
const VERTICAL_GAP = 64;
// 1. 获取所有待链接分支中,最右侧的边界
const pendingBranchNodes = nodes.value.filter(node => {
const isBranch = node.id.includes('top') || node.id.includes('bottom');
const isAlreadyLinked = edges.value.some(edge => edge.source === node.id);
return isBranch && !isAlreadyLinked;
});
let nextX = 0;
if (pendingBranchNodes.length > 0) {
// 如果有分支待汇合,新主干 X = 分支最右侧位置 + 小间距
// 注意:这里最好加上节点自身的宽度(假设是 160
const rightmostX = Math.max(...pendingBranchNodes.map(n => n.position.x));
nextX = rightmostX + MAX_WIDTH + HORIZONTAL_GAP;
} else {
// 如果没有分支,按正常主干逻辑追加
const mainNodes = nodes.value.filter(n => !n.id.includes('-'));
const lastMain = mainNodes[mainNodes.length - 1];
nextX = (lastMain?.position.x || 0) + MAX_WIDTH + VERTICAL_GAP;
}
const newId = (nodes.value.reduce((max, n) => Math.max(max, parseInt(n.id) || 0), 0) + 1).toString();
// 2. 创建新节点Y 轴保持 0主干中轴
const newNode = {
id: newId,
type: "my-custom",
position: { x: nextX, y: 0 },
label: `任务 ${newId}`,
data: { title: `任务 ${newId}`, isFinished: false, isActive: false },
};
// 3. 连线逻辑不变,但确保 type 是 smoothstep
const newEdges = [];
// 链接分支到汇合点
pendingBranchNodes.forEach(branch => {
newEdges.push({
id: `e${branch.id}-${newId}`,
source: branch.id,
target: newId,
type: "smoothstep",
// 调整 pathOptions 让折线更陡峭/紧凑,视觉上更像在“链接点处”
pathOptions: { borderRadius: 10, offset: 10 }
});
});
// 主干相连
const mainNodes = nodes.value.filter(n => !n.id.includes('-'));
if (mainNodes.length > 0) {
const prevMain = mainNodes[mainNodes.length - 1];
newEdges.push({
id: `e${prevMain.id}-${newId}`,
source: prevMain.id,
target: newId,
type: "smoothstep"
});
}
nodes.value.push(newNode);
edges.value.push(...newEdges);
};
const handleNodeClick = (clickedNodeId) => {
// 1. 先判断点击的是否是已禁用的节点,如果是则直接跳过
const targetNode = nodes.value.find((n) => n.id === clickedNodeId);
if (targetNode?.data?.isFinished) return;
// 2. 一次性映射出所有节点的新状态
nodes.value = nodes.value.map((node) => {
return {
...node,
// 只有点击的那个变 true其他所有节点强制变 false
data: {
...node.data,
isActive: node.id === clickedNodeId,
},
// 顺便同步官方的选中状态,确保视觉和逻辑统一
selected: node.id === clickedNodeId,
};
});
};
// 监听 rect 的变化(当节点增减或尺寸测量完成时触发)
watch(
() => canvasRect.value,
async (rect) => {
console.log("rect", -rect.x + PADDING, rect.width);
if (rect.width > 0) {
await nextTick();
setViewport({
x: -rect.x + PADDING,
y: -rect.y + PADDING,
zoom: 1,
});
}
},
{ deep: true }
);
// 初始化setViewport
const onInit = (instance) => {
const validNodes = flowNodes.value.filter((n) => n.dimensions.width > 0);
if (validNodes.length > 0) {
const rect = canvasRect.value;
setViewport({
x: -rect.x + PADDING,
y: -rect.y + PADDING,
zoom: 1,
});
}
};
</script>
<template>
<el-button type="primary" @click="addNode">添加节点</el-button>
<div class="scroll-container">
<el-scrollbar>
<div :style="canvasStyle">
<VueFlow
:nodes="nodes"
:edges="edges"
:fit-view-on-init="false"
:zoom-on-scroll="false"
:nodes-draggable="false"
:zoom-on-double-click="false"
:pan-on-drag="false"
:nodes-connectable="false"
:selection-key="null"
:pan-activation-action="null"
:zoom-on-pinch="false"
:min-zoom="1"
:max-zoom="1"
:translate-extent="translateExtent"
@pane-ready="onInit"
>
<template #node-my-custom="nodeProps">
<sopNode v-bind="nodeProps" @activate-node="handleNodeClick" />
</template>
</VueFlow>
</div>
</el-scrollbar>
</div>
</template>
<style>
/* 官方基础样式还是要引,不然无法拖拽和缩放 */
@import "@vue-flow/core/dist/style.css";
@import "@vue-flow/core/dist/theme-default.css";
</style>
<style lang="scss" scoped>
:deep(.vue-flow__edge-path) {
stroke: #e2e8f0;
stroke-width: 1.5;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { Handle, Position } from "@vue-flow/core";
// 接收数据
const props = defineProps(["id","data", "label"]);
const emit = defineEmits(['activateNode']);
const updateItem = () => {
// 已经完成就不能在点击了
if(props.data.isFinished){
return;
}
emit('activateNode', props.id);
};
</script>
<template>
<div class="custom-step-node" :class="{ 'is-finished': data.isFinished }" @click="updateItem">
<div class="node-capsule" :class="{ 'is-active': data.isActive }">
<Handle type="target" :position="Position.Left" class="hidden-handle" />
<span class="status-dot"></span>
<span class="node-label">{{ data.title }}</span>
<Handle type="source" :position="Position.Right" class="hidden-handle" />
</div>
<div class="node-input-area" @click.stop>
<div class="input-wrapper-container">
<div class="input-wrapper">
<el-input type="text" v-model="data.value" @click.stop></el-input>
</div>
<span class="percent-unit">%</span>
</div>
<div class="node-tooltips">产能赋权</div>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-step-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px; /* 胶囊与输入框的间距 */
width: 120px;
cursor: pointer;
}
/* 胶囊样式 */
.node-capsule {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 20px; /* 圆角胶囊 */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
position: relative;
width: 100%;
white-space: nowrap;
transition: all 0.3s ease;
}
/* 激活状态(蓝框) */
.node-capsule.is-active {
border-color: #2563eb;
color: #2563eb;
box-shadow: 0 0 0 1px #2563eb;
}
/* 状态小圆点 */
.status-dot {
width: 6px;
height: 6px;
background-color: #d1d5db; /* 默认灰色 */
border-radius: 50%;
margin-right: 8px;
}
.is-active .status-dot {
background-color: #2563eb; /* 激活蓝色 */
}
.node-label {
font-size: 13px;
color: #4b5563;
}
.is-active .node-label {
color: #2563eb;
font-weight: 500;
}
/* 输入框区域 */
.node-input-area {
display: inline-flex;
flex-direction: column;
align-items: center;
.input-wrapper-container{
display: inline-flex;
align-items: center;
gap: 4px;
}
.node-tooltips{
color: #90a1b9;
font-size: 12px;
opacity: 0;
margin-top: 3px;
}
&:hover .node-tooltips{
opacity: 1;
}
}
.input-wrapper {
width: 60px;
:deep(.el-input__inner){
text-align: center;
font-weight: 600;
}
}
.percent-input {
width: 100%;
border: none;
text-align: center;
font-size: 14px;
font-weight: bold;
color: #1f2937;
outline: none;
}
.percent-unit {
font-size: 12px;
color: #9ca3af;
}
.is-finished {
filter: grayscale(1);
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* 隐藏连接点逻辑,但保持在胶囊中心高度 */
.hidden-handle {
width: 0;
height: 0;
border: none;
background-color: transparent;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<el-drawer
v-model="drawerVisible"
size="100%"
class="standard-ui-back-drawer"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
:with-header="false"
modal-class="standard-overlay-dialog-flat"
@opened="isOpened = true"
@closed="isOpened = false"
>
<!-- 头部 -->
<!-- 内容 -->
<div class="flow-context-wrapper">
<nodeFlow v-if="drawerVisible" />
</div>
<!-- -->
<template #footer>
<div class="custom-flat-drawer-footer">
<div class="stats-info"></div>
<div class="actions">
<el-button link @click="drawerVisible = false">取消</el-button>
<el-button type="primary" class="btn-confirm">确认保存变更</el-button>
</div>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import nodeFlow from "@/components/nodeFlow/index.vue";
defineOptions({ name: "FlowDetail" });
const drawerVisible = defineModel("drawerVisible", { default: true });
const isOpened = ref(false);
</script>
<style lang="scss" scoped></style>

View File

@@ -29,11 +29,14 @@
</div>
</div>
</div>
<!-- 详情 -->
<!-- <flow-detail /> -->
</div>
</template>
<script setup lang="ts">
import subTabs from "./subTabs.vue";
import flowCard from "./flowCard.vue";
import FlowDetail from './detail.vue';
defineOptions({ name: "Flow" });
const activeTab = ref<string>(1);