379 lines
10 KiB
Vue
379 lines
10 KiB
Vue
<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>
|