Pre Merge pull request !676 from O无忧O/dev1

pull/676/MERGE
O无忧O 2025-01-23 03:43:34 +00:00 committed by Gitee
commit edecddd548
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
231 changed files with 18351 additions and 7356 deletions

View File

@ -62,7 +62,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"

27
abc.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<body>
<svg
width="200"
height="200"
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0D47A1;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="50" cy="100" r="30" fill="url(#grad1)" />
<rect x="100" y="70" width="60" height="60" fill="url(#grad1)" />
<circle cx="170" cy="100" r="30" fill="url(#grad1)" />
<line x1="80" y1="100" x2="100" y2="100" stroke="#000" stroke-width="2" />
<line x1="160" y1="100" x2="170" y2="100" stroke="#000" stroke-width="2" />
<text x="50" y="100" text-anchor="middle" fill="#fff" font-size="12" dy=".3em">开始</text>
<text x="130" y="100" text-anchor="middle" fill="#fff" font-size="12" dy=".3em">过程</text>
<text x="170" y="100" text-anchor="middle" fill="#fff" font-size="12" dy=".3em">结束</text>
</svg>
</body>
</html>

View File

@ -25,7 +25,6 @@
"lint:lint-staged": "lint-staged -c "
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"@form-create/designer": "^3.2.6",
"@form-create/element-ui": "^3.2.11",
"@iconify/iconify": "^3.1.1",
@ -58,7 +57,9 @@
"markmap-toolbar": "^0.17.0",
"markmap-view": "^0.16.0",
"min-dash": "^4.1.1",
"mitt": "^3.0.1",
"mitt": "^3.0.1",
"@element-plus/icons-vue": "^2.3.1",
"@maxgraph/core": "^0.14.0",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",

File diff suppressed because it is too large Load Diff

9
src/api/flow/index.ts Normal file
View File

@ -0,0 +1,9 @@
import request from '@/config/axios'
export const register = (data: any) => {
return request.post({
url: '/api/flow/register',
method: 'post',
data
})
}

View File

