增加流程管理功能
parent
fbec62e3c1
commit
1f1db18c3e
|
|
@ -62,7 +62,7 @@
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
"lint:lint-staged": "lint-staged -c "
|
"lint:lint-staged": "lint-staged -c "
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.1.0",
|
|
||||||
"@form-create/designer": "^3.2.6",
|
"@form-create/designer": "^3.2.6",
|
||||||
"@form-create/element-ui": "^3.2.11",
|
"@form-create/element-ui": "^3.2.11",
|
||||||
"@iconify/iconify": "^3.1.1",
|
"@iconify/iconify": "^3.1.1",
|
||||||
|
|
@ -59,6 +58,8 @@
|
||||||
"markmap-view": "^0.16.0",
|
"markmap-view": "^0.16.0",
|
||||||
"min-dash": "^4.1.1",
|
"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",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ importers:
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@element-plus/icons-vue':
|
'@element-plus/icons-vue':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.3.1
|
||||||
version: 2.3.1(vue@3.5.12(typescript@5.3.3))
|
version: 2.3.1(vue@3.5.12(typescript@5.3.3))
|
||||||
'@form-create/designer':
|
'@form-create/designer':
|
||||||
specifier: ^3.2.6
|
specifier: ^3.2.6
|
||||||
|
|
@ -20,6 +20,9 @@ importers:
|
||||||
'@iconify/iconify':
|
'@iconify/iconify':
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.1.1
|
version: 3.1.1
|
||||||
|
'@maxgraph/core':
|
||||||
|
specifier: ^0.14.0
|
||||||
|
version: 0.14.0
|
||||||
'@microsoft/fetch-event-source':
|
'@microsoft/fetch-event-source':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
|
|
@ -1341,6 +1344,9 @@ packages:
|
||||||
'@lezer/markdown@1.3.2':
|
'@lezer/markdown@1.3.2':
|
||||||
resolution: {integrity: sha512-Wu7B6VnrKTbBEohqa63h5vxXjiC4pO5ZQJ/TDbhJxPQaaIoRD/6UVDhSDtVsCwVZV12vvN9KxuLL3ATMnlG0oQ==}
|
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':
|
'@microsoft/fetch-event-source@2.0.1':
|
||||||
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||||
|
|
||||||
|
|
@ -6974,6 +6980,8 @@ snapshots:
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
'@lezer/highlight': 1.2.1
|
'@lezer/highlight': 1.2.1
|
||||||
|
|
||||||
|
'@maxgraph/core@0.14.0': {}
|
||||||
|
|
||||||
'@microsoft/fetch-event-source@2.0.1': {}
|
'@microsoft/fetch-event-source@2.0.1': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@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 type { RouteRecordRaw } from 'vue-router'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import remainingRouter from './modules/remaining'
|
import remainingRouter from './modules/remaining'
|
||||||
|
import flowRouter from './modules/flowrouter'
|
||||||
|
|
||||||
// 创建路由实例
|
// 创建路由实例
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#,createWebHistory URL不带#
|
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#,createWebHistory URL不带#
|
||||||
strict: true,
|
strict: true,
|
||||||
routes: remainingRouter as RouteRecordRaw[],
|
routes: [...remainingRouter, ...flowRouter] as RouteRecordRaw[],
|
||||||
scrollBehavior: () => ({ left: 0, top: 0 })
|
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: '/',
|
path: '/',
|
||||||
component: Layout,
|
component: Layout,
|
||||||
redirect: '/index',
|
redirect: '/flow',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
meta: {},
|
meta: {},
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<router-link to="/" class="back-btn">
|
<router-link to="/" class="back-btn">
|
||||||
<i class="icon">←</i>
|
<i class="icon">←</i>
|
||||||
</router-link>
|
</router-link>
|
||||||
<img src="@/assets/logo.svg" alt="Logo">
|
<img src="@/assets/logo.svg" alt="Logo"/>
|
||||||
<span>Flow Design</span>
|
<span>Flow Design</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<i class="icon">🔍</i>
|
<i class="icon">🔍</i>
|
||||||
<input type="text" placeholder="搜索文件">
|
<input type="text" placeholder="搜索文件"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-options">
|
<div class="view-options">
|
||||||
<button class="btn-view active">
|
<button class="btn-view active">
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 路由出口 -->
|
<!-- 路由出口 -->
|
||||||
<router-view></router-view>
|
<router-view/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -147,11 +147,11 @@ const processedData = {
|
||||||
defineExpose(processedData)
|
defineExpose(processedData)
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
router.push('/flow-edit')
|
router.push('/flow-editor')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTemplateDialog = () => {
|
const openTemplateDialog = () => {
|
||||||
router.push('/flow-edit')
|
router.push('/flow-editor')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, provide } from 'vue'
|
import { ref, provide } from 'vue'
|
||||||
import { Graph } from '@maxgraph/core'
|
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'
|
import { accordionData } from '@/assets/data/accordion-data'
|
||||||
|
|
||||||
const graph = ref<Graph>()
|
const graph = ref<Graph>()
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FlowHeader from '../components/wu-flow-lib/components/FlowHeader.vue'
|
import FlowHeader from '@/components/wu-flow-lib/components/FlowHeader.vue'
|
||||||
import FlowCanvas from '../components/wu-flow-lib/components/FlowCanvas.vue'
|
import FlowCanvas from '@/components/wu-flow-lib/components/FlowCanvas.vue'
|
||||||
import ShapeLibrary from '../components/wu-flow-lib/components/ShapeLibrary.vue'
|
import ShapeLibrary from '@/components/wu-flow-lib/components/ShapeLibrary.vue'
|
||||||
import StyleLibrary from '../components/wu-flow-lib/components/StyleLibrary.vue'
|
import StyleLibrary from '@/components/wu-flow-lib/components/StyleLibrary.vue'
|
||||||
import PropertyPanel from '../components/wu-flow-lib/components/PropertyPanel.vue'
|
import PropertyPanel from '@/components/wu-flow-lib/components/PropertyPanel.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="@/assets/logo.svg" alt="Flow Design">
|
<img src="@/assets/logo.svg" alt="Flow Design"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<router-link to="/home">首页</router-link>
|
<router-link to="/home">首页</router-link>
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<router-view></router-view>
|
<router-view/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -28,12 +28,13 @@ import { useRouter } from 'vue-router'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
localStorage.setItem('token', '123456')
|
router.push('/flow/default')
|
||||||
const token = localStorage.getItem('token')
|
// localStorage.setItem('token', '123456')
|
||||||
if (token) {
|
// const token = localStorage.getItem('token')
|
||||||
router.push('/files')
|
// if (token) {
|
||||||
}
|
// router.push('/files')
|
||||||
console.log('onMounted')
|
// }
|
||||||
|
// console.log('onMounted')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<div class="example-grid">
|
<div class="example-grid">
|
||||||
<div class="example-card">
|
<div class="example-card">
|
||||||
<div class="example-image">
|
<div class="example-image">
|
||||||
<img src="@/assets/examples/business-process.svg" alt="业务流程图">
|
<img src="@/assets/examples/business-process.svg" alt="业务流程图" />
|
||||||
<div class="example-overlay">
|
<div class="example-overlay">
|
||||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
<div class="example-card">
|
<div class="example-card">
|
||||||
<div class="example-image">
|
<div class="example-image">
|
||||||
<img src="@/assets/examples/mind-map.svg" alt="思维导图">
|
<img src="@/assets/examples/mind-map.svg" alt="思维导图" />
|
||||||
<div class="example-overlay">
|
<div class="example-overlay">
|
||||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
|
|
||||||
<div class="example-card">
|
<div class="example-card">
|
||||||
<div class="example-image">
|
<div class="example-image">
|
||||||
<img src="@/assets/examples/uml.svg" alt="UML图">
|
<img src="@/assets/examples/uml.svg" alt="UML图" />
|
||||||
<div class="example-overlay">
|
<div class="example-overlay">
|
||||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
|
|
||||||
<div class="example-card">
|
<div class="example-card">
|
||||||
<div class="example-image">
|
<div class="example-image">
|
||||||
<img src="@/assets/examples/org-chart.svg" alt="组织架构图">
|
<img src="@/assets/examples/org-chart.svg" alt="组织架构图" />
|
||||||
<div class="example-overlay">
|
<div class="example-overlay">
|
||||||
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
<router-link to="/flow-edit" class="btn-try">立即尝试</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,37 +29,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- 右侧注册区域 -->
|
<!-- 右侧注册区域 -->
|
||||||
<div class="register-content">
|
<div class="register-content">
|
||||||
<div class="register-box">
|
<div class="register-box">
|
||||||
<div class="register-header">
|
<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>
|
<h2>欢迎加入 Flow Design</h2>
|
||||||
<p class="subtitle">开启您的流程设计之旅</p>
|
<p class="subtitle">开启您的流程设计之旅</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form
|
<el-form ref="formRef" :model="form" :rules="rules" class="register-form">
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
class="register-form"
|
|
||||||
>
|
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input
|
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" />
|
||||||
v-model="form.username"
|
|
||||||
placeholder="用户名"
|
|
||||||
:prefix-icon="User"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="email">
|
<el-form-item prop="email">
|
||||||
<el-input
|
<el-input v-model="form.email" placeholder="邮箱" :prefix-icon="Message" />
|
||||||
v-model="form.email"
|
|
||||||
placeholder="邮箱"
|
|
||||||
:prefix-icon="Message"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="password">
|
<el-form-item prop="password">
|
||||||
|
|
@ -117,7 +104,7 @@ import { ElMessage } from 'element-plus'
|
||||||
import 'element-plus/es/components/message/style/css'
|
import 'element-plus/es/components/message/style/css'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { User, Message, Lock } from '@element-plus/icons-vue'
|
import { User, Message, Lock } from '@element-plus/icons-vue'
|
||||||
import { register } from '@/api/auth'
|
import { register } from '@/api/flow'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
@ -200,7 +187,7 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
||||||
}, 1500)
|
}, 1500)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
console.error('注册失败:', error)
|
console.error('注册失败:', error)
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
|
|
@ -322,7 +309,7 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
||||||
|
|
||||||
/* 复用其他样式并添加邮箱图标 */
|
/* 复用其他样式并添加邮箱图标 */
|
||||||
.icon-email::before {
|
.icon-email::before {
|
||||||
content: "📧";
|
content: '📧';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 服务条款样式 */
|
/* 服务条款样式 */
|
||||||
|
|
@ -393,11 +380,11 @@ const submitForm = (formEl: FormInstance | undefined): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-user::before {
|
.icon-user::before {
|
||||||
content: "👤";
|
content: '👤';
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-lock::before {
|
.icon-lock::before {
|
||||||
content: "🔒";
|
content: '🔒';
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
<div class="file-card" v-for="file in files" :key="file.id">
|
<div class="file-card" v-for="file in files" :key="file.id">
|
||||||
<div class="file-preview">
|
<div class="file-preview">
|
||||||
<img :src="file.preview" :alt="file.name">
|
<img :src="file.preview" :alt="file.name"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-info">
|
<div class="file-info">
|
||||||
<h3>{{ file.name }}</h3>
|
<h3>{{ file.name }}</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
<template>
|
<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">
|
<el-row :gutter="20">
|
||||||
<!-- 左侧部门树 -->
|
<!-- 左侧部门树 -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue