Files
yidaima/RuoYi-Vue/easycode-web/src/components/AppModuleDiagramView.vue

379 lines
10 KiB
Vue
Raw Normal View History

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