
image.png
"jsplumb": "^2.15.6"
<template>
<div>
<HighForm>
<template #high-form-body>
<div class="flow-container" ref="containerRef">
<!-- 顶部工具栏 -->
<div class="toolbar">
<el-icon>
<el-tooltip content="放大">
<ZoomOut @click="zoomIn" />
</el-tooltip>
</el-icon>
<el-icon>
<el-tooltip content="缩小">
<ZoomIn @click="zoomOut" />
</el-tooltip>
</el-icon>
<el-icon>
<el-tooltip content="居中">
<Operation @click="fitToCenter" />
</el-tooltip>
</el-icon>
<el-icon>
<el-tooltip content="导入">
<Upload @click="openImportDialog" />
</el-tooltip>
</el-icon>
<el-icon>
<el-tooltip content="清空">
<Delete @click="clearCanvas" />
</el-tooltip>
</el-icon>
</div>
<!-- 左侧选择面板 -->
<div class="sidebar">
<div class="section">
<h4>节点类型</h4>
<div class="node-item" draggable="true" @dragstart="onDragStart($event, 'start')">开始</div>
<div class="node-item" draggable="true" @dragstart="onDragStart($event, 'task')">任务</div>
<div class="node-item" draggable="true" @dragstart="onDragStart($event, 'end')">结束</div>
</div>
</div>
<!-- 节点容器(视口) -->
<div class="nodes-area" ref="nodesAreaRef" @dragover.prevent @drop="onDrop">
<!-- 👇 可平移的画布层(jsPlumb 操作此层内的节点) -->
<div
ref="canvasLayerRef"
class="canvas-layer"
:style="{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoomLevel})`,
transformOrigin: '0 0'
}">
<div
v-for="node in nodes"
:key="node.id"
:id="`${node.id}`"
:class="['flow-node', node.type]"
@contextmenu.prevent="(e) => showContextMenu(e, node)"
@click="selectNode(node)">
<!-- 内容不变 -->
<div class="side-bar" :style="{ backgroundColor: getSideBarColor(node.type) }"></div>
<div class="label">{{ node.name || '未命名' }}</div>
<div class="endpoint" :data-node-id="node.id"></div>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }">
<!-- <button @click="editNode(contextMenu.node)">编辑</button>-->
<button @click="deleteNode(contextMenu.node)">删除</button>
<button @click="hideContextMenu">取消</button>
</div>
<!-- 属性面板 -->
<div class="properties-panel" v-show="nodeDialog">
<h4>节点信息</h4>
<el-form :model="selectedNode">
<el-form-item label="节点名称">
<el-input v-model="selectedNode.name" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点描述">
<el-input v-model="selectedNode.description" placeholder="请输入节点描述" />
</el-form-item>
<el-form-item label="程序类型">
<el-select v-model="selectedNode.programType" placeholder="请选择程序类型">
<el-option label="审核信息" value="审核信息" />
<el-option label="审批流程" value="审批流程" />
<el-option label="通知提醒" value="通知提醒" />
</el-select>
</el-form-item>
<el-form-item label="程序编码">
<el-input v-model="selectedNode.programCode" placeholder="请输入程序编码" />
</el-form-item>
<el-form-item label="扩展属性1">
<el-input v-model="selectedNode.attribute1" placeholder="请输入扩展属性1" />
</el-form-item>
<el-form-item label="扩展属性2">
<el-input v-model="selectedNode.attribute2" placeholder="请输入扩展属性2" />
</el-form-item>
<el-form-item label="扩展属性3">
<el-input v-model="selectedNode.attribute3" placeholder="请输入扩展属性3" />
</el-form-item>
<el-form-item label="扩展属性4">
<el-input v-model="selectedNode.attribute4" placeholder="请输入扩展属性4" />
</el-form-item>
<el-form-item label="扩展属性5">
<el-input v-model="selectedNode.attribute5" placeholder="请输入扩展属性5" />
</el-form-item>
</el-form>
<div class="actions">
<el-button @click="nodeDialog = false" style="margin: 0 auto">关闭</el-button>
</div>
</div>
</div>
</template>
<template #high-form-footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</HighForm>
<!-- 导入数据 Dialog -->
<el-dialog v-model="importDialogVisible" title="导入流程数据" width="600px" @close="importJsonText = ''">
<p style="margin-bottom: 10px; font-size: 12px; color: #666">请粘贴有效的 JSON 数据(格式参考导出内容):</p>
<el-input
v-model="importJsonText"
type="textarea"
:rows="12"
placeholder='{ "nodes": [...], "connections": [...] }'
style="font-family: monospace; font-size: 12px" />
<template #footer>
<span class="dialog-footer">
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmImport" :disabled="!importJsonText.trim()"> 确定导入 </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ElMessageBox } from 'element-plus';
import { jsPlumb } from 'jsplumb';
import { getInfoGraph, saveGraph } from '@/api/system/jsplumb.js';
const { proxy } = getCurrentInstance();
const importDialogVisible = ref(false);
const importJsonText = ref('');
const canvasLayerRef = ref(null);
// 数据
const nodes = ref([]);
const connections = ref([]);
let nodeIdCounter = 6;
const MIN_ZOOM = 0.3;
const MAX_ZOOM = 3;
const nodeDialog = ref(false);
const nodesAreaRef = ref(null);
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
node: null
});
// 当前选中节点
const selectedNode = ref({
id: null,
type: '',
name: '',
description: '',
programType: '',
programCode: '',
attribute1: '',
attribute2: '',
attribute3: '',
attribute4: '',
attribute5: '',
x: 0,
y: 0
});
const zoomLevel = ref(1); // 初始缩放 100%
const offset = ref({ x: 0, y: 0 }); // 定义画布偏移量
let isDragging = false; // 跟踪鼠标是否按下
let startX = 0;
let startY = 0;
// 存储当前正在拖拽的节点类型
let draggedNodeType = null;
// jsPlumb 实例
let jsPlumbInstance = null;
const route = useRoute();
// 拖拽开始
function onDragStart(event, type) {
draggedNodeType = type;
// 可选:设置拖拽图标(某些浏览器支持)
event.dataTransfer.setData('text/plain', type);
// 允许复制效果(非必须)
event.dataTransfer.effectAllowed = 'copy';
}
// 拖拽结束
function onDrop(event) {
event.preventDefault();
if (!draggedNodeType) return;
// 获取画布区域的边界(用于计算相对坐标)
const rect = nodesAreaRef.value.getBoundingClientRect();
// 鼠标在视口中的位置 → 转换为 canvasLayer 内的坐标(考虑缩放和平移)
const clientX = event.clientX;
const clientY = event.clientY;
// 1. 先减去画布偏移(nodesArea 的位置)
const offsetX = clientX - rect.left;
const offsetY = clientY - rect.top;
// 2. 考虑当前 zoom 和 offset:实际 canvas 坐标 = (offsetX - offset.x * zoom) / zoom
// 因为 transform: translate(offset.x, offset.y) scale(zoom)
// 所以反向计算真实坐标:
const canvasX = (offsetX - offset.value.x) / zoomLevel.value;
const canvasY = (offsetY - offset.value.y) / zoomLevel.value;
// 创建新节点(使用计算出的位置)
const id = nodeIdCounter++;
const newNode = {
id,
type: draggedNodeType,
name: draggedNodeType === 'start' ? '开始' : draggedNodeType === 'end' ? '结束' : '任务',
description: '',
programCode: '',
programType: '',
attribute1: '',
attribute2: '',
attribute3: '',
attribute4: '',
attribute5: '',
x: Math.round(canvasX),
y: Math.round(canvasY)
};
nodes.value.push(newNode);
// 清除拖拽状态
draggedNodeType = null;
// 等 DOM 更新后初始化 jsPlumb
nextTick(() => {
const el = document.getElementById(`${id}`);
if (el && jsPlumbInstance) {
el.style.left = newNode.x + 'px';
el.style.top = newNode.y + 'px';
jsPlumbInstance.draggable(el, {
containment: canvasLayerRef.value,
stop: () => {
const node = nodes.value.find((n) => n.id === id);
if (node) {
node.x = parseInt(el.style.left) || 0;
node.y = parseInt(el.style.top) || 0;
}
}
});
jsPlumbInstance.makeSource(el, {
filter: '.endpoint',
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
jsPlumbInstance.makeTarget(el, {
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
}
});
}
// 初始化 jsPlumb
function initJsPlumb() {
jsPlumbInstance = jsPlumb.getInstance({
Container: canvasLayerRef.value, // ✅ 关键:Container 是可移动的 layer
Connector: ['Flowchart', { cornerRadius: 5 }],
Endpoint: ['Dot', { radius: 6 }],
Anchor: 'Continuous',
PaintStyle: { stroke: '#1e90ff', strokeWidth: 1 },
EndpointStyle: { fill: '#1e90ff' },
ConnectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
DragOptions: { cursor: 'pointer' }
});
}
// 获取节点颜色
function getSideBarColor(type) {
switch (type) {
case 'start':
return '#00cc00';
case 'end':
return '#cc0000';
case 'task':
return '#1e90ff';
case 'gateway':
return '#ff9900';
default:
return '#ccc';
}
}
// 导入数据按钮
function openImportDialog() {
importJsonText.value = ''; // 清空上次内容
importDialogVisible.value = true;
}
// 导入弹框的确定按钮
function confirmImport() {
try {
const parsed = JSON.parse(importJsonText.value.trim());
// 验证结构
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.connections)) {
proxy.$modal.msgError('导入数据格式错误:缺少 nodes 或 connections 字段');
return;
}
// 清空当前画布
jsPlumbInstance.deleteEveryEndpoint(); // 删除所有连接和端点
nodes.value = []; // 清空节点列表
// 重置节点 ID 计数器(可选:避免 ID 冲突)
// 这里简单取最大 ID + 1,更严谨的做法是记录最大值
let maxId = 0;
// 添加新节点
parsed.nodes.forEach((node) => {
if (typeof node.id !== 'number' && typeof node.id !== 'string') {
throw new Error('节点缺少有效 id');
}
const idNum = Number(node.id);
if (isNaN(idNum)) {
throw new Error(`无效的节点 ID: ${node.id}`);
}
maxId = Math.max(maxId, idNum);
nodes.value.push({
id: idNum,
type: node.type || 'task',
name: node.name || '未命名',
description: node.description || '',
programType: node.programType || '',
programCode: node.programCode || '',
attribute1: node.attribute1 || '',
attribute2: node.attribute2 || '',
attribute3: node.attribute3 || '',
attribute4: node.attribute4 || '',
attribute5: node.attribute5 || '',
x: node.x ?? 100,
y: node.y ?? 100
});
});
nodeIdCounter = maxId + 1; // 更新 ID 计数器
// 等待 DOM 更新后初始化节点和连线
nextTick(() => {
// 1. 初始化每个节点(拖拽 + 端点)
nodes.value.forEach((node) => {
const el = document.getElementById(`${node.id}`);
if (!el || !jsPlumbInstance) return;
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
jsPlumbInstance.draggable(el, {
containment: canvasLayerRef.value,
stop: (event) => {
const updatedNode = nodes.value.find((n) => n.id === node.id);
if (updatedNode) {
updatedNode.x = parseInt(el.style.left, 10) || 0;
updatedNode.y = parseInt(el.style.top, 10) || 0;
}
}
});
jsPlumbInstance.makeSource(el, {
filter: '.endpoint',
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
jsPlumbInstance.makeTarget(el, {
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
});
// 2. 恢复连接线
parsed.connections.forEach((conn) => {
const sourceEl = document.getElementById(`${conn.sourceId}`);
const targetEl = document.getElementById(`${conn.targetId}`);
if (sourceEl && targetEl) {
jsPlumbInstance.connect({
source: sourceEl,
target: targetEl,
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
anchor: 'Continuous'
});
} else {
console.warn(`跳过无效连接: ${conn.sourceId} -> ${conn.targetId}`);
}
});
proxy.$modal.msgSuccess('流程数据导入成功!');
importDialogVisible.value = false;
fitToCenter();
});
} catch (error) {
proxy.$modal.msgError(`导入失败:${error.message || '无效的 JSON 格式'}`);
}
}
//保存节点
// function saveNode() {}
// 删除节点
function deleteNode(node) {
const index = nodes.value.findIndex((n) => n.id === node.id);
if (index !== -1) {
jsPlumbInstance.remove(document.getElementById(`${node.id}`));
nodes.value.splice(index, 1);
}
hideContextMenu();
}
// 选择节点
function selectNode(node) {
selectedNode.value = node;
nodeDialog.value = true;
}
// 右键菜单
function showContextMenu(e, node) {
contextMenu.value = {
visible: true,
x: e.clientX,
y: e.clientY,
node
};
}
function hideContextMenu() {
contextMenu.value.visible = false;
}
//取消按钮
function handleCancel() {
const obj = {
path: '/bxcmd/jsplumb',
query: {}
};
proxy.$tab.closeOpenPage(obj);
}
// 保存按钮
function handleSave() {
console.log(jsPlumbInstance.getAllConnections(), '879');
const connections = jsPlumbInstance.getAllConnections().map((conn) => ({
sourceId: Number(conn.sourceId),
targetId: Number(conn.targetId)
}));
const params = {
graphId: route.query.graphId,
graphName: route.query.graphName,
description: route.query.description,
nodes: nodes.value,
connections
};
console.log('保存数据:', params);
saveGraph(params).then((res) => {
proxy.$modal.msgSuccess('保存成功!');
// handleCancel();
});
}
// 点击空白关闭右键菜单
document.addEventListener('click', () => {
hideContextMenu();
});
//鼠标移动
function onMouseMove(event) {
if (isDragging) {
// offset.value.x = event.clientX - startX;
// offset.value.y = event.clientY - startY;
// 考虑缩放:鼠标移动 1px,画布应移动 1/zoom px(视觉一致)
offset.value.x = (event.clientX - startX) / zoomLevel.value;
offset.value.y = (event.clientY - startY) / zoomLevel.value;
}
}
//鼠标按下
function onMouseDown(event) {
const target = event.target;
// 如果点击的是节点本身、端点、或 jsPlumb 连接线,则不触发画布拖拽
if (
target.closest('.flow-node') || // 节点内部
target.classList.contains('endpoint') || // 端点
target.classList.contains('_jsPlumb_connector') || // jsPlumb 连接线
target.closest('.toolbar') ||
target.closest('.sidebar') ||
target.closest('.properties-panel') ||
target.closest('.context-menu')
) {
return; // 让 jsPlumb 或其他组件处理
}
// 否则,开始拖动画布
isDragging = true;
startX = event.clientX - offset.value.x;
startY = event.clientY - offset.value.y;
if (nodesAreaRef.value) {
nodesAreaRef.value.classList.add('dragging');
}
}
//鼠标抬起
function onMouseUp() {
isDragging = false;
if (nodesAreaRef.value) {
nodesAreaRef.value.classList.remove('dragging');
}
}
//清空画布
function clearCanvas() {
ElMessageBox.confirm('确定要清空整个画布吗?此操作不可恢复。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// 1. 清除所有 jsPlumb 连接和端点
if (jsPlumbInstance) {
jsPlumbInstance.deleteEveryEndpoint(); // 删除连接线和端点
}
// 2. 👇 手动从 DOM 中移除所有节点元素
const canvas = canvasLayerRef.value;
if (canvas) {
// 获取所有 flow-node 元素并逐个移除
const nodeElements = canvas.querySelectorAll('.flow-node');
nodeElements.forEach((el) => el.remove());
}
// 3. 清空 Vue 数据
nodes.value = [];
// 4. 取消选中
selectedNode.value = {
id: null,
type: '',
name: '',
description: '',
programType: '',
programCode: '',
attribute1: '',
attribute2: '',
attribute3: '',
attribute4: '',
attribute5: '',
x: 0,
y: 0
};
// 5. 重置视图
zoomLevel.value = 1;
offset.value = { x: 0, y: 0 };
proxy.$modal.msgSuccess('流程数据已保存!');
nodeDialog.value = false;
})
.catch(() => {
// 用户取消
});
}
//放大
function zoomIn() {
if (zoomLevel.value < MAX_ZOOM) {
zoomLevel.value = Math.round((zoomLevel.value + 0.2) * 10) / 10;
}
}
//缩小
function zoomOut() {
if (zoomLevel.value > MIN_ZOOM) {
zoomLevel.value = Math.round((zoomLevel.value - 0.2) * 10) / 10;
}
}
//居中
function fitToCenter() {
if (nodes.value.length === 0) return;
// 1. 计算所有节点的边界框
let minX = Infinity,
minY = Infinity;
let maxX = -Infinity,
maxY = -Infinity;
nodes.value.forEach((node) => {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + 160); // 节点宽约 160px
maxY = Math.max(maxY, node.y + 40); // 节点高约 40px
});
const width = maxX - minX;
const height = maxY - minY;
// 2. 获取视口尺寸(nodesArea)
const viewRect = nodesAreaRef.value?.getBoundingClientRect();
if (!viewRect) return;
const viewWidth = viewRect.width;
const viewHeight = viewRect.height;
// 3. 计算缩放比例(留 20% 边距)
const scaleX = viewWidth / (width * 1.2);
const scaleY = viewHeight / (height * 1.2);
const newZoom = Math.min(scaleX, scaleY, 1); // 不超过 100%
// 4. 计算居中偏移
const contentCenterX = minX + width / 2;
const contentCenterY = minY + height / 2;
const offsetAfterZoom = {
x: viewWidth / 2 - contentCenterX * newZoom,
y: viewHeight / 2 - contentCenterY * newZoom
};
// 5. 应用
zoomLevel.value = parseFloat(newZoom.toFixed(2));
offset.value = offsetAfterZoom;
}
function loadData() {
getInfoGraph({ graphId: route.query.graphId }).then((res) => {
console.log('加载数据:', res);
nodes.value = res.data.nodes;
connections.value = res.data.connections;
nextTick(() => {
nodes.value.forEach((node) => {
const el = document.getElementById(`${node.id}`);
if (el && jsPlumbInstance) {
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
jsPlumbInstance.draggable(el, {
containment: canvasLayerRef.value,
stop: (event) => {
const node = nodes.value.find((n) => n.id === id);
if (node) {
const style = window.getComputedStyle(el);
node.x = parseInt(style.left, 10) || 0;
node.y = parseInt(style.top, 10) || 0;
}
}
});
jsPlumbInstance.makeSource(el, {
filter: '.endpoint',
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
jsPlumbInstance.makeTarget(el, {
anchor: 'Continuous',
allowLoopback: false,
endpoint: ['Dot', { radius: 6 }],
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
});
}
});
connections.value.forEach((conn) => {
const sourceId = `${conn.sourceId}`;
const targetId = `${conn.targetId}`;
jsPlumbInstance.connect({
source: sourceId,
target: targetId,
connector: ['Flowchart', { cornerRadius: 5 }],
paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
anchor: 'Continuous'
});
});
fitToCenter();
});
});
}
onActivated(() => {
if (route.query.graphId) {
loadData();
}
});
onMounted(() => {
initJsPlumb();
const nodesAreaEl = nodesAreaRef.value;
if (!nodesAreaEl) return; // 防御性编程
nodesAreaEl.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
onBeforeUnmount(() => {
const nodesAreaEl = nodesAreaRef.value;
if (nodesAreaEl) {
nodesAreaEl.removeEventListener('mousedown', onMouseDown);
}
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
});
</script>
<style lang="scss" scoped>
.nodes-area {
flex: 1;
position: relative;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><circle cx="10" cy="10" r="1" fill="%23ccc"/></svg>')
repeat;
background-size: 10px 10px;
overflow: hidden;
cursor: grab;
user-select: none;
transition: transform 0.2s; /* 平滑过渡(可选) */
}
/* 👇 拖拽中状态 */
.nodes-area.dragging {
cursor: grabbing;
/* 可选:降低非关键元素的干扰 */
/* 例如:临时隐藏端点或降低透明度(不推荐隐藏节点) */
}
/* 可选:拖拽时禁止文本选择(更彻底) */
.nodes-area.dragging * {
user-select: none !important;
}
.canvas-layer {
//position: absolute;
//left: 0;
//top: 0;
//min-width: 2000px;
//min-height: 2000px;
min-height: calc(100vh - 220px);
}
.flow-container {
position: relative;
width: 100%;
height: calc(100vh - 140px);
background: #f9f9f9;
display: flex;
flex-direction: column;
}
.toolbar {
padding: 16px;
//background: white;
//border-bottom: 1px solid #ddd;
////display: flex;
////gap: 8px;
text-align: right;
.el-icon {
margin: 0 5px;
cursor: pointer;
font-size: 20px;
:focus {
outline: none; /* 移除默认边框 */
}
}
}
.sidebar {
width: 200px;
background: white;
border-right: 1px solid #eee;
padding: 10px;
box-shadow: 1px 0 5px rgba(0, 0, 0, 0.05);
overflow-y: auto;
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
}
.sidebar .section {
margin-bottom: 15px;
}
.sidebar .section h4 {
font-size: 14px;
color: #666;
margin-top: 0;
margin-bottom: 8px;
}
.sidebar .node-item {
padding: 6px;
border: 1px dashed #ccc;
margin-bottom: 6px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
text-align: center;
font-size: 14px;
}
.sidebar .node-item:hover {
background-color: #f0f8ff;
}
.flow-node {
position: absolute;
width: 160px;
height: 40px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
cursor: move;
user-select: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.flow-node.start {
border-color: #00cc00;
background: #e8fff7;
}
.flow-node.end {
border-color: #cc0000;
background: #ffe8e8;
}
.flow-node.task {
border-color: #1e90ff;
background: #f0f8ff;
}
.flow-node.gateway {
border-color: #ff9900;
background: #fff9e8;
}
.side-bar {
width: 4px;
height: 30px;
margin-right: 8px;
border-radius: 2px;
}
.icon {
font-size: 14px;
margin-right: 8px;
}
.label {
font-size: 12px;
font-weight: 500;
flex: 1;
text-align: left;
}
.star {
font-size: 10px;
color: #1e90ff;
margin-left: 4px;
}
.endpoint {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 10px;
height: 10px;
background: #1e90ff;
border-radius: 50%;
cursor: pointer;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
padding: 5px;
}
.context-menu button {
display: block;
width: 100%;
padding: 6px 10px;
text-align: left;
border: none;
background: none;
cursor: pointer;
}
.context-menu button:hover {
background: #f0f0f0;
}
.properties-panel {
position: absolute;
right: 10px;
top: 60px;
width: 300px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.properties-panel h4 {
margin-top: 0;
font-size: 14px;
color: #333;
}
.property-group {
margin: 8px 0;
}
.property-group label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.property-group input,
.property-group select,
.property-group textarea {
width: 100%;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 12px;
}
.actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
</style>
{
"graphName": "系统https访问地址",
"description": "用于https访问或地址拼接",
"graphId": 0,
"nodes": [
{
"id": 101,
"type": "start",
"name": "开始",
"description": "这是流程的起点",
"programType": "REQ",
"programCode": "BXINIT",
"attribute1": "111",
"attribute2": "112",
"attribute3": "113",
"attribute4": "114",
"attribute5": "115",
"x": 120,
"y": 200
},
{
"id": 102,
"type": "task",
"name": "提交",
"description": "用户填写并提交表单",
"x": 300,
"y": 200
},
{
"id": 103,
"type": "task",
"name": "审核",
"description": "由直属领导审批",
"x": 500,
"y": 150
},
{
"id": 104,
"type": "task",
"name": "复核",
"description": "财务确认预算合规",
"x": 500,
"y": 250
},
{
"id": 105,
"type": "end",
"name": "结束",
"description": "流程完成",
"x": 700,
"y": 200
}
],
"connections": [
{"sourceId": 101, "targetId": 102},
{"sourceId": 102, "targetId": 103},
{"sourceId": 102, "targetId": 104},
{"sourceId": 103, "targetId": 105},
{"sourceId": 104, "targetId": 105}
]
}