add:增加vue-flow组件
This commit is contained in:
252
src/components/nodeFlow/index.vue
Normal file
252
src/components/nodeFlow/index.vue
Normal 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>
|
||||
154
src/components/nodeFlow/sopNode.vue
Normal file
154
src/components/nodeFlow/sopNode.vue
Normal 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>
|
||||
38
src/pages/stage/flow/detail.vue
Normal file
38
src/pages/stage/flow/detail.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user