增加流程管理功能
parent
fbec62e3c1
commit
1f1db18c3e
|
|
@ -62,7 +62,7 @@
|
|||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
"lint:lint-staged": "lint-staged -c "
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"@form-create/designer": "^3.2.6",
|
||||
"@form-create/element-ui": "^3.2.11",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
|
|
@ -58,7 +57,9 @@
|
|||
"markmap-toolbar": "^0.17.0",
|
||||
"markmap-view": "^0.16.0",
|
||||
"min-dash": "^4.1.1",
|
||||
"mitt": "^3.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@maxgraph/core": "^0.14.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ importers:
|
|||
.:
|
||||
dependencies:
|
||||
'@element-plus/icons-vue':
|
||||
specifier: ^2.1.0
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1(vue@3.5.12(typescript@5.3.3))
|
||||
'@form-create/designer':
|
||||
specifier: ^3.2.6
|
||||
|
|
@ -20,6 +20,9 @@ importers:
|
|||
'@iconify/iconify':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
'@maxgraph/core':
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0
|
||||
'@microsoft/fetch-event-source':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
|
|
@ -1341,6 +1344,9 @@ packages:
|
|||
'@lezer/markdown@1.3.2':
|
||||
resolution: {integrity: sha512-Wu7B6VnrKTbBEohqa63h5vxXjiC4pO5ZQJ/TDbhJxPQaaIoRD/6UVDhSDtVsCwVZV12vvN9KxuLL3ATMnlG0oQ==}
|
||||
|
||||
'@maxgraph/core@0.14.0':
|
||||
resolution: {integrity: sha512-PRfoX8Z7jwG3lDm+C+h6nS5pseW+m6PxzP5drP5azSsHHCdHndLt7eFvHPIxseKm0jp3YcGpyuzJvb490s2u9g==}
|
||||
|
||||
'@microsoft/fetch-event-source@2.0.1':
|
||||
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||
|
||||
|
|
@ -6974,6 +6980,8 @@ snapshots:
|
|||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
|
||||
'@maxgraph/core@0.14.0': {}
|
||||
|
||||
'@microsoft/fetch-event-source@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
import request from '@/config/axios'
|
||||
export const register = (data: any) => {
|
||||
return request.post({
|
||||
url: '/api/flow/register',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
export const ToolbarIcons = {
|
||||
grid: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M3 3h18v18H3z"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M3 15h18"/>
|
||||
<path d="M9 3v18"/>
|
||||
<path d="M15 3v18"/>
|
||||
</svg>`,
|
||||
|
||||
export: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>`,
|
||||
|
||||
undo: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M3 7v6h6"/>
|
||||
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
|
||||
</svg>`,
|
||||
|
||||
redo: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M21 7v6h-6"/>
|
||||
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>
|
||||
</svg>`,
|
||||
|
||||
delete: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>`,
|
||||
|
||||
fit: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M15 3h6v6"/>
|
||||
<path d="M9 21H3v-6"/>
|
||||
<path d="M21 3l-7 7"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
</svg>`,
|
||||
|
||||
zoomOut: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>`,
|
||||
|
||||
zoomIn: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
<line x1="11" y1="8" x2="11" y2="14"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>`,
|
||||
|
||||
back: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M19 12H5"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>`,
|
||||
|
||||
curvedLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<path d="M3 12c4-6 14-6 18 0"/>
|
||||
</svg>`,
|
||||
|
||||
orthogonalLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<polyline points="3 7 12 7 12 17 21 17"/>
|
||||
</svg>`,
|
||||
|
||||
straightLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
</svg>`
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
export const ToolbarIcons = {
|
||||
zoomIn: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="11" y1="8" x2="11" y2="14"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>`,
|
||||
|
||||
zoomOut: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>`,
|
||||
|
||||
fit: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M15 3h6v6M9 3H3v6M15 21h6v-6M9 21H3v-6"></path>
|
||||
</svg>`,
|
||||
|
||||
delete: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>`,
|
||||
|
||||
undo: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 7v6h6"></path>
|
||||
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path>
|
||||
</svg>`,
|
||||
|
||||
redo: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 7v6h-6"></path>
|
||||
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path>
|
||||
</svg>`,
|
||||
|
||||
straightLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>`,
|
||||
|
||||
orthogonalLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="5,12 12,12 12,19"></polyline>
|
||||
</svg>`,
|
||||
|
||||
curvedLine: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M5 12c8-10 8 10 16 0"></path>
|
||||
</svg>`,
|
||||
|
||||
autoLayout: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>`,
|
||||
|
||||
export: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>`,
|
||||
|
||||
back: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>`
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div class="accordion">
|
||||
<div v-for="(item, index) in items" :key="index" class="accordion-item">
|
||||
<div class="accordion-header" @click="toggleItem(index)">
|
||||
<span class="accordion-title">{{ item.title }}</span>
|
||||
<span class="accordion-icon">{{ activeIndex === index ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div class="accordion-content" :class="{ 'active': activeIndex === index }">
|
||||
<template v-if="item.type === 'form'">
|
||||
<div v-for="(field, fieldIndex) in item.content" :key="fieldIndex" class="form-item">
|
||||
<label>{{ field.label }}</label>
|
||||
<input v-if="field.type === 'text'" type="text" v-model="field.value">
|
||||
<select v-else-if="field.type === 'select'" v-model="field.value">
|
||||
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.content }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
items: Array<{
|
||||
title: string;
|
||||
type?: 'text' | 'form';
|
||||
content: any;
|
||||
}>
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
activeIndex.value = activeIndex.value === index ? -1 : index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.accordion {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
padding: 12px 16px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.accordion-content.active {
|
||||
max-height: 500px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-item label {
|
||||
min-width: 60px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-item input,
|
||||
.form-item select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.form-item input:focus,
|
||||
.form-item select:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
<template>
|
||||
<div class="flow-editor">
|
||||
<!-- 顶部区域:菜单和工具栏 -->
|
||||
<FlowHeader />
|
||||
|
||||
<!-- 主内容区域:左中右布局 -->
|
||||
<div class="flow-content">
|
||||
<!-- 左侧:节点库和风格库 -->
|
||||
<div class="left-panel" :style="{ width: isLeftPanelVisible ? leftPanelWidth + 'px' : '0' }">
|
||||
<div class="panel-content" v-show="isLeftPanelVisible">
|
||||
<FlowAccordion />
|
||||
</div>
|
||||
<div class="resize-handle left" @mousedown="startResize('left', $event)">
|
||||
<div class="panel-toggle" @click.stop="toggleLeftPanel">
|
||||
<el-icon :class="{ 'is-collapsed': !isLeftPanelVisible }">
|
||||
<CaretLeft />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:画布 -->
|
||||
<div class="center-panel">
|
||||
<FlowCanvas />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:属性面板 -->
|
||||
<div class="right-panel" :style="{ width: isRightPanelVisible ? rightPanelWidth + 'px' : '0' }">
|
||||
<div class="resize-handle right" @mousedown="startResize('right', $event)">
|
||||
<div class="panel-toggle" @click.stop="toggleRightPanel">
|
||||
<el-icon :class="{ 'is-collapsed': !isRightPanelVisible }">
|
||||
<CaretRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content" v-show="isRightPanelVisible">
|
||||
<PropertyPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import FlowAccordion from './panel/FlowAccordion.vue'
|
||||
import FlowCanvas from './canvas/FlowCanvas.vue'
|
||||
import PropertyPanel from './property/PropertyPanel.vue'
|
||||
import { CaretLeft, CaretRight } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'FlowEditor'
|
||||
})
|
||||
|
||||
// 面板宽度状态
|
||||
const leftPanelWidth = ref(280)
|
||||
const rightPanelWidth = ref(300)
|
||||
const minWidth = 200
|
||||
const maxWidth = 500
|
||||
|
||||
// 拖拽状态
|
||||
const isResizing = ref(false)
|
||||
const currentPanel = ref<'left' | 'right' | null>(null)
|
||||
const startX = ref(0)
|
||||
const startWidth = ref(0)
|
||||
|
||||
// 面板显示状态
|
||||
const isLeftPanelVisible = ref(true)
|
||||
const isRightPanelVisible = ref(true)
|
||||
|
||||
// 切换面板显示/隐藏
|
||||
const toggleLeftPanel = () => {
|
||||
isLeftPanelVisible.value = !isLeftPanelVisible.value
|
||||
}
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
isRightPanelVisible.value = !isRightPanelVisible.value
|
||||
}
|
||||
|
||||
// 开始拖拽
|
||||
const startResize = (panel: 'left' | 'right', event: MouseEvent) => {
|
||||
isResizing.value = true
|
||||
currentPanel.value = panel
|
||||
startX.value = event.clientX
|
||||
startWidth.value = panel === 'left' ? leftPanelWidth.value : rightPanelWidth.value
|
||||
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
// 拖拽过程
|
||||
const handleResize = (event: MouseEvent) => {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const diff = event.clientX - startX.value
|
||||
const newWidth = currentPanel.value === 'left'
|
||||
? startWidth.value + diff
|
||||
: startWidth.value - diff
|
||||
|
||||
// 限制最小和最大宽度
|
||||
const clampedWidth = Math.min(Math.max(newWidth, minWidth), maxWidth)
|
||||
|
||||
if (currentPanel.value === 'left') {
|
||||
leftPanelWidth.value = clampedWidth
|
||||
} else {
|
||||
rightPanelWidth.value = clampedWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 结束拖拽
|
||||
const stopResize = () => {
|
||||
isResizing.value = false
|
||||
currentPanel.value = null
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-editor {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flow-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-right: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-left: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #f5f7fa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: #e6e6e6;
|
||||
}
|
||||
|
||||
.resize-handle.left {
|
||||
right: -2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resize-handle.right {
|
||||
left: -2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 拖拽时的遮罩,防止选中其他内容 */
|
||||
.flow-editor.resizing::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
width: 16px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.panel-toggle.left {
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.panel-toggle.right {
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.is-collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 修改过渡动画 */
|
||||
.left-panel,
|
||||
.right-panel {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resize-handle[draggable="true"] {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* 当面板打开时才显示拖拽光标 */
|
||||
.left-panel:not([style*="width: 0"]) .resize-handle,
|
||||
.right-panel:not([style*="width: 0"]) .resize-handle {
|
||||
cursor: col-resize;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="flow-header">
|
||||
<FlowMenu />
|
||||
<FlowToolbar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FlowMenu from './menu/FlowMenu.vue'
|
||||
import FlowToolbar from './menu/FlowToolbar.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'FlowHeader'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
flex-shrink: 0; /* 防止头部被压缩 */
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/* SplitPane.css */
|
||||
.split-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* ... 其他样式代码 ... */
|
||||
|
||||
.right-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
<template>
|
||||
<div class="split-container">
|
||||
<div class="toolbar">
|
||||
<slot name="toolbar">默认工具栏</slot>
|
||||
<div class="right-panel-trigger" v-if="!state.rightPanelVisible" @click="showRightPanel">
|
||||
<span class="trigger-icon">⚙</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-container">
|
||||
<div class="left-panel" :style="{ width: state.leftPanelVisible ? state.leftPanelWidth + 'px' : '0' }">
|
||||
<slot name="left">左侧面板</slot>
|
||||
</div>
|
||||
<div class="divider left-divider" @mousedown="leftDragHandler.startDrag">
|
||||
<div class="toggle-button" @click.stop="toggleLeftPanel">
|
||||
<span class="arrow-icon">
|
||||
{{ state.leftPanelVisible ? '◀' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center-panel">
|
||||
<slot name="center">中间面板</slot>
|
||||
</div>
|
||||
<div v-if="!state.rightPanelPopup"
|
||||
class="divider right-divider"
|
||||
@mousedown="rightDragHandler.startDrag">
|
||||
<div class="toggle-button" @click.stop="toggleRightPanel">
|
||||
<span class="arrow-icon">
|
||||
{{ state.rightPanelVisible ? '▶' : '◀' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="state.rightPanelVisible"
|
||||
class="right-panel-container"
|
||||
:class="{
|
||||
'popup-mode': state.rightPanelPopup,
|
||||
'docked-mode': !state.rightPanelPopup
|
||||
}"
|
||||
:style="rightPanelStyle">
|
||||
<div class="right-panel-header"
|
||||
@mousedown="handleDragStart"
|
||||
:class="{ 'draggable': state.rightPanelPopup }">
|
||||
<span class="panel-title">
|
||||
<slot name="right-title">设置</slot>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<span class="mode-toggle" @click="toggleRightPanelMode">
|
||||
{{ state.rightPanelDocked ? '⇱' : '⇲' }}
|
||||
</span>
|
||||
<span class="close-button" @click="hideRightPanel">✕</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-panel-content">
|
||||
<slot name="right">右侧面板</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useDragHandler } from '../utils/dragHandler'
|
||||
import './SplitPane.css' // 引入 CSS 文件
|
||||
|
||||
const state = reactive({
|
||||
leftPanelWidth: 300,
|
||||
rightPanelWidth: 400,
|
||||
leftPanelVisible: true,
|
||||
rightPanelVisible: false, // 默认隐藏
|
||||
rightPanelPopup: false,
|
||||
rightPanelDocked: true, // 新增:控制右侧面板是否吸附
|
||||
// 添加右侧面板位置信息
|
||||
rightPanelPosition: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 添加拖动相关的状态
|
||||
const isDragging = ref(false)
|
||||
const dragStartPosition = reactive({
|
||||
x: 0,
|
||||
y: 0,
|
||||
panelX: 0,
|
||||
panelY: 0
|
||||
})
|
||||
|
||||
// 添加拖动处理函数
|
||||
const handleDragStart = (e: MouseEvent) => {
|
||||
if (!state.rightPanelPopup) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPosition.x = e.clientX
|
||||
dragStartPosition.y = e.clientY
|
||||
dragStartPosition.panelX = state.rightPanelPosition.x
|
||||
dragStartPosition.panelY = state.rightPanelPosition.y
|
||||
}
|
||||
|
||||
const handleDrag = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = e.clientX - dragStartPosition.x
|
||||
const deltaY = e.clientY - dragStartPosition.y
|
||||
|
||||
state.rightPanelPosition.x = dragStartPosition.panelX + deltaX
|
||||
state.rightPanelPosition.y = dragStartPosition.panelY + deltaY
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
// 在组件卸载时移除全局事件监听
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleDrag)
|
||||
window.addEventListener('mouseup', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleDrag)
|
||||
window.removeEventListener('mouseup', handleDragEnd)
|
||||
})
|
||||
|
||||
const toggleLeftPanel = () => {
|
||||
state.leftPanelVisible = !state.leftPanelVisible
|
||||
}
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
state.rightPanelVisible = !state.rightPanelVisible
|
||||
}
|
||||
|
||||
const toggleRightPanelMode = () => {
|
||||
state.rightPanelDocked = !state.rightPanelDocked
|
||||
state.rightPanelPopup = !state.rightPanelDocked
|
||||
if (!state.rightPanelDocked) {
|
||||
// 切换到弹出模式时重置位置
|
||||
state.rightPanelPosition.x = 0
|
||||
state.rightPanelPosition.y = 0
|
||||
}
|
||||
}
|
||||
|
||||
const leftDragHandler = useDragHandler(state, 'left')
|
||||
const rightDragHandler = useDragHandler(state, 'right')
|
||||
|
||||
/**
|
||||
* 显示右侧面板。
|
||||
* 此函数用于设置状态,使右侧面板弹出显示并且可见。
|
||||
*/
|
||||
const showRightPanel = () => {
|
||||
state.rightPanelVisible = true
|
||||
if (!state.rightPanelDocked) {
|
||||
state.rightPanelPopup = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏右侧面板的函数。
|
||||
* 通过设置状态中的 rightPanelPopup 和 rightPanelVisible 为 false 来隐藏右侧面板。
|
||||
*/
|
||||
const hideRightPanel = () => {
|
||||
if (state.rightPanelPopup) {
|
||||
state.rightPanelPopup = false
|
||||
state.rightPanelVisible = false
|
||||
} else {
|
||||
state.rightPanelVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加计算属性用于设置右侧面板样式
|
||||
const rightPanelStyle = computed(() => {
|
||||
if (!state.rightPanelPopup) return {}
|
||||
|
||||
return {
|
||||
transform: `translate(${state.rightPanelPosition.x}px, ${state.rightPanelPosition.y}px)`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.split-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel, .right-panel {
|
||||
background-color: #f5f7fa;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
flex: 1;
|
||||
background-color: #ffffff;
|
||||
min-width: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 5px;
|
||||
cursor: ew-resize;
|
||||
background-color: #e4e7ed;
|
||||
transition: background-color 0.3s;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider:hover {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background-color: #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left-divider .toggle-button {
|
||||
border-radius: 0 25px 25px 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-divider .toggle-button {
|
||||
border-radius: 25px 0 0 25px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #606266;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.right-panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-panel-container.docked-mode {
|
||||
width: v-bind('state.rightPanelWidth + "px"');
|
||||
border-left: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.right-panel-container.popup-mode {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 60px;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.mode-toggle:hover {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.right-divider {
|
||||
width: 5px;
|
||||
background-color: #e4e7ed;
|
||||
cursor: ew-resize;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.right-divider .toggle-button {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background-color: #e4e7ed;
|
||||
border-radius: 25px 0 0 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
left: -20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.right-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.right-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.right-panel-trigger {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f7fa;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.right-panel-trigger:hover {
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.trigger-icon {
|
||||
font-size: 20px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.right-panel-header {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div class="flow-canvas-wrapper">
|
||||
<div class="flow-canvas" ref="canvasRef" @dragover="handleDragOver" @drop="handleDrop"
|
||||
:class="{ 'show-grid': showGrid }">
|
||||
</div>
|
||||
|
||||
<div ref="graphOutlineRef" v-if="showGraphOutline" class="graph-outline" @mousedown="onMouseDown">
|
||||
<div class="title-bar">
|
||||
<span>缩略图</span>
|
||||
<button @click="closeGraphOutline" class="close-button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextEditToolbar :visible="showTextToolbar" :position="toolbarPosition" :currentStyle="currentNodeStyle" />
|
||||
|
||||
<TableEditor
|
||||
:visible="showTableEditor"
|
||||
@update:visible="showTableEditor = $event"
|
||||
:initial-data="currentTableConfig"
|
||||
@confirm="handleTableUpdate"
|
||||
/>
|
||||
|
||||
<ExportPreview
|
||||
v-model:visible="showExportPreview"
|
||||
:image-url="exportImageUrl"
|
||||
@confirm="handleExportConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { Graph } from "@maxgraph/core";
|
||||
import { flowEventBus } from "../../events";
|
||||
import { WuFlowAction } from "../../utils/flow-utils";
|
||||
import { CanvasEventHandler } from "../../handlers/CanvasEventHandler";
|
||||
import TextEditToolbar from '../menu/TextEditToolbar.vue';
|
||||
import TableEditor from '../property/TableEditor.vue';
|
||||
import ExportPreview from '../property/ExportPreview.vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
interface FlowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const showGraphOutline = ref(true);
|
||||
let isDragging = false;
|
||||
let offsetX = ref(0);
|
||||
let offsetY = ref(0);
|
||||
let wuFlowAction: WuFlowAction | null = null;
|
||||
const canvasRef = ref<HTMLElement>();
|
||||
const graphOutlineRef = ref<HTMLElement>();
|
||||
let graph: Graph | null = null;
|
||||
|
||||
// 添加网格显示状态
|
||||
const showGrid = ref(true);
|
||||
|
||||
// 在 script setup 顶部其他 ref 变量附近添加
|
||||
const showTextToolbar = ref(false);
|
||||
const toolbarPosition = ref<{ left: string; top: string }>({ top: '0px', left: '0px' });
|
||||
const currentNodeStyle = ref({
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 14,
|
||||
fontColor: '#000000',
|
||||
fontWeight: 'normal',
|
||||
textDecoration: 'none'
|
||||
});
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (isDragging && graphOutlineRef.value) {
|
||||
graphOutlineRef.value.style.left = `${event.clientX - offsetX.value}px`;
|
||||
graphOutlineRef.value.style.top = `${event.clientY - offsetY.value}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (graphOutlineRef.value) {
|
||||
isDragging = true;
|
||||
offsetX.value = event.clientX - graphOutlineRef.value.offsetLeft;
|
||||
offsetY.value = event.clientY - graphOutlineRef.value.offsetTop;
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const closeGraphOutline = () => {
|
||||
showGraphOutline.value = false;
|
||||
};
|
||||
|
||||
let eventHandler: CanvasEventHandler;
|
||||
|
||||
onMounted(() => {
|
||||
initFlowCanvas();
|
||||
eventHandler = new CanvasEventHandler(wuFlowAction, graph, canvasRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
eventHandler?.destroy();
|
||||
});
|
||||
|
||||
const initFlowCanvas = () => {
|
||||
wuFlowAction = new WuFlowAction(canvasRef.value, graphOutlineRef.value);
|
||||
graph = wuFlowAction.getGraph();
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
wuFlowAction?.insertVertex(event);
|
||||
};
|
||||
|
||||
const showTableEditor = ref(false);
|
||||
const currentTableConfig = ref(null);
|
||||
const currentTableCell = ref(null);
|
||||
|
||||
// 监听表格编辑事件
|
||||
flowEventBus.on('edit-table', ({ cell, config }) => {
|
||||
currentTableCell.value = cell;
|
||||
currentTableConfig.value = config;
|
||||
showTableEditor.value = true;
|
||||
});
|
||||
|
||||
// 处理表格更新
|
||||
const handleTableUpdate = ({ html, config }) => {
|
||||
if (currentTableCell.value) {
|
||||
flowEventBus.emit('table-updated', {
|
||||
cell: currentTableCell.value,
|
||||
html,
|
||||
config
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showExportPreview = ref(false);
|
||||
const exportImageUrl = ref('');
|
||||
|
||||
const handleExportConfirm = (imageUrl: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = 'flow.png';
|
||||
link.click();
|
||||
ElMessage({
|
||||
message: '图片已开始下载',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: 'FlowCanvas'
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '../../styles/flow-canvas.css';
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
export const FlowIcons = {
|
||||
// 基础流程图标
|
||||
Start: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
End: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
Process: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Decision: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 4L20 12L12 20L4 12L12 4Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Arrow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12H20M20 12L14 6M20 12L14 18" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// UML节点图标
|
||||
Class: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" stroke-width="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
<line x1="3" y1="15" x2="21" y2="15" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Interface: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" stroke-width="2" stroke-dasharray="4 2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Package: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 3H10V7H21V21H3V3Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Component: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M20 12L16 8L16 16L20 12Z" stroke-width="2"/>
|
||||
<rect x="4" y="6" width="12" height="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Object: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Enumeration: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" stroke-width="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
<text x="7" y="15" font-size="8" fill="currentColor">enum</text>
|
||||
</svg>`,
|
||||
|
||||
Association: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Inheritance: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="2"/>
|
||||
<path d="M14 6L20 12L14 18" fill="none" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Implementation: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="2" stroke-dasharray="4 2"/>
|
||||
<path d="M14 6L20 12L14 18" fill="none" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Dependency: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="2" stroke-dasharray="4 2"/>
|
||||
<path d="M14 6L20 12L14 18" fill="none" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 高级组件图标
|
||||
SubProcess: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Parallel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
<line x1="8" y1="9" x2="16" y2="9" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke-width="2"/>
|
||||
<line x1="8" y1="15" x2="16" y2="15" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Timer: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
<path d="M12 8v4l3 3" stroke-width="2"/>
|
||||
</svg>`
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Menu Components
|
||||
export { default as FlowToolbar } from './menu/FlowToolbar.vue';
|
||||
export { default as TextEditToolbar } from './menu/TextEditToolbar.vue';
|
||||
|
||||
// Node Components
|
||||
export { default as ShapeLibrary } from './node/ShapeLibrary.vue';
|
||||
export * from './node/flowNodes';
|
||||
|
||||
// Property Components
|
||||
export { default as StyleLibrary } from './property/StyleLibrary.vue';
|
||||
export { default as TableEditor } from './property/TableEditor.vue';
|
||||
export { default as ExportPreview } from './property/ExportPreview.vue';
|
||||
|
||||
// Canvas Components
|
||||
export { default as FlowCanvas } from './canvas/FlowCanvas.vue';
|
||||
|
||||
// Panel Components
|
||||
export { default as FlowAccordion } from './panel/FlowAccordion.vue';
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
<template>
|
||||
<div class="flow-menu">
|
||||
<el-menu mode="horizontal" :ellipsis="false">
|
||||
<el-menu-item v-for="menu in menuConfig" :key="menu.key" :index="menu.key" @mouseenter="(e) => showSubmenu(menu.key, e)">
|
||||
{{ menu.label }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 子菜单浮层 -->
|
||||
<div v-if="activeSubmenu" class="submenu-popup" :style="submenuStyle" @mouseleave="hideSubmenu">
|
||||
<template v-for="item in activeSubmenuItems" :key="item.key">
|
||||
<el-divider v-if="item.type === 'divider'" />
|
||||
<div v-else class="submenu-item" @click="handleMenuClick(item)">
|
||||
<el-icon v-if="item.icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="menu-label">{{ item.label }}</span>
|
||||
<span v-if="item.shortcut" class="shortcut">{{ item.shortcut }}</span>
|
||||
<el-checkbox v-if="item.type === 'checkbox'" v-model="item.checked" @click.stop />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { menuConfig } from './menuConfig'
|
||||
import { flowEventBus } from '../../events'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const activeSubmenu = ref(null)
|
||||
const submenuPosition = ref({ x: 0, y: 0 })
|
||||
const size = ref({ width: 1000, height: 800 })
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const activeSubmenuItems = computed(() => {
|
||||
if (!activeSubmenu.value) return []
|
||||
return menuConfig.find(m => m.key === activeSubmenu.value)?.children || []
|
||||
})
|
||||
|
||||
const submenuStyle = computed(() => ({
|
||||
left: submenuPosition.value.x + 'px',
|
||||
top: submenuPosition.value.y + 'px'
|
||||
}))
|
||||
|
||||
const showSubmenu = (key: string, event: MouseEvent) => {
|
||||
const menuEl = event.target as HTMLElement
|
||||
const rect = menuEl.getBoundingClientRect()
|
||||
submenuPosition.value = {
|
||||
x: rect.left,
|
||||
y: rect.bottom
|
||||
}
|
||||
activeSubmenu.value = key
|
||||
}
|
||||
|
||||
const hideSubmenu = () => {
|
||||
activeSubmenu.value = null
|
||||
}
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked
|
||||
|
||||
// 处理复选框状态变化
|
||||
switch (item.key) {
|
||||
case 'showGrid':
|
||||
flowEventBus.emit('toggle-grid', item.checked)
|
||||
break
|
||||
case 'showMinimap':
|
||||
flowEventBus.emit('toggle-minimap', item.checked)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理编辑菜单事件
|
||||
switch (item.key) {
|
||||
// 文件菜单
|
||||
case 'new':
|
||||
flowEventBus.emit('file-new')
|
||||
break
|
||||
case 'open':
|
||||
flowEventBus.emit('file-open')
|
||||
break
|
||||
case 'save':
|
||||
flowEventBus.emit('file-save')
|
||||
break
|
||||
case 'exportImage':
|
||||
flowEventBus.emit('file-export-image')
|
||||
break
|
||||
case 'exportJson':
|
||||
flowEventBus.emit('file-export-json')
|
||||
break
|
||||
|
||||
// 编辑菜单
|
||||
case 'undo':
|
||||
flowEventBus.emit('undo')
|
||||
break
|
||||
case 'redo':
|
||||
flowEventBus.emit('redo')
|
||||
break
|
||||
case 'cut':
|
||||
flowEventBus.emit('cut')
|
||||
break
|
||||
case 'copy':
|
||||
flowEventBus.emit('copy')
|
||||
break
|
||||
case 'paste':
|
||||
flowEventBus.emit('paste')
|
||||
break
|
||||
case 'delete':
|
||||
flowEventBus.emit('delete')
|
||||
break
|
||||
case 'selectAll':
|
||||
flowEventBus.emit('select-all')
|
||||
break
|
||||
case 'invertSelect':
|
||||
flowEventBus.emit('invert-select')
|
||||
break
|
||||
case 'cancelSelect':
|
||||
flowEventBus.emit('cancel-select')
|
||||
break
|
||||
|
||||
// 视图菜单
|
||||
case 'zoomIn':
|
||||
flowEventBus.emit('zoom-in')
|
||||
break
|
||||
case 'zoomOut':
|
||||
flowEventBus.emit('zoom-out')
|
||||
break
|
||||
case 'fit':
|
||||
flowEventBus.emit('fit')
|
||||
break
|
||||
|
||||
// 画布菜单
|
||||
case 'backgroundColor':
|
||||
ElMessageBox.prompt('请输入背景颜色', '设置背景颜色', {
|
||||
inputPattern: /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/,
|
||||
inputErrorMessage: '请输入有效的十六进制颜色代码',
|
||||
inputValue: item.value
|
||||
}).then(({ value }) => {
|
||||
item.value = value;
|
||||
flowEventBus.emit('change-background-color', value);
|
||||
}).catch(() => { })
|
||||
break;
|
||||
|
||||
case 'gridSize':
|
||||
ElMessageBox.prompt('请输入网格大小', '设置网格大小', {
|
||||
inputPattern: /^\d+$/,
|
||||
inputErrorMessage: '请输入有效的整数',
|
||||
inputValue: item.value
|
||||
}).then(({ value }) => {
|
||||
item.value = Number(value);
|
||||
flowEventBus.emit('change-grid-size', item.value);
|
||||
}).catch(() => { })
|
||||
break;
|
||||
|
||||
case 'canvasSize':
|
||||
ElMessageBox.prompt('请输入画布大小 (宽x高)', '设置画布大小', {
|
||||
inputPattern: /^\d+x\d+$/,
|
||||
inputErrorMessage: '格式: 宽x高,例如: 1000x800',
|
||||
inputValue: `${size.value.width}x${size.value.height}`
|
||||
}).then(({ value }) => {
|
||||
const [width, height] = value.split('x').map(Number)
|
||||
size.value = { width, height }
|
||||
if (item.value) {
|
||||
item.value = size.value;
|
||||
}
|
||||
flowEventBus.emit('change-canvas-size', size.value);
|
||||
}).catch(() => { })
|
||||
break;
|
||||
|
||||
// 排列菜单
|
||||
case 'hierarchicalLayout':
|
||||
flowEventBus.emit('hierarchical-layout');
|
||||
break;
|
||||
case 'circleLayout':
|
||||
flowEventBus.emit('circle-layout');
|
||||
break;
|
||||
case 'organicLayout':
|
||||
flowEventBus.emit('organic-layout');
|
||||
break;
|
||||
case 'bringToFront':
|
||||
flowEventBus.emit('bring-to-front');
|
||||
break;
|
||||
case 'sendToBack':
|
||||
flowEventBus.emit('send-to-back');
|
||||
break;
|
||||
}
|
||||
|
||||
hideSubmenu()
|
||||
}
|
||||
|
||||
// 添加快捷键处理
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isCtrl) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'n':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('file-new')
|
||||
break
|
||||
case 'o':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('file-open')
|
||||
break
|
||||
case 's':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('file-save')
|
||||
break
|
||||
case 'z':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('undo')
|
||||
break
|
||||
case 'y':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('redo')
|
||||
break
|
||||
case 'x':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('cut')
|
||||
break
|
||||
case 'c':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('copy')
|
||||
break
|
||||
case 'v':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('paste')
|
||||
break
|
||||
case 'a':
|
||||
e.preventDefault()
|
||||
flowEventBus.emit('select-all')
|
||||
break
|
||||
}
|
||||
} else if (e.key === 'Delete') {
|
||||
flowEventBus.emit('delete')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-menu {
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-bottom: none !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.submenu-popup {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 5px 0;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.el-divider {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
margin-left: auto;
|
||||
padding-left: 16px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,874 @@
|
|||
<template>
|
||||
<div class="flow-toolbar">
|
||||
<!-- 导出组 -->
|
||||
<div class="toolbar-group">
|
||||
<button title="导出" @click="handleExport">
|
||||
<span class="icon" v-html="ToolbarIcons.export"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- 布局组 -->
|
||||
<div class="toolbar-group">
|
||||
<el-dropdown @command="handleLayoutChange" trigger="click">
|
||||
<button title="自动布局">
|
||||
<span class="icon" v-html="currentLayoutIcon"></span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="layout in layouts" :key="layout.type" :command="layout.type">
|
||||
<span class="layout-icon" v-html="layout.icon"></span>
|
||||
<span class="layout-label">{{ layout.label }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- 线条样式组 -->
|
||||
<div class="toolbar-group">
|
||||
<button title="曲线" @click="handleEdgeStyle('curved')">
|
||||
<span class="icon" v-html="ToolbarIcons.curvedLine"></span>
|
||||
</button>
|
||||
<button title="折线" @click="handleEdgeStyle('orthogonal')">
|
||||
<span class="icon" v-html="ToolbarIcons.orthogonalLine"></span>
|
||||
</button>
|
||||
<button title="直线" @click="handleEdgeStyle('straight')">
|
||||
<span class="icon" v-html="ToolbarIcons.straightLine"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- 编辑组 -->
|
||||
<div class="toolbar-group">
|
||||
<button title="重做" @click="handleRedo">
|
||||
<span class="icon" v-html="ToolbarIcons.redo"></span>
|
||||
</button>
|
||||
<button title="撤销" @click="handleUndo">
|
||||
<span class="icon" v-html="ToolbarIcons.undo"></span>
|
||||
</button>
|
||||
<button title="删除" @click="handleDelete">
|
||||
<span class="icon" v-html="ToolbarIcons.delete"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- 视图组 -->
|
||||
<div class="toolbar-group">
|
||||
<button title="适应画布" @click="handleFit">
|
||||
<span class="icon" v-html="ToolbarIcons.fit"></span>
|
||||
</button>
|
||||
<button title="缩小" @click="handleZoom(0.8)">
|
||||
<span class="icon" v-html="ToolbarIcons.zoomOut"></span>
|
||||
</button>
|
||||
<button title="放大" @click="handleZoom(1.2)">
|
||||
<span class="icon" v-html="ToolbarIcons.zoomIn"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮组,靠右对齐 -->
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div class="toolbar-group">
|
||||
<router-link to="/" class="back-button" title="返回首页">
|
||||
<span class="icon" v-html="ToolbarIcons.back"></span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 添加背景颜色选择器 -->
|
||||
<div class="color-picker-wrapper">
|
||||
<button class="toolbar-btn" title="背景颜色" @click.stop="toggleColorPicker">
|
||||
<span class="icon" v-html="ToolbarIcons.backgroundColor"></span>
|
||||
</button>
|
||||
<!-- 颜色选择面板 -->
|
||||
<div v-if="showColorPicker" class="color-picker-panel" @click.stop>
|
||||
<div class="color-grid">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
class="color-item"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="selectColor(color)"
|
||||
>
|
||||
<span v-if="selectedColor === color" class="color-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-color">
|
||||
<input type="color" v-model="customColor" @change="selectColor(customColor)" />
|
||||
<span>自定义颜色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:class="{ active: showGrid }"
|
||||
title="显示网格"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<span class="icon" v-html="ToolbarIcons.grid"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 字体设置组 -->
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<el-dropdown @command="handleFontChange" trigger="click">
|
||||
<button title="字体设置">
|
||||
<span class="icon">🔤</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="font in fonts"
|
||||
:key="font.value"
|
||||
:command="font.value"
|
||||
:style="{ fontFamily: font.value }"
|
||||
>
|
||||
{{ font.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group" style="position: relative">
|
||||
<button class="toolbar-btn" title="节点颜色" @click.stop="toggleNodeColorPicker">
|
||||
<span class="icon" v-html="ToolbarIcons.nodeColor"></span>
|
||||
</button>
|
||||
<!-- 颜色选择面板 -->
|
||||
<div v-if="showNodeColorPicker" class="color-picker-panel" @click.stop>
|
||||
<div class="color-grid">
|
||||
<div
|
||||
v-for="color in nodeColors"
|
||||
:key="color"
|
||||
class="color-item"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click.stop="setNodeColor(color)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-color">
|
||||
<input
|
||||
type="color"
|
||||
v-model="customNodeColor"
|
||||
@change.stop="setNodeColor(customNodeColor)"
|
||||
/>
|
||||
<span>自定义颜色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
title="样式刷"
|
||||
:class="{ active: isStyleBrushActive }"
|
||||
@click="toggleStyleBrush"
|
||||
>
|
||||
<span class="icon">🖌️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-tooltip content="插入图片" placement="bottom">
|
||||
<el-button @click="handleInsertImage">
|
||||
<span class="toolbar-icon" v-html="MenuIcons.image"></span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
type="file"
|
||||
ref="imageInput"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
|
||||
<!-- 边框设置组 -->
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<!-- 边框颜色 -->
|
||||
<el-tooltip :content="hasSelectedNode ? '边框颜色' : '未选择节点'" placement="bottom">
|
||||
<el-button
|
||||
class="toolbar-btn"
|
||||
@click.stop="toggleBorderColorPicker"
|
||||
:disabled="!hasSelectedNode"
|
||||
:class="{ disabled: !hasSelectedNode }"
|
||||
>
|
||||
<span class="icon" v-html="ToolbarIcons.borderColor"></span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 边框样式 -->
|
||||
<el-tooltip :content="hasSelectedNode ? '边框样式' : '未选择节点'" placement="bottom">
|
||||
<el-dropdown @command="handleBorderStyle" trigger="click">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:disabled="!hasSelectedNode"
|
||||
:class="{ disabled: !hasSelectedNode }"
|
||||
>
|
||||
<span class="icon" v-html="ToolbarIcons.borderStyle"></span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="solid">实线</el-dropdown-item>
|
||||
<el-dropdown-item command="dashed">虚线</el-dropdown-item>
|
||||
<el-dropdown-item command="dotted">点线</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 边框宽度 -->
|
||||
<el-tooltip :content="hasSelectedNode ? '边框宽度' : '未选择节点'" placement="bottom">
|
||||
<el-dropdown @command="handleBorderWidth" trigger="click">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:disabled="!hasSelectedNode"
|
||||
:class="{ disabled: !hasSelectedNode }"
|
||||
>
|
||||
<span class="icon" v-html="ToolbarIcons.borderWidth"></span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="width in borderWidths" :key="width" :command="width">
|
||||
{{ width }}px
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 边框颜色选择面板 -->
|
||||
<div v-if="showBorderColorPicker" class="color-picker-panel" @click.stop>
|
||||
<div class="color-grid">
|
||||
<div
|
||||
v-for="color in borderColors"
|
||||
:key="color"
|
||||
class="color-item"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="setBorderColor(color)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-color">
|
||||
<input
|
||||
type="color"
|
||||
v-model="customBorderColor"
|
||||
@change="setBorderColor(customBorderColor)"
|
||||
/>
|
||||
<span>自定义颜色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对齐工具组 -->
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<el-tooltip content="左对齐" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('left')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignLeft"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="水平居中" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('center')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignCenter"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="右对齐" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('right')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignRight"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="顶部对齐" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('top')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignTop"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="垂直居中" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('middle')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignMiddle"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="底部对齐" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('bottom')">
|
||||
<span class="icon" v-html="ToolbarIcons.alignBottom"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="水平等距分布" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('distributeHorizontally')">
|
||||
<span class="icon" v-html="ToolbarIcons.distributeHorizontally"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="垂直等距分布" placement="bottom">
|
||||
<button class="toolbar-btn" @click="handleAlign('distributeVertically')">
|
||||
<span class="icon" v-html="ToolbarIcons.distributeVertically"></span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { flowEventBus } from '../../events'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FlowIcons, ToolbarIcons, MenuIcons } from '../../icons/index'
|
||||
|
||||
const handleZoom = (scale: number) => {
|
||||
flowEventBus.emit('zoom', scale)
|
||||
}
|
||||
|
||||
const handleFit = () => {
|
||||
flowEventBus.emit('fit')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
flowEventBus.emit('delete')
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
flowEventBus.emit('undo')
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
flowEventBus.emit('reDo')
|
||||
}
|
||||
|
||||
const handleEdgeStyle = (style: string) => {
|
||||
flowEventBus.emit('edge-style', style)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
flowEventBus.emit('export')
|
||||
ElMessage({
|
||||
message: '导出成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
|
||||
// 布局配置
|
||||
const layouts = [
|
||||
{
|
||||
type: 'hierarchical',
|
||||
label: '层次布局',
|
||||
icon: '⚡' // 这里可以替换成实际的SVG图标
|
||||
},
|
||||
{
|
||||
type: 'circle',
|
||||
label: '圆形布局',
|
||||
icon: '⭕'
|
||||
},
|
||||
{
|
||||
type: 'organic',
|
||||
label: '有机布局',
|
||||
icon: '🌿'
|
||||
},
|
||||
{
|
||||
type: 'stack',
|
||||
label: '堆叠布局',
|
||||
icon: '📚'
|
||||
},
|
||||
{
|
||||
type: 'parallel',
|
||||
label: '平行边布局',
|
||||
icon: '⫴'
|
||||
},
|
||||
{
|
||||
type: 'partition',
|
||||
label: '分区布局',
|
||||
icon: '🔲'
|
||||
}
|
||||
]
|
||||
|
||||
const currentLayout = ref('hierarchical')
|
||||
|
||||
const currentLayoutIcon = computed(() => {
|
||||
const layout = layouts.find((l) => l.type === currentLayout.value)
|
||||
return layout?.icon || layouts[0].icon
|
||||
})
|
||||
|
||||
const handleLayoutChange = (type: string) => {
|
||||
currentLayout.value = type
|
||||
flowEventBus.emit('change-layout', type)
|
||||
}
|
||||
|
||||
const showColorPicker = ref(false)
|
||||
const selectedColor = ref('#ffffff')
|
||||
const customColor = ref('#ffffff')
|
||||
|
||||
// 预定义的颜色列表
|
||||
const colors = [
|
||||
'#ffffff',
|
||||
'#f0f0f0',
|
||||
'#e8f4f9',
|
||||
'#e3f3ff',
|
||||
'#f9f0ff',
|
||||
'#fff3e8',
|
||||
'#f5fff0',
|
||||
'#fff0f0'
|
||||
]
|
||||
|
||||
const toggleColorPicker = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
showColorPicker.value = !showColorPicker.value
|
||||
|
||||
// 添加全局点击事件来关闭颜色选择器
|
||||
if (showColorPicker.value) {
|
||||
document.addEventListener('click', closeColorPicker)
|
||||
}
|
||||
}
|
||||
|
||||
const closeColorPicker = () => {
|
||||
showColorPicker.value = false
|
||||
document.removeEventListener('click', closeColorPicker)
|
||||
}
|
||||
|
||||
const selectColor = (color: string) => {
|
||||
selectedColor.value = color
|
||||
// 通过事件总线发送背景颜色变更事件
|
||||
flowEventBus.emit('change-background', color)
|
||||
showColorPicker.value = false
|
||||
}
|
||||
|
||||
const showGrid = ref(true)
|
||||
|
||||
const toggleGrid = () => {
|
||||
showGrid.value = !showGrid.value
|
||||
flowEventBus.emit('toggle-grid', showGrid.value)
|
||||
}
|
||||
|
||||
// 字体配置
|
||||
const fonts = [
|
||||
{ label: '默认字体', value: 'Arial' },
|
||||
{ label: '微软雅黑', value: 'Microsoft YaHei' },
|
||||
{ label: '宋体', value: 'SimSun' },
|
||||
{ label: '黑体', value: 'SimHei' },
|
||||
{ label: '楷体', value: 'KaiTi' },
|
||||
{ label: 'Times New Roman', value: 'Times New Roman' },
|
||||
{ label: 'Helvetica', value: 'Helvetica' }
|
||||
]
|
||||
|
||||
const handleFontChange = (fontFamily: string) => {
|
||||
flowEventBus.emit('change-font', fontFamily)
|
||||
}
|
||||
|
||||
const showNodeColorPicker = ref(false)
|
||||
const customNodeColor = ref('#ffffff')
|
||||
|
||||
const nodeColors = [
|
||||
'#67C23A',
|
||||
'#F56C6C',
|
||||
'#409EFF',
|
||||
'#E6A23C',
|
||||
'#909399',
|
||||
'#95A5A6',
|
||||
'#3498DB',
|
||||
'#9B59B6',
|
||||
'#F1C40F',
|
||||
'#34495E',
|
||||
'#1ABC9C',
|
||||
'#7F8C8D',
|
||||
'#D35400',
|
||||
'#27AE60',
|
||||
'#8E44AD'
|
||||
]
|
||||
|
||||
const toggleNodeColorPicker = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
showNodeColorPicker.value = !showNodeColorPicker.value
|
||||
}
|
||||
|
||||
const setNodeColor = (color: string) => {
|
||||
flowEventBus.emit('change-node-color', color)
|
||||
showNodeColorPicker.value = false
|
||||
}
|
||||
|
||||
// 点击其他地方关闭颜色选择器
|
||||
onMounted(() => {
|
||||
const closeColorPicker = () => {
|
||||
showNodeColorPicker.value = false
|
||||
}
|
||||
document.addEventListener('click', closeColorPicker)
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeColorPicker)
|
||||
})
|
||||
|
||||
// 监听节点选中状态变化
|
||||
flowEventBus.on('selection-changed', (cells: any[]) => {
|
||||
hasSelectedNode.value = cells?.some((cell) => cell.isVertex()) || false
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
flowEventBus.off('selection-changed')
|
||||
})
|
||||
|
||||
const isStyleBrushActive = ref(false)
|
||||
let copiedStyle: any = null
|
||||
|
||||
const toggleStyleBrush = () => {
|
||||
if (!isStyleBrushActive.value) {
|
||||
// 获取当前选中节点的样式
|
||||
flowEventBus.emit('get-selected-style', (style: any) => {
|
||||
copiedStyle = style
|
||||
isStyleBrushActive.value = true
|
||||
flowEventBus.emit('activate-style-brush', style)
|
||||
})
|
||||
} else {
|
||||
isStyleBrushActive.value = false
|
||||
copiedStyle = null
|
||||
flowEventBus.emit('deactivate-style-brush')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听样式应用完成事件
|
||||
flowEventBus.on('style-brush-applied', () => {
|
||||
isStyleBrushActive.value = false
|
||||
copiedStyle = null
|
||||
})
|
||||
|
||||
const imageInput = ref<HTMLInputElement>()
|
||||
|
||||
const handleInsertImage = () => {
|
||||
imageInput.value?.click()
|
||||
}
|
||||
|
||||
const onImageSelected = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const imageUrl = event.target?.result as string
|
||||
// 发送事件到 FlowCanvas 处理图片插入
|
||||
flowEventBus.emit('insert-image', imageUrl)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
// 清空input,以便可以重复选择同一文件
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
// 边框相关数据
|
||||
const borderWidths = [1, 2, 3, 4, 5, 6, 8, 10]
|
||||
const borderColors = [
|
||||
'#000000',
|
||||
'#333333',
|
||||
'#666666',
|
||||
'#999999',
|
||||
'#409EFF',
|
||||
'#67C23A',
|
||||
'#E6A23C',
|
||||
'#F56C6C'
|
||||
]
|
||||
const showBorderColorPicker = ref(false)
|
||||
const customBorderColor = ref('#000000')
|
||||
|
||||
// 边框相关方法
|
||||
const handleBorderStyle = (style: string) => {
|
||||
flowEventBus.emit('change-border-style', style)
|
||||
}
|
||||
|
||||
const handleBorderWidth = (width: number) => {
|
||||
flowEventBus.emit('change-border-width', width)
|
||||
}
|
||||
|
||||
const toggleBorderColorPicker = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
showBorderColorPicker.value = !showBorderColorPicker.value
|
||||
}
|
||||
|
||||
const setBorderColor = (color: string) => {
|
||||
flowEventBus.emit('change-border-color', color)
|
||||
showBorderColorPicker.value = false
|
||||
}
|
||||
|
||||
const hasSelectedNode = ref(false)
|
||||
|
||||
// 对齐处理函数
|
||||
const handleAlign = (type: string) => {
|
||||
flowEventBus.emit('align-cells', type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 2px;
|
||||
background: #fff;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #dcdfe6;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.icon :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
button:hover .icon {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #606266;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-picker-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-check {
|
||||
color: #409eff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.custom-color input {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-color span {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.layout-icon {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.layout-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.color-picker-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.color-item:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.custom-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.custom-color input {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-color span {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* 添加相对定位到按钮容器 */
|
||||
.toolbar-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 确保颜色选择器的父容器也是相对定位 */
|
||||
.toolbar-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.toolbar-btn.disabled:hover {
|
||||
background-color: transparent;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 禁用状态下的下拉菜单样式 */
|
||||
.el-dropdown.is-disabled .toolbar-btn {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<div class="text-edit-toolbar"
|
||||
v-show="visible"
|
||||
:style="position">
|
||||
<!-- 字体选择 -->
|
||||
<el-select v-model="localStyle.fontFamily"
|
||||
size="small"
|
||||
@change="handleStyleChange('fontFamily')">
|
||||
<el-option v-for="font in fontFamilies"
|
||||
:key="font"
|
||||
:label="font"
|
||||
:value="font"
|
||||
:style="{ fontFamily: font }">
|
||||
{{ font }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 字号选择 -->
|
||||
<el-select v-model="localStyle.fontSize"
|
||||
size="small"
|
||||
@change="handleStyleChange('fontSize')">
|
||||
<el-option v-for="size in fontSizes"
|
||||
:key="size"
|
||||
:label="`${size}px`"
|
||||
:value="size">
|
||||
{{ size }}px
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 文字颜色 -->
|
||||
<el-color-picker v-model="localStyle.fontColor"
|
||||
size="small"
|
||||
@change="handleStyleChange('fontColor')" />
|
||||
|
||||
<!-- 加粗按钮 -->
|
||||
<el-button size="small"
|
||||
:type="localStyle.fontWeight === 'bold' ? 'primary' : 'default'"
|
||||
@click="toggleFontWeight">
|
||||
B
|
||||
</el-button>
|
||||
|
||||
<!-- 下划线按钮 -->
|
||||
<el-button size="small"
|
||||
:type="localStyle.textDecoration === 'underline' ? 'primary' : 'default'"
|
||||
@click="toggleTextDecoration">
|
||||
U
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { flowEventBus } from '../../events'
|
||||
|
||||
export default {
|
||||
name: 'TextEditToolbar',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ top: '0px', left: '0px' })
|
||||
},
|
||||
currentStyle: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 14,
|
||||
fontColor: '#000000',
|
||||
fontWeight: 'normal',
|
||||
textDecoration: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localStyle: { ...this.currentStyle },
|
||||
fontSizes: [12, 14, 16, 18, 20, 24, 28, 32],
|
||||
fontFamilies: ['Arial', 'Times New Roman', '微软雅黑', '宋体', 'Helvetica']
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentStyle: {
|
||||
handler(newStyle) {
|
||||
this.localStyle = { ...newStyle }
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleStyleChange(property) {
|
||||
flowEventBus.emit('update-text-style', {
|
||||
[property]: this.localStyle[property]
|
||||
})
|
||||
},
|
||||
toggleFontWeight() {
|
||||
this.localStyle.fontWeight =
|
||||
this.localStyle.fontWeight === 'bold' ? 'normal' : 'bold'
|
||||
this.handleStyleChange('fontWeight')
|
||||
},
|
||||
toggleTextDecoration() {
|
||||
this.localStyle.textDecoration =
|
||||
this.localStyle.textDecoration === 'underline' ? 'none' : 'underline'
|
||||
this.handleStyleChange('textDecoration')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-edit-toolbar {
|
||||
position: fixed;
|
||||
background: white;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
padding: 8px 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
export const menuConfig = [
|
||||
{
|
||||
key: 'file',
|
||||
label: '文件',
|
||||
children: [
|
||||
{ key: 'new', label: '新建', icon: 'Document', shortcut: 'Ctrl+N' },
|
||||
{ key: 'open', label: '打开', icon: 'FolderOpened', shortcut: 'Ctrl+O' },
|
||||
{ key: 'save', label: '保存', icon: 'Download', shortcut: 'Ctrl+S' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'exportImage', label: '导出图片', icon: 'Picture', shortcut: 'Ctrl+E' },
|
||||
{ key: 'exportJson', label: '导出JSON', icon: 'Document' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
children: [
|
||||
{ key: 'undo', label: '撤销', icon: 'Back', shortcut: 'Ctrl+Z' },
|
||||
{ key: 'redo', label: '重做', icon: 'Right', shortcut: 'Ctrl+Y' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'cut', label: '剪切', icon: 'Scissors', shortcut: 'Ctrl+X' },
|
||||
{ key: 'copy', label: '复制', icon: 'Document', shortcut: 'Ctrl+C' },
|
||||
{ key: 'paste', label: '粘贴', icon: 'DocumentCopy', shortcut: 'Ctrl+V' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '删除', icon: 'Delete', shortcut: 'Delete' },
|
||||
{ key: 'selectAll', label: '全选', icon: 'Select', shortcut: 'Ctrl+A' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'select',
|
||||
label: '选择',
|
||||
children: [
|
||||
{ key: 'selectAll', label: '全选', icon: 'Select' },
|
||||
{ key: 'invertSelect', label: '反选', icon: 'RefreshRight' },
|
||||
{ key: 'cancelSelect', label: '取消选择', icon: 'Close' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '视图',
|
||||
key: 'view',
|
||||
children: [
|
||||
{ key: 'zoomIn', label: '放大', icon: 'ZoomIn' },
|
||||
{ key: 'zoomOut', label: '缩小', icon: 'ZoomOut' },
|
||||
{ key: 'fit', label: '适应画布', icon: 'FullScreen' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'showGrid', label: '显示网格', type: 'checkbox', checked: true },
|
||||
{ key: 'showMinimap', label: '显示缩略图', type: 'checkbox', checked: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '插入',
|
||||
key: 'insert',
|
||||
children: [
|
||||
{ label: '图片', key: 'insertImage', icon: 'Picture' },
|
||||
{ label: '表格', key: 'insertTable', icon: 'Grid' },
|
||||
{ label: '文本', key: 'insertText', icon: 'EditPen' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas',
|
||||
label: '画布',
|
||||
children: [
|
||||
{ key: 'backgroundColor', label: '背景颜色', icon: 'Brush', type: 'color', value: '#ffffff' },
|
||||
{ key: 'gridSize', label: '网格大小', icon: 'Grid', type: 'number', value: 10, min: 5, max: 50 },
|
||||
{ key: 'canvasSize', label: '画布大小', icon: 'FullScreen', type: 'size',
|
||||
value: { width: 1000, height: 800 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '排列',
|
||||
key: 'arrange',
|
||||
children: [
|
||||
{ key: 'hierarchicalLayout', label: '层次布局', icon: 'Sort' },
|
||||
{ key: 'circleLayout', label: '圆形布局', icon: 'CirclePlus' },
|
||||
{ key: 'organicLayout', label: '有机布局', icon: 'Connection' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'bringToFront', label: '置于顶层', icon: 'TopRight' },
|
||||
{ key: 'sendToBack', label: '置于底层', icon: 'BottomLeft' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
<template>
|
||||
<div class="shape-library">
|
||||
<div v-for="(category, index) in categories" :key="index" class="accordion-item">
|
||||
<div class="accordion-header" @click="toggleCategory(index)">
|
||||
<span class="accordion-title">{{ category.title }}</span>
|
||||
<span class="accordion-icon">{{ activeIndex === index ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div class="accordion-content" :class="{ active: activeIndex === index }">
|
||||
<div class="icon-grid">
|
||||
<div
|
||||
v-for="(item, itemIndex) in category.items"
|
||||
:key="itemIndex"
|
||||
class="icon-item"
|
||||
:style="{ backgroundColor: item.backgroundColor }"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, item)"
|
||||
>
|
||||
<div class="icon-wrapper" v-html="getIconSvg(item.icon)"></div>
|
||||
<span class="icon-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="more-nodes">
|
||||
<button class="more-nodes-btn" @click="showMoreNodes">
|
||||
<el-icon><CirclePlus /></el-icon>
|
||||
更多节点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NodeSelector v-model:visible="isDialogVisible" @select="handleNodeSelect" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { CirclePlus } from '@element-plus/icons-vue'
|
||||
import { FlowIcons, ToolbarIcons, UMLIcons } from '../../icons/index'
|
||||
import NodeSelector from '../property/NodeSelector.vue'
|
||||
import flowNodes from './flowNodes'
|
||||
import extraNodes from '@/assets/data/extra-nodes.json'
|
||||
|
||||
const StylePreviewSVG = `
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="8" width="12" height="8" rx="2" fill="var(--node-color, #409EFF)" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<path d="M14 16L14 22" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<rect x="8" y="22" width="12" height="8" rx="2" fill="var(--node-color, #409EFF)" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<path d="M20 26L28 26" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<rect x="28" y="22" width="12" height="8" rx="2" fill="var(--node-color, #409EFF)" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<path d="M34 30L34 36" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
<rect x="28" y="36" width="12" height="8" rx="2" fill="var(--node-color, #409EFF)" stroke="var(--border-color, #409EFF)" stroke-width="1.5"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'ShapeLibrary',
|
||||
components: {
|
||||
NodeSelector,
|
||||
CirclePlus
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeIndex: 0,
|
||||
categories: flowNodes.categories,
|
||||
isDialogVisible: false,
|
||||
AllIcons: { ...FlowIcons, ...UMLIcons, ...ToolbarIcons }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleCategory(index: number) {
|
||||
this.activeIndex = this.activeIndex === index ? -1 : index
|
||||
},
|
||||
getIconSvg(type: string) {
|
||||
if (type.startsWith('style-')) {
|
||||
// 为风格图标返回统一的流程图SVG,通过CSS变量控制颜色
|
||||
const style = type.replace('style-', '')
|
||||
const colors = {
|
||||
default: { node: '#409EFF', border: '#409EFF' },
|
||||
primary: { node: '#409EFF', border: '#409EFF' },
|
||||
success: { node: '#67C23A', border: '#67C23A' },
|
||||
warning: { node: '#E6A23C', border: '#E6A23C' },
|
||||
danger: { node: '#F56C6C', border: '#F56C6C' }
|
||||
// 可以添加更多风格的颜色
|
||||
}
|
||||
|
||||
const color = colors[style] || colors.default
|
||||
return StylePreviewSVG.replace(/var\(--node-color,[^)]+\)/g, color.node).replace(
|
||||
/var\(--border-color,[^)]+\)/g,
|
||||
color.border
|
||||
)
|
||||
}
|
||||
return this.AllIcons[type as keyof typeof FlowIcons] || FlowIcons.Process
|
||||
},
|
||||
handleDragStart(event: DragEvent, item: any) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(item))
|
||||
}
|
||||
},
|
||||
showMoreNodes() {
|
||||
this.isDialogVisible = true
|
||||
},
|
||||
handleNodeSelect(nodes: any[]) {
|
||||
// 按分类组织节点
|
||||
const nodesByCategory = nodes.reduce((acc: any, node: any) => {
|
||||
const category = extraNodes.categories.find((c) =>
|
||||
c.nodes.some((n) => n.type === node.type)
|
||||
)
|
||||
if (category) {
|
||||
if (!acc[category.name]) {
|
||||
acc[category.name] = []
|
||||
}
|
||||
acc[category.name].push({
|
||||
type: node.type,
|
||||
label: node.name,
|
||||
icon: node.icon,
|
||||
backgroundColor: '#fff'
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// 添加新分类到列表中
|
||||
Object.entries(nodesByCategory).forEach(([categoryName, items]) => {
|
||||
const existingCategory = this.categories.find((c) => c.title === categoryName)
|
||||
if (existingCategory) {
|
||||
existingCategory.items = [...existingCategory.items, ...(items as any[])]
|
||||
} else {
|
||||
this.categories.push({
|
||||
title: categoryName,
|
||||
items: items as any[]
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shape-library {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
padding: 12px 16px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.accordion-content.active {
|
||||
max-height: 500px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.icon-item:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.more-nodes {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.more-nodes-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.more-nodes-btn:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.more-nodes-btn .el-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
import { FlowIcons } from '../icons';
|
||||
|
||||
interface FlowNode {
|
||||
type: string;
|
||||
label: string;
|
||||
size?: [number, number] | null;
|
||||
icon: string;
|
||||
backgroundColor?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface NodeCategory {
|
||||
title: string;
|
||||
items: FlowNode[];
|
||||
}
|
||||
|
||||
const flowCategories: NodeCategory[] = [
|
||||
{
|
||||
title: '基础流程',
|
||||
items: [
|
||||
{
|
||||
type: 'Start',
|
||||
label: '开始',
|
||||
icon: 'Start',
|
||||
size: [50, 50],
|
||||
backgroundColor: '#e8f5e9',
|
||||
style: {
|
||||
shape: 'ellipse',
|
||||
fillColor: '#e8f5e9',
|
||||
strokeColor: '#4caf50',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'End',
|
||||
label: '结束',
|
||||
icon: 'End',
|
||||
size: [50, 50],
|
||||
backgroundColor: '#ffebee',
|
||||
style: {
|
||||
shape: 'end',
|
||||
fillColor: '#ffebee',
|
||||
strokeColor: '#f44336',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Process',
|
||||
label: '处理',
|
||||
icon: 'Process',
|
||||
backgroundColor: '#e3f2fd',
|
||||
style: {
|
||||
shape: 'rectangle',
|
||||
fillColor: '#e3f2fd',
|
||||
strokeColor: '#2196f3',
|
||||
strokeWidth: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Decision',
|
||||
label: '判断',
|
||||
icon: 'Decision',
|
||||
backgroundColor: '#fff3e0',
|
||||
style: {
|
||||
shape: 'rhombus',
|
||||
fillColor: '#fff3e0',
|
||||
strokeColor: '#ff9800',
|
||||
strokeWidth: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Note',
|
||||
label: '备注',
|
||||
icon: 'Note',
|
||||
size: [120, 80],
|
||||
backgroundColor: '#fff9c4',
|
||||
style: {
|
||||
shape: 'note',
|
||||
fillColor: '#fff9c4',
|
||||
strokeColor: '#ffd54f',
|
||||
strokeWidth: 1,
|
||||
shadow: true,
|
||||
rounded: true,
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
spacingTop: 5,
|
||||
spacingLeft: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Text',
|
||||
label: '文字',
|
||||
icon: 'Text',
|
||||
size: [100, 40],
|
||||
backgroundColor: 'transparent',
|
||||
style: {
|
||||
shape: 'text',
|
||||
strokeColor: 'none',
|
||||
fillColor: 'none',
|
||||
fontColor: '#333333',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Arial',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
editable: true,
|
||||
autosize: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '基础形状',
|
||||
items: [
|
||||
{
|
||||
type: 'Rectangle',
|
||||
label: '矩形',
|
||||
icon: 'Rectangle',
|
||||
style: {
|
||||
shape: 'rectangle',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Ellipse',
|
||||
label: '圆形',
|
||||
icon: 'Ellipse',
|
||||
style: {
|
||||
shape: 'ellipse',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Rhombus',
|
||||
label: '菱形',
|
||||
icon: 'Rhombus',
|
||||
style: {
|
||||
shape: 'rhombus',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Triangle',
|
||||
label: '三角形',
|
||||
icon: 'Triangle',
|
||||
style: {
|
||||
shape: 'triangle',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Hexagon',
|
||||
label: '六边形',
|
||||
icon: 'Hexagon',
|
||||
style: {
|
||||
shape: 'hexagon',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Star',
|
||||
label: '五角星',
|
||||
icon: 'Star',
|
||||
style: {
|
||||
shape: 'star',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
strokeWidth: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'UML图形',
|
||||
items: [
|
||||
{
|
||||
type: 'Class',
|
||||
label: '类',
|
||||
icon: 'ClassIcon',
|
||||
style: {
|
||||
shape: 'umlClass',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
value: `<div style="padding: 8px">
|
||||
<div style="border-bottom: 1px solid #333; font-weight: bold">类名</div>
|
||||
<div style="border-bottom: 1px solid #333">+ 属性1: 类型</div>
|
||||
<div>+ 方法1(): 返回类型</div>
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Interface',
|
||||
label: '接口',
|
||||
icon: 'InterfaceIcon',
|
||||
style: {
|
||||
shape: 'umlInterface',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
dashed: true,
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
value: `<div style="padding: 8px">
|
||||
<div style="border-bottom: 1px solid #333; font-style: italic"><<interface>></div>
|
||||
<div style="border-bottom: 1px solid #333; font-weight: bold">接口名</div>
|
||||
<div>+ 方法1(): 返回类型</div>
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Package',
|
||||
label: '包',
|
||||
icon: 'PackageIcon',
|
||||
style: {
|
||||
shape: 'package',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
html: 1,
|
||||
whiteSpace: 'wrap'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Component',
|
||||
label: '组件',
|
||||
icon: 'ComponentIcon',
|
||||
style: {
|
||||
shape: 'component',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
value: `<div style="padding: 8px">
|
||||
<div style="font-style: italic"><<component>></div>
|
||||
<div style="font-weight: bold">组件名</div>
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Aggregation',
|
||||
label: '聚合',
|
||||
icon: 'AggregationIcon',
|
||||
style: {
|
||||
shape: 'rhombus',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'Composition',
|
||||
label: '组合',
|
||||
icon: 'CompositionIcon',
|
||||
style: {
|
||||
shape: 'rhombus',
|
||||
fillColor: '#333333',
|
||||
strokeColor: '#333333'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '泳道',
|
||||
items: [
|
||||
{
|
||||
type: 'VSwimlane',
|
||||
label: '垂直泳道',
|
||||
icon: 'VSwimlane',
|
||||
size: [200, 400],
|
||||
backgroundColor: '#f5f5f5',
|
||||
style: {
|
||||
shape: 'swimlane',
|
||||
vertical: true, // 垂直泳道(设置为 false 则为水平泳道)
|
||||
fillColor: '#f5f5f5',
|
||||
strokeColor: '#bdbdbd',
|
||||
startSize: 40,
|
||||
horizontal: false,
|
||||
separatorColor: '#bdbdbd',
|
||||
fontColor: '#333333',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Arial',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'HSwimlane',
|
||||
label: '水平泳道',
|
||||
icon: 'HSwimlane',
|
||||
size: [400, 200],
|
||||
backgroundColor: '#f5f5f5',
|
||||
style: {
|
||||
shape: 'swimlane',
|
||||
vertical: false, // 垂直泳道(设置为 false 则为水平泳道)
|
||||
fillColor: '#f5f5f5',
|
||||
strokeColor: '#bdbdbd',
|
||||
startSize: 40,
|
||||
horizontal: true,
|
||||
separatorColor: '#bdbdbd',
|
||||
fontColor: '#333333',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Arial',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '表格组件',
|
||||
items: [
|
||||
{
|
||||
type: 'Table',
|
||||
label: '表格',
|
||||
icon: 'TableIcon',
|
||||
size: [300, 200],
|
||||
backgroundColor: '#ffffff',
|
||||
style: {
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
strokeWidth: 1,
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
value: `
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">列1</th>
|
||||
<th style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">列2</th>
|
||||
<th style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">列3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据1</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据2</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据4</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据5</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">数据6</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
editable: true,
|
||||
resizable: true,
|
||||
autosize: true,
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ title: '列1', key: 'col1', width: 100 },
|
||||
{ title: '列2', key: 'col2', width: 100 },
|
||||
{ title: '列3', key: 'col3', width: 100 }
|
||||
],
|
||||
data: [
|
||||
{ col1: '数据1', col2: '数据2', col3: '数据3' },
|
||||
{ col1: '数据4', col2: '数据5', col3: '数据6' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'SimpleTable',
|
||||
label: '简单表格',
|
||||
icon: 'SimpleTableIcon',
|
||||
size: [200, 120],
|
||||
backgroundColor: '#ffffff',
|
||||
style: {
|
||||
shape: 'table',
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#333333',
|
||||
strokeWidth: 1,
|
||||
html: 1,
|
||||
whiteSpace: 'wrap',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
value: `
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<tr>
|
||||
<td style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">名称</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">内容</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">说明</td>
|
||||
<td style="border: 1px solid #333; padding: 4px;">描述</td>
|
||||
</tr>
|
||||
</table>
|
||||
`,
|
||||
editable: true,
|
||||
resizable: true,
|
||||
autosize: true,
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ title: '名称', key: 'name', width: 80 },
|
||||
{ title: '内容', key: 'content', width: 120 }
|
||||
],
|
||||
data: [
|
||||
{ name: '名称', content: '内容' },
|
||||
{ name: '说明', content: '描述' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default {
|
||||
categories: flowCategories
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<el-tabs v-model="activeTab" class="flow-tabs" type="card">
|
||||
<el-tab-pane label="图形库" name="shapes">
|
||||
<ShapeLibrary />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="风格" name="styles">
|
||||
<StyleLibrary />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import ShapeLibrary from '../node/ShapeLibrary.vue';
|
||||
import StyleLibrary from '../property/StyleLibrary.vue';
|
||||
|
||||
const activeTab = ref('shapes');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-tabs {
|
||||
margin: 3px;
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="预览导出图片"
|
||||
width="800px"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<div class="preview-container">
|
||||
<div class="preview-wrapper" :style="previewBackground">
|
||||
<img :src="imageUrl" class="preview-image" />
|
||||
</div>
|
||||
<div class="background-selector">
|
||||
<span>背景:</span>
|
||||
<el-radio-group v-model="background" size="small">
|
||||
<el-radio-button label="transparent">透明</el-radio-button>
|
||||
<el-radio-button label="white">白色</el-radio-button>
|
||||
<el-radio-button label="black">黑色</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
imageUrl: string
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
'confirm': [imageUrl: string]
|
||||
}>()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emits('update:visible', value)
|
||||
})
|
||||
|
||||
const background = ref('transparent')
|
||||
|
||||
const previewBackground = computed(() => {
|
||||
switch (background.value) {
|
||||
case 'transparent':
|
||||
return {
|
||||
background: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAADFJREFUOI1j/P///38GKgETlQwYNWDUABYGBgYGxv///zNSYsB/BgYGBioawEgNXwAAoBAP/gOzm/MAAAAASUVORK5CYII=)'
|
||||
}
|
||||
case 'white':
|
||||
return { background: '#ffffff' }
|
||||
case 'black':
|
||||
return { background: '#000000' }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
emits('confirm', props.imageUrl)
|
||||
handleClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.background-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
<template>
|
||||
<div v-if="visible" class="nodes-dialog-overlay" @click="handleClose">
|
||||
<div class="nodes-dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>选择节点类型</h3>
|
||||
<div class="header-actions">
|
||||
<button class="btn-confirm" @click="handleConfirm" :disabled="!hasSelection">
|
||||
确定 ({{ selectedNodes.length }})
|
||||
</button>
|
||||
<button class="close-btn" @click="handleClose">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<!-- 左侧类型树 -->
|
||||
<div class="category-tree">
|
||||
<div
|
||||
v-for="category in extraNodes.categories"
|
||||
:key="category.name"
|
||||
class="tree-item"
|
||||
:class="{ active: selectedCategories.includes(category.name) }"
|
||||
@click="toggleCategory(category.name)"
|
||||
>
|
||||
<span class="tree-icon">📁</span>
|
||||
<span class="tree-label">{{ category.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧节点列表 -->
|
||||
<div class="nodes-list">
|
||||
<template v-if="currentNodes.length">
|
||||
<div class="nodes-grid">
|
||||
<div
|
||||
v-for="node in currentNodes"
|
||||
:key="node.type"
|
||||
class="node-item"
|
||||
:class="{ active: isNodeSelected(node) }"
|
||||
@click="toggleNodeSelect(node)"
|
||||
>
|
||||
<span v-if="isNodeSelected(node)" class="check-icon">✓</span>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-state">
|
||||
请选择左侧节点类型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from 'vue'
|
||||
import extraNodes from '@/assets/data/extra-nodes.json'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeSelector',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:visible', 'select'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const selectedCategories = ref<string[]>([])
|
||||
const selectedNodes = ref<any[]>([])
|
||||
|
||||
const currentNodes = computed(() => {
|
||||
if (selectedCategories.value.length === 0) return []
|
||||
return extraNodes.categories
|
||||
.filter(c => selectedCategories.value.includes(c.name))
|
||||
.flatMap(c => c.nodes)
|
||||
})
|
||||
|
||||
const hasSelection = computed(() => selectedNodes.value.length > 0)
|
||||
|
||||
const toggleCategory = (name: string) => {
|
||||
const index = selectedCategories.value.indexOf(name)
|
||||
if (index === -1) {
|
||||
selectedCategories.value.push(name)
|
||||
} else {
|
||||
selectedCategories.value.splice(index, 1)
|
||||
// 移除该分类下的所有已选节点
|
||||
const categoryNodes = extraNodes.categories
|
||||
.find(c => c.name === name)?.nodes || []
|
||||
selectedNodes.value = selectedNodes.value.filter(node =>
|
||||
!categoryNodes.some(n => n.type === node.type)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isNodeSelected = (node: any) => {
|
||||
return selectedNodes.value.some(n => n.type === node.type)
|
||||
}
|
||||
|
||||
const toggleNodeSelect = (node: any) => {
|
||||
const index = selectedNodes.value.findIndex(n => n.type === node.type)
|
||||
if (index === -1) {
|
||||
selectedNodes.value.push(node)
|
||||
} else {
|
||||
selectedNodes.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
selectedCategories.value = []
|
||||
selectedNodes.value = []
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedNodesWithData = selectedNodes.value.map(node => ({
|
||||
...node,
|
||||
type: node.type,
|
||||
label: node.name,
|
||||
icon: node.icon,
|
||||
template: node.template
|
||||
}))
|
||||
|
||||
emit('select', selectedNodesWithData)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCategories,
|
||||
selectedNodes,
|
||||
currentNodes,
|
||||
hasSelection,
|
||||
extraNodes,
|
||||
toggleCategory,
|
||||
isNodeSelected,
|
||||
toggleNodeSelect,
|
||||
handleClose,
|
||||
handleConfirm
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nodes-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nodes-dialog {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-confirm:disabled {
|
||||
background: #a0cfff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 20px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-tree {
|
||||
width: 200px;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nodes-list {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node-item:hover {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-item.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<div class="property-panel">
|
||||
<div class="panel-header"> 属性面板 </div>
|
||||
<div class="panel-content" v-if="selectedNode">
|
||||
<!-- 基础属性 -->
|
||||
<div class="property-section">
|
||||
<div class="section-title">基础属性</div>
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="selectedNode.label" @change="updateNodeProperty('label')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宽度">
|
||||
<el-input-number v-model="selectedNode.width" @change="updateNodeProperty('width')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="高度">
|
||||
<el-input-number v-model="selectedNode.height" @change="updateNodeProperty('height')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 样式属性 -->
|
||||
<div class="property-section">
|
||||
<div class="section-title">样式属性</div>
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="填充色">
|
||||
<el-color-picker
|
||||
v-model="selectedNode.fillColor"
|
||||
@change="updateNodeProperty('fillColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="边框色">
|
||||
<el-color-picker
|
||||
v-model="selectedNode.strokeColor"
|
||||
@change="updateNodeProperty('strokeColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="边框宽度">
|
||||
<el-input-number
|
||||
v-model="selectedNode.strokeWidth"
|
||||
@change="updateNodeProperty('strokeWidth')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 文字属性 -->
|
||||
<div class="property-section">
|
||||
<div class="section-title">文字属性</div>
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="字体">
|
||||
<el-select v-model="selectedNode.fontFamily" @change="updateNodeProperty('fontFamily')">
|
||||
<el-option label="Arial" value="Arial" />
|
||||
<el-option label="微软雅黑" value="Microsoft YaHei" />
|
||||
<el-option label="宋体" value="SimSun" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="字号">
|
||||
<el-input-number
|
||||
v-model="selectedNode.fontSize"
|
||||
@change="updateNodeProperty('fontSize')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体颜色">
|
||||
<el-color-picker
|
||||
v-model="selectedNode.fontColor"
|
||||
@change="updateNodeProperty('fontColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-empty" v-else> 请选择一个节点 </div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { flowEventBus } from '../../events'
|
||||
import type { Cell } from '@maxgraph/core'
|
||||
|
||||
interface NodeProperties {
|
||||
label: string
|
||||
width: number
|
||||
height: number
|
||||
fillColor: string
|
||||
strokeColor: string
|
||||
strokeWidth: number
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontColor: string
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'PropertyPanel'
|
||||
})
|
||||
|
||||
const selectedNode = ref<NodeProperties | null>(null)
|
||||
|
||||
// 监听节点选择事件
|
||||
onMounted(() => {
|
||||
flowEventBus.on('node-selected', (nodes: [Cell]) => {
|
||||
if (nodes.length === 0) {
|
||||
return
|
||||
}
|
||||
let node: Cell = nodes[0]
|
||||
selectedNode.value = {
|
||||
label: node.getValue() || '',
|
||||
width: node.geometry.width,
|
||||
height: node.geometry.height,
|
||||
fillColor: node.getStyle()['fillColor'] || '#ffffff',
|
||||
strokeColor: node.getStyle()['strokeColor'] || '#000000',
|
||||
strokeWidth: node.getStyle()['strokeWidth'] || 1,
|
||||
fontFamily: node.getStyle()['fontFamily'] || 'Arial',
|
||||
fontSize: node.getStyle()['fontSize'] || 14,
|
||||
fontColor: node.getStyle()['fontColor'] || '#000000'
|
||||
}
|
||||
})
|
||||
|
||||
flowEventBus.on('node-unselected', () => {
|
||||
selectedNode.value = null
|
||||
})
|
||||
})
|
||||
|
||||
// 更新节点属性
|
||||
const updateNodeProperty = (property: string) => {
|
||||
flowEventBus.emit('update-node-property', {
|
||||
property,
|
||||
value: selectedNode.value[property]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.property-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.property-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
<template>
|
||||
<div class="style-library">
|
||||
<div class="style-list">
|
||||
<div v-for="(style, index) in styles"
|
||||
:key="index"
|
||||
class="style-item"
|
||||
@click="applyStyle(style)">
|
||||
<div class="style-preview" :style="getPreviewStyle(style)">
|
||||
<div class="style-name">{{ style.name }}</div>
|
||||
<div class="preview-node start-node" :style="getStartEndStyle(style)">开始</div>
|
||||
<div class="preview-node process-node" :style="style.nodeStyle">处理</div>
|
||||
<div class="preview-node end-node" :style="getStartEndStyle(style)">结束</div>
|
||||
<svg class="preview-edges" width="100%" height="100%">
|
||||
<defs>
|
||||
<marker :id="'arrow-' + index"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="4"
|
||||
markerHeight="4"
|
||||
orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" :fill="style.edgeStyle.strokeColor"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<path :style="getEdgeStyle(style, index)" d="M56,40 L102,40" />
|
||||
<path :style="getEdgeStyle(style, index)" d="M140,40 L194,40" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { flowEventBus } from '../../events'
|
||||
|
||||
export default {
|
||||
name: 'StyleLibrary',
|
||||
data() {
|
||||
return {
|
||||
styles: [
|
||||
{
|
||||
name: '经典蓝',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderColor: '#2196f3',
|
||||
color: '#1976d2'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#2196f3',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '商务灰',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderColor: '#9e9e9e',
|
||||
color: '#616161'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#9e9e9e',
|
||||
strokeWidth: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '活力橙',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#fff3e0',
|
||||
borderColor: '#ff9800',
|
||||
color: '#f57c00'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#ff9800',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '清新绿',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderColor: '#4caf50',
|
||||
color: '#2e7d32'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#4caf50',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '科技紫',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
borderColor: '#9c27b0',
|
||||
color: '#6a1b9a'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#9c27b0',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '暗夜黑',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#424242',
|
||||
borderColor: '#212121',
|
||||
color: '#ffffff'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#757575',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '珊瑚红',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#ffebee',
|
||||
borderColor: '#f44336',
|
||||
color: '#c62828'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#f44336',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '海洋蓝',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#e0f7fa',
|
||||
borderColor: '#00bcd4',
|
||||
color: '#006064'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#00bcd4',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '柔和粉',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#fce4ec',
|
||||
borderColor: '#e91e63',
|
||||
color: '#ad1457'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#e91e63',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '极光绿',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderColor: '#4caf50',
|
||||
color: '#2e7d32'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#9c27b0',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '日落橙',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#fff3e0',
|
||||
borderColor: '#ff9800',
|
||||
color: '#e65100'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#2196f3',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '深海蓝',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderColor: '#1976d2',
|
||||
color: '#0d47a1'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#4caf50',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '霓虹紫',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
borderColor: '#9c27b0',
|
||||
color: '#4a148c'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#ff9800',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '薄暮红',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#ffebee',
|
||||
borderColor: '#f44336',
|
||||
color: '#b71c1c'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#00bcd4',
|
||||
strokeWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '青玉白',
|
||||
nodeStyle: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#00bcd4',
|
||||
color: '#006064'
|
||||
},
|
||||
edgeStyle: {
|
||||
strokeColor: '#ff4081',
|
||||
strokeWidth: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
applyStyle(style) {
|
||||
flowEventBus.emit('apply-graph-style', style)
|
||||
},
|
||||
getPreviewStyle(style) {
|
||||
return {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '10px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}
|
||||
},
|
||||
getStartEndStyle(style) {
|
||||
return {
|
||||
...style.nodeStyle,
|
||||
borderRadius: '50%',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px'
|
||||
}
|
||||
},
|
||||
getEdgeStyle(style, index) {
|
||||
return {
|
||||
stroke: style.edgeStyle.strokeColor,
|
||||
strokeWidth: style.edgeStyle.strokeWidth,
|
||||
fill: 'none',
|
||||
markerEnd: `url(#arrow-${index})`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.style-library {
|
||||
position: absolute; /* 添加绝对定位 */
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 280px; /* 设置固定宽度 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f5f7fa;
|
||||
border-left: 1px solid #e4e7ed; /* 添加左边框 */
|
||||
z-index: 100; /* 确保在其他元素上层 */
|
||||
}
|
||||
|
||||
.style-list {
|
||||
flex: 1;
|
||||
padding: 2px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%; /* 确保高度填满 */
|
||||
box-sizing: border-box; /* 确保padding不会增加实际高度 */
|
||||
}
|
||||
|
||||
.style-item {
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background-color: #fff; /* 添加白色背景 */
|
||||
border-radius: 4px; /* 添加圆角 */
|
||||
}
|
||||
|
||||
.style-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.style-preview {
|
||||
height: 60px;
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.style-name {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: rgba(255, 255, 255, 0.9); /* 半透明背景 */
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.preview-node {
|
||||
position: relative;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
min-width: 40px;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.start-node {
|
||||
margin-left: 20px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.process-node {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.end-node {
|
||||
margin-right: 20px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.preview-edges {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.style-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.style-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.style-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.style-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
:modelValue="visible"
|
||||
@update:modelValue="$emit('update:visible', $event)"
|
||||
title="编辑表格"
|
||||
width="800px"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<div class="table-editor">
|
||||
<!-- 表格配置 -->
|
||||
<div class="table-config">
|
||||
<el-button-group>
|
||||
<el-button @click="addRow">添加行</el-button>
|
||||
<el-button @click="addColumn">添加列</el-button>
|
||||
<el-button @click="deleteRow" :disabled="!selectedCell.row">删除行</el-button>
|
||||
<el-button @click="deleteColumn" :disabled="!selectedCell.col">删除列</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<!-- 表格预览和编辑 -->
|
||||
<div class="table-preview">
|
||||
<table class="editable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(col, index) in tableData.columns"
|
||||
:key="index"
|
||||
@click="selectColumn(index)"
|
||||
:class="{ selected: selectedCell.col === index }">
|
||||
<el-input v-model="col.title" size="small" @change="updateTable" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in tableData.data"
|
||||
:key="rowIndex">
|
||||
<td v-for="(col, colIndex) in tableData.columns"
|
||||
:key="colIndex"
|
||||
@click="selectCell(rowIndex, colIndex)"
|
||||
:class="{ selected: selectedCell.row === rowIndex && selectedCell.col === colIndex }">
|
||||
<el-input v-model="row[col.key]" size="small" @change="updateTable" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确认</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TableEditor',
|
||||
props: {
|
||||
visible: Boolean,
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
columns: [],
|
||||
data: []
|
||||
})
|
||||
}
|
||||
},
|
||||
emits: ['update:visible', 'update:value', 'confirm'],
|
||||
data() {
|
||||
return {
|
||||
tableData: {
|
||||
columns: [],
|
||||
data: []
|
||||
},
|
||||
selectedCell: {
|
||||
row: null,
|
||||
col: null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
initialData: {
|
||||
handler(val) {
|
||||
this.tableData = JSON.parse(JSON.stringify(val))
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addRow() {
|
||||
const newRow = {}
|
||||
this.tableData.columns.forEach(col => {
|
||||
newRow[col.key] = ''
|
||||
})
|
||||
this.tableData.data.push(newRow)
|
||||
this.updateTable()
|
||||
},
|
||||
addColumn() {
|
||||
const columnIndex = this.tableData.columns.length + 1
|
||||
const key = `col${columnIndex}`
|
||||
this.tableData.columns.push({
|
||||
title: `列${columnIndex}`,
|
||||
key,
|
||||
width: 100
|
||||
})
|
||||
this.tableData.data.forEach(row => {
|
||||
row[key] = ''
|
||||
})
|
||||
this.updateTable()
|
||||
},
|
||||
deleteRow() {
|
||||
if (this.selectedCell.row !== null) {
|
||||
this.tableData.data.splice(this.selectedCell.row, 1)
|
||||
this.selectedCell.row = null
|
||||
this.updateTable()
|
||||
}
|
||||
},
|
||||
deleteColumn() {
|
||||
if (this.selectedCell.col !== null) {
|
||||
const key = this.tableData.columns[this.selectedCell.col].key
|
||||
this.tableData.columns.splice(this.selectedCell.col, 1)
|
||||
this.tableData.data.forEach(row => {
|
||||
delete row[key]
|
||||
})
|
||||
this.selectedCell.col = null
|
||||
this.updateTable()
|
||||
}
|
||||
},
|
||||
selectCell(row, col) {
|
||||
this.selectedCell = { row, col }
|
||||
},
|
||||
selectColumn(col) {
|
||||
this.selectedCell = { row: null, col }
|
||||
},
|
||||
updateTable() {
|
||||
this.generateTableHtml()
|
||||
},
|
||||
generateTableHtml() {
|
||||
const html = `
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
${this.tableData.columns.map(col =>
|
||||
`<th style="border: 1px solid #333; padding: 4px; background-color: #f5f5f5;">${col.title}</th>`
|
||||
).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.tableData.data.map(row =>
|
||||
`<tr>${this.tableData.columns.map(col =>
|
||||
`<td style="border: 1px solid #333; padding: 4px;">${row[col.key]}</td>`
|
||||
).join('')}</tr>`
|
||||
).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
this.$emit('update:value', html)
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
handleConfirm() {
|
||||
this.$emit('confirm', {
|
||||
html: this.generateTableHtml(),
|
||||
config: this.tableData
|
||||
})
|
||||
this.handleClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-editor {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.table-config {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.editable-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.editable-table th,
|
||||
.editable-table td {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.editable-table th {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.editable-table .selected {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export const MenuIcons = {
|
||||
delete: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`,
|
||||
|
||||
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`,
|
||||
|
||||
layers: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"/></svg>`,
|
||||
|
||||
moveUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>`,
|
||||
|
||||
moveDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`,
|
||||
|
||||
group: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M3 5v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H5c-1.11 0-2 .9-2 2zm12 4c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3zm-9 8c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1H6v-1z"/></svg>`,
|
||||
|
||||
ungroup: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></svg>`,
|
||||
|
||||
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>`,
|
||||
|
||||
permission: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>`,
|
||||
|
||||
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`,
|
||||
|
||||
color: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-.99 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>`,
|
||||
|
||||
image: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>`
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import mitt from 'mitt'
|
||||
|
||||
type Events = {
|
||||
[K in string]: unknown
|
||||
} & {
|
||||
zoom: number
|
||||
fit: void
|
||||
delete: void
|
||||
export: void
|
||||
undo: void
|
||||
reDo: void
|
||||
'edge-style': string
|
||||
'auto-layout': void
|
||||
'change-background': string
|
||||
'toggle-grid': boolean
|
||||
'change-layout': string
|
||||
'change-font': string
|
||||
'update-text-style': any
|
||||
}
|
||||
|
||||
export const flowEventBus = mitt<Events>()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ConstraintHandler } from "@maxgraph/core";
|
||||
|
||||
export class CustomConstraintHandler extends ConstraintHandler {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { RubberBandHandler } from "@maxgraph/core";
|
||||
|
||||
export class CustomRubberBandHandler extends RubberBandHandler {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { Graph, PopupMenuHandler } from "@maxgraph/core";
|
||||
import { MenuIcons } from '../constants/menuIcons';
|
||||
|
||||
export class MyCustomPopupMenuHandler extends PopupMenuHandler {
|
||||
constructor(graph: Graph) {
|
||||
super(graph);
|
||||
// Configures automatic expand on mouseover
|
||||
this.autoExpand = true; // TODO autoExpand is not working
|
||||
|
||||
this.factoryMethod = (menu, cell, evt) => {
|
||||
// 只对节点显示菜单
|
||||
if (cell && cell.isVertex()) {
|
||||
menu.addItem('删除节点', null, () => {
|
||||
this.graph.removeCells([cell]);
|
||||
}, null, MenuIcons.delete);
|
||||
|
||||
menu.addItem('复制节点', null, () => {
|
||||
const clone = this.graph.cloneCells([cell])[0];
|
||||
const geo = clone.getGeometry();
|
||||
geo.x += 50;
|
||||
geo.y += 50;
|
||||
this.graph.addCell(clone, this.graph.getDefaultParent(), null, null, null);
|
||||
}, null, MenuIcons.copy);
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// 层级操作
|
||||
const layerSubmenu = menu.addItem('层级操作', null, null, null, MenuIcons.layers);
|
||||
menu.addItem('置于顶层', null, () => {
|
||||
this.graph.moveCells([cell], 0, 0, true); // true 表示置于顶层
|
||||
}, layerSubmenu, MenuIcons.moveUp);
|
||||
menu.addItem('置于底层', null, () => {
|
||||
this.graph.moveCells([cell], 0, 0, false); // false 表示置于底层
|
||||
}, layerSubmenu, MenuIcons.moveDown);
|
||||
menu.addItem('上移一层', null, () => {
|
||||
this.graph.orderCells(true, [cell]); // true 表示下移一层
|
||||
}, layerSubmenu, MenuIcons.moveUp);
|
||||
menu.addItem('下移一层', null, () => {
|
||||
this.graph.orderCells(false, [cell]); // true 表示下移一层
|
||||
}, layerSubmenu, MenuIcons.moveDown);
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// 组合操作
|
||||
const groupSubmenu = menu.addItem('组合操作', null, null, null, MenuIcons.group);
|
||||
menu.addItem('组合', null, () => {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells.length > 1) {
|
||||
this.graph.groupCells(null, 0, cells);
|
||||
}
|
||||
}, groupSubmenu, MenuIcons.group);
|
||||
menu.addItem('解除组合', null, () => {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
this.graph.ungroupCells(cells);
|
||||
}, groupSubmenu, MenuIcons.ungroup);
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// 锁定操作
|
||||
// menu.addItem(cell.getAttribute('locked') ? '解除锁定' : '锁定', null, () => {
|
||||
// this.graph.toggleCellStyles('locked', true, [cell]);
|
||||
// }, null, cell.getAttribute('locked') ? '🔓' : '🔒');
|
||||
|
||||
// 权限操作
|
||||
const permissionSubmenu = menu.addItem('权限设置', null, null, null, MenuIcons.lock);
|
||||
menu.addItem('只读', null, () => {
|
||||
cell.setAttribute('permission', 'readonly');
|
||||
}, permissionSubmenu, MenuIcons.lock);
|
||||
menu.addItem('可编辑', null, () => {
|
||||
cell.setAttribute('permission', 'editable');
|
||||
}, permissionSubmenu, MenuIcons.permission);
|
||||
menu.addItem('完全控制', null, () => {
|
||||
cell.setAttribute('permission', 'full');
|
||||
}, permissionSubmenu, MenuIcons.permission);
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// 编辑操作
|
||||
const editSubmenu = menu.addItem('编辑', null, null, null, MenuIcons.edit);
|
||||
menu.addItem('修改文本', null, () => {
|
||||
// 触发文本编辑
|
||||
this.graph.startEditingAtCell(cell);
|
||||
}, editSubmenu, MenuIcons.edit);
|
||||
|
||||
menu.addItem('修改颜色', null, () => {
|
||||
// 这里可以触发颜色选择事件
|
||||
console.log('修改颜色');
|
||||
}, editSubmenu, MenuIcons.color);
|
||||
|
||||
// 在现有菜单项中添加插入图片选项
|
||||
menu.addItem('插入图片', null, () => {
|
||||
// 创建一个隐藏的文件输入框
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
|
||||
input.onchange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const imageUrl = event.target?.result as string;
|
||||
// 在当前位置插入图片
|
||||
const geo = cell.getGeometry().clone();
|
||||
const imageCell = this.graph.insertVertex(
|
||||
this.graph.getDefaultParent(),
|
||||
null,
|
||||
'',
|
||||
geo.x + 50,
|
||||
geo.y + 50,
|
||||
100, // 默认宽度
|
||||
100, // 默认高度
|
||||
{
|
||||
shape: 'image',
|
||||
image: imageUrl,
|
||||
imageAlign: 'center',
|
||||
imageVerticalAlign: 'middle',
|
||||
resizable: true
|
||||
}
|
||||
);
|
||||
this.graph.setSelectionCell(imageCell);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
document.body.removeChild(input);
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, null, MenuIcons.image);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 重写创建菜单项的方法来添加自定义样式
|
||||
protected createMenuItem(title: string, image: string | null, funct: (() => void) | null, parent: any, icon?: string): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-menu-item';
|
||||
|
||||
if (icon) {
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'menu-icon';
|
||||
iconSpan.innerHTML = icon;
|
||||
item.appendChild(iconSpan);
|
||||
}
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'menu-label';
|
||||
labelSpan.textContent = title;
|
||||
item.appendChild(labelSpan);
|
||||
|
||||
if (parent) {
|
||||
item.classList.add('has-submenu');
|
||||
}
|
||||
|
||||
if (funct) {
|
||||
item.onclick = funct;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { ConnectionHandler, Graph } from "@maxgraph/core";
|
||||
|
||||
export class WuConnectionHandler extends ConnectionHandler {
|
||||
constructor(graph: Graph) {
|
||||
console.log("WuConnectionHandler constructor");
|
||||
super(graph);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Geometry, ConnectionConstraint, Point } from "@maxgraph/core";
|
||||
|
||||
export class WuCustomGeometryClass extends Geometry {
|
||||
// Defines the default constraints for the vertices
|
||||
constraints = [new ConnectionConstraint(new Point(0.25, 0), true), new ConnectionConstraint(new Point(0.5, 0), true), new ConnectionConstraint(new Point(0.75, 0), true), new ConnectionConstraint(new Point(0, 0.25), true), new ConnectionConstraint(new Point(0, 0.5), true), new ConnectionConstraint(new Point(0, 0.75), true), new ConnectionConstraint(new Point(1, 0.25), true), new ConnectionConstraint(new Point(1, 0.5), true), new ConnectionConstraint(new Point(1, 0.75), true), new ConnectionConstraint(new Point(0.25, 1), true), new ConnectionConstraint(new Point(0.5, 1), true), new ConnectionConstraint(new Point(0.75, 1), true)];
|
||||
}
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
import { Graph, GraphDataModel, CellState, GraphPluginConstructor, InternalEvent, Cell, RectangleShape } from "@maxgraph/core";
|
||||
import { WuCustomGeometryClass } from "./WuCustomGeometryClass";
|
||||
import { WuConnectionHandler } from "./WuConnectionHandler";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { flowEventBus } from '../events';
|
||||
import { CustomPopupMenuHandler } from '../handlers/CustomPopupMenuHandler';
|
||||
import { KeyHandler } from '../handlers/KeyHandler';
|
||||
import { WuConstraintHandler } from "../handlers/WuConstraintHandler";
|
||||
import { Codec, utils, ModelXmlSerializer } from "@maxgraph/core";
|
||||
|
||||
export class WuCustomGraph extends Graph {
|
||||
private copiedStyle: any = null;
|
||||
private isStyleBrushActive = false;
|
||||
private keyHandler: KeyHandler;
|
||||
|
||||
constructor(container: HTMLElement, model: GraphDataModel, plugins: GraphPluginConstructor[]) {
|
||||
super(container, model, plugins);
|
||||
this.initDoubleClickHandler();
|
||||
this.initClickHandler();
|
||||
this.initStyleBrush();
|
||||
this.initEdgeAnimation();
|
||||
this.initConnectorShape();
|
||||
this.initPopupMenu();
|
||||
this.keyHandler = new KeyHandler(this);
|
||||
|
||||
// // 添加选中状态变化监听
|
||||
this.addListener(InternalEvent.CLICK, (sender, evt) => {
|
||||
const cells = this.getSelectionCells();
|
||||
flowEventBus.emit('selection-changed', cells);
|
||||
console.log('select....')
|
||||
flowEventBus.emit('node-selected', cells);
|
||||
});
|
||||
|
||||
flowEventBus.on('apply-graph-style', (style: any) => {
|
||||
this.applyGraphStyle(style);
|
||||
});
|
||||
|
||||
this.initTableEditor();
|
||||
|
||||
// 注册表格形状
|
||||
this.getStylesheet().putCellStyle('table', {
|
||||
shape: 'html',
|
||||
whiteSpace: 'wrap',
|
||||
verticalAlign: 'top',
|
||||
align: 'left',
|
||||
overflow: 'visible'
|
||||
});
|
||||
|
||||
// 监听对齐事件
|
||||
flowEventBus.on('align-cells', (type: string) => {
|
||||
this.alignCells(type);
|
||||
});
|
||||
}
|
||||
|
||||
private initDoubleClickHandler() {
|
||||
// 监听双击事件
|
||||
this.addListener(InternalEvent.CLICK, () => {
|
||||
flowEventBus.emit('hide-text-toolbar');
|
||||
});
|
||||
this.addListener(InternalEvent.DOUBLE_CLICK, (sender: any, evt: any) => {
|
||||
flowEventBus.emit('hide-text-toolbar');
|
||||
const cell = evt.getProperty('cell');
|
||||
if (cell && cell.isVertex()) {
|
||||
// 获取节点的值(标签)
|
||||
const label = cell.getValue() || '未命名节点';
|
||||
// 显示提示信息
|
||||
ElMessage({
|
||||
message: `双击了节点: ${label}`,
|
||||
type: 'info',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initClickHandler() {
|
||||
this.addListener(InternalEvent.DOUBLE_CLICK, (sender: any, evt: any) => {
|
||||
// 先隐藏工具栏
|
||||
flowEventBus.emit('hide-text-toolbar');
|
||||
const cell = evt.getProperty('cell');
|
||||
if (cell && cell.isVertex()) {
|
||||
// 获取点击位置
|
||||
const x = evt.getProperty('event').clientX;
|
||||
const y = evt.getProperty('event').clientY;
|
||||
|
||||
// 获取当前节点的样式
|
||||
const style = cell.getStyle();
|
||||
|
||||
// 延迟一下再显示工具栏,避免闪烁
|
||||
setTimeout(() => {
|
||||
flowEventBus.emit('show-text-toolbar', {
|
||||
position: { left: `${x}px`, top: `${y + 20}px` },
|
||||
cell,
|
||||
style: {
|
||||
fontFamily: style.fontFamily || 'Arial',
|
||||
fontSize: style.fontSize || 14,
|
||||
fontColor: style.fontColor || '#000000',
|
||||
fontWeight: style.fontWeight || 'normal',
|
||||
textDecoration: style.textDecoration || 'none'
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initStyleBrush() {
|
||||
flowEventBus.on('activate-style-brush', (style: any) => {
|
||||
this.isStyleBrushActive = true;
|
||||
this.copiedStyle = style;
|
||||
});
|
||||
|
||||
flowEventBus.on('deactivate-style-brush', () => {
|
||||
this.isStyleBrushActive = false;
|
||||
this.copiedStyle = null;
|
||||
});
|
||||
|
||||
this.addListener(InternalEvent.CLICK, (sender: any, evt: any) => {
|
||||
if (this.isStyleBrushActive && this.copiedStyle) {
|
||||
const cell = evt.getProperty('cell');
|
||||
if (cell && cell.isVertex()) {
|
||||
// 应用复制的样式
|
||||
flowEventBus.emit('update-text-style', this.copiedStyle);
|
||||
// 通知样式已应用
|
||||
flowEventBus.emit('style-brush-applied');
|
||||
this.isStyleBrushActive = false;
|
||||
this.copiedStyle = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private initEdgeAnimation() {
|
||||
this.addListener(InternalEvent.CLICK, (sender: any, evt: any) => {
|
||||
const cell = evt.getProperty('cell');
|
||||
if (cell && cell.isEdge()) {
|
||||
const state = this.view.getState(cell);
|
||||
if (state) {
|
||||
// 创建动画效果
|
||||
const animate = () => {
|
||||
state.shape.node.style.strokeDasharray = '5,5';
|
||||
state.shape.node.style.animation = 'flowLine 1s linear infinite';
|
||||
};
|
||||
|
||||
// 添加动画样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes flowLine {
|
||||
from {
|
||||
stroke-dashoffset: 10;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// 应用动画
|
||||
animate();
|
||||
|
||||
// 3秒后移除动画
|
||||
setTimeout(() => {
|
||||
if (state?.shape?.node) {
|
||||
state.shape.node.style.strokeDasharray = '';
|
||||
state.shape.node.style.animation = '';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initConnectorShape() {
|
||||
this.getStylesheet().putCellStyle('connector', {
|
||||
shape: 'ellipse',
|
||||
perimeter: 'ellipsePerimeter',
|
||||
fillColor: '#fff',
|
||||
strokeColor: '#5b8ffa',
|
||||
strokeWidth: 2,
|
||||
image: `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="white" stroke="#5b8ffa" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="4" fill="#5b8ffa"/>
|
||||
</svg>
|
||||
`)}`,
|
||||
imageWidth: 24,
|
||||
imageHeight: 24,
|
||||
imageAlign: 'center',
|
||||
verticalAlign: 'middle'
|
||||
});
|
||||
}
|
||||
|
||||
private initPopupMenu() {
|
||||
new CustomPopupMenuHandler();
|
||||
}
|
||||
|
||||
private initTableEditor() {
|
||||
this.addListener(InternalEvent.DOUBLE_CLICK, (sender: any, evt: any) => {
|
||||
const cell = evt.getProperty('cell');
|
||||
if (cell && cell.isVertex() && cell.getStyle()['shape'] === 'table') {
|
||||
const tableConfig = cell.getStyle()['tableConfig'];
|
||||
flowEventBus.emit('edit-table', {
|
||||
cell,
|
||||
config: tableConfig
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
flowEventBus.on('table-updated', ({ cell, html, config }) => {
|
||||
this.model.beginUpdate();
|
||||
try {
|
||||
const style = { ...cell.getStyle() };
|
||||
style.value = html;
|
||||
style.tableConfig = config;
|
||||
this.setCellStyle(style, cell);
|
||||
} finally {
|
||||
this.model.endUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAllConnectionConstraints = (terminal: CellState | null, _source: boolean) => {
|
||||
// Overridden to define per-geometry connection points
|
||||
return (terminal?.cell?.geometry as WuCustomGeometryClass)?.constraints ?? null;
|
||||
};
|
||||
createConnectionHandler() {
|
||||
const r = new WuConnectionHandler(this);
|
||||
r.constraintHandler = new WuConstraintHandler(this);
|
||||
return r;
|
||||
}
|
||||
|
||||
handleStyleApply({ style, cells }: { style: any, cells: any[] | null }) {
|
||||
const model = this.model;
|
||||
model.beginUpdate();
|
||||
console.log(model.cells);
|
||||
try {
|
||||
if (cells) {
|
||||
// 应用到指定的节点
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
// 应用节点样式
|
||||
this.setCellStyle({
|
||||
fillColor: style.nodeStyle.backgroundColor,
|
||||
strokeColor: style.nodeStyle.borderColor,
|
||||
fontColor: style.nodeStyle.color
|
||||
}, cell);
|
||||
} else if (cell.isEdge()) {
|
||||
// 应用边的样式
|
||||
this.setCellStyle({
|
||||
strokeColor: style.edgeStyle.strokeColor,
|
||||
strokeWidth: style.edgeStyle.strokeWidth
|
||||
}, cell);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 应用到所有节点
|
||||
const allCells = this.model.cells
|
||||
Object.values(allCells).forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
this.setCellStyle({
|
||||
fillColor: style.nodeStyle.backgroundColor,
|
||||
strokeColor: style.nodeStyle.borderColor,
|
||||
fontColor: style.nodeStyle.color
|
||||
}, [cell]);
|
||||
} else if (cell.isEdge()) {
|
||||
this.setCellStyle({
|
||||
strokeColor: style.edgeStyle.strokeColor,
|
||||
strokeWidth: style.edgeStyle.strokeWidth
|
||||
}, [cell]);
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
model.endUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private applyGraphStyle(style: any) {
|
||||
let cells = this.getSelectionCells();
|
||||
console.log(cells);
|
||||
if (cells.length === 0) {
|
||||
cells = Object.values(this.model.cells);
|
||||
}
|
||||
if (!cells.length) {
|
||||
return;
|
||||
}
|
||||
this.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const newStyle = {
|
||||
...cell.getStyle(),
|
||||
fillColor: style.nodeStyle.backgroundColor,
|
||||
strokeColor: style.nodeStyle.borderColor,
|
||||
fontColor: style.nodeStyle.color
|
||||
};
|
||||
this.setCellStyle(newStyle, [cell]);
|
||||
} else if (cell.isEdge()) {
|
||||
const newStyle = {
|
||||
...cell.getStyle(),
|
||||
strokeColor: style.edgeStyle.strokeColor,
|
||||
strokeWidth: style.edgeStyle.strokeWidth
|
||||
};
|
||||
this.setCellStyle(newStyle, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.model.endUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
getGraphData() {
|
||||
|
||||
const serializer = new ModelXmlSerializer(this.model)
|
||||
return serializer.export()
|
||||
|
||||
}
|
||||
|
||||
loadGraphData(data: string) {
|
||||
const model = new GraphDataModel();
|
||||
const serializer = new ModelXmlSerializer(model);
|
||||
serializer.import(data);
|
||||
}
|
||||
|
||||
getGraphSvg() {
|
||||
const scale = 1
|
||||
const bounds = this.getGraphBounds()
|
||||
|
||||
return `<svg width="${bounds.width * scale}" height="${bounds.height * scale}"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
${this.container.innerHTML}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
// 对齐功能
|
||||
alignCells = (type: string) => {
|
||||
const cells = this.getSelectionCells();
|
||||
if (cells.length < 2) return;
|
||||
|
||||
this.model.beginUpdate();
|
||||
try {
|
||||
const bounds = cells.map(cell => this.getBoundingBox([cell]));
|
||||
const first = bounds[0];
|
||||
|
||||
switch (type) {
|
||||
case 'left':
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], first.x - bound.x, 0);
|
||||
});
|
||||
break;
|
||||
case 'center':
|
||||
const centerX = first.x + first.width / 2;
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], centerX - (bound.x + bound.width / 2), 0);
|
||||
});
|
||||
break;
|
||||
case 'right':
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], (first.x + first.width) - (bound.x + bound.width), 0);
|
||||
});
|
||||
break;
|
||||
case 'top':
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], 0, first.y - bound.y);
|
||||
});
|
||||
break;
|
||||
case 'middle':
|
||||
const centerY = first.y + first.height / 2;
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], 0, centerY - (bound.y + bound.height / 2));
|
||||
});
|
||||
break;
|
||||
case 'bottom':
|
||||
bounds.slice(1).forEach((bound, i) => {
|
||||
this.moveCells([cells[i + 1]], 0, (first.y + first.height) - (bound.y + bound.height));
|
||||
});
|
||||
break;
|
||||
case 'distributeHorizontally':
|
||||
const sortedX = [...cells].sort((a, b) => this.getBoundingBox([a]).x - this.getBoundingBox([b]).x);
|
||||
const totalWidth = this.getBoundingBox([sortedX[sortedX.length - 1]]).x - this.getBoundingBox([sortedX[0]]).x;
|
||||
const spacing = totalWidth / (cells.length - 1);
|
||||
sortedX.slice(1, -1).forEach((cell, i) => {
|
||||
const targetX = this.getBoundingBox([sortedX[0]]).x + spacing * (i + 1);
|
||||
const currentX = this.getBoundingBox([cell]).x;
|
||||
this.moveCells([cell], targetX - currentX, 0);
|
||||
});
|
||||
break;
|
||||
case 'distributeVertically':
|
||||
const sortedY = [...cells].sort((a, b) => this.getBoundingBox([a]).y - this.getBoundingBox([b]).y);
|
||||
const totalHeight = this.getBoundingBox([sortedY[sortedY.length - 1]]).y - this.getBoundingBox([sortedY[0]]).y;
|
||||
const spacingY = totalHeight / (cells.length - 1);
|
||||
sortedY.slice(1, -1).forEach((cell, i) => {
|
||||
const targetY = this.getBoundingBox([sortedY[0]]).y + spacingY * (i + 1);
|
||||
const currentY = this.getBoundingBox([cell]).y;
|
||||
this.moveCells([cell], 0, targetY - currentY);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { SelectionHandler, Graph, Cell, InternalMouseEvent } from "@maxgraph/core";
|
||||
|
||||
export class WuSelectHander extends SelectionHandler {
|
||||
constructor(graph: Graph) {
|
||||
console.log("WuSelectHander constructor");
|
||||
super(graph);
|
||||
}
|
||||
|
||||
selectCellForEvent(cell: Cell, me: InternalMouseEvent): Cell {
|
||||
console.log("WuSelectHander selectCellForEvent");
|
||||
return cell;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
import { Graph,ModelXmlSerializer} from "@maxgraph/core";
|
||||
import { flowEventBus } from "../events";
|
||||
import { WuFlowAction } from "../utils/flow-utils";
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
import { HierarchicalLayout, CircleLayout, FastOrganicLayout } from "@maxgraph/core";
|
||||
|
||||
export class CanvasEventHandler {
|
||||
private showGrid = ref(true);
|
||||
private showTextToolbar = ref(false);
|
||||
private toolbarPosition = ref({ top: '0px', left: '0px' });
|
||||
private currentNodeStyle = ref({
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 14,
|
||||
fontColor: '#000000',
|
||||
fontWeight: 'normal',
|
||||
textDecoration: 'none'
|
||||
});
|
||||
|
||||
constructor(
|
||||
private wuFlowAction: WuFlowAction,
|
||||
private graph: Graph | null,
|
||||
private canvasRef: HTMLElement | undefined
|
||||
) {
|
||||
this.initEvents();
|
||||
}
|
||||
|
||||
private initEvents() {
|
||||
// 注册事件监听
|
||||
flowEventBus.on("zoom", this.handleZoom);
|
||||
flowEventBus.on("fit", this.handleFit);
|
||||
flowEventBus.on("delete", this.handleDelete);
|
||||
flowEventBus.on("export", this.handleExport);
|
||||
flowEventBus.on("undo", this.handleUndo);
|
||||
flowEventBus.on("reDo", this.handleReDo);
|
||||
flowEventBus.on("edge-style", this.handleEdgeStyle);
|
||||
flowEventBus.on("auto-layout", this.handleAutoLayout);
|
||||
flowEventBus.on('change-background', this.handleBackgroundChange);
|
||||
flowEventBus.on('toggle-grid', this.handleToggleGrid);
|
||||
flowEventBus.on('change-layout', this.handleLayoutChange);
|
||||
flowEventBus.on('change-font', this.handleFontChange);
|
||||
flowEventBus.on('change-node-color', this.handleNodeColorChange);
|
||||
flowEventBus.on('insert-image', this.handleInsertImage);
|
||||
flowEventBus.on('change-border-color', this.handleBorderColor);
|
||||
flowEventBus.on('change-border-style', this.handleBorderStyle);
|
||||
flowEventBus.on('change-border-width', this.handleBorderWidth);
|
||||
flowEventBus.on('file-new', this.handleNewFile);
|
||||
flowEventBus.on('file-open', this.handleOpenFile);
|
||||
flowEventBus.on('file-save', this.handleSaveFile);
|
||||
flowEventBus.on('file-export-image', this.handleExportImage);
|
||||
flowEventBus.on('file-export-json', this.handleExportJson);
|
||||
flowEventBus.on('cut', this.handleCut);
|
||||
flowEventBus.on('copy', this.handleCopy);
|
||||
flowEventBus.on('paste', this.handlePaste);
|
||||
flowEventBus.on('select-all', this.handleSelectAll);
|
||||
flowEventBus.on('invert-select', this.handleInvertSelect);
|
||||
flowEventBus.on('cancel-select', this.handleCancelSelect);
|
||||
flowEventBus.on('zoom-in', this.handleZoomIn);
|
||||
flowEventBus.on('zoom-out', this.handleZoomOut);
|
||||
flowEventBus.on('toggle-grid', this.handleToggleGrid);
|
||||
flowEventBus.on('toggle-minimap', this.handleToggleMinimap);
|
||||
flowEventBus.on('change-background-color', this.handleBackgroundColor);
|
||||
flowEventBus.on('change-grid-size', this.handleGridSize);
|
||||
flowEventBus.on('change-canvas-size', this.handleCanvasSize);
|
||||
flowEventBus.on('hierarchical-layout', this.handleHierarchicalLayout);
|
||||
flowEventBus.on('circle-layout', this.handleCircleLayout);
|
||||
flowEventBus.on('organic-layout', this.handleOrganicLayout);
|
||||
flowEventBus.on('bring-to-front', this.handleBringToFront);
|
||||
flowEventBus.on('send-to-back', this.handleSendToBack);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
// 清理所有事件监听
|
||||
flowEventBus.off("zoom", this.handleZoom);
|
||||
flowEventBus.off("fit", this.handleFit);
|
||||
flowEventBus.off("delete", this.handleDelete);
|
||||
flowEventBus.off("export", this.handleExport);
|
||||
flowEventBus.off("undo", this.handleUndo);
|
||||
flowEventBus.off("reDo", this.handleReDo);
|
||||
flowEventBus.off("edge-style", this.handleEdgeStyle);
|
||||
flowEventBus.off("auto-layout", this.handleAutoLayout);
|
||||
flowEventBus.off('change-background', this.handleBackgroundChange);
|
||||
flowEventBus.off('toggle-grid', this.handleToggleGrid);
|
||||
flowEventBus.off('change-layout', this.handleLayoutChange);
|
||||
flowEventBus.off('change-font', this.handleFontChange);
|
||||
flowEventBus.off('change-node-color', this.handleNodeColorChange);
|
||||
flowEventBus.off('insert-image', this.handleInsertImage);
|
||||
flowEventBus.off('change-border-color', this.handleBorderColor);
|
||||
flowEventBus.off('change-border-style', this.handleBorderStyle);
|
||||
flowEventBus.off('change-border-width', this.handleBorderWidth);
|
||||
flowEventBus.off('file-new', this.handleNewFile);
|
||||
flowEventBus.off('file-open', this.handleOpenFile);
|
||||
flowEventBus.off('file-save', this.handleSaveFile);
|
||||
flowEventBus.off('file-export-image', this.handleExportImage);
|
||||
flowEventBus.off('file-export-json', this.handleExportJson);
|
||||
flowEventBus.off('cut', this.handleCut);
|
||||
flowEventBus.off('copy', this.handleCopy);
|
||||
flowEventBus.off('paste', this.handlePaste);
|
||||
flowEventBus.off('select-all', this.handleSelectAll);
|
||||
flowEventBus.off('invert-select', this.handleInvertSelect);
|
||||
flowEventBus.off('cancel-select', this.handleCancelSelect);
|
||||
flowEventBus.off('zoom-in', this.handleZoomIn);
|
||||
flowEventBus.off('zoom-out', this.handleZoomOut);
|
||||
flowEventBus.off('toggle-grid', this.handleToggleGrid);
|
||||
flowEventBus.off('toggle-minimap', this.handleToggleMinimap);
|
||||
flowEventBus.off('change-background-color', this.handleBackgroundColor);
|
||||
flowEventBus.off('change-grid-size', this.handleGridSize);
|
||||
flowEventBus.off('change-canvas-size', this.handleCanvasSize);
|
||||
flowEventBus.off('hierarchical-layout', this.handleHierarchicalLayout);
|
||||
flowEventBus.off('circle-layout', this.handleCircleLayout);
|
||||
flowEventBus.off('organic-layout', this.handleOrganicLayout);
|
||||
flowEventBus.off('bring-to-front', this.handleBringToFront);
|
||||
flowEventBus.off('send-to-back', this.handleSendToBack);
|
||||
}
|
||||
|
||||
private handleZoom = (scale: number) => {
|
||||
this.graph?.zoomTo(scale);
|
||||
};
|
||||
|
||||
private handleFit = () => {
|
||||
this.graph?.fit();
|
||||
};
|
||||
|
||||
private handleDelete = () => {
|
||||
const cells = this.graph?.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph?.removeCells(cells);
|
||||
}
|
||||
};
|
||||
|
||||
private handleExport = () => {
|
||||
const exportedXml = this.wuFlowAction.getXmlSerializer();
|
||||
sessionStorage.setItem("flow-canvas-data", exportedXml);
|
||||
};
|
||||
|
||||
private handleUndo = () => {
|
||||
// 撤销操作
|
||||
};
|
||||
|
||||
private handleReDo = () => {
|
||||
// 重做操作
|
||||
};
|
||||
|
||||
private handleEdgeStyle = (style: string) => {
|
||||
this.wuFlowAction.getDefaultEdgeStyle().edgeStyle = style;
|
||||
};
|
||||
|
||||
private handleAutoLayout = () => {
|
||||
this.wuFlowAction.handleLayoutChange("hierarchical");
|
||||
};
|
||||
|
||||
private handleBackgroundChange = (color: string) => {
|
||||
if (this.canvasRef) {
|
||||
this.canvasRef.style.backgroundColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFontChange = (fontFamily: string) => {
|
||||
this.wuFlowAction.changeFont(fontFamily);
|
||||
};
|
||||
|
||||
private handleNodeColorChange = (color: string) => {
|
||||
this.wuFlowAction.changeNodeColor(color);
|
||||
};
|
||||
|
||||
private handleInsertImage = (imageUrl: string) => {
|
||||
if (this.canvasRef) {
|
||||
const container = this.canvasRef.getBoundingClientRect();
|
||||
const x = container.width / 2;
|
||||
const y = container.height / 2;
|
||||
this.wuFlowAction.insertImageNode(x, y, imageUrl);
|
||||
}
|
||||
};
|
||||
|
||||
private handleBorderColor = (color: string) => {
|
||||
this.wuFlowAction.changeBorderColor(color);
|
||||
};
|
||||
|
||||
private handleBorderStyle = (style: string) => {
|
||||
this.wuFlowAction.changeBorderStyle(style);
|
||||
};
|
||||
|
||||
private handleBorderWidth = (width: number) => {
|
||||
this.wuFlowAction.changeBorderWidth(width);
|
||||
};
|
||||
|
||||
private handleToggleGrid = (show: boolean) => {
|
||||
this.showGrid.value = show;
|
||||
if (this.graph) {
|
||||
this.graph.setGridEnabled(show);
|
||||
this.graph.setGridVisible(show);
|
||||
}
|
||||
};
|
||||
|
||||
private handleLayoutChange = (type: string) => {
|
||||
this.wuFlowAction.handleLayoutChange(type);
|
||||
};
|
||||
|
||||
private handleNewFile = () => {
|
||||
if (this.graph) {
|
||||
this.graph.removeCells(this.graph.getChildCells(this.graph.getDefaultParent()));
|
||||
}
|
||||
};
|
||||
|
||||
private handleOpenFile = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string);
|
||||
this.wuFlowAction?.loadGraphData(data);
|
||||
} catch (error) {
|
||||
ElMessage.error('文件格式错误');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
private handleSaveFile = () => {
|
||||
if (this.graph) {
|
||||
const data = this.wuFlowAction?.getXmlSerializer();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'flow.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
private handleExportImage = () => {
|
||||
if (this.graph) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const bounds = this.graph.getGraphBounds();
|
||||
const scale = 1;
|
||||
canvas.width = bounds.width * scale;
|
||||
canvas.height = bounds.height * scale;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale);
|
||||
const svgString = this.wuFlowAction?.getGraphSvg();
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
flowEventBus.emit('export-preview', canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleExportJson = () => {
|
||||
if (this.graph) {
|
||||
const data = this.wuFlowAction?.getXmlSerializer();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'flow.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
private handleCut = () => {
|
||||
if (this.graph) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.handleCopy();
|
||||
this.graph.removeCells(cells);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleCopy = () => {
|
||||
if (this.graph) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
const encoder = new ModelXmlSerializer(this.graph.model);
|
||||
const cellsXml = encoder.encode(cells);
|
||||
navigator.clipboard.writeText(cellsXml);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handlePaste = async () => {
|
||||
if (this.graph) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const decoder = new ModelXmlSerializer(this.graph.model);
|
||||
const cells = decoder.decode(text);
|
||||
if (cells?.length) {
|
||||
this.graph.importCells(cells);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('粘贴失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleSelectAll = () => {
|
||||
if (this.graph) {
|
||||
const parent = this.graph.getDefaultParent();
|
||||
const children = this.graph.getChildVertices(parent);
|
||||
this.graph.setSelectionCells(children);
|
||||
}
|
||||
};
|
||||
|
||||
private handleInvertSelect = () => {
|
||||
if (this.graph) {
|
||||
const parent = this.graph.getDefaultParent();
|
||||
const allCells = this.graph.getChildVertices(parent);
|
||||
const selectedCells = this.graph.getSelectionCells();
|
||||
const invertedCells = allCells.filter(cell => !selectedCells.includes(cell));
|
||||
this.graph.setSelectionCells(invertedCells);
|
||||
}
|
||||
};
|
||||
|
||||
private handleCancelSelect = () => {
|
||||
if (this.graph) {
|
||||
this.graph.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
private handleZoomIn = () => {
|
||||
if (this.graph) {
|
||||
const scale = this.graph.view.scale;
|
||||
this.graph.zoomTo(scale * 1.2);
|
||||
}
|
||||
};
|
||||
|
||||
private handleZoomOut = () => {
|
||||
if (this.graph) {
|
||||
const scale = this.graph.view.scale;
|
||||
this.graph.zoomTo(scale * 0.8);
|
||||
}
|
||||
};
|
||||
|
||||
private handleToggleMinimap = (show: boolean) => {
|
||||
flowEventBus.emit('show-graph-outline', show);
|
||||
};
|
||||
|
||||
private handleBackgroundColor = (color: string) => {
|
||||
if (this.canvasRef) {
|
||||
this.canvasRef.style.backgroundColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
private handleGridSize = (size: number) => {
|
||||
if (this.graph) {
|
||||
this.graph.gridSize = size;
|
||||
// 刷新网格显示
|
||||
const isGridVisible = this.showGrid.value;
|
||||
this.graph.setGridEnabled(isGridVisible);
|
||||
this.graph.setGridVisible(isGridVisible);
|
||||
}
|
||||
};
|
||||
|
||||
private handleCanvasSize = (size: { width: number; height: number }) => {
|
||||
if (this.canvasRef) {
|
||||
this.canvasRef.style.width = `${size.width}px`;
|
||||
this.canvasRef.style.height = `${size.height}px`;
|
||||
// 通知图形需要重新布局
|
||||
this.graph?.fit();
|
||||
}
|
||||
};
|
||||
|
||||
private handleHierarchicalLayout = () => {
|
||||
if (this.graph) {
|
||||
const layout = new HierarchicalLayout(this.graph);
|
||||
layout.execute(this.graph.getDefaultParent());
|
||||
}
|
||||
};
|
||||
|
||||
private handleCircleLayout = () => {
|
||||
if (this.graph) {
|
||||
const layout = new CircleLayout(this.graph);
|
||||
layout.execute(this.graph.getDefaultParent());
|
||||
}
|
||||
};
|
||||
|
||||
private handleOrganicLayout = () => {
|
||||
if (this.graph) {
|
||||
const layout = new FastOrganicLayout(this.graph);
|
||||
layout.execute(this.graph.getDefaultParent());
|
||||
}
|
||||
};
|
||||
|
||||
private handleBringToFront = () => {
|
||||
if (this.graph) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
this.graph.orderCells(true, cells);
|
||||
}
|
||||
};
|
||||
|
||||
private handleSendToBack = () => {
|
||||
if (this.graph) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
this.graph.orderCells(false, cells);
|
||||
}
|
||||
};
|
||||
|
||||
// ... 其他事件处理方法
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { MaxPopupMenu, Cell, Graph } from '@maxgraph/core';
|
||||
import { flowEventBus } from '../events';
|
||||
|
||||
export class CustomPopupMenuHandler extends MaxPopupMenu {
|
||||
|
||||
|
||||
protected addPopupMenuItems(menu: HTMLElement, cell: Cell, evt: MouseEvent): void {
|
||||
if (cell && cell.isVertex()) {
|
||||
// 删除选项
|
||||
const deleteItem = document.createElement('div');
|
||||
deleteItem.className = 'menu-item';
|
||||
deleteItem.innerHTML = '删除';
|
||||
deleteItem.onclick = () => {
|
||||
flowEventBus.emit('delete');
|
||||
this.hideMenu();
|
||||
};
|
||||
menu.appendChild(deleteItem);
|
||||
|
||||
// 复制选项
|
||||
const copyItem = document.createElement('div');
|
||||
copyItem.className = 'menu-item';
|
||||
copyItem.innerHTML = '复制';
|
||||
copyItem.onclick = () => {
|
||||
const graph = this.getGraph();
|
||||
if (graph) {
|
||||
const clone = graph.cloneCells([cell])[0];
|
||||
const geo = clone.getGeometry();
|
||||
geo.x += 50;
|
||||
geo.y += 50;
|
||||
graph.addCell(clone);
|
||||
}
|
||||
this.hideMenu();
|
||||
};
|
||||
menu.appendChild(copyItem);
|
||||
}
|
||||
}
|
||||
|
||||
protected createMenu(): HTMLElement {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'graph-context-menu';
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Graph, Cell } from '@maxgraph/core';
|
||||
|
||||
export class KeyHandler {
|
||||
private graph: Graph;
|
||||
private copiedCells: Cell[] = [];
|
||||
private pasteOffset = 30; // 粘贴时的偏移量
|
||||
|
||||
constructor(graph: Graph) {
|
||||
this.graph = graph;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init() {
|
||||
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Delete 键删除选中元素
|
||||
if (event.key === 'Delete') {
|
||||
this.handleDelete(event);
|
||||
}
|
||||
|
||||
// Ctrl + C 复制
|
||||
if (event.ctrlKey && event.key === 'c') {
|
||||
this.handleCopy(event);
|
||||
}
|
||||
|
||||
// Ctrl + V 粘贴
|
||||
if (event.ctrlKey && event.key === 'v') {
|
||||
this.handlePaste(event);
|
||||
}
|
||||
|
||||
// Ctrl + Z 撤销
|
||||
if (event.ctrlKey && event.key === 'z') {
|
||||
this.handleUndo(event);
|
||||
}
|
||||
|
||||
// Ctrl + Y 重做
|
||||
if (event.ctrlKey && event.key === 'y') {
|
||||
this.handleRedo(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDelete(event: KeyboardEvent) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells && cells.length > 0) {
|
||||
event.preventDefault();
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
this.graph.removeCells(cells);
|
||||
this.graph.clearSelection();
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCopy(event: KeyboardEvent) {
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells && cells.length > 0) {
|
||||
event.preventDefault();
|
||||
// 存储选中的单元格
|
||||
this.copiedCells = cells;
|
||||
|
||||
// 可选:显示复制成功提示
|
||||
console.log('已复制', cells.length, '个元素');
|
||||
}
|
||||
}
|
||||
|
||||
private handlePaste(event: KeyboardEvent) {
|
||||
if (this.copiedCells.length > 0) {
|
||||
event.preventDefault();
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
// 克隆所有复制的单元格
|
||||
const clones = this.graph.cloneCells(this.copiedCells);
|
||||
|
||||
// 为每个克隆的单元格添加偏移
|
||||
clones.forEach(clone => {
|
||||
if (clone.isVertex()) {
|
||||
const geo = clone.getGeometry();
|
||||
if (geo) {
|
||||
geo.x += this.pasteOffset;
|
||||
geo.y += this.pasteOffset;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 插入克隆的单元格
|
||||
const parent = this.graph.getDefaultParent();
|
||||
this.graph.addCells(clones, parent);
|
||||
|
||||
// 选中新粘贴的单元格
|
||||
this.graph.setSelectionCells(clones);
|
||||
|
||||
// 增加下次粘贴的偏移量
|
||||
this.pasteOffset += 30;
|
||||
// 如果偏移太大,重置
|
||||
if (this.pasteOffset > 150) {
|
||||
this.pasteOffset = 30;
|
||||
}
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUndo(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
this.graph.model.undo();
|
||||
}
|
||||
|
||||
private handleRedo(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
this.graph.model.redo();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { ConnectionHandler, CellState ,Graph} from '@maxgraph/core';
|
||||
|
||||
export class WuConnctionHandler extends ConnectionHandler {
|
||||
// constructor(graph: Graph, options: any) {
|
||||
// super(graph, options);
|
||||
// }
|
||||
isConnectableCell(cell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Special case: Snaps source of new connections to fixed points
|
||||
* Without a connect preview in connectionHandler.createEdgeState mouseMove
|
||||
* and getSourcePerimeterPoint should be overriden by setting sourceConstraint
|
||||
* sourceConstraint to null in mouseMove and updating it and returning the
|
||||
* nearest point (cp) in getSourcePerimeterPoint (see below)
|
||||
*/
|
||||
updateEdgeState(pt, constraint) {
|
||||
if (pt != null && this.previous != null) {
|
||||
const constraints = this.graph.getAllConnectionConstraints(this.previous, true) ;// .getAllConnectionConstraints(this.previous);
|
||||
let nearestConstraint = null;
|
||||
let dist = null;
|
||||
for (let i = 0; i < constraints.length; i++) {
|
||||
const cp = this.graph.getConnectionPoint(this.previous, constraints[i]);
|
||||
if (cp != null) {
|
||||
const tmp = (cp.x - pt.x) * (cp.x - pt.x) + (cp.y - pt.y) * (cp.y - pt.y);
|
||||
if (dist == null || tmp < dist) {
|
||||
nearestConstraint = constraints[i];
|
||||
dist = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nearestConstraint != null) {
|
||||
this.sourceConstraint = nearestConstraint;
|
||||
}
|
||||
|
||||
// In case the edge style must be changed during the preview:
|
||||
// this.edgeState.style.edgeStyle = 'orthogonalEdgeStyle';
|
||||
// And to use the new edge style in the new edge inserted into the graph,
|
||||
// update the cell style as follows:
|
||||
// this.edgeState.cell.style = utils.setStyle(this.edgeState.cell.style, 'edgeStyle', this.edgeState.style.edgeStyle);
|
||||
}
|
||||
return super.updateEdgeState(pt, constraint);
|
||||
}
|
||||
createEdgeState(me) {
|
||||
// Connect preview
|
||||
const edge = this.graph.createEdge(null, null, null, null, null, {
|
||||
edgeStyle: 'orthogonalEdgeStyle'
|
||||
});
|
||||
return new CellState(this.graph.view, edge, this.graph.getCellStyle(edge));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { ConstraintHandler, Graph, mathUtils } from '@maxgraph/core';
|
||||
|
||||
export class WuConstraintHandler extends ConstraintHandler {
|
||||
intersects(icon, point, source, existingEdge) {
|
||||
return !source || existingEdge || mathUtils.intersects(icon.bounds, point);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
export const FlowIcons = {
|
||||
// 基础流程图标
|
||||
Start: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
End: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
Process: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Decision: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 4L20 12L12 20L4 12L12 4Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Arrow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12H20M20 12L14 6M20 12L14 18" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 高级组件图标
|
||||
SubProcess: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Parallel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
<line x1="8" y1="9" x2="16" y2="9" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke-width="2"/>
|
||||
<line x1="8" y1="15" x2="16" y2="15" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Timer: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="8" stroke-width="2"/>
|
||||
<path d="M12 8v4l3 3" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 基础形状节点图标
|
||||
Rectangle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Ellipse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<ellipse cx="12" cy="12" rx="8" ry="6" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
DoubleEllipse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<ellipse cx="12" cy="12" rx="8" ry="6" stroke-width="2"/>
|
||||
<ellipse cx="12" cy="12" rx="5" ry="3.5" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Rhombus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 4L20 12L12 20L4 12L12 4Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Line: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Image: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="2" stroke-width="2"/>
|
||||
<path d="M4 19L8 15L12 19" stroke-width="2"/>
|
||||
<path d="M12 19L16 15L20 19" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
ArrowConnector: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12H20M20 12L14 6M20 12L14 18" stroke-width="2"/>
|
||||
<circle cx="4" cy="12" r="2" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Label: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 6h16M4 12h16M4 18h12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Cylinder: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M6 7C6 5.89543 8.68629 5 12 5C15.3137 5 18 5.89543 18 7V17C18 18.1046 15.3137 19 12 19C8.68629 19 6 18.1046 6 17V7Z" stroke-width="2"/>
|
||||
<path d="M6 7C6 8.10457 8.68629 9 12 9C15.3137 9 18 8.10457 18 7" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Swimlane: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" stroke-width="2"/>
|
||||
<line x1="4" y1="8" x2="20" y2="8" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="20" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Connector: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="6" cy="12" r="2" stroke-width="2"/>
|
||||
<circle cx="18" cy="12" r="2" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Actor: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="7" r="3" stroke-width="2"/>
|
||||
<path d="M12 10v7M8 20h8M8 14l4-4l4 4" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Cloud: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M6.5 15.5C5.11929 15.5 4 14.3807 4 13C4 11.6193 5.11929 10.5 6.5 10.5C6.57089 10.5 6.64124 10.5032 6.71092 10.5097C7.06447 8.48561 8.85496 7 11 7C13.2091 7 15 8.79086 15 11C15 11.1839 14.9859 11.3643 14.9587 11.5402C15.4882 11.1963 16.1204 11 16.8 11C18.5673 11 20 12.4327 20 14.2C20 15.9673 18.5673 17.4 16.8 17.4H7.2C6.53726 17.4 6 16.8627 6 16.2C6 15.5373 6.53726 15 7.2 15H6.5Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Triangle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 4L20 18H4L12 4Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Hexagon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 4L19 8V16L12 20L5 16V8L12 4Z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// UML 图标
|
||||
ClassIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="14" x2="22" y2="14" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
InterfaceIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
<line x1="2" y1="14" x2="22" y2="14" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
</svg>`,
|
||||
|
||||
PackageIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M2,2 h8 v4 h12 v16 h-20 z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
ComponentIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="4" y="4" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="1" y="8" width="6" height="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="1" y="13" width="6" height="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="17" y="8" width="6" height="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="17" y="13" width="6" height="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
AggregationIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M2,12 L12,2 L22,12 L12,22 Z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
CompositionIcon: `<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M2,12 L12,2 L22,12 L12,22 Z" fill="currentColor" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Note: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke-width="2"/>
|
||||
<path d="M14 2v6h6" stroke-width="2"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13" stroke-width="2"/>
|
||||
<line x1="8" y1="17" x2="16" y2="17" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Text: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<text x="4" y="16" font-size="16" font-family="Arial" fill="currentColor">Aa</text>
|
||||
<line x1="4" y1="19" x2="20" y2="19" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
Star: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 2l2.4 7.4h7.6l-6.2 4.5 2.4 7.4-6.2-4.5-6.2 4.5 2.4-7.4-6.2-4.5h7.6z" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
VSwimlane: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="2" y="2" width="20" height="20" stroke-width="2"/>
|
||||
<line x1="8" y1="2" x2="8" y2="22" stroke-width="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="22" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
HSwimlane: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="2" y="2" width="20" height="20" stroke-width="2"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke-width="2"/>
|
||||
<line x1="2" y1="16" x2="22" y2="16" stroke-width="2"/>
|
||||
</svg>`,
|
||||
TableIcon: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
<line x1="3" y1="15" x2="21" y2="15" stroke-width="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" stroke-width="2"/>
|
||||
<line x1="15" y1="3" x2="15" y2="21" stroke-width="2"/>
|
||||
</svg>
|
||||
`,
|
||||
SimpleTableIcon: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke-width="2"/>
|
||||
<line x1="3" y1="15" x2="21" y2="15" stroke-width="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" stroke-width="2"/>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
export const ToolbarIcons = {
|
||||
// ... 其他图标 ...
|
||||
|
||||
// 背景颜色图标
|
||||
backgroundColor: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2"/>
|
||||
<path d="M4 8h16" stroke-width="2"/>
|
||||
<path d="M8 12h8" stroke-width="2" stroke-dasharray="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 节点颜色图标
|
||||
nodeColor: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 2L2 12L12 22L22 12L12 2Z" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="4" stroke-width="2"/>
|
||||
<path d="M12 6V8M12 16V18M6 12H8M16 12H18" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 网格图标
|
||||
grid: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v16H4V4z" stroke-width="2"/>
|
||||
<path d="M4 12h16M12 4v16" stroke-width="1" stroke-dasharray="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 导出图标
|
||||
export: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 3v12M7 8l5-5 5 5" stroke-width="2"/>
|
||||
<path d="M4 14v4a2 2 0 002 2h12a2 2 0 002-2v-4" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 撤销图标
|
||||
undo: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7v6h6" stroke-width="2"/>
|
||||
<path d="M3 13c0-4.97 4.03-9 9-9 4.97 0 9 4.03 9 9s-4.03 9-9 9" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 重做图标
|
||||
redo: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 7v6h-6" stroke-width="2"/>
|
||||
<path d="M21 13c0-4.97-4.03-9-9-9-4.97 0-9 4.03-9 9s4.03 9 9 9" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 删除图标
|
||||
delete: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" stroke-width="2"/>
|
||||
<path d="M5 7l1 12a2 2 0 002 2h8a2 2 0 002-2l1-12" stroke-width="2"/>
|
||||
<path d="M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 适应画布图标
|
||||
fit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v16H4V4z" stroke-width="2"/>
|
||||
<path d="M9 9h6v6H9V9z" stroke-width="2"/>
|
||||
<path d="M4 4l5 5M20 4l-5 5M4 20l5-5M20 20l-5-5" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 缩小图标
|
||||
zoomOut: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="11" cy="11" r="7" stroke-width="2"/>
|
||||
<path d="M16 16l4 4" stroke-width="2"/>
|
||||
<path d="M8 11h6" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 放大图标
|
||||
zoomIn: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="11" cy="11" r="7" stroke-width="2"/>
|
||||
<path d="M16 16l4 4" stroke-width="2"/>
|
||||
<path d="M8 11h6M11 8v6" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 返回图标
|
||||
back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M15 4l-8 8 8 8" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 直线图标
|
||||
straightLine: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12h16" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 折线图标
|
||||
orthogonalLine: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12h8v-6h8" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
// 曲线图标
|
||||
curvedLine: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12C8 12 16 4 20 12" stroke-width="2"/>
|
||||
</svg>`,
|
||||
|
||||
borderColor: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M17.75 7L14 3.25l-10 10V17h3.75l10-10zm2.96-2.96c.39-.39.39-1.02 0-1.41L18.37.29c-.39-.39-1.02-.39-1.41 0L15 2.25 18.75 6l1.96-1.96z"/>
|
||||
<path d="M0 20h24v4H0z"/>
|
||||
</svg>`,
|
||||
borderStyle: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M3 16h18v2H3zm0-5h18v2H3zm0-5h18v2H3z"/>
|
||||
</svg>`,
|
||||
borderWidth: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M3 17h18v1H3z"/>
|
||||
<path d="M3 12h18v2H3z"/>
|
||||
<path d="M3 5h18v3H3z"/>
|
||||
</svg>`,
|
||||
|
||||
// 对齐图标
|
||||
alignLeft: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 128h64v768h-64z M256 320h512v128H256z M256 576h320v128H256z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alignCenter: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M480 128h64v768h-64z M256 320h512v128H256z M352 576h320v128H352z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alignRight: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M832 128h64v768h-64z M256 320h512v128H256z M448 576h320v128H448z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alignTop: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 128h768v64H128z M320 256h128v512H320z M576 256h128v320H576z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alignMiddle: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 480h768v64H128z M320 256h128v512H320z M576 352h128v320H576z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alignBottom: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 832h768v64H128z M320 256h128v512H320z M576 448h128v320H576z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
distributeHorizontally: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 128h64v768h-64z M832 128h64v768h-64z M448 320h128v384H448z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
distributeVertically: `<svg viewBox="0 0 1024 1024" width="16" height="16">
|
||||
<path d="M128 128h768v64H128z M128 832h768v64H128z M320 448h384v128H320z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
export const MenuIcons = {
|
||||
delete: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`,
|
||||
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2v-4" stroke-width="2"/>
|
||||
<path d="M8 12h11c1.1 0 2-.9 2-2v-4H8v4z"/></svg>`,
|
||||
image: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
// UML 图标定义
|
||||
export const UMLIcons = {
|
||||
Class: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="14" x2="22" y2="14" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Interface: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
<text x="6" y="6" font-size="6" fill="currentColor">«interface»</text>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Package: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M2,2 h6 v4 h14 v16 h-20 v-20" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Component: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="4" y="4" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="1" y="8" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="19" y="8" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="8" y="1" width="3" height="4" fill="currentColor"/>
|
||||
<rect x="8" y="19" width="3" height="4" fill="currentColor"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Object: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
<text x="6" y="6" font-size="6" font-style="italic" fill="currentColor">object</text>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Enumeration: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<rect x="2" y="2" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
<text x="6" y="6" font-size="6" fill="currentColor">«enumeration»</text>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Association: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<polygon points="18,8 22,12 18,16" fill="currentColor"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Inheritance: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<polygon points="16,8 22,12 16,16" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Implementation: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
<polygon points="16,8 22,12 16,16" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
`,
|
||||
|
||||
Dependency: `
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2" stroke-dasharray="4"/>
|
||||
<polygon points="18,8 22,12 18,16" fill="currentColor"/>
|
||||
</svg>
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { App } from 'vue'
|
||||
import SplitPane from './components/SplitPane.vue'
|
||||
import FlowAccordion from './components/panel/FlowAccordion.vue'
|
||||
import Accordion from './components/Accordion.vue'
|
||||
import FlowCanvas from './components/canvas/FlowCanvas.vue'
|
||||
import FlowToolbar from './components/menu/FlowToolbar.vue'
|
||||
import { WuFlowAction } from './utils/flow-utils'
|
||||
|
||||
// 导出单个组件
|
||||
export { SplitPane, FlowAccordion, Accordion, FlowCanvas, FlowToolbar, WuFlowAction }
|
||||
|
||||
// 导出插件安装函数
|
||||
export default {
|
||||
install: (app: App) => {
|
||||
app.component('WuSplitPane', SplitPane)
|
||||
app.component('WuFlowAccordion', FlowAccordion)
|
||||
app.component('WuAccordion', Accordion)
|
||||
app.component('WuFlowCanvas', FlowCanvas)
|
||||
app.component('WuFlowToolbar', FlowToolbar)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
.flow-canvas-wrapper {
|
||||
padding: 6px;
|
||||
height: calc(100% - 12px);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flow-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
transition: background-color 0.3s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flow-canvas.style-brush-active {
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.flow-canvas.show-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, #f0f0f0 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #f0f0f0 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 20px 20px;
|
||||
background-image: linear-gradient(to right, #f0f0f0 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #f0f0f0 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.elements {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.node-bg {
|
||||
fill: white;
|
||||
stroke: #dcdfe6;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.node:hover .node-bg {
|
||||
stroke: #409eff;
|
||||
}
|
||||
|
||||
.node :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 12px;
|
||||
fill: #303133;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-outline {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
cursor: move;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #f0f0f0;
|
||||
padding: 0px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-toolbar {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.text-toolbar button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-toolbar button.active {
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.text-toolbar select {
|
||||
padding: 4px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-toolbar input[type="color"] {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mxRubberband {
|
||||
border-color: #b18426;
|
||||
background: #db9b0b;
|
||||
}
|
||||
|
||||
/* 右键菜单样式 */
|
||||
.mxPopupMenu {
|
||||
position: absolute;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
padding: 5px 0;
|
||||
min-width: 120px;
|
||||
z-index: 9999;
|
||||
border: 1px solid #e4e7ed;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.custom-menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
padding: 0 20px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-menu-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.custom-menu-item:hover .menu-icon svg {
|
||||
fill: #409eff;
|
||||
}
|
||||
|
||||
.custom-menu-item:active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mxPopupMenu hr {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
border: none;
|
||||
background-color: #e4e7ed;
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
|
||||
/* 子菜单样式 */
|
||||
.custom-menu-item.has-submenu::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid #c0c4cc;
|
||||
border-top: 2px solid #c0c4cc;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.custom-menu-item.disabled {
|
||||
cursor: not-allowed;
|
||||
color: #c0c4cc;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.custom-menu-item.disabled:hover {
|
||||
background-color: #fff;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
interface DragState {
|
||||
leftPanelWidth: number;
|
||||
rightPanelWidth: number;
|
||||
leftPanelVisible: boolean;
|
||||
rightPanelVisible: boolean;
|
||||
}
|
||||
|
||||
export function useDragHandler(state: DragState, type: 'left' | 'right') {
|
||||
const minWidth = 0
|
||||
const maxWidth = 800
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
let isDragging = false
|
||||
|
||||
const startDrag = (e: MouseEvent) => {
|
||||
const isVisible = type === 'left' ? state.leftPanelVisible : state.rightPanelVisible
|
||||
if (!isVisible) return
|
||||
|
||||
isDragging = true
|
||||
startX = e.clientX
|
||||
startWidth = type === 'left' ? state.leftPanelWidth : state.rightPanelWidth
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
const onDrag = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const containerWidth = window.innerWidth
|
||||
const diff = e.clientX - startX
|
||||
|
||||
if (type === 'left') {
|
||||
// 左侧面板拖动限制
|
||||
const maxAllowedWidth = containerWidth - (state.rightPanelVisible ? state.rightPanelWidth : 0) - 400 // 保留中间面板最小宽度
|
||||
const newWidth = Math.min(
|
||||
maxAllowedWidth,
|
||||
Math.max(minWidth, startWidth + diff)
|
||||
)
|
||||
state.leftPanelWidth = newWidth
|
||||
} else {
|
||||
// 右侧面板拖动限制
|
||||
const maxAllowedWidth = containerWidth - (state.leftPanelVisible ? state.leftPanelWidth : 0) - 400 // 保留中间面板最小宽度
|
||||
const newWidth = Math.min(
|
||||
maxAllowedWidth,
|
||||
Math.max(minWidth, startWidth - diff)
|
||||
)
|
||||
state.rightPanelWidth = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging = false
|
||||
document.body.style.userSelect = ''
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
onDrag,
|
||||
stopDrag
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Graph, EditorToolbar } from "mxgraph/javascript/mxClient";
|
||||
|
||||
export class flowToolsBars {
|
||||
constructor(graph: Graph) {
|
||||
new EditorToolbar(graph.container, graph.editor);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
import {
|
||||
Graph, HierarchicalLayout, Outline, InternalEvent,
|
||||
GraphDataModel, ModelXmlSerializer, CircleLayout, FastOrganicLayout,
|
||||
StackLayout, ParallelEdgeLayout, PartitionLayout, ConstraintHandler, getDefaultPlugins, CellStyle,Cell,
|
||||
SelectionCellsHandler, SelectionHandler, GraphPluginConstructor, CellState, ConnectionHandler
|
||||
} from "@maxgraph/core";
|
||||
import { CustomRubberBandHandler } from "../flow/CustomRubberBandHandler";
|
||||
import { WuCustomGraph } from "../flow/WuCustomGraph";
|
||||
import { MyCustomPopupMenuHandler } from "../flow/MyCustomPopupMenuHandler";
|
||||
import { WuSelectHander } from "../flow/WuSelectHander";
|
||||
import { ShapeRegister } from './shape-register';
|
||||
import { WuConnctionHandler } from "../handlers/WuConnctionHandler";
|
||||
|
||||
export class WuFlowAction {
|
||||
private graph: WuCustomGraph;
|
||||
private canvasRef: HTMLElement;
|
||||
private static shapesRegistered = false; // 添加静态标志
|
||||
|
||||
constructor(container: HTMLElement, graphOutlineRef: HTMLElement) {
|
||||
this.canvasRef = container;
|
||||
// 只在第一次时注册自定义形状
|
||||
if (!WuFlowAction.shapesRegistered) {
|
||||
ShapeRegister.registerCustomShapes();
|
||||
WuFlowAction.shapesRegistered = true;
|
||||
}
|
||||
this.init(container, graphOutlineRef)
|
||||
}
|
||||
init(container: HTMLElement, graphOutlineRef: HTMLElement) {
|
||||
//
|
||||
const plugins: GraphPluginConstructor[] = [...getDefaultPlugins(),MyCustomPopupMenuHandler,CustomRubberBandHandler,WuSelectHander,SelectionCellsHandler,SelectionHandler];
|
||||
if (container) {
|
||||
InternalEvent.disableContextMenu(container);
|
||||
const flowCanvasData = sessionStorage.getItem("flow-canvas-data");
|
||||
if (flowCanvasData) {
|
||||
const model = new GraphDataModel();
|
||||
const serializer = new ModelXmlSerializer(model);
|
||||
serializer.import(flowCanvasData);
|
||||
this.graph = new WuCustomGraph(container, model, plugins);
|
||||
} else {
|
||||
this.graph = new WuCustomGraph(container, undefined, plugins);
|
||||
}
|
||||
// 启用基本功能
|
||||
this.graph.setEnabled(true);
|
||||
// 启用连接功能
|
||||
this.graph.setConnectable(true);
|
||||
// 启用多选功能
|
||||
this.graph.allowLoops = true;
|
||||
// 允许从任意点开始连接
|
||||
// 允许悬浮显示连接点
|
||||
// 允许创建目标连接
|
||||
|
||||
// 允许连接到空白处创建新节点
|
||||
this.graph.setAllowDanglingEdges(true);
|
||||
// 设置默认边样式
|
||||
this.graph.getStylesheet().getDefaultEdgeStyle().edgeStyle = 'orthogonalEdgeStyle';
|
||||
this.graph.getStylesheet().getDefaultEdgeStyle().rounded = true;
|
||||
this.graph.getStylesheet().getDefaultEdgeStyle().strokeColor = "#5b8ffa";
|
||||
this.graph.getStylesheet().getDefaultEdgeStyle().strokeWidth = 2;
|
||||
this.graph.setMultigraph(true);
|
||||
this.graph.setDropEnabled(true);
|
||||
// 启用选择功能
|
||||
this.graph.setPanning(true); // 暂时禁用平移,防止与橡皮筋选择冲突
|
||||
this.graph.swimlaneSelectionEnabled = true;
|
||||
|
||||
const outline = new Outline(this.graph, graphOutlineRef);
|
||||
outline.border = 1;
|
||||
outline.enabled = true;
|
||||
if (graphOutlineRef) {
|
||||
graphOutlineRef.style.right = "10px";
|
||||
graphOutlineRef.style.top = "10px";
|
||||
}
|
||||
}
|
||||
this.graph.setDropEnabled(true);
|
||||
this.graph.addListener(InternalEvent.CELL_CONNECTED, (sender, event: InternalEvent) => {
|
||||
console.log("WuFlowAction sender", sender, event);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
handleLayoutChange(type: string) {
|
||||
const parent = this.graph.getDefaultParent()
|
||||
this.graph.model.beginUpdate()
|
||||
try {
|
||||
let layout
|
||||
switch (type) {
|
||||
case 'hierarchical':
|
||||
layout = new HierarchicalLayout(this.graph)
|
||||
layout.orientation = 'north'
|
||||
layout.interHierarchySpacing = 30
|
||||
break
|
||||
case 'circle':
|
||||
layout = new CircleLayout(this.graph, 3)
|
||||
break
|
||||
case 'organic':
|
||||
layout = new FastOrganicLayout(this.graph)
|
||||
break
|
||||
case 'stack':
|
||||
layout = new StackLayout(this.graph)
|
||||
break
|
||||
case 'parallel':
|
||||
layout = new ParallelEdgeLayout(this.graph)
|
||||
break
|
||||
case 'partition':
|
||||
layout = new PartitionLayout(this.graph, true, 100, 120)
|
||||
break
|
||||
default:
|
||||
layout = new HierarchicalLayout(this.graph)
|
||||
}
|
||||
layout.execute(parent)
|
||||
|
||||
} catch (error) {
|
||||
|
||||
} finally {
|
||||
this.graph.model.endUpdate()
|
||||
}
|
||||
}
|
||||
changeFont(fontFamily: string) {
|
||||
if (!this.graph) return
|
||||
const cells = this.graph.getSelectionCells()
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate()
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const style = { ...cell.getStyle() }
|
||||
style.fontFamily = fontFamily
|
||||
this.graph.setCellStyle(style, [cell])
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
this.graph.model.endUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
getDefaultEdgeStyle() {
|
||||
return this.graph.getStylesheet().getDefaultEdgeStyle()
|
||||
}
|
||||
getXmlSerializer() {
|
||||
const model = this.graph.getDataModel()
|
||||
const serializer = new ModelXmlSerializer(model)
|
||||
return serializer.export()
|
||||
}
|
||||
|
||||
getGraphSvg() {
|
||||
return this.graph.getGraphSvg()
|
||||
}
|
||||
insertVertex(event: DragEvent) {
|
||||
const jsonStr = event.dataTransfer?.getData("application/json");
|
||||
if (jsonStr && this.graph) {
|
||||
const item = JSON.parse(jsonStr);
|
||||
const rect = this.canvasRef.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
this.graph.batchUpdate(() => {
|
||||
const parent = this.graph.getDefaultParent();
|
||||
// 获取节点尺寸,如果未定义则使用默认值
|
||||
console.log('item', item );
|
||||
const size = item?.size || [100, 50];
|
||||
let style: CellStyle = {
|
||||
fillColor: item.backgroundColor || "orange",
|
||||
shape: item.shape ||item.style.shape || "ellipse",
|
||||
verticalAlign: "middle",
|
||||
verticalLabelPosition: "middle",
|
||||
editable: true,
|
||||
movable: true,
|
||||
resizable: true,
|
||||
};
|
||||
if (item.style) {
|
||||
style = {
|
||||
...item.style, editable: true,
|
||||
movable: true,
|
||||
resizable: true,
|
||||
connectable: true,
|
||||
}
|
||||
}
|
||||
|
||||
const vertex = this.graph.insertVertex({
|
||||
parent,
|
||||
position: [x, y],
|
||||
size: size,
|
||||
style: style,
|
||||
value: item.label,
|
||||
});
|
||||
vertex.connectable = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getGraph() {
|
||||
return this.graph;
|
||||
}
|
||||
|
||||
addAnimation(cell: CellState) {
|
||||
const state = this.graph.view.getState(cell.cell);
|
||||
state?.shape?.node.getElementsByTagName('path')[0].removeAttribute('visibility');
|
||||
state?.shape?.node.getElementsByTagName('path')[0].setAttribute('stroke-width', '6');
|
||||
state?.shape?.node.getElementsByTagName('path')[0].setAttribute('stroke', 'lightGray');
|
||||
state?.shape?.node.getElementsByTagName('path')[1].setAttribute('class', 'flow');
|
||||
}
|
||||
|
||||
changeNodeColor(color: string) {
|
||||
if (!this.graph) return;
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const style = { ...cell.getStyle() };
|
||||
style.fillColor = color;
|
||||
this.graph.setCellStyle(style, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedNodeStyle(styleProps: any) {
|
||||
const graph = this.getGraph();
|
||||
const cell = graph.getSelectionCell();
|
||||
if (cell) {
|
||||
const style = cell.getStyle();
|
||||
const newStyle = { ...cell.getStyle(), ...styleProps };
|
||||
graph.setCellStyle(newStyle, [cell]);
|
||||
}
|
||||
}
|
||||
|
||||
updateTextStyle(styleProps: any) {
|
||||
if (!this.graph) return;
|
||||
console.log("updateTextStyle1", styleProps);
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const style = { ...cell.getStyle() };
|
||||
// 只更新变化的属性
|
||||
Object.entries(styleProps).forEach(([key, value]) => {
|
||||
if (style[key] !== value) {
|
||||
style[key] = value;
|
||||
}
|
||||
});
|
||||
this.graph.setCellStyle(style, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedNodeStyle() {
|
||||
if (!this.graph) return null;
|
||||
const cell = this.graph.getSelectionCell();
|
||||
if (cell?.isVertex()) {
|
||||
const style = cell.getStyle();
|
||||
return {
|
||||
fontFamily: style.fontFamily || 'Arial',
|
||||
fontSize: style.fontSize || 14,
|
||||
fontColor: style.fontColor || '#000000',
|
||||
fontWeight: style['font-weight'] || 'normal',
|
||||
textDecoration: style['text-decoration'] || 'none'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
insertImageNode(x: number, y: number, imageUrl: string) {
|
||||
const graph = this.getGraph();
|
||||
if (graph) {
|
||||
const parent = graph.getDefaultParent();
|
||||
graph.model.beginUpdate();
|
||||
try {
|
||||
const vertex = graph.insertVertex(
|
||||
parent,
|
||||
null,
|
||||
'',
|
||||
x - 50, // 居中显示
|
||||
y - 50,
|
||||
100, // 默认宽度
|
||||
100, // 默认高度
|
||||
{
|
||||
shape: 'image',
|
||||
image: imageUrl,
|
||||
imageAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
resizable: true
|
||||
}
|
||||
);
|
||||
graph.setSelectionCell(vertex);
|
||||
} finally {
|
||||
graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changeBorderColor(color: string) {
|
||||
if (!this.graph) return;
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const style = { ...cell.getStyle() };
|
||||
style.strokeColor = color;
|
||||
this.graph.setCellStyle(style, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changeBorderStyle(style: string) {
|
||||
if (!this.graph) return;
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const cellStyle = { ...cell.getStyle() };
|
||||
cellStyle.dashed = style === 'dashed';
|
||||
// cellStyle.dotted = style === 'dotted';
|
||||
this.graph.setCellStyle(cellStyle, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changeBorderWidth(width: number) {
|
||||
if (!this.graph) return;
|
||||
const cells = this.graph.getSelectionCells();
|
||||
if (cells?.length) {
|
||||
this.graph.model.beginUpdate();
|
||||
try {
|
||||
cells.forEach(cell => {
|
||||
if (cell.isVertex()) {
|
||||
const style = { ...cell.getStyle() };
|
||||
style.strokeWidth = width;
|
||||
this.graph.setCellStyle(style, [cell]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadGraphData(data: any) {
|
||||
if (!this.graph) return;
|
||||
this.graph.model.beginUpdate();
|
||||
this.graph.model.import(data);
|
||||
this.graph.model.endUpdate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { Graph, CellRenderer, Shape } from '@maxgraph/core';
|
||||
import { FlowIcons, UMLIcons } from '../icons';
|
||||
|
||||
export class ShapeRegister {
|
||||
static registerCustomShapes() {
|
||||
// 注册基础流程图形状
|
||||
CellRenderer.registerShape('start', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
// 绘制圆形
|
||||
c.ellipse(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
// 绘制内部小圆
|
||||
const insetSize = Math.min(w, h) * 0.3;
|
||||
c.setFillColor('#4caf50');
|
||||
c.ellipse(x + (w-insetSize)/2, y + (h-insetSize)/2, insetSize, insetSize);
|
||||
c.fill();
|
||||
}
|
||||
});
|
||||
|
||||
CellRenderer.registerShape('end', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
// 绘制圆形
|
||||
c.ellipse(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
// 绘制内部圆环
|
||||
const insetSize = Math.min(w, h) * 0.3;
|
||||
c.setStrokeWidth(2);
|
||||
c.setStrokeColor('#f44336');
|
||||
c.ellipse(x + (w-insetSize)/2, y + (h-insetSize)/2, insetSize, insetSize);
|
||||
c.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册 UML 图形状
|
||||
Object.entries(UMLIcons).forEach(([name, svg]) => {
|
||||
CellRenderer.registerShape(name.toLowerCase(), class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
c.rect(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
if (this.state.style.shape === name.toLowerCase()) {
|
||||
const svgElement = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
|
||||
const svgContent = svgElement.innerHTML;
|
||||
c.setSvg(svgContent, x, y, w, h);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册 UML 类图形状
|
||||
CellRenderer.registerShape('umlClass', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
c.rect(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
// 绘制分隔线
|
||||
const headerHeight = h / 3;
|
||||
c.begin();
|
||||
c.moveTo(x, y + headerHeight);
|
||||
c.lineTo(x + w, y + headerHeight);
|
||||
c.moveTo(x, y + headerHeight * 2);
|
||||
c.lineTo(x + w, y + headerHeight * 2);
|
||||
c.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册 UML 接口形状
|
||||
CellRenderer.registerShape('umlInterface', class extends Shape {
|
||||
constructor(state: any) {
|
||||
super(state);
|
||||
this.isDashed = true;
|
||||
}
|
||||
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
c.setDashed(true, [2, 2]);
|
||||
c.rect(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
// 绘制分隔线
|
||||
const headerHeight = h / 3;
|
||||
c.begin();
|
||||
c.moveTo(x, y + headerHeight);
|
||||
c.lineTo(x + w, y + headerHeight);
|
||||
c.moveTo(x, y + headerHeight * 2);
|
||||
c.lineTo(x + w, y + headerHeight * 2);
|
||||
c.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册包形状
|
||||
CellRenderer.registerShape('package', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
const tabWidth = w * 0.3;
|
||||
const tabHeight = h * 0.2;
|
||||
|
||||
c.begin();
|
||||
// 绘制标签部分
|
||||
c.moveTo(x, y);
|
||||
c.lineTo(x + tabWidth, y);
|
||||
c.lineTo(x + tabWidth, y + tabHeight);
|
||||
c.lineTo(x + w, y + tabHeight);
|
||||
c.lineTo(x + w, y + h);
|
||||
c.lineTo(x, y + h);
|
||||
c.close();
|
||||
c.fillAndStroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册组件形状
|
||||
CellRenderer.registerShape('component', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
c.rect(x, y, w, h);
|
||||
c.fillAndStroke();
|
||||
|
||||
const portSize = Math.min(w, h) * 0.2;
|
||||
|
||||
// 绘制端口
|
||||
c.begin();
|
||||
c.rect(x - portSize, y + h * 0.25, portSize, portSize);
|
||||
c.rect(x - portSize, y + h * 0.6, portSize, portSize);
|
||||
c.fillAndStroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册备注形状
|
||||
CellRenderer.registerShape('note', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
const fold = Math.min(w, h) * 0.2; // 折角大小
|
||||
|
||||
c.begin();
|
||||
c.moveTo(x, y);
|
||||
c.lineTo(x + w - fold, y);
|
||||
c.lineTo(x + w, y + fold);
|
||||
c.lineTo(x + w, y + h);
|
||||
c.lineTo(x, y + h);
|
||||
c.close();
|
||||
c.fillAndStroke();
|
||||
|
||||
// 绘制折角
|
||||
c.begin();
|
||||
c.moveTo(x + w - fold, y);
|
||||
c.lineTo(x + w - fold, y + fold);
|
||||
c.lineTo(x + w, y + fold);
|
||||
c.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册五角星形状
|
||||
CellRenderer.registerShape('star', class extends Shape {
|
||||
paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
const cx = x + w/2;
|
||||
const cy = y + h/2;
|
||||
const r = Math.min(w, h)/2;
|
||||
const points = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// 外部点
|
||||
const angle = (i * 72 - 90) * Math.PI/180;
|
||||
points.push([
|
||||
cx + r * Math.cos(angle),
|
||||
cy + r * Math.sin(angle)
|
||||
]);
|
||||
// 内部点
|
||||
const innerAngle = ((i * 72 + 36) - 90) * Math.PI/180;
|
||||
points.push([
|
||||
cx + (r/2) * Math.cos(innerAngle),
|
||||
cy + (r/2) * Math.sin(innerAngle)
|
||||
]);
|
||||
}
|
||||
|
||||
c.begin();
|
||||
c.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
c.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
c.lineTo(points[0][0], points[0][1]);
|
||||
c.close();
|
||||
c.fillAndStroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册表格形状
|
||||
// graph.getStylesheet().putCellStyle('table', {
|
||||
// shape: 'html',
|
||||
// whiteSpace: 'wrap',
|
||||
// verticalAlign: 'top',
|
||||
// align: 'left',
|
||||
// overflow: 'visible'
|
||||
// });
|
||||
|
||||
// // 注册泳道形状
|
||||
// CellRenderer.registerShape('swimlane', class extends Shape {
|
||||
// paintBackground(c: any, x: number, y: number, w: number, h: number) {
|
||||
// const style = this.state.style;
|
||||
// const isHorizontal = style.horizontal;
|
||||
// const startSize = style.startSize || 30;
|
||||
|
||||
// // 绘制外框
|
||||
// c.begin();
|
||||
// c.rect(x, y, w, h);
|
||||
// c.fillAndStroke();
|
||||
|
||||
// // 绘制标题区域
|
||||
// c.begin();
|
||||
// if (isHorizontal) {
|
||||
// c.rect(x, y, w, startSize);
|
||||
// } else {
|
||||
// c.rect(x, y, startSize, h);
|
||||
// }
|
||||
// c.fillAndStroke();
|
||||
// }
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ import type { App } from 'vue'
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import remainingRouter from './modules/remaining'
|
||||
import flowRouter from './modules/flowrouter'
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#,createWebHistory URL不带#
|
||||
strict: true,
|
||||
routes: remainingRouter as RouteRecordRaw[],
|
||||
routes: [...remainingRouter, ...flowRouter] as RouteRecordRaw[],
|
||||
scrollBehavior: () => ({ left: 0, top: 0 })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
export default [
|
||||
{
|
||||
path: '/flow',
|
||||
name: 'Flow',
|
||||
component: () => import('@/views/flow/Home.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/flow/default' },
|
||||
{
|
||||
path: 'default',
|
||||
name: 'default',
|
||||
component: () => import('@/views/flow/HomeDefault.vue')
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'pricing',
|
||||
component: () => import('@/views/flow/Pricing.vue')
|
||||
},
|
||||
{
|
||||
path: 'contact',
|
||||
name: 'contact',
|
||||
component: () => import('@/views/flow/Contact.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/flow/FileManager.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/files/my-files' },
|
||||
{
|
||||
path: 'my-files',
|
||||
name: 'my-files',
|
||||
component: () => import('@/views/flow/files/MyFiles.vue')
|
||||
},
|
||||
{
|
||||
path: 'recommended',
|
||||
name: 'recommended',
|
||||
component: () => import('@/views/flow/files/Recommended.vue')
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
name: 'recent',
|
||||
component: () => import('@/views/flow/files/Recent.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/flow/Register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/flow/Register.vue')
|
||||
},
|
||||
{
|
||||
path: '/flow-editor',
|
||||
name: 'FlowEditor',
|
||||
component: () => import('@/components/wu-flow-lib/components/FlowEditor.vue')
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/flow/Register.vue')
|
||||
}
|
||||
]
|
||||
|
|
@ -53,7 +53,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
|||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/index',
|
||||
redirect: '/flow',
|
||||
name: 'Home',
|
||||
meta: {},
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<router-link to="/" class="back-btn">
|
||||
<i class="icon">←</i>
|
||||
</router-link>
|
||||
<img src="@/assets/logo.svg" alt="Logo">
|
||||
<img src="@/assets/logo.svg" alt="Logo"/>
|
||||
<span>Flow Design</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
<div class="search-bar">
|
||||
<div class="search-input">
|
||||
<i class="icon">🔍</i>
|
||||
<input type="text" placeholder="搜索文件">
|
||||
<input type="text" placeholder="搜索文件"/>
|
||||
</div>
|
||||
<div class="view-options">
|
||||
<button class="btn-view active">
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 路由出口 -->
|
||||
<router-view></router-view>
|
||||
<router-view/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -147,11 +147,11 @@ const processedData = {
|
|||
defineExpose(processedData)
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/flow-edit')
|
||||
router.push('/flow-editor')
|
||||
}
|
||||
|
||||
const openTemplateDialog = () => {
|
||||
router.push('/flow-edit')
|
||||
router.push('/flow-editor')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, provide } from 'vue'
|
||||
import { Graph } from '@maxgraph/core'
|
||||
import { SplitPane, Accordion, FlowAccordion, FlowCanvas, FlowToolbar } from '../components/wu-flow-lib'
|
||||
import { SplitPane, Accordion, FlowAccordion, FlowCanvas, FlowToolbar } from '@/components/wu-flow-lib'
|
||||
import { accordionData } from '@/assets/data/accordion-data'
|
||||
|
||||
const graph = ref<Graph>()
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FlowHeader from '../components/wu-flow-lib/components/FlowHeader.vue'
|
||||
import FlowCanvas from '../components/wu-flow-lib/components/FlowCanvas.vue'
|
||||
import ShapeLibrary from '../components/wu-flow-lib/components/ShapeLibrary.vue'
|
||||
import StyleLibrary from '../components/wu-flow-lib/components/StyleLibrary.vue'
|
||||
import PropertyPanel from '../components/wu-flow-lib/components/PropertyPanel.vue'
|
||||
import FlowHeader from '@/components/wu-flow-lib/components/FlowHeader.vue'
|
||||
import FlowCanvas from '@/components/wu-flow-lib/components/FlowCanvas.vue'
|
||||
import ShapeLibrary from '@/components/wu-flow-lib/components/ShapeLibrary.vue'
|
||||
import StyleLibrary from '@/components/wu-flow-lib/components/StyleLibrary.vue'
|
||||
import PropertyPanel from '@/components/wu-flow-lib/components/PropertyPanel.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<header class="header">
|
||||
<nav class="nav">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.svg" alt="Flow Design">
|
||||
<img src="@/assets/logo.svg" alt="Flow Design"/>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<router-link to="/home">首页</router-link>
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<router-view></router-view>
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
|
@ -28,12 +28,13 @@ import { useRouter } from 'vue-router'
|
|||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
localStorage.setItem('token', '123456')
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
router.push('/files')
|
||||
}
|
||||
console.log('onMounted')
|
||||
router.push('/flow/default')
|
||||
// localStorage.setItem('token', '123456')
|
||||
// const token = localStorage.getItem('token')
|
||||
// if (token) {
|
||||
// router.push('/files')
|
||||
// }
|
||||
// console.log('onMounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@
|
|||
<div class="examples-content">
|
||||
<h2>流程图案例展示</h2>
|
||||
<p class="section-desc">适用于各种场景的流程图模板,帮助您快速开始</p>
|
||||
|
||||
|
||||
<div class="example-grid">
|
||||
<div class="example-card">
|
||||
<div class="example-image">
|
||||
<img src="@/assets/examples/business-process.svg" alt="业务流程图">
|
||||
<img src="@/assets/examples/business-process.svg" alt="业务流程图" />
|
||||
<div class="example-overlay">
|
||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||
</div>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<div class="example-card">
|
||||
<div class="example-image">
|
||||
<img src="@/assets/examples/mind-map.svg" alt="思维导图">
|
||||
<img src="@/assets/examples/mind-map.svg" alt="思维导图" />
|
||||
<div class="example-overlay">
|
||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||
</div>
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
<div class="example-card">
|
||||
<div class="example-image">
|
||||
<img src="@/assets/examples/uml.svg" alt="UML图">
|
||||
<img src="@/assets/examples/uml.svg" alt="UML图" />
|
||||
<div class="example-overlay">
|
||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||
</div>
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
|
||||
<div class="example-card">
|
||||
<div class="example-image">
|
||||
<img src="@/assets/examples/org-chart.svg" alt="组织架构图">
|
||||
<img src="@/assets/examples/org-chart.svg" alt="组织架构图" />
|
||||
<div class="example-overlay">
|
||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||
</div>
|
||||
|
|
@ -273,4 +273,4 @@
|
|||
left: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -29,39 +29,26 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img src="@/assets/images/flow-banner.svg" alt="Flow Design Banner" class="banner-image">
|
||||
<img src="@/assets/images/flow-banner.svg" alt="Flow Design Banner" class="banner-image" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧注册区域 -->
|
||||
<div class="register-content">
|
||||
<div class="register-box">
|
||||
<div class="register-header">
|
||||
<img src="@/assets/logo.svg" alt="Flow Design" class="logo">
|
||||
<img src="@/assets/logo.svg" alt="Flow Design" class="logo" />
|
||||
<h2>欢迎加入 Flow Design</h2>
|
||||
<p class="subtitle">开启您的流程设计之旅</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="register-form"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="register-form">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="邮箱"
|
||||
:prefix-icon="Message"
|
||||
/>
|
||||
<el-input v-model="form.email" placeholder="邮箱" :prefix-icon="Message" />
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
|
|
@ -71,7 +58,7 @@
|
|||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
|
|
@ -81,16 +68,16 @@
|
|||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item prop="agreeTerms">
|
||||
<el-checkbox v-model="form.agreeTerms">
|
||||
我已阅读并同意<el-link type="primary" href="#" class="terms-link">服务条款</el-link>
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
<el-button
|
||||
type="primary"
|
||||
class="register-button"
|
||||
:loading="loading"
|
||||
:disabled="!form.agreeTerms"
|
||||
|
|
@ -99,7 +86,7 @@
|
|||
注 册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<div class="form-footer">
|
||||
<span>已有账号?</span>
|
||||
<router-link to="/login" class="login-link">立即登录</router-link>
|
||||
|
|
@ -117,7 +104,7 @@ import { ElMessage } from 'element-plus'
|
|||
import 'element-plus/es/components/message/style/css'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { User, Message, Lock } from '@element-plus/icons-vue'
|
||||
import { register } from '@/api/auth'
|
||||
import { register } from '@/api/flow'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
|
@ -160,7 +147,7 @@ const rules = reactive<FormRules>({
|
|||
{ validator: validatePass2, trigger: 'blur' }
|
||||
],
|
||||
agreeTerms: [
|
||||
{
|
||||
{
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请阅读并同意服务条款'))
|
||||
|
|
@ -175,7 +162,7 @@ const rules = reactive<FormRules>({
|
|||
|
||||
const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
||||
if (!formEl) return Promise.reject('表单不存在')
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
formEl.validate((valid) => {
|
||||
if (!valid) {
|
||||
|
|
@ -189,24 +176,24 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
|||
email: form.email,
|
||||
password: form.password
|
||||
})
|
||||
.then(() => {
|
||||
ElMessage({
|
||||
message: '注册成功!正在跳转到登录页面...',
|
||||
type: 'success',
|
||||
duration: 1500
|
||||
.then(() => {
|
||||
ElMessage({
|
||||
message: '注册成功!正在跳转到登录页面...',
|
||||
type: 'success',
|
||||
duration: 1500
|
||||
})
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('注册失败:', error)
|
||||
reject(error)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
resolve()
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('注册失败:', error)
|
||||
reject(error)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -322,7 +309,7 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
|||
|
||||
/* 复用其他样式并添加邮箱图标 */
|
||||
.icon-email::before {
|
||||
content: "📧";
|
||||
content: '📧';
|
||||
}
|
||||
|
||||
/* 服务条款样式 */
|
||||
|
|
@ -393,11 +380,11 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
|||
}
|
||||
|
||||
.icon-user::before {
|
||||
content: "👤";
|
||||
content: '👤';
|
||||
}
|
||||
|
||||
.icon-lock::before {
|
||||
content: "🔒";
|
||||
content: '🔒';
|
||||
}
|
||||
|
||||
input {
|
||||
|
|
@ -489,4 +476,4 @@ input:focus {
|
|||
}
|
||||
|
||||
/* 其他样式保持不变... */
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="file-list">
|
||||
<div class="file-card" v-for="file in files" :key="file.id">
|
||||
<div class="file-preview">
|
||||
<img :src="file.preview" :alt="file.name">
|
||||
<img :src="file.preview" :alt="file.name"/>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<h3>{{ file.name }}</h3>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
<template>
|
||||
<doc-alert title="用户体系" url="https://doc.iocoder.cn/user-center/" />
|
||||
<doc-alert title="三方登陆" url="https://doc.iocoder.cn/social-user/" />
|
||||
<doc-alert title="Excel 导入导出" url="https://doc.iocoder.cn/excel-import-and-export/" />
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧部门树 -->
|
||||
|
|
|
|||
Loading…
Reference in New Issue