328 lines
9.2 KiB
Vue
328 lines
9.2 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="app-module-diagram">
|
|||
|
|
<el-empty v-if="!graph.nodes.length" description="生成应用蓝图后可查看系统功能模块图" />
|
|||
|
|
<template v-else>
|
|||
|
|
<div class="module-toolbar">
|
|||
|
|
<div class="module-summary">
|
|||
|
|
<span>节点:{{ graph.nodes.length }}</span>
|
|||
|
|
<span>连线:{{ graph.edges.length }}</span>
|
|||
|
|
</div>
|
|||
|
|
<el-button :icon="Picture" @click="exportPng">导出图片</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="module-scroll">
|
|||
|
|
<svg ref="svgRef" class="module-canvas" :viewBox="`0 0 ${canvas.width} ${canvas.height}`" :style="canvasStyle">
|
|||
|
|
<defs>
|
|||
|
|
<marker
|
|||
|
|
id="app-module-arrow"
|
|||
|
|
markerWidth="10"
|
|||
|
|
markerHeight="10"
|
|||
|
|
refX="8"
|
|||
|
|
refY="3"
|
|||
|
|
orient="auto"
|
|||
|
|
markerUnits="strokeWidth"
|
|||
|
|
>
|
|||
|
|
<path d="M0,0 L0,6 L8,3 z" fill="#8aa0bc" />
|
|||
|
|
</marker>
|
|||
|
|
</defs>
|
|||
|
|
|
|||
|
|
<g class="lane-layer">
|
|||
|
|
<g v-for="lane in graph.lanes" :key="lane.key">
|
|||
|
|
<rect class="lane-bg" :x="lane.x - 16" y="22" :width="lane.width + 32" :height="canvas.height - 44" rx="10" />
|
|||
|
|
<text class="lane-title" :x="lane.x" y="46">{{ lane.title }}</text>
|
|||
|
|
</g>
|
|||
|
|
</g>
|
|||
|
|
|
|||
|
|
<g class="edge-layer">
|
|||
|
|
<g v-for="edge in edgeViews" :key="edge.id">
|
|||
|
|
<path class="module-edge" :d="edge.path" marker-end="url(#app-module-arrow)" />
|
|||
|
|
<text class="edge-label" :x="edge.labelX" :y="edge.labelY">{{ edge.label }}</text>
|
|||
|
|
</g>
|
|||
|
|
</g>
|
|||
|
|
|
|||
|
|
<g class="node-layer">
|
|||
|
|
<g
|
|||
|
|
v-for="node in graph.nodes"
|
|||
|
|
:key="node.id"
|
|||
|
|
class="module-node"
|
|||
|
|
:class="[node.kind, node.lane]"
|
|||
|
|
:transform="`translate(${node.x}, ${node.y})`"
|
|||
|
|
>
|
|||
|
|
<rect class="module-node-box" :width="node.width" :height="node.height" rx="8" />
|
|||
|
|
<rect class="module-node-head" :width="node.width" height="52" rx="8" />
|
|||
|
|
<rect class="module-node-head-fill" y="44" :width="node.width" height="8" />
|
|||
|
|
<text class="node-title" x="14" y="27">{{ shortText(node.title, 18) }}</text>
|
|||
|
|
<text class="node-subtitle" x="14" y="46">{{ shortText(node.subtitle, 28) }}</text>
|
|||
|
|
<g v-for="(badge, badgeIndex) in node.badges" :key="badge" :transform="`translate(${badgeX(node, badgeIndex)}, 72)`">
|
|||
|
|
<rect class="node-badge-bg" :width="badgeWidth(badge)" height="22" rx="5" />
|
|||
|
|
<text class="node-badge-text" :x="badgeWidth(badge) / 2" y="15">{{ shortText(badge, 14) }}</text>
|
|||
|
|
</g>
|
|||
|
|
</g>
|
|||
|
|
</g>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { computed, ref } from 'vue'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
import { Picture } from '@element-plus/icons-vue'
|
|||
|
|
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
|
|||
|
|
import { saveBlob } from '@/utils/download'
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
blueprint: {
|
|||
|
|
type: Object,
|
|||
|
|
default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const svgRef = ref(null)
|
|||
|
|
const graph = computed(() => buildAppModuleGraph(props.blueprint))
|
|||
|
|
|
|||
|
|
const nodeMap = computed(() => {
|
|||
|
|
const map = new Map()
|
|||
|
|
graph.value.nodes.forEach((node) => map.set(node.id, node))
|
|||
|
|
return map
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const canvas = computed(() => {
|
|||
|
|
const nodes = graph.value.nodes
|
|||
|
|
const lanes = graph.value.lanes || []
|
|||
|
|
const width = Math.max(980, ...lanes.map((lane) => lane.x + lane.width + 48), ...nodes.map((node) => node.x + node.width + 48))
|
|||
|
|
const height = Math.max(430, ...nodes.map((node) => node.y + node.height + 48))
|
|||
|
|
return { width, height }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const canvasStyle = computed(() => ({
|
|||
|
|
width: `${canvas.value.width}px`,
|
|||
|
|
height: `${canvas.value.height}px`
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
const edgeViews = computed(() => graph.value.edges
|
|||
|
|
.map((edge) => {
|
|||
|
|
const source = nodeMap.value.get(edge.source)
|
|||
|
|
const target = nodeMap.value.get(edge.target)
|
|||
|
|
if (!source || !target) return null
|
|||
|
|
|
|||
|
|
const sourceX = source.x + source.width
|
|||
|
|
const sourceY = source.y + source.height / 2
|
|||
|
|
const targetX = target.x
|
|||
|
|
const targetY = target.y + target.height / 2
|
|||
|
|
const curve = Math.max(64, Math.abs(targetX - sourceX) / 2)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
...edge,
|
|||
|
|
path: `M ${sourceX} ${sourceY} C ${sourceX + curve} ${sourceY}, ${targetX - curve} ${targetY}, ${targetX} ${targetY}`,
|
|||
|
|
labelX: (sourceX + targetX) / 2,
|
|||
|
|
labelY: (sourceY + targetY) / 2 - 9
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.filter(Boolean))
|
|||
|
|
|
|||
|
|
function shortText(value, maxLength) {
|
|||
|
|
const text = String(value ?? '')
|
|||
|
|
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function badgeWidth(value) {
|
|||
|
|
const text = String(value ?? '')
|
|||
|
|
return Math.min(112, Math.max(48, text.length * 8 + 20))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function badgeX(node, index) {
|
|||
|
|
return node.badges.slice(0, index).reduce((x, badge) => x + badgeWidth(badge) + 6, 14)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function exportStyle() {
|
|||
|
|
return `
|
|||
|
|
.lane-bg{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1}
|
|||
|
|
.lane-title{fill:#475569;font-family:Arial,sans-serif;font-size:13px;font-weight:700}
|
|||
|
|
.module-edge{fill:none;stroke:#8aa0bc;stroke-width:1.7}
|
|||
|
|
.edge-label{fill:#64748b;font-family:Arial,sans-serif;font-size:12px;paint-order:stroke;stroke:#f8fafc;stroke-width:4px;text-anchor:middle}
|
|||
|
|
.module-node-box{fill:#fff;stroke:#d6e0ee;stroke-width:1}
|
|||
|
|
.module-node-head{fill:#eef5ff}
|
|||
|
|
.module-node-head-fill{fill:#eef5ff}
|
|||
|
|
.module-node.access .module-node-head,.module-node.access .module-node-head-fill{fill:#ecfdf5}
|
|||
|
|
.module-node.role .module-node-head,.module-node.role .module-node-head-fill{fill:#eff6ff}
|
|||
|
|
.module-node.frontend .module-node-head,.module-node.frontend .module-node-head-fill{fill:#f0fdf4}
|
|||
|
|
.module-node.admin .module-node-head,.module-node.admin .module-node-head-fill{fill:#fff7ed}
|
|||
|
|
.node-title{fill:#0f172a;font-family:Arial,sans-serif;font-size:15px;font-weight:700}
|
|||
|
|
.node-subtitle{fill:#64748b;font-family:Arial,sans-serif;font-size:12px}
|
|||
|
|
.node-badge-bg{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1}
|
|||
|
|
.node-badge-text{fill:#475569;font-family:Arial,sans-serif;font-size:11px;font-weight:700;text-anchor:middle}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function exportPng() {
|
|||
|
|
if (!svgRef.value) return
|
|||
|
|
|
|||
|
|
const svg = svgRef.value.cloneNode(true)
|
|||
|
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
|
|||
|
|
style.textContent = exportStyle()
|
|||
|
|
svg.insertBefore(style, svg.firstChild)
|
|||
|
|
const svgText = `<?xml version="1.0" encoding="UTF-8"?>${svg.outerHTML}`
|
|||
|
|
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
|
|||
|
|
const url = URL.createObjectURL(svgBlob)
|
|||
|
|
const image = new Image()
|
|||
|
|
image.decoding = 'async'
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await new Promise((resolve, reject) => {
|
|||
|
|
image.onload = resolve
|
|||
|
|
image.onerror = reject
|
|||
|
|
image.src = url
|
|||
|
|
})
|
|||
|
|
const canvasElement = document.createElement('canvas')
|
|||
|
|
canvasElement.width = canvas.value.width
|
|||
|
|
canvasElement.height = canvas.value.height
|
|||
|
|
const context = canvasElement.getContext('2d')
|
|||
|
|
context.fillStyle = '#f8fafc'
|
|||
|
|
context.fillRect(0, 0, canvasElement.width, canvasElement.height)
|
|||
|
|
context.drawImage(image, 0, 0)
|
|||
|
|
const blob = await new Promise((resolve) => canvasElement.toBlob(resolve, 'image/png'))
|
|||
|
|
if (blob) {
|
|||
|
|
saveBlob(blob, 'app-module-diagram.png')
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
ElMessage.warning('导出图片失败,请稍后重试')
|
|||
|
|
} finally {
|
|||
|
|
URL.revokeObjectURL(url)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
.app-module-diagram {
|
|||
|
|
background: #f8fafc;
|
|||
|
|
min-height: 430px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-toolbar {
|
|||
|
|
align-items: center;
|
|||
|
|
background: #ffffff;
|
|||
|
|
border-bottom: 1px solid #e3e8f0;
|
|||
|
|
display: flex;
|
|||
|
|
gap: 16px;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 14px 18px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-summary {
|
|||
|
|
color: #64748b;
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
font-size: 13px;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-scroll {
|
|||
|
|
min-height: 430px;
|
|||
|
|
overflow: auto;
|
|||
|
|
padding: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-canvas {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.lane-bg {
|
|||
|
|
fill: #f8fafc;
|
|||
|
|
stroke: #e2e8f0;
|
|||
|
|
stroke-width: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.lane-title {
|
|||
|
|
fill: #475569;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-edge {
|
|||
|
|
fill: none;
|
|||
|
|
stroke: #8aa0bc;
|
|||
|
|
stroke-width: 1.7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.edge-label {
|
|||
|
|
fill: #64748b;
|
|||
|
|
font-size: 12px;
|
|||
|
|
paint-order: stroke;
|
|||
|
|
stroke: #f8fafc;
|
|||
|
|
stroke-width: 4px;
|
|||
|
|
text-anchor: middle;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node-box {
|
|||
|
|
fill: #ffffff;
|
|||
|
|
filter: drop-shadow(0 8px 18px rgba(15, 23, 42, 0.08));
|
|||
|
|
stroke: #d6e0ee;
|
|||
|
|
stroke-width: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node-head,
|
|||
|
|
.module-node-head-fill {
|
|||
|
|
fill: #eef5ff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node.access {
|
|||
|
|
.module-node-head,
|
|||
|
|
.module-node-head-fill {
|
|||
|
|
fill: #ecfdf5;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node.role {
|
|||
|
|
.module-node-head,
|
|||
|
|
.module-node-head-fill {
|
|||
|
|
fill: #eff6ff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node.frontend {
|
|||
|
|
.module-node-head,
|
|||
|
|
.module-node-head-fill {
|
|||
|
|
fill: #f0fdf4;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-node.admin {
|
|||
|
|
.module-node-head,
|
|||
|
|
.module-node-head-fill {
|
|||
|
|
fill: #fff7ed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-title {
|
|||
|
|
fill: #0f172a;
|
|||
|
|
font-size: 15px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-subtitle {
|
|||
|
|
fill: #64748b;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-badge-bg {
|
|||
|
|
fill: #f8fafc;
|
|||
|
|
stroke: #e2e8f0;
|
|||
|
|
stroke-width: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-badge-text {
|
|||
|
|
fill: #475569;
|
|||
|
|
font-size: 11px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-anchor: middle;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 860px) {
|
|||
|
|
.module-toolbar {
|
|||
|
|
align-items: flex-start;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|