Files
yidaima/RuoYi-Vue/easycode-web/src/components/AppModuleDiagramView.vue
2026-05-24 13:29:42 +08:00

328 lines
9.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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