276
src/shared/components/FlowchartEditor/CustomNode.vue
Normal file
276
src/shared/components/FlowchartEditor/CustomNode.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div
|
||||
class="custom-node"
|
||||
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
|
||||
:data-node-type="nodeType"
|
||||
:draggable="!isEditing"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dblclick="handleDoubleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<!-- 连线点 - 根据节点类型动态显示 -->
|
||||
<NodeHandles
|
||||
:node-type="nodeType"
|
||||
:node-config="nodeConfig"
|
||||
/>
|
||||
|
||||
<!-- 节点内容 -->
|
||||
<div class="node-content">
|
||||
<!-- 显示模式 -->
|
||||
<span v-if="!isEditing" class="node-label">{{ displayLabel }}</span>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<input
|
||||
v-if="isEditing"
|
||||
ref="editInput"
|
||||
v-model="editText"
|
||||
class="node-input"
|
||||
@blur="handleSaveEdit"
|
||||
@keydown.enter="handleSaveEdit"
|
||||
@keydown.escape="handleCancelEdit"
|
||||
@click.stop
|
||||
@focusout="handleSaveEdit"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的文字用于保持尺寸 -->
|
||||
<span v-if="isEditing" class="node-label-hidden" aria-hidden="true">{{ displayLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬停时显示的操作按钮 -->
|
||||
<NodeActions
|
||||
v-if="isHovered"
|
||||
@delete="handleDelete"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onUnmounted, nextTick, computed, watch } from 'vue'
|
||||
import { getNodeTypeConfig } from './useNodeStyles'
|
||||
import NodeHandles from './NodeHandles.vue'
|
||||
import NodeActions from './NodeActions.vue'
|
||||
|
||||
// 类型定义
|
||||
interface Props {
|
||||
id: string
|
||||
type: string
|
||||
data: any
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
delete: [nodeId: string]
|
||||
update: [nodeId: string, newLabel: string]
|
||||
}
|
||||
|
||||
// Props 和 Emits
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 响应式状态
|
||||
const isHovered = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editText = ref('')
|
||||
const editInput = ref<HTMLInputElement>()
|
||||
|
||||
// 定时器和事件处理器
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let globalClickHandler: ((event: MouseEvent) => void) | null = null
|
||||
|
||||
// 计算属性
|
||||
const nodeType = computed(() => props.data.originalType || props.type)
|
||||
const nodeConfig = computed(() => getNodeTypeConfig(nodeType.value))
|
||||
const displayLabel = computed(() => props.data.customLabel || nodeConfig.value.label)
|
||||
|
||||
|
||||
// 事件处理器
|
||||
const handleDelete = () => emit('delete', props.id)
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// 检查是否点击在连线点区域
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.vue-flow__handle')) {
|
||||
// 如果在连线点区域,禁用节点拖拽
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (isEditing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否在连线点区域开始拖拽
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.vue-flow__handle')) {
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (!isEditing.value) {
|
||||
isEditing.value = true
|
||||
editText.value = displayLabel.value
|
||||
nextTick(() => {
|
||||
editInput.value?.focus()
|
||||
editInput.value?.select()
|
||||
})
|
||||
addGlobalClickHandler()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (isEditing.value) {
|
||||
// 保存编辑的文本
|
||||
if (editText.value.trim()) {
|
||||
emit('update', props.id, editText.value.trim())
|
||||
}
|
||||
isEditing.value = false
|
||||
removeGlobalClickHandler()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editText.value = ''
|
||||
removeGlobalClickHandler()
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
}
|
||||
hideTimeout = setTimeout(() => {
|
||||
isHovered.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 全局点击处理器
|
||||
const addGlobalClickHandler = () => {
|
||||
if (globalClickHandler) return
|
||||
|
||||
globalClickHandler = (event: MouseEvent) => {
|
||||
if (isEditing.value && !(event.target as Element)?.closest('.custom-node')) {
|
||||
handleSaveEdit()
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', globalClickHandler, { capture: true })
|
||||
}
|
||||
|
||||
const removeGlobalClickHandler = () => {
|
||||
if (globalClickHandler) {
|
||||
document.removeEventListener('click', globalClickHandler, { capture: true })
|
||||
globalClickHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 清理函数
|
||||
onUnmounted(() => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
}
|
||||
removeGlobalClickHandler()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主容器 */
|
||||
.custom-node {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.custom-node.is-hovered {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.custom-node.is-hovered .node-content {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 节点内容区域 */
|
||||
.node-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 节点标签 */
|
||||
.node-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 编辑输入框 */
|
||||
.node-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 隐藏标签(用于保持尺寸) */
|
||||
.node-label-hidden {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
58
src/shared/components/FlowchartEditor/NodeActions.vue
Normal file
58
src/shared/components/FlowchartEditor/NodeActions.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div
|
||||
class="node-actions"
|
||||
@mouseenter="$emit('mouseenter')"
|
||||
@mouseleave="$emit('mouseleave')"
|
||||
>
|
||||
<button
|
||||
class="action-btn delete-btn"
|
||||
@click.stop="$emit('delete')"
|
||||
title="删除节点"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineEmits<{
|
||||
delete: []
|
||||
mouseenter: []
|
||||
mouseleave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-actions {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -20px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
233
src/shared/components/FlowchartEditor/NodeHandles.vue
Normal file
233
src/shared/components/FlowchartEditor/NodeHandles.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<!-- 开始节点:只有输出 handle -->
|
||||
<template v-if="nodeType === 'start'">
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
:style="getHandleStyle('#10b981', { bottom: '-10px' })"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 结束节点:只有输入 handle -->
|
||||
<template v-else-if="nodeType === 'end'">
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
:style="getHandleStyle('#ef4444', { top: '-10px' })"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 选择判断节点:一个输入 + 两个输出(是/否) -->
|
||||
<template v-else-if="nodeType === 'decision'">
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Left"
|
||||
id="yes"
|
||||
:style="{
|
||||
background: '#10b981',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid white',
|
||||
zIndex: 10,
|
||||
left: '-10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
}"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
id="no"
|
||||
:style="{
|
||||
background: '#ef4444',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid white',
|
||||
zIndex: 10,
|
||||
right: '-10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 是/否标签 -->
|
||||
<div class="decision-labels">
|
||||
<span class="decision-label decision-label-yes">是</span>
|
||||
<span class="decision-label decision-label-no">否</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 循环判断节点:两个输入 + 两个输出(进入/循环体返回 + 继续/退出) -->
|
||||
<template v-else-if="nodeType === 'loop'">
|
||||
<!-- 进入循环的输入 -->
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
id="enter"
|
||||
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
|
||||
/>
|
||||
<!-- 循环体返回的输入 -->
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Bottom"
|
||||
id="return"
|
||||
:style="
|
||||
getHandleStyle('#8b5cf6', {
|
||||
bottom: '-16px',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<!-- 继续执行循环体 -->
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
id="continue"
|
||||
:style="
|
||||
getHandleStyle('#10b981', {
|
||||
right: '-10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<!-- 退出循环 -->
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Left"
|
||||
id="exit"
|
||||
:style="
|
||||
getHandleStyle('#ef4444', {
|
||||
left: '-10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="loop-labels">
|
||||
<span class="loop-label loop-label-enter">进入</span>
|
||||
<span class="loop-label loop-label-return">返回</span>
|
||||
<span class="loop-label loop-label-continue">继续</span>
|
||||
<span class="loop-label loop-label-exit">退出</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 上下两个 handle -->
|
||||
<template v-else>
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
:style="getHandleStyle(nodeConfig.color, { top: '-10px' })"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
:style="getHandleStyle(nodeConfig.color, { bottom: '-10px' })"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Handle, Position } from "@vue-flow/core"
|
||||
|
||||
interface Props {
|
||||
nodeType: string
|
||||
nodeConfig: {
|
||||
color: string
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// 获取 handle 样式
|
||||
const getHandleStyle = (color: string, position: Record<string, string>) => ({
|
||||
background: color,
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
border: "2px solid white",
|
||||
zIndex: 10,
|
||||
...position,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 判断节点标签样式 */
|
||||
.decision-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
position: absolute;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.decision-label-yes {
|
||||
left: -25px;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.decision-label-no {
|
||||
right: -25px;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
/* 循环节点标签样式 */
|
||||
.loop-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loop-label {
|
||||
position: absolute;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.loop-label-enter {
|
||||
right: 20px;
|
||||
top: -45px;
|
||||
}
|
||||
|
||||
.loop-label-return {
|
||||
right: 20px;
|
||||
bottom: -45px;
|
||||
}
|
||||
|
||||
.loop-label-continue {
|
||||
right: -40px;
|
||||
top: -16px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.loop-label-exit {
|
||||
left: -40px;
|
||||
top: -16px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
398
src/shared/components/FlowchartEditor/Toolbar.vue
Normal file
398
src/shared/components/FlowchartEditor/Toolbar.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getNodeTypeConfig } from './useNodeStyles'
|
||||
|
||||
// 拖拽开始处理
|
||||
const onDragStart = (event: DragEvent, type: string) => {
|
||||
if (!event.dataTransfer || !type) return
|
||||
|
||||
event.dataTransfer.setData("application/vueflow", type)
|
||||
event.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
canUndo?: boolean
|
||||
canRedo?: boolean
|
||||
isSaving?: boolean
|
||||
lastSaved?: Date | null
|
||||
hasUnsavedChanges?: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
deleteNode: [nodeId: string]
|
||||
undo: []
|
||||
redo: []
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
// 工具栏状态
|
||||
|
||||
// 节点类型定义 - 优化性能
|
||||
const nodeTypes = computed(() => [
|
||||
'start',
|
||||
'input',
|
||||
'default',
|
||||
'decision',
|
||||
'loop',
|
||||
'output',
|
||||
'end'
|
||||
].map(type => {
|
||||
const config = getNodeTypeConfig(type)
|
||||
return {
|
||||
type,
|
||||
...config
|
||||
}
|
||||
}))
|
||||
|
||||
// 获取保存状态标题
|
||||
const getSaveStatusTitle = () => {
|
||||
if (props.isSaving) {
|
||||
return '正在保存...'
|
||||
} else if (props.hasUnsavedChanges) {
|
||||
return '有未保存的更改'
|
||||
} else if (props.lastSaved) {
|
||||
return `已保存 - ${new Date(props.lastSaved).toLocaleTimeString()}`
|
||||
} else {
|
||||
return '已保存'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<!-- 工具栏头部 -->
|
||||
<div class="toolbar-header">
|
||||
<div class="header-content">
|
||||
<h3>节点库</h3>
|
||||
<div class="save-status-indicator" :class="{
|
||||
'saving': props.isSaving,
|
||||
'unsaved': props.hasUnsavedChanges && !props.isSaving,
|
||||
'saved': !props.hasUnsavedChanges && !props.isSaving
|
||||
}" :title="getSaveStatusTitle()">
|
||||
<span v-if="props.isSaving" class="spinner">⏳</span>
|
||||
<span v-else-if="props.hasUnsavedChanges">●</span>
|
||||
<span v-else>✔</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="description">拖拽节点到画布中</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<div class="nodes">
|
||||
<div
|
||||
v-for="nodeType in nodeTypes"
|
||||
:key="nodeType.type"
|
||||
class="node-item"
|
||||
:draggable="true"
|
||||
@dragstart="onDragStart($event, nodeType.type)"
|
||||
:style="{ borderColor: nodeType.color }"
|
||||
:title="`${nodeType.label} - ${nodeType.description}`"
|
||||
>
|
||||
<div class="node-icon" :style="{ backgroundColor: nodeType.color }">
|
||||
{{ nodeType.icon }}
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<div class="node-label">{{ nodeType.label }}</div>
|
||||
<div class="node-description">{{ nodeType.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏操作 -->
|
||||
<div class="toolbar-actions">
|
||||
<div class="history-controls">
|
||||
<button
|
||||
class="action-btn history-btn"
|
||||
:disabled="!canUndo"
|
||||
@click="$emit('undo')"
|
||||
title="撤销 (Ctrl+Z)"
|
||||
>
|
||||
<span class="btn-icon">↶</span>
|
||||
<span class="btn-text">撤销</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn history-btn"
|
||||
:disabled="!canRedo"
|
||||
@click="$emit('redo')"
|
||||
title="重做 (Ctrl+Y)"
|
||||
>
|
||||
<span class="btn-icon">↷</span>
|
||||
<span class="btn-text">重做</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="action-btn clear-btn" @click="$emit('clear')" title="清空画布">
|
||||
<span class="btn-icon">🗑️</span>
|
||||
<span class="btn-text">清空画布</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
width: 140px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 40px);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
.toolbar-header {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.toolbar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 保存状态指示器样式 */
|
||||
.save-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-status-indicator.saving {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.save-status-indicator.unsaved {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.save-status-indicator.saved {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
|
||||
/* 节点列表样式 */
|
||||
.nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.node-item:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.node-item:active {
|
||||
cursor: grabbing;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-description {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
/* 工具栏操作按钮样式 */
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background: #f3f4f6;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.history-btn {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.history-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: #f9fafb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.history-btn:disabled:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* 滚动条样式 */
|
||||
.toolbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
width: 180px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,221 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import Toolbar from "./Toolbar.vue"
|
||||
import "@vue-flow/core/dist/style.css"
|
||||
import "@vue-flow/core/dist/theme-default.css"
|
||||
import "@vue-flow/controls/dist/style.css"
|
||||
import { useVueFlow, VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core"
|
||||
import { Controls } from "@vue-flow/controls"
|
||||
import { Background } from "@vue-flow/background"
|
||||
|
||||
import { useDnD } from "./useDnD"
|
||||
import { useHistory } from "./useHistory"
|
||||
import { useFlowOperations } from "./useFlowOperations"
|
||||
import { useCache } from "./useCache"
|
||||
import CustomNode from "./CustomNode.vue"
|
||||
|
||||
// Vue Flow 实例
|
||||
const { addNodes, addEdges, removeNodes, removeEdges } = useVueFlow()
|
||||
|
||||
// 节点和边的响应式数据
|
||||
const nodes = ref<Node[]>([])
|
||||
const edges = ref<Edge[]>([])
|
||||
|
||||
// 历史记录管理
|
||||
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
|
||||
|
||||
// 缓存管理
|
||||
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
|
||||
nodes,
|
||||
edges,
|
||||
'flowchart-editor-data'
|
||||
)
|
||||
|
||||
// 拖拽处理
|
||||
const { isDragOver, onDragOver, onDragLeave, onDrop } = useDnD()
|
||||
|
||||
// 流程操作
|
||||
const {
|
||||
handleConnect,
|
||||
handleEdgeClick,
|
||||
handleNodeDelete,
|
||||
handleNodeUpdate,
|
||||
clearCanvas,
|
||||
deleteSelected
|
||||
} = useFlowOperations(
|
||||
nodes,
|
||||
edges,
|
||||
addNodes,
|
||||
addEdges,
|
||||
removeNodes,
|
||||
removeEdges,
|
||||
saveState
|
||||
)
|
||||
|
||||
// 拖拽处理包装
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
onDragOver(event)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDragLeave()
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
// 处理正常的节点创建拖拽
|
||||
const newNode = onDrop(event)
|
||||
if (newNode) {
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销/重做处理
|
||||
const handleUndo = () => {
|
||||
const state = undo()
|
||||
if (state) {
|
||||
nodes.value = [...state.nodes]
|
||||
edges.value = [...state.edges]
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
const state = redo()
|
||||
if (state) {
|
||||
nodes.value = [...state.nodes]
|
||||
edges.value = [...state.edges]
|
||||
}
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
const handleClear = () => {
|
||||
clearCanvas()
|
||||
clearCache()
|
||||
}
|
||||
|
||||
// 键盘事件
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement) return
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
deleteSelected()
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === 'z' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleUndo()
|
||||
} else if (event.key === 'z' && event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleRedo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// 从缓存恢复数据
|
||||
loadFromCache()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 暴露节点和边数据给父组件
|
||||
defineExpose({
|
||||
nodes,
|
||||
edges,
|
||||
getFlowchartData: () => ({
|
||||
nodes: nodes.value,
|
||||
edges: edges.value
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">可拖拽的流程图编辑器</div>
|
||||
<div class="container">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@connect="handleConnect"
|
||||
@edge-click="handleEdgeClick"
|
||||
:default-edge-options="{
|
||||
type: 'step',
|
||||
style: {
|
||||
stroke: '#6366f1',
|
||||
strokeWidth: 2.5,
|
||||
cursor: 'pointer',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#6366f1',
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
}"
|
||||
:connection-line-style="{
|
||||
stroke: '#6366f1',
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: '8,4',
|
||||
markerEnd: 'url(#connection-arrow)',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
}"
|
||||
:fit-view-on-init="false"
|
||||
:connect-on-click="false"
|
||||
:multi-selection-key-code="null"
|
||||
:delete-key-code="null"
|
||||
>
|
||||
<!-- SVG 定义用于连接线箭头 -->
|
||||
<defs>
|
||||
<marker
|
||||
id="connection-arrow"
|
||||
markerWidth="12"
|
||||
markerHeight="12"
|
||||
refX="10"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M0,0 L0,6 L10,3 z" fill="#6366f1" stroke="#6366f1" strokeWidth="0.5" />
|
||||
</marker>
|
||||
</defs>
|
||||
<template #node-custom="{ data, id, type }">
|
||||
<CustomNode
|
||||
:id="id"
|
||||
:type="type"
|
||||
:data="data"
|
||||
@delete="handleNodeDelete"
|
||||
@update="handleNodeUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Background variant="lines" :gap="20" :size="1" />
|
||||
<Controls />
|
||||
<Toolbar
|
||||
:can-undo="canUndo"
|
||||
:can-redo="canRedo"
|
||||
:is-saving="isSaving"
|
||||
:last-saved="lastSaved"
|
||||
:has-unsaved-changes="hasUnsavedChanges"
|
||||
@clear="handleClear"
|
||||
@undo="handleUndo"
|
||||
@redo="handleRedo"
|
||||
@deleteNode="handleNodeDelete"
|
||||
/>
|
||||
</VueFlow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 133px);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
83
src/shared/components/FlowchartEditor/useCache.ts
Normal file
83
src/shared/components/FlowchartEditor/useCache.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useStorage, useDebounceFn } from '@vueuse/core'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
|
||||
/**
|
||||
* 缓存管理 - 使用 @vueuse 的 useStorage
|
||||
*/
|
||||
export function useCache(
|
||||
nodes: any,
|
||||
edges: any,
|
||||
storageKey: string = 'flowchart-editor-data'
|
||||
) {
|
||||
const isSaving = ref(false)
|
||||
const lastSaved = ref<Date | null>(null)
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
// 使用 useStorage 管理数据存储
|
||||
const storedData = useStorage<{
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
timestamp: string
|
||||
}>(storageKey, {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
timestamp: ''
|
||||
})
|
||||
|
||||
// 防抖保存
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
isSaving.value = true
|
||||
storedData.value.nodes = nodes.value
|
||||
storedData.value.edges = edges.value
|
||||
storedData.value.timestamp = new Date().toISOString()
|
||||
lastSaved.value = new Date()
|
||||
hasUnsavedChanges.value = false
|
||||
isSaving.value = false
|
||||
}, 500)
|
||||
|
||||
// 立即保存
|
||||
const saveToCache = () => {
|
||||
isSaving.value = true
|
||||
storedData.value.nodes = nodes.value
|
||||
storedData.value.edges = edges.value
|
||||
storedData.value.timestamp = new Date().toISOString()
|
||||
lastSaved.value = new Date()
|
||||
hasUnsavedChanges.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
// 从缓存加载数据
|
||||
const loadFromCache = () => {
|
||||
if (storedData.value.nodes?.length || storedData.value.edges?.length) {
|
||||
nodes.value = storedData.value.nodes
|
||||
edges.value = storedData.value.edges
|
||||
lastSaved.value = storedData.value.timestamp ? new Date(storedData.value.timestamp) : null
|
||||
hasUnsavedChanges.value = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 清除缓存数据
|
||||
const clearCache = () => {
|
||||
storedData.value = { nodes: [], edges: [], timestamp: '' }
|
||||
lastSaved.value = null
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
// 监听节点和边的变化
|
||||
watch([nodes, edges], () => {
|
||||
hasUnsavedChanges.value = true
|
||||
debouncedSave()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
lastSaved,
|
||||
hasUnsavedChanges,
|
||||
saveToCache,
|
||||
loadFromCache,
|
||||
clearCache
|
||||
}
|
||||
}
|
||||
71
src/shared/components/FlowchartEditor/useDnD.ts
Normal file
71
src/shared/components/FlowchartEditor/useDnD.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ref } from 'vue'
|
||||
import { useVueFlow } from "@vue-flow/core"
|
||||
import { nanoid } from "nanoid"
|
||||
import { getNodeTypeConfig, createNodeStyle, getNodeDimensions } from "./useNodeStyles"
|
||||
|
||||
/**
|
||||
* 简化的拖拽处理
|
||||
*/
|
||||
export function useDnD() {
|
||||
const { addNodes, screenToFlowCoordinate } = useVueFlow()
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 拖拽悬停处理
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
// 拖拽离开处理
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
// 拖拽放置处理
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
const type = event.dataTransfer?.getData("application/vueflow")
|
||||
if (!type) return
|
||||
|
||||
// 获取鼠标在画布中的坐标
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
// 根据节点类型获取实际尺寸
|
||||
const dimensions = getNodeDimensions(type)
|
||||
|
||||
// 调整位置,使节点中心点对齐到鼠标位置
|
||||
const adjustedPosition = {
|
||||
x: position.x - dimensions.width / 2,
|
||||
y: position.y - dimensions.height / 2
|
||||
}
|
||||
|
||||
const nodeId = `node-${nanoid()}`
|
||||
const config = getNodeTypeConfig(type)
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: 'custom',
|
||||
position: adjustedPosition,
|
||||
data: {
|
||||
label: config.label,
|
||||
color: config.color,
|
||||
originalType: type
|
||||
},
|
||||
style: createNodeStyle(type)
|
||||
}
|
||||
|
||||
addNodes([newNode])
|
||||
return newNode
|
||||
}
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}
|
||||
}
|
||||
107
src/shared/components/FlowchartEditor/useFlowOperations.ts
Normal file
107
src/shared/components/FlowchartEditor/useFlowOperations.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { nanoid } from "nanoid"
|
||||
import type { Ref } from 'vue'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
|
||||
/**
|
||||
* 简化的流程操作
|
||||
*/
|
||||
export function useFlowOperations(
|
||||
nodes: Ref<Node[]>,
|
||||
edges: Ref<Edge[]>,
|
||||
addNodes: (nodes: Node[]) => void,
|
||||
addEdges: (edges: Edge[]) => void,
|
||||
removeNodes: (nodeIds: string[]) => void,
|
||||
removeEdges: (edgeIds: string[]) => void,
|
||||
saveState: (nodes: Node[], edges: Edge[]) => void
|
||||
) {
|
||||
// 连接处理
|
||||
const handleConnect = (params: any) => {
|
||||
const newEdge: Edge = {
|
||||
id: `edge-${nanoid()}`,
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
sourceHandle: params.sourceHandle,
|
||||
targetHandle: params.targetHandle,
|
||||
type: 'default'
|
||||
}
|
||||
|
||||
addEdges([newEdge])
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
// 边点击删除
|
||||
const handleEdgeClick = (event: any) => {
|
||||
removeEdges([event.edge.id])
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
// 节点删除
|
||||
const handleNodeDelete = (nodeId: string) => {
|
||||
// 删除相关边
|
||||
const relatedEdges = edges.value.filter(edge =>
|
||||
edge.source === nodeId || edge.target === nodeId
|
||||
)
|
||||
if (relatedEdges.length > 0) {
|
||||
removeEdges(relatedEdges.map(edge => edge.id))
|
||||
}
|
||||
|
||||
removeNodes([nodeId])
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
// 节点更新
|
||||
const handleNodeUpdate = (nodeId: string, newLabel: string) => {
|
||||
const nodeIndex = nodes.value.findIndex(node => node.id === nodeId)
|
||||
|
||||
if (nodeIndex !== -1) {
|
||||
const oldNode = nodes.value[nodeIndex]
|
||||
|
||||
// 创建新的节点对象以确保响应式更新
|
||||
const updatedNode = {
|
||||
...oldNode,
|
||||
data: {
|
||||
...oldNode.data,
|
||||
customLabel: newLabel
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Vue Flow 的更新方法
|
||||
nodes.value[nodeIndex] = updatedNode
|
||||
|
||||
// 强制触发响应式更新
|
||||
nodes.value = [...nodes.value]
|
||||
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
const clearCanvas = () => {
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
// 删除选中的节点和边
|
||||
const deleteSelected = () => {
|
||||
const selectedNodes = nodes.value.filter(node => (node as any).selected)
|
||||
const selectedEdges = edges.value.filter(edge => (edge as any).selected)
|
||||
|
||||
if (selectedNodes.length > 0) {
|
||||
removeNodes(selectedNodes.map(node => node.id))
|
||||
}
|
||||
if (selectedEdges.length > 0) {
|
||||
removeEdges(selectedEdges.map(edge => edge.id))
|
||||
}
|
||||
saveState(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
return {
|
||||
handleConnect,
|
||||
handleEdgeClick,
|
||||
handleNodeDelete,
|
||||
handleNodeUpdate,
|
||||
clearCanvas,
|
||||
deleteSelected
|
||||
}
|
||||
}
|
||||
63
src/shared/components/FlowchartEditor/useHistory.ts
Normal file
63
src/shared/components/FlowchartEditor/useHistory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
|
||||
/**
|
||||
* 简化的历史记录管理
|
||||
*/
|
||||
export function useHistory() {
|
||||
const history = ref<{ nodes: Node[], edges: Edge[] }[]>([])
|
||||
const historyIndex = ref(-1)
|
||||
|
||||
// 是否可以撤销
|
||||
const canUndo = computed(() => historyIndex.value > 0)
|
||||
|
||||
// 是否可以重做
|
||||
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
|
||||
|
||||
// 保存状态到历史记录
|
||||
const saveState = (nodes: Node[], edges: Edge[]) => {
|
||||
const currentState = { nodes: [...nodes], edges: [...edges] }
|
||||
|
||||
// 如果当前不在历史记录的末尾,删除后面的记录
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1)
|
||||
}
|
||||
|
||||
history.value.push(currentState)
|
||||
historyIndex.value = history.value.length - 1
|
||||
|
||||
// 限制历史记录数量
|
||||
if (history.value.length > 20) {
|
||||
history.value.shift()
|
||||
historyIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销
|
||||
const undo = () => {
|
||||
if (canUndo.value) {
|
||||
historyIndex.value--
|
||||
const state = history.value[historyIndex.value]
|
||||
return state
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 重做
|
||||
const redo = () => {
|
||||
if (canRedo.value) {
|
||||
historyIndex.value++
|
||||
const state = history.value[historyIndex.value]
|
||||
return state
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
saveState,
|
||||
undo,
|
||||
redo
|
||||
}
|
||||
}
|
||||
137
src/shared/components/FlowchartEditor/useNodeStyles.ts
Normal file
137
src/shared/components/FlowchartEditor/useNodeStyles.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 节点样式管理
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取节点类型配置
|
||||
*/
|
||||
export function getNodeTypeConfig(type: string) {
|
||||
const configs: Record<
|
||||
string,
|
||||
{ label: string; color: string; icon: string; description: string }
|
||||
> = {
|
||||
start: {
|
||||
label: "开始",
|
||||
color: "#10b981",
|
||||
icon: "▶",
|
||||
description: "流程开始",
|
||||
},
|
||||
input: {
|
||||
label: "输入",
|
||||
color: "#06b6d4",
|
||||
icon: "📥",
|
||||
description: "数据输入",
|
||||
},
|
||||
default: {
|
||||
label: "赋值",
|
||||
color: "#3b82f6",
|
||||
icon: "⚙",
|
||||
description: "赋值语句",
|
||||
},
|
||||
decision: {
|
||||
label: "判断",
|
||||
color: "#f59e0b",
|
||||
icon: "❓",
|
||||
description: "条件语句",
|
||||
},
|
||||
loop: {
|
||||
label: "循环",
|
||||
color: "#8b5cf6",
|
||||
icon: "🔄",
|
||||
description: "循环语句",
|
||||
},
|
||||
output: {
|
||||
label: "输出",
|
||||
color: "#84cc16",
|
||||
icon: "📤",
|
||||
description: "数据输出",
|
||||
},
|
||||
end: {
|
||||
label: "结束",
|
||||
color: "#ef4444",
|
||||
icon: "⏹",
|
||||
description: "流程结束",
|
||||
},
|
||||
}
|
||||
return (
|
||||
configs[type] || {
|
||||
label: "节点",
|
||||
color: "#6b7280",
|
||||
icon: "⚪",
|
||||
description: "未知节点",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点样式
|
||||
*/
|
||||
export function getNodeStyle(type: string, color: string) {
|
||||
const baseStyle = {
|
||||
background: color,
|
||||
color: "white",
|
||||
border: `2px solid ${color}`,
|
||||
borderRadius: "10px",
|
||||
fontSize: "16px",
|
||||
fontWeight: "500",
|
||||
width: "auto", // 自动宽度
|
||||
height: "auto", // 自动高度
|
||||
minWidth: "100px",
|
||||
minHeight: "40px",
|
||||
maxWidth: "400px",
|
||||
maxHeight: "160px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
}
|
||||
|
||||
// 根据节点类型调整样式
|
||||
switch (type) {
|
||||
case "start":
|
||||
case "end":
|
||||
return {
|
||||
...baseStyle,
|
||||
}
|
||||
case "decision":
|
||||
return {
|
||||
...baseStyle,
|
||||
borderRadius: "8px",
|
||||
minWidth: "140px",
|
||||
minHeight: "50px",
|
||||
}
|
||||
case "loop":
|
||||
return {
|
||||
...baseStyle,
|
||||
borderRadius: "8px",
|
||||
minWidth: "140px",
|
||||
minHeight: "50px",
|
||||
}
|
||||
default:
|
||||
return baseStyle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建节点样式
|
||||
*/
|
||||
export function createNodeStyle(type: string) {
|
||||
const config = getNodeTypeConfig(type)
|
||||
return getNodeStyle(type, config.color)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点尺寸
|
||||
*/
|
||||
export function getNodeDimensions(type: string) {
|
||||
switch (type) {
|
||||
case "start":
|
||||
case "end":
|
||||
return { width: 100, height: 40 }
|
||||
case "decision":
|
||||
case "loop":
|
||||
return { width: 140, height: 50 }
|
||||
default:
|
||||
return { width: 120, height: 40 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user