Files
yidaima/RuoYi-Vue/easycode-web/src/components/AppModuleDiagramView.vue
2026-07-07 14:17:47 +08:00

379 lines
10 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">
<div class="module-toolbar">
<div class="module-summary">
<span>节点{{ graph.nodes.length }}</span>
<span>连线{{ graph.edges.length }}</span>
</div>
<div class="module-controls">
<el-radio-group :model-value="sourceMode" size="small" @change="setSourceMode">
<el-radio-button label="blueprint">自动蓝图</el-radio-button>
<el-radio-button label="outline">缩进结构</el-radio-button>
</el-radio-group>
<el-radio-group :model-value="normalizedLayoutOptions.density" size="small" @change="(value) => updateLayoutOption('density', value)">
<el-radio-button label="compact">紧凑</el-radio-button>
<el-radio-button label="standard">标准</el-radio-button>
<el-radio-button label="relaxed">宽松</el-radio-button>
</el-radio-group>
<el-radio-group :model-value="normalizedLayoutOptions.direction" size="small" @change="(value) => updateLayoutOption('direction', value)">
<el-radio-button label="horizontal">横向</el-radio-button>
<el-radio-button label="vertical">竖向</el-radio-button>
</el-radio-group>
<el-select
:model-value="normalizedLayoutOptions.fontSize"
size="small"
class="module-font-select"
@change="(value) => updateLayoutOption('fontSize', value)"
>
<el-option label="小" value="small" />
<el-option label="标准" value="standard" />
<el-option label="大" value="large" />
</el-select>
<el-button :icon="Download" @click="exportSvg">SVG</el-button>
<el-button :icon="Picture" @click="exportPng">PNG</el-button>
</div>
</div>
<div v-if="editable" class="module-editor-pane">
<el-input
:model-value="activeOutlineText"
type="textarea"
:rows="12"
spellcheck="false"
@input="updateOutlineText"
/>
<div v-if="outlineWarnings.length" class="module-warning-list">
<el-tag
v-for="warning in outlineWarnings"
:key="`${warning.code}-${warning.line}`"
type="warning"
effect="plain"
>
{{ warning.line || 1 }} {{ warning.message }}
</el-tag>
</div>
</div>
<el-empty v-if="!graph.nodes.length" description="生成系统模块设计后可查看系统功能模块图" />
<div v-else class="module-scroll">
<svg ref="svgRef" class="module-canvas" :viewBox="`0 0 ${canvas.width} ${canvas.height}`" :style="canvasStyle">
<g class="edge-layer">
<path
v-for="edge in graph.edges"
:key="edge.id"
class="tree-edge"
:d="edge.path"
fill="none"
stroke="#222222"
stroke-width="1.2"
vector-effect="non-scaling-stroke"
/>
</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" fill="#ffffff" stroke="#222222" stroke-width="1" />
<text
v-if="node.vertical"
class="module-label"
:x="node.width / 2"
:y="verticalTextY(node)"
:font-size="node.fontSize || 16"
>
<tspan
v-for="(char, charIndex) in verticalChars(node.title)"
:key="`${node.id}-${charIndex}`"
:x="node.width / 2"
:dy="charIndex === 0 ? 0 : 18"
>
{{ char }}
</tspan>
</text>
<text
v-else
class="node-title"
:x="node.width / 2"
:y="node.height / 2"
:font-size="node.fontSize || (node.kind === 'root' ? 17 : 16)"
>
{{ shortText(node.title, 18) }}
</text>
</g>
</g>
<text class="figure-caption" :x="canvas.width / 2" :y="canvas.height - 24">{{ graph.caption }}</text>
</svg>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Picture } from '@element-plus/icons-vue'
import { buildAppModuleExportSvg } from '@/utils/appModuleExport'
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
import {
blueprintToModuleOutline,
moduleOutlineToText,
parseModuleOutlineText
} from '@/utils/appModuleOutline'
import { saveBlob } from '@/utils/download'
const DEFAULT_LAYOUT_OPTIONS = {
density: 'standard',
direction: 'horizontal',
fontSize: 'standard'
}
const props = defineProps({
blueprint: {
type: Object,
default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
},
projectName: {
type: String,
default: ''
},
sourceMode: {
type: String,
default: 'blueprint'
},
outlineText: {
type: String,
default: ''
},
layoutOptions: {
type: Object,
default: () => ({ ...DEFAULT_LAYOUT_OPTIONS })
},
editable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:sourceMode', 'update:outlineText', 'update:layoutOptions', 'graph-change'])
const svgRef = ref(null)
const normalizedLayoutOptions = computed(() => ({
density: props.layoutOptions?.density || DEFAULT_LAYOUT_OPTIONS.density,
direction: props.layoutOptions?.direction || DEFAULT_LAYOUT_OPTIONS.direction,
fontSize: props.layoutOptions?.fontSize || DEFAULT_LAYOUT_OPTIONS.fontSize
}))
const blueprintOutline = computed(() => blueprintToModuleOutline(props.blueprint, { projectName: props.projectName }))
const blueprintOutlineText = computed(() => moduleOutlineToText(blueprintOutline.value))
const activeOutlineText = computed(() => props.sourceMode === 'outline' ? props.outlineText : blueprintOutlineText.value)
const parsedOutline = computed(() => parseModuleOutlineText(activeOutlineText.value))
const outlineWarnings = computed(() => parsedOutline.value.warnings || [])
const graph = computed(() => buildAppModuleGraph(parsedOutline.value.outline, normalizedLayoutOptions.value))
const canvas = computed(() => graph.value.canvas || { width: 760, height: 420 })
const canvasStyle = computed(() => ({
width: `${canvas.value.width}px`,
height: `${canvas.value.height}px`
}))
watch(graph, (value) => {
emit('graph-change', value)
}, { immediate: true })
function shortText(value, maxLength) {
const text = String(value ?? '')
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
}
function verticalChars(value) {
const chars = Array.from(String(value ?? '').trim())
if (chars.length <= 7) return chars
return [...chars.slice(0, 6), '…']
}
function verticalTextY(node) {
const count = verticalChars(node.title).length
return Math.max(22, (node.height - (count - 1) * 18) / 2 + 5)
}
function setSourceMode(value) {
const nextMode = value === 'outline' ? 'outline' : 'blueprint'
emit('update:sourceMode', nextMode)
if (nextMode === 'outline' && !props.outlineText) {
emit('update:outlineText', blueprintOutlineText.value)
}
}
function updateOutlineText(value) {
if (props.sourceMode !== 'outline') {
emit('update:sourceMode', 'outline')
}
emit('update:outlineText', value)
}
function updateLayoutOption(key, value) {
emit('update:layoutOptions', {
...normalizedLayoutOptions.value,
[key]: value
})
}
function exportSvg() {
const svgText = buildAppModuleExportSvg(graph.value)
saveBlob(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }), 'system-module-diagram.svg')
}
async function exportPng() {
if (!svgRef.value) return
const svgText = buildAppModuleExportSvg(graph.value)
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')
if (!context) throw new Error('Canvas context unavailable')
context.fillStyle = '#ffffff'
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, 'system-module-diagram.png')
}
} catch (error) {
ElMessage.warning('导出图片失败,请稍后重试')
} finally {
URL.revokeObjectURL(url)
}
}
</script>
<style scoped lang="scss">
.app-module-diagram {
background: #ffffff;
min-height: 420px;
}
.module-toolbar {
align-items: center;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 16px;
justify-content: space-between;
padding: 12px 18px;
}
.module-summary {
color: #4b5563;
display: flex;
flex-wrap: wrap;
font-size: 13px;
gap: 12px;
}
.module-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.module-font-select {
width: 92px;
}
.module-editor-pane {
background: #fbfdff;
border-bottom: 1px solid #e5e7eb;
display: grid;
gap: 10px;
padding: 16px 18px;
}
.module-warning-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.module-scroll {
background: #ffffff;
min-height: 420px;
overflow: auto;
padding: 24px;
}
.module-canvas {
background: #ffffff;
display: block;
font-family: SimSun, "宋体", "Microsoft YaHei", Arial, sans-serif;
}
.tree-edge {
fill: none;
shape-rendering: crispEdges;
stroke: #222222;
stroke-width: 1.2;
}
.module-node-box {
fill: #ffffff;
shape-rendering: crispEdges;
stroke: #222222;
stroke-width: 1;
}
.node-title {
dominant-baseline: middle;
fill: #111111;
font-size: 16px;
font-weight: 600;
text-anchor: middle;
}
.module-node.root .node-title {
font-size: 17px;
}
.module-label {
fill: #111111;
font-size: 16px;
font-weight: 600;
text-anchor: middle;
}
.figure-caption {
fill: #111111;
font-size: 14px;
text-anchor: middle;
}
@media (max-width: 860px) {
.module-toolbar {
align-items: flex-start;
flex-direction: column;
}
.module-controls {
justify-content: flex-start;
}
}
</style>