2026-05-24 13:29:42 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="app-module-diagram">
|
2026-07-07 14:17:47 +08:00
|
|
|
|
<div class="module-toolbar">
|
|
|
|
|
|
<div class="module-summary">
|
|
|
|
|
|
<span>节点:{{ graph.nodes.length }}</span>
|
|
|
|
|
|
<span>连线:{{ graph.edges.length }}</span>
|
2026-05-24 13:29:42 +08:00
|
|
|
|
</div>
|
2026-07-07 14:17:47 +08:00
|
|
|
|
<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"
|
2026-05-24 13:29:42 +08:00
|
|
|
|
>
|
2026-07-07 14:17:47 +08:00
|
|
|
|
<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)"
|
2026-05-24 13:29:42 +08:00
|
|
|
|
>
|
2026-07-07 14:17:47 +08:00
|
|
|
|
{{ shortText(node.title, 18) }}
|
|
|
|
|
|
</text>
|
2026-05-24 13:29:42 +08:00
|
|
|
|
</g>
|
2026-07-07 14:17:47 +08:00
|
|
|
|
</g>
|
|
|
|
|
|
|
|
|
|
|
|
<text class="figure-caption" :x="canvas.width / 2" :y="canvas.height - 24">{{ graph.caption }}</text>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
2026-05-24 13:29:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-07-07 14:17:47 +08:00
|
|
|
|
import { computed, ref, watch } from 'vue'
|
2026-05-24 13:29:42 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-07-07 14:17:47 +08:00
|
|
|
|
import { Download, Picture } from '@element-plus/icons-vue'
|
|
|
|
|
|
import { buildAppModuleExportSvg } from '@/utils/appModuleExport'
|
2026-05-24 13:29:42 +08:00
|
|
|
|
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
|
2026-07-07 14:17:47 +08:00
|
|
|
|
import {
|
|
|
|
|
|
blueprintToModuleOutline,
|
|
|
|
|
|
moduleOutlineToText,
|
|
|
|
|
|
parseModuleOutlineText
|
|
|
|
|
|
} from '@/utils/appModuleOutline'
|
2026-05-24 13:29:42 +08:00
|
|
|
|
import { saveBlob } from '@/utils/download'
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
const DEFAULT_LAYOUT_OPTIONS = {
|
|
|
|
|
|
density: 'standard',
|
|
|
|
|
|
direction: 'horizontal',
|
|
|
|
|
|
fontSize: 'standard'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:29:42 +08:00
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
blueprint: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
|
2026-07-07 14:17:47 +08:00
|
|
|
|
},
|
|
|
|
|
|
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
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
const emit = defineEmits(['update:sourceMode', 'update:outlineText', 'update:layoutOptions', 'graph-change'])
|
2026-05-24 13:29:42 +08:00
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
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))
|
2026-05-24 13:29:42 +08:00
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
const canvas = computed(() => graph.value.canvas || { width: 760, height: 420 })
|
2026-05-24 13:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
const canvasStyle = computed(() => ({
|
|
|
|
|
|
width: `${canvas.value.width}px`,
|
|
|
|
|
|
height: `${canvas.value.height}px`
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
watch(graph, (value) => {
|
|
|
|
|
|
emit('graph-change', value)
|
|
|
|
|
|
}, { immediate: true })
|
2026-05-24 13:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
function shortText(value, maxLength) {
|
|
|
|
|
|
const text = String(value ?? '')
|
|
|
|
|
|
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
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)
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
function updateLayoutOption(key, value) {
|
|
|
|
|
|
emit('update:layoutOptions', {
|
|
|
|
|
|
...normalizedLayoutOptions.value,
|
|
|
|
|
|
[key]: value
|
|
|
|
|
|
})
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
function exportSvg() {
|
|
|
|
|
|
const svgText = buildAppModuleExportSvg(graph.value)
|
|
|
|
|
|
saveBlob(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }), 'system-module-diagram.svg')
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function exportPng() {
|
|
|
|
|
|
if (!svgRef.value) return
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
const svgText = buildAppModuleExportSvg(graph.value)
|
2026-05-24 13:29:42 +08:00
|
|
|
|
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')
|
2026-07-07 14:17:47 +08:00
|
|
|
|
if (!context) throw new Error('Canvas context unavailable')
|
|
|
|
|
|
context.fillStyle = '#ffffff'
|
2026-05-24 13:29:42 +08:00
|
|
|
|
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) {
|
2026-07-07 14:17:47 +08:00
|
|
|
|
saveBlob(blob, 'system-module-diagram.png')
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.warning('导出图片失败,请稍后重试')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.app-module-diagram {
|
2026-07-07 14:17:47 +08:00
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
min-height: 420px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.module-toolbar {
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
background: #ffffff;
|
2026-07-07 14:17:47 +08:00
|
|
|
|
border-bottom: 1px solid #e5e7eb;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
justify-content: space-between;
|
2026-07-07 14:17:47 +08:00
|
|
|
|
padding: 12px 18px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.module-summary {
|
2026-07-07 14:17:47 +08:00
|
|
|
|
color: #4b5563;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-controls {
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
justify-content: flex-end;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-font-select {
|
|
|
|
|
|
width: 92px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-editor-pane {
|
|
|
|
|
|
background: #fbfdff;
|
|
|
|
|
|
border-bottom: 1px solid #e5e7eb;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
padding: 16px 18px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-warning-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 8px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-scroll {
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
min-height: 420px;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
padding: 24px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.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;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.module-node-box {
|
|
|
|
|
|
fill: #ffffff;
|
2026-07-07 14:17:47 +08:00
|
|
|
|
shape-rendering: crispEdges;
|
|
|
|
|
|
stroke: #222222;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
stroke-width: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-title {
|
2026-07-07 14:17:47 +08:00
|
|
|
|
dominant-baseline: middle;
|
|
|
|
|
|
fill: #111111;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
text-anchor: middle;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-node.root .node-title {
|
|
|
|
|
|
font-size: 17px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.module-label {
|
|
|
|
|
|
fill: #111111;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
text-anchor: middle;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 14:17:47 +08:00
|
|
|
|
.figure-caption {
|
|
|
|
|
|
fill: #111111;
|
|
|
|
|
|
font-size: 14px;
|
2026-05-24 13:29:42 +08:00
|
|
|
|
text-anchor: middle;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 860px) {
|
|
|
|
|
|
.module-toolbar {
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
2026-07-07 14:17:47 +08:00
|
|
|
|
|
|
|
|
|
|
.module-controls {
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
2026-05-24 13:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|