@ -1,13 +1,14 @@
import request from '@/config/axios'
export interface UserVO {
id: number
id?: number
username: string
nickname: string
deptId: number
postIds: string[]
email: string
mobile: string
password: string
sex: number
avatar: string
loginIp: string

View File

@ -0,0 +1,5 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#E5E9F2"/>
<circle cx="64" cy="48" r="24" fill="#C0CCDA"/>
<path d="M64 80C42.464 80 24.572 92.436 16 110.444C24.572 121.564 43.36 128 64 128C84.64 128 103.428 121.564 112 110.444C103.428 92.436 85.536 80 64 80Z" fill="#C0CCDA"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,51 @@
interface AccordionItem {
title: string;
type: 'form' | 'text';
content: FormItem[] | string;
}
interface FormItem {
label: string;
type: 'text' | 'select';
value: string;
options?: { label: string; value: string; }[];
}
export const accordionData: AccordionItem[] = [
{
title: '基本设置',
type: 'form',
content: [
{
label: '名称',
type: 'text',
value: ''
},
{
label: '类型',
type: 'select',
value: '',
options: [
{ label: '类型1', value: 'type1' },
{ label: '类型2', value: 'type2' }
]
}
]
},
{
title: '高级设置',
type: 'form',
content: [
{
label: '配置项',
type: 'text',
value: ''
}
]
},
{
title: '其他信息',
type: 'text',
content: '这是一些其他信息的描述文本'
}
]

View File

@ -0,0 +1,72 @@
{
"categories": [
{
"name": "UML类图",
"nodes": [
{
"type": "uml-class",
"name": "类",
"icon": "📦",
"template": {
"title": "类名",
"attributes": ["+ 属性1: string", "- 属性2: number"],
"methods": ["+ 方法1()", "# 方法2(param: string)"]
}
},
{
"type": "interface",
"name": "接口",
"icon": "🔌",
"template": {
"title": "接口名",
"methods": ["+ 方法1()", "+ 方法2()"]
}
}
]
},
{
"name": "思维导图",
"nodes": [
{
"type": "topic",
"name": "主题",
"icon": "💡",
"template": {
"title": "主题"
}
},
{
"type": "subtopic",
"name": "子主题",
"icon": "📝",
"template": {
"title": "子主题"
}
}
]
},
{
"name": "网络拓扑",
"nodes": [
{
"type": "server",
"name": "服务器",
"icon": "🖥️",
"template": {
"title": "Server",
"ip": "192.168.1.1"
}
},
{
"type": "router",
"name": "路由器",
"icon": "📡",
"template": {
"title": "Router",
"ip": "192.168.1.254"
}
}
]
}
]
}

View File

@ -0,0 +1,55 @@
{
"files": [
{
"id": 1,
"name": "业务流程图",
"preview": "business-process",
"modifiedDate": "2024-01-20"
},
{
"id": 2,
"name": "思维导图",
"preview": "mind-map",
"modifiedDate": "2024-01-18"
},
{
"id": 3,
"name": "UML类图",
"preview": "uml",
"modifiedDate": "2024-01-15"
}
],
"templates": [
{
"id": 1,
"name": "项目管理流程",
"description": "适用于项目规划和任务管理",
"preview": "business-process"
},
{
"id": 2,
"name": "组织架构图",
"description": "快速创建公司组织结构图",
"preview": "org-chart"
}
],
"communityItems": [
{
"id": 1,
"title": "电商系统架构图",
"preview": "business-process",
"author": "张三",
"authorAvatar": "default-avatar",
"likes": 128,
"comments": 32
}
],
"deletedFiles": [
{
"id": 1,
"name": "旧版流程图",
"preview": "business-process",
"deleteTime": "2024-03-10"
}
]
}

View File

@ -0,0 +1,15 @@
<svg width="400" height="225" viewBox="0 0 400 225" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#F5F7FA"/>
<rect x="50" y="92.5" width="100" height="40" rx="4" fill="#409EFF"/>
<text x="100" y="117.5" text-anchor="middle" fill="white" font-size="14">开始</text>
<path d="M150 112.5H200" stroke="#409EFF" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="200" y="92.5" width="100" height="40" rx="4" fill="#409EFF"/>
<text x="250" y="117.5" text-anchor="middle" fill="white" font-size="14">处理</text>
<path d="M300 112.5H350" stroke="#409EFF" stroke-width="2" marker-end="url(#arrow)"/>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#409EFF"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 859 B

View File

@ -0,0 +1,81 @@
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<!-- 渐变背景 -->
<defs>
<linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2c5edf;stop-opacity:1" />
<stop offset="100%" style="stop-color:#5b9fff;stop-opacity:1" />
</linearGradient>
<!-- 流程图连线动画 -->
<path id="flowPath1" d="M100,200 C200,200 300,300 400,300" />
<path id="flowPath2" d="M400,300 C500,300 600,200 700,200" />
</defs>
<!-- 背景 -->
<rect width="100%" height="100%" fill="url(#skyGradient)" />
<!-- 装饰性圆点 -->
<g class="dots" fill="#ffffff" fill-opacity="0.1">
<circle cx="100" cy="100" r="2">
<animate attributeName="r" values="2;4;2" dur="3s" repeatCount="indefinite" />
</circle>
<circle cx="300" cy="200" r="3">
<animate attributeName="r" values="3;5;3" dur="4s" repeatCount="indefinite" />
</circle>
<circle cx="500" cy="150" r="2">
<animate attributeName="r" values="2;4;2" dur="3.5s" repeatCount="indefinite" />
</circle>
<circle cx="700" cy="300" r="3">
<animate attributeName="r" values="3;5;3" dur="4.5s" repeatCount="indefinite" />
</circle>
</g>
<!-- 流程图节点和连线 -->
<g class="flow-elements">
<!-- 节点 -->
<rect x="80" y="180" width="40" height="40" rx="5" fill="#ffffff" opacity="0.2">
<animate attributeName="opacity" values="0.2;0.4;0.2" dur="3s" repeatCount="indefinite" />
</rect>
<rect x="380" y="280" width="40" height="40" rx="5" fill="#ffffff" opacity="0.2">
<animate attributeName="opacity" values="0.2;0.4;0.2" dur="3s" repeatCount="indefinite" />
</rect>
<rect x="680" y="180" width="40" height="40" rx="5" fill="#ffffff" opacity="0.2">
<animate attributeName="opacity" values="0.2;0.4;0.2" dur="3s" repeatCount="indefinite" />
</rect>
<!-- 动态连线 -->
<g stroke="#ffffff" stroke-width="2" stroke-opacity="0.3" fill="none">
<path d="">
<animate
attributeName="d"
values="M120,200 C200,200 300,300 380,300;M120,200 C200,200 300,290 380,300;M120,200 C200,200 300,300 380,300"
dur="4s"
repeatCount="indefinite"
/>
</path>
<path d="">
<animate
attributeName="d"
values="M420,300 C500,300 600,200 680,200;M420,300 C500,310 600,200 680,200;M420,300 C500,300 600,200 680,200"
dur="4s"
repeatCount="indefinite"
/>
</path>
</g>
<!-- 流动的点 -->
<circle r="3" fill="#ffffff">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M120,200 C200,200 300,300 380,300"
/>
</circle>
<circle r="3" fill="#ffffff">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M420,300 C500,300 600,200 680,200"
/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,17 @@
<svg width="400" height="225" viewBox="0 0 400 225" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#F5F7FA"/>
<circle cx="200" cy="112.5" r="40" fill="#409EFF"/>
<text x="200" y="117.5" text-anchor="middle" fill="white" font-size="14">核心主题</text>
<circle cx="100" cy="72.5" r="30" fill="#67C23A"/>
<text x="100" y="77.5" text-anchor="middle" fill="white" font-size="12">分支1</text>
<circle cx="100" cy="152.5" r="30" fill="#67C23A"/>
<text x="100" y="157.5" text-anchor="middle" fill="white" font-size="12">分支2</text>
<circle cx="300" cy="72.5" r="30" fill="#67C23A"/>
<text x="300" y="77.5" text-anchor="middle" fill="white" font-size="12">分支3</text>
<circle cx="300" cy="152.5" r="30" fill="#67C23A"/>
<text x="300" y="157.5" text-anchor="middle" fill="white" font-size="12">分支4</text>
<path d="M160 112.5L130 72.5" stroke="#67C23A" stroke-width="2"/>
<path d="M160 112.5L130 152.5" stroke="#67C23A" stroke-width="2"/>
<path d="M240 112.5L270 72.5" stroke="#67C23A" stroke-width="2"/>
<path d="M240 112.5L270 152.5" stroke="#67C23A" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,13 @@
<svg width="400" height="225" viewBox="0 0 400 225" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#F5F7FA"/>
<rect x="150" y="22.5" width="100" height="40" rx="4" fill="#409EFF"/>
<text x="200" y="47.5" text-anchor="middle" fill="white" font-size="14">CEO</text>
<rect x="50" y="92.5" width="100" height="40" rx="4" fill="#67C23A"/>
<text x="100" y="117.5" text-anchor="middle" fill="white" font-size="14">技术部</text>
<rect x="250" y="92.5" width="100" height="40" rx="4" fill="#67C23A"/>
<text x="300" y="117.5" text-anchor="middle" fill="white" font-size="14">市场部</text>
<path d="M200 62.5V82.5" stroke="#409EFF" stroke-width="2"/>
<path d="M100 82.5V92.5" stroke="#409EFF" stroke-width="2"/>
<path d="M300 82.5V92.5" stroke="#409EFF" stroke-width="2"/>
<path d="M100 82.5H300" stroke="#409EFF" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@ -0,0 +1,10 @@
<svg width="400" height="225" viewBox="0 0 400 225" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#F5F7FA"/>
<rect x="100" y="42.5" width="200" height="140" rx="4" fill="white" stroke="#409EFF" stroke-width="2"/>
<line x1="100" y1="82.5" x2="300" y2="82.5" stroke="#409EFF" stroke-width="2"/>
<line x1="100" y1="142.5" x2="300" y2="142.5" stroke="#409EFF" stroke-width="2"/>
<text x="200" y="67.5" text-anchor="middle" fill="#409EFF" font-size="14" font-weight="bold">Class</text>
<text x="110" y="107.5" fill="#666666" font-size="12">- attribute1: Type</text>
<text x="110" y="127.5" fill="#666666" font-size="12">- attribute2: Type</text>
<text x="110" y="167.5" fill="#666666" font-size="12">+ method(): ReturnType</text>
</svg>

After

Width:  |  Height:  |  Size: 787 B

14
src/assets/hero-bg.svg Normal file
View File

@ -0,0 +1,14 @@
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H1440V800H0V0Z" fill="url(#paint0_linear)"/>
<g opacity="0.1">
<circle cx="720" cy="400" r="300" stroke="#409EFF" stroke-width="2"/>
<circle cx="720" cy="400" r="200" stroke="#409EFF" stroke-width="2"/>
<circle cx="720" cy="400" r="100" stroke="#409EFF" stroke-width="2"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="0" y1="0" x2="1440" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#ECF5FF"/>
<stop offset="1" stop-color="#E4E7ED"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 644 B

View File

@ -0,0 +1,4 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 2L28 15L15 28L2 15L15 2Z" stroke="#409EFF" stroke-width="0.5"/>
<circle cx="15" cy="15" r="2" fill="#409EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,43 @@
<svg width="800" height="600" viewBox="0 0 800 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 背景装饰圆圈 -->
<circle cx="400" cy="300" r="250" stroke="white" stroke-width="2" stroke-opacity="0.1"/>
<circle cx="400" cy="300" r="200" stroke="white" stroke-width="2" stroke-opacity="0.1"/>
<circle cx="400" cy="300" r="150" stroke="white" stroke-width="2" stroke-opacity="0.1"/>
<!-- 流程图示意 -->
<g opacity="0.2">
<!-- 开始节点 -->
<circle cx="250" cy="200" r="30" fill="white"/>
<!-- 处理节点1 -->
<rect x="350" y="170" width="100" height="60" rx="4" fill="white"/>
<!-- 判断节点 -->
<path d="M500 200 L550 230 L500 260 L450 230 Z" fill="white"/>
<!-- 处理节点2 -->
<rect x="350" y="300" width="100" height="60" rx="4" fill="white"/>
<!-- 结束节点 -->
<circle cx="550" cy="330" r="30" fill="white"/>
<!-- 连接线 -->
<path d="M280 200 H350" stroke="white" stroke-width="2"/>
<path d="M450 200 H450" stroke="white" stroke-width="2"/>
<path d="M550 230 V330" stroke="white" stroke-width="2"/>
<path d="M500 230 H400 V300" stroke="white" stroke-width="2"/>
<path d="M450 330 H520" stroke="white" stroke-width="2"/>
</g>
<!-- 装饰点 -->
<circle cx="200" cy="150" r="4" fill="white" fill-opacity="0.3"/>
<circle cx="600" cy="450" r="4" fill="white" fill-opacity="0.3"/>
<circle cx="150" cy="400" r="4" fill="white" fill-opacity="0.3"/>
<circle cx="650" cy="200" r="4" fill="white" fill-opacity="0.3"/>
<!-- 装饰线 -->
<line x1="100" y1="100" x2="150" y2="150" stroke="white" stroke-opacity="0.1"/>
<line x1="700" y1="500" x2="650" y2="450" stroke="white" stroke-opacity="0.1"/>
<line x1="100" y1="500" x2="150" y2="450" stroke="white" stroke-opacity="0.1"/>
<line x1="700" y1="100" x2="650" y2="150" stroke="white" stroke-opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

4
src/assets/logo.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="24" height="24" rx="4" fill="#409EFF"/>
<path d="M10 16H22M16 10V22" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@ -0,0 +1,48 @@
/* 通用的文件列表横向布局样式 */
.file-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 20px 0;
}
.file-card {
width: 280px;
background: #fff;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.file-card:hover {
transform: translateY(-2px);
}
.file-preview {
width: 100%;
height: 160px;
background: #f5f7fa;
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-info {
padding: 12px;
}
.file-info h3 {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
margin-bottom: 4px;
}
.file-info p {
font-size: 12px;
color: #909399;
}

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -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>`
}

View File

@ -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>`
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>`
}

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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' }
]
}
]

View File

@ -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-')) {
// SVGCSS
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>

View File

@ -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
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -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>`
};

View File

@ -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>()

View File

@ -0,0 +1,5 @@
import { ConstraintHandler } from "@maxgraph/core";
export class CustomConstraintHandler extends ConstraintHandler {
}

View File

@ -0,0 +1,5 @@
import { RubberBandHandler } from "@maxgraph/core";
export class CustomRubberBandHandler extends RubberBandHandler {
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
import { ConnectionHandler, Graph } from "@maxgraph/core";
export class WuConnectionHandler extends ConnectionHandler {
constructor(graph: Graph) {
console.log("WuConnectionHandler constructor");
super(graph);
}
}

View File

@ -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)];
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
};
// ... 其他事件处理方法
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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>
`,
};

View File

@ -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)
}
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -0,0 +1,7 @@
import { Graph, EditorToolbar } from "mxgraph/javascript/mxClient";
export class flowToolsBars {
constructor(graph: Graph) {
new EditorToolbar(graph.container, graph.editor);
}
}

View File

@ -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();
}
}

View File

@ -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();
// }
// });
}
}

View File

@ -200,103 +200,107 @@ const clear = () => {
</script>
<template>
<div
:class="prefixCls"
class="fixed right-0 top-[45%] h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px"
@click="drawer = true"
>
<Icon color="#fff" icon="ep:setting" />
</div>
<div>
<div
:class="prefixCls"
class="fixed right-0 top-[45%] h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px"
@click="drawer = true"
>
<Icon color="#fff" icon="ep:setting" />
</div>
<ElDrawer v-model="drawer" :z-index="4000" direction="rtl" size="350px">
<template #header>
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
</template>
<ElDrawer v-model="drawer" :z-index="4000" direction="rtl" size="350px">
<template #header>
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
</template>
<div class="text-center">
<!-- 主题 -->
<ElDivider>{{ t('setting.theme') }}</ElDivider>
<ThemeSwitch />
<div class="text-center">
<!-- 主题 -->
<ElDivider>{{ t('setting.theme') }}</ElDivider>
<ThemeSwitch />
<!-- 布局 -->
<ElDivider>{{ t('setting.layout') }}</ElDivider>
<LayoutRadioPicker />
<!-- 布局 -->
<ElDivider>{{ t('setting.layout') }}</ElDivider>
<LayoutRadioPicker />
<!-- 系统主题 -->
<ElDivider>{{ t('setting.systemTheme') }}</ElDivider>
<ColorRadioPicker
v-model="systemTheme"
:schema="[
'#409eff',
'#009688',
'#536dfe',
'#ff5c93',
'#ee4f12',
'#0096c7',
'#9c27b0',
'#ff9800'
]"
@change="setSystemTheme"
/>
<!-- 头部主题 -->
<ElDivider>{{ t('setting.headerTheme') }}</ElDivider>
<ColorRadioPicker
v-model="headerTheme"
:schema="[
'#fff',
'#151515',
'#5172dc',
'#e74c3c',
'#24292e',
'#394664',
'#009688',
'#383f45'
]"
@change="setHeaderTheme"
/>
<!-- 菜单主题 -->
<template v-if="layout !== 'top'">
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
<!-- 系统主题 -->
<ElDivider>{{ t('setting.systemTheme') }}</ElDivider>
<ColorRadioPicker
v-model="menuTheme"
v-model="systemTheme"
:schema="[
'#409eff',
'#009688',
'#536dfe',
'#ff5c93',
'#ee4f12',
'#0096c7',
'#9c27b0',
'#ff9800'
]"
@change="setSystemTheme"
/>
<!-- 头部主题 -->
<ElDivider>{{ t('setting.headerTheme') }}</ElDivider>
<ColorRadioPicker
v-model="headerTheme"
:schema="[
'#fff',
'#001529',
'#212121',
'#273352',
'#191b24',
'#383f45',
'#001628',
'#344058'
'#151515',
'#5172dc',
'#e74c3c',
'#24292e',
'#394664',
'#009688',
'#383f45'
]"
@change="setMenuTheme"
@change="setHeaderTheme"
/>
</template>
</div>
<!-- 界面显示 -->
<ElDivider>{{ t('setting.interfaceDisplay') }}</ElDivider>
<InterfaceDisplay />
<!-- 菜单主题 -->
<template v-if="layout !== 'top'">
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
<ColorRadioPicker
v-model="menuTheme"
:schema="[
'#fff',
'#1e293b', //
'#2c3e50', //
'#252b3a', //
'#2b2d42', //
'#1a237e', //
'#263238', //
'#1f2937' //
]"
@change="setMenuTheme"
/>
</template>
</div>
<ElDivider />
<div>
<ElButton class="w-full" type="primary" @click="copyConfig">{{ t('setting.copy') }}</ElButton>
</div>
<div class="mt-5px">
<ElButton class="w-full" type="danger" @click="clear">
{{ t('setting.clearAndReset') }}
</ElButton>
</div>
</ElDrawer>
<!-- 界面显示 -->
<ElDivider>{{ t('setting.interfaceDisplay') }}</ElDivider>
<InterfaceDisplay />
<ElDivider />
<div>
<ElButton class="w-full" type="primary" @click="copyConfig">{{ t('setting.copy') }}</ElButton>
</div>
<div class="mt-5px">
<ElButton class="w-full" type="danger" @click="clear">
{{ t('setting.clearAndReset') }}
</ElButton>
</div>
</ElDrawer>
</div>
</template>
<style lang="scss" scoped>
$prefix-cls: #{$namespace}-setting;
.#{$prefix-cls} {
z-index: 1200;
border-radius: 6px 0 0 6px;
z-index: 1200;/*修正没有z-index会被表格层覆盖,值不要超过4000*/
/* 修正没有z-index会被表格层覆盖,值不要超过4000 */
}
</style>

View File

@ -2,12 +2,13 @@ import type { App } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import remainingRouter from './modules/remaining'
import flowRouter from './modules/flowrouter'
// 创建路由实例
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#createWebHistory URL不带#
strict: true,
routes: remainingRouter as RouteRecordRaw[],
routes: [...remainingRouter, ...flowRouter] as RouteRecordRaw[],
scrollBehavior: () => ({ left: 0, top: 0 })
})

View File

@ -0,0 +1,73 @@
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: 'features',
name: 'features',
component: () => import('@/views/flow/Features.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')
},
{
path: '/login2',
name: 'Login2',
component: () => import('@/views/flow/Login.vue')
}
]

View File

@ -53,7 +53,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
{
path: '/',
component: Layout,
redirect: '/index',
redirect: '/flow',
name: 'Home',
meta: {},
children: [

View File

@ -5,13 +5,13 @@
--left-menu-min-width: 64px;
--left-menu-bg-color: #001529;
--left-menu-bg-color: #1e293b;
--left-menu-bg-light-color: #0f2438;
--left-menu-bg-light-color: #2c3e50;
--left-menu-bg-active-color: var(--el-color-primary);
--left-menu-text-color: #bfcbd9;
--left-menu-text-color: #e2e8f0;
--left-menu-text-active-color: #fff;

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="定义编号" align="center" prop="id" width="400" />

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="审批接入(流程表单)" url="https://doc.iocoder.cn/bpm/use-bpm-form/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="审批接入(业务表单)" url="https://doc.iocoder.cn/bpm/use-business-form/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="流程表达式" url="https://doc.iocoder.cn/bpm/expression/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="流程发起、取消、重新发起" url="https://doc.iocoder.cn/bpm/process-instance/" />
<!-- 第一步通过流程定义的列表选择对应的流程 -->
<ContentWrap v-if="!selectProcessDefinition" v-loading="loading">
<el-tabs tab-position="left" v-model="categoryActive">

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="流程发起、取消、重新发起" url="https://doc.iocoder.cn/bpm/process-instance/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,90 +1,86 @@
<template>
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="发起人" prop="startUserId">
<el-select v-model="queryParams.startUserId" placeholder="请选择发起人" class="!w-240px">
<el-option
v-for="user in userList"
:key="user.id"
:label="user.nickname"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="流程名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入流程名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="发起人" prop="startUserId">
<el-select v-model="queryParams.startUserId" placeholder="请选择发起人" class="!w-240px">
<el-option
v-for="user in userList"
:key="user.id"
:label="user.nickname"
:value="user.id"
/>
</el-form-item>
<el-form-item label="所属流程" prop="processDefinitionId">
<el-input
v-model="queryParams.processDefinitionId"
placeholder="请输入流程定义的编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
</el-select>
</el-form-item>
<el-form-item label="流程名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入流程名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="所属流程" prop="processDefinitionId">
<el-input
v-model="queryParams.processDefinitionId"
placeholder="请输入流程定义的编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="流程分类" prop="category">
<el-select
v-model="queryParams.category"
placeholder="请选择流程分类"
clearable
class="!w-240px"
>
<el-option
v-for="category in categoryList"
:key="category.code"
:label="category.name"
:value="category.code"
/>
</el-form-item>
<el-form-item label="流程分类" prop="category">
<el-select
v-model="queryParams.category"
placeholder="请选择流程分类"
clearable
class="!w-240px"
>
<el-option
v-for="category in categoryList"
:key="category.code"
:label="category.name"
:value="category.code"
/>
</el-select>
</el-form-item>
<el-form-item label="流程状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择流程状态"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="发起时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
</el-select>
</el-form-item>
<el-form-item label="流程状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择流程状态"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</el-select>
</el-form-item>
<el-form-item label="发起时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<ContentWrap>

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="执行监听器、任务监听器" url="https://doc.iocoder.cn/bpm/listener/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,12 +1,4 @@
<template>
<doc-alert title="审批通过、不通过、驳回" url="https://doc.iocoder.cn/bpm/task-todo-done/" />
<doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" />
<doc-alert
title="审批转办、委派、抄送"
url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/"
/>
<doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,12 +1,4 @@
<template>
<doc-alert title="审批通过、不通过、驳回" url="https://doc.iocoder.cn/bpm/task-todo-done/" />
<doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" />
<doc-alert
title="审批转办、委派、抄送"
url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/"
/>
<doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="【通用】跟进记录、待办事项" url="https://doc.iocoder.cn/crm/follow-up/" />
<el-row :gutter="20">
<el-col :span="4" class="min-w-[200px]">
<div class="side-item-list">

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【商机】商机管理、商机状态" url="https://doc.iocoder.cn/crm/business/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【商机】商机管理、商机状态" url="https://doc.iocoder.cn/crm/business/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【线索】线索管理" url="https://doc.iocoder.cn/crm/clue/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【合同】合同管理、合同提醒" url="https://doc.iocoder.cn/crm/contract/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<el-form
ref="formRef"

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【合同】合同管理、合同提醒" url="https://doc.iocoder.cn/crm/contract/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<!-- 列表 -->
<ContentWrap>
<el-tabs>

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<el-form
ref="formRef"

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="【产品】产品管理、产品分类" url="https://doc.iocoder.cn/crm/product/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="【产品】产品管理、产品分类" url="https://doc.iocoder.cn/crm/product/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【回款】回款管理、回款计划" url="https://doc.iocoder.cn/crm/receivable/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,7 +1,4 @@
<template>
<doc-alert title="【回款】回款管理、回款计划" url="https://doc.iocoder.cn/crm/receivable/" />
<doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="ERP 手册(功能开启)" url="https://doc.iocoder.cn/erp/build/" />
<div class="flex flex-col">
<!-- 销售/采购的全局统计 -->
<el-row :gutter="16" class="row">

View File

@ -1,6 +1,4 @@
<template>
<doc-alert title="【产品】产品信息、分类、单位" url="https://doc.iocoder.cn/erp/product/" />
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form

Some files were not shown because too many files have changed in this diff Show More