feat: add module diagram editor controls
This commit is contained in:
@@ -1,168 +1,237 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-module-diagram">
|
<div class="app-module-diagram">
|
||||||
<el-empty v-if="!graph.nodes.length" description="生成应用蓝图后可查看系统功能模块图" />
|
<div class="module-toolbar">
|
||||||
<template v-else>
|
<div class="module-summary">
|
||||||
<div class="module-toolbar">
|
<span>节点:{{ graph.nodes.length }}</span>
|
||||||
<div class="module-summary">
|
<span>连线:{{ graph.edges.length }}</span>
|
||||||
<span>节点:{{ graph.nodes.length }}</span>
|
|
||||||
<span>连线:{{ graph.edges.length }}</span>
|
|
||||||
</div>
|
|
||||||
<el-button :icon="Picture" @click="exportPng">导出图片</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="module-controls">
|
||||||
<div class="module-scroll">
|
<el-radio-group :model-value="sourceMode" size="small" @change="setSourceMode">
|
||||||
<svg ref="svgRef" class="module-canvas" :viewBox="`0 0 ${canvas.width} ${canvas.height}`" :style="canvasStyle">
|
<el-radio-button label="blueprint">自动蓝图</el-radio-button>
|
||||||
<defs>
|
<el-radio-button label="outline">缩进结构</el-radio-button>
|
||||||
<marker
|
</el-radio-group>
|
||||||
id="app-module-arrow"
|
<el-radio-group :model-value="normalizedLayoutOptions.density" size="small" @change="(value) => updateLayoutOption('density', value)">
|
||||||
markerWidth="10"
|
<el-radio-button label="compact">紧凑</el-radio-button>
|
||||||
markerHeight="10"
|
<el-radio-button label="standard">标准</el-radio-button>
|
||||||
refX="8"
|
<el-radio-button label="relaxed">宽松</el-radio-button>
|
||||||
refY="3"
|
</el-radio-group>
|
||||||
orient="auto"
|
<el-radio-group :model-value="normalizedLayoutOptions.direction" size="small" @change="(value) => updateLayoutOption('direction', value)">
|
||||||
markerUnits="strokeWidth"
|
<el-radio-button label="horizontal">横向</el-radio-button>
|
||||||
>
|
<el-radio-button label="vertical">竖向</el-radio-button>
|
||||||
<path d="M0,0 L0,6 L8,3 z" fill="#8aa0bc" />
|
</el-radio-group>
|
||||||
</marker>
|
<el-select
|
||||||
</defs>
|
:model-value="normalizedLayoutOptions.fontSize"
|
||||||
|
size="small"
|
||||||
<g class="lane-layer">
|
class="module-font-select"
|
||||||
<g v-for="lane in graph.lanes" :key="lane.key">
|
@change="(value) => updateLayoutOption('fontSize', value)"
|
||||||
<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>
|
<el-option label="小" value="small" />
|
||||||
</g>
|
<el-option label="标准" value="standard" />
|
||||||
</g>
|
<el-option label="大" value="large" />
|
||||||
|
</el-select>
|
||||||
<g class="edge-layer">
|
<el-button :icon="Download" @click="exportSvg">SVG</el-button>
|
||||||
<g v-for="edge in edgeViews" :key="edge.id">
|
<el-button :icon="Picture" @click="exportPng">PNG</el-button>
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Picture } from '@element-plus/icons-vue'
|
import { Download, Picture } from '@element-plus/icons-vue'
|
||||||
|
import { buildAppModuleExportSvg } from '@/utils/appModuleExport'
|
||||||
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
|
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
|
||||||
|
import {
|
||||||
|
blueprintToModuleOutline,
|
||||||
|
moduleOutlineToText,
|
||||||
|
parseModuleOutlineText
|
||||||
|
} from '@/utils/appModuleOutline'
|
||||||
import { saveBlob } from '@/utils/download'
|
import { saveBlob } from '@/utils/download'
|
||||||
|
|
||||||
|
const DEFAULT_LAYOUT_OPTIONS = {
|
||||||
|
density: 'standard',
|
||||||
|
direction: 'horizontal',
|
||||||
|
fontSize: 'standard'
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
blueprint: {
|
blueprint: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
|
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 svgRef = ref(null)
|
||||||
const graph = computed(() => buildAppModuleGraph(props.blueprint))
|
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 nodeMap = computed(() => {
|
const canvas = computed(() => graph.value.canvas || { width: 760, height: 420 })
|
||||||
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(() => ({
|
const canvasStyle = computed(() => ({
|
||||||
width: `${canvas.value.width}px`,
|
width: `${canvas.value.width}px`,
|
||||||
height: `${canvas.value.height}px`
|
height: `${canvas.value.height}px`
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const edgeViews = computed(() => graph.value.edges
|
watch(graph, (value) => {
|
||||||
.map((edge) => {
|
emit('graph-change', value)
|
||||||
const source = nodeMap.value.get(edge.source)
|
}, { immediate: true })
|
||||||
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) {
|
function shortText(value, maxLength) {
|
||||||
const text = String(value ?? '')
|
const text = String(value ?? '')
|
||||||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
|
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeWidth(value) {
|
function verticalChars(value) {
|
||||||
const text = String(value ?? '')
|
const chars = Array.from(String(value ?? '').trim())
|
||||||
return Math.min(112, Math.max(48, text.length * 8 + 20))
|
if (chars.length <= 7) return chars
|
||||||
|
return [...chars.slice(0, 6), '…']
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeX(node, index) {
|
function verticalTextY(node) {
|
||||||
return node.badges.slice(0, index).reduce((x, badge) => x + badgeWidth(badge) + 6, 14)
|
const count = verticalChars(node.title).length
|
||||||
|
return Math.max(22, (node.height - (count - 1) * 18) / 2 + 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportStyle() {
|
function setSourceMode(value) {
|
||||||
return `
|
const nextMode = value === 'outline' ? 'outline' : 'blueprint'
|
||||||
.lane-bg{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1}
|
emit('update:sourceMode', nextMode)
|
||||||
.lane-title{fill:#475569;font-family:Arial,sans-serif;font-size:13px;font-weight:700}
|
if (nextMode === 'outline' && !props.outlineText) {
|
||||||
.module-edge{fill:none;stroke:#8aa0bc;stroke-width:1.7}
|
emit('update:outlineText', blueprintOutlineText.value)
|
||||||
.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}
|
function updateOutlineText(value) {
|
||||||
.module-node.access .module-node-head,.module-node.access .module-node-head-fill{fill:#ecfdf5}
|
if (props.sourceMode !== 'outline') {
|
||||||
.module-node.role .module-node-head,.module-node.role .module-node-head-fill{fill:#eff6ff}
|
emit('update:sourceMode', 'outline')
|
||||||
.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}
|
emit('update:outlineText', value)
|
||||||
.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}
|
function updateLayoutOption(key, value) {
|
||||||
.node-badge-text{fill:#475569;font-family:Arial,sans-serif;font-size:11px;font-weight:700;text-anchor:middle}`
|
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() {
|
async function exportPng() {
|
||||||
if (!svgRef.value) return
|
if (!svgRef.value) return
|
||||||
|
|
||||||
const svg = svgRef.value.cloneNode(true)
|
const svgText = buildAppModuleExportSvg(graph.value)
|
||||||
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 svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
|
||||||
const url = URL.createObjectURL(svgBlob)
|
const url = URL.createObjectURL(svgBlob)
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
@@ -178,12 +247,13 @@ async function exportPng() {
|
|||||||
canvasElement.width = canvas.value.width
|
canvasElement.width = canvas.value.width
|
||||||
canvasElement.height = canvas.value.height
|
canvasElement.height = canvas.value.height
|
||||||
const context = canvasElement.getContext('2d')
|
const context = canvasElement.getContext('2d')
|
||||||
context.fillStyle = '#f8fafc'
|
if (!context) throw new Error('Canvas context unavailable')
|
||||||
|
context.fillStyle = '#ffffff'
|
||||||
context.fillRect(0, 0, canvasElement.width, canvasElement.height)
|
context.fillRect(0, 0, canvasElement.width, canvasElement.height)
|
||||||
context.drawImage(image, 0, 0)
|
context.drawImage(image, 0, 0)
|
||||||
const blob = await new Promise((resolve) => canvasElement.toBlob(resolve, 'image/png'))
|
const blob = await new Promise((resolve) => canvasElement.toBlob(resolve, 'image/png'))
|
||||||
if (blob) {
|
if (blob) {
|
||||||
saveBlob(blob, 'app-module-diagram.png')
|
saveBlob(blob, 'system-module-diagram.png')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.warning('导出图片失败,请稍后重试')
|
ElMessage.warning('导出图片失败,请稍后重试')
|
||||||
@@ -195,126 +265,103 @@ async function exportPng() {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.app-module-diagram {
|
.app-module-diagram {
|
||||||
background: #f8fafc;
|
background: #ffffff;
|
||||||
min-height: 430px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-toolbar {
|
.module-toolbar {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 18px;
|
padding: 12px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-summary {
|
.module-summary {
|
||||||
color: #64748b;
|
color: #4b5563;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
gap: 12px;
|
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 {
|
.module-scroll {
|
||||||
min-height: 430px;
|
background: #ffffff;
|
||||||
|
min-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-canvas {
|
.module-canvas {
|
||||||
|
background: #ffffff;
|
||||||
display: block;
|
display: block;
|
||||||
|
font-family: SimSun, "宋体", "Microsoft YaHei", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lane-bg {
|
.tree-edge {
|
||||||
fill: #f8fafc;
|
|
||||||
stroke: #e2e8f0;
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-title {
|
|
||||||
fill: #475569;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-edge {
|
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #8aa0bc;
|
shape-rendering: crispEdges;
|
||||||
stroke-width: 1.7;
|
stroke: #222222;
|
||||||
}
|
stroke-width: 1.2;
|
||||||
|
|
||||||
.edge-label {
|
|
||||||
fill: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
paint-order: stroke;
|
|
||||||
stroke: #f8fafc;
|
|
||||||
stroke-width: 4px;
|
|
||||||
text-anchor: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-node-box {
|
.module-node-box {
|
||||||
fill: #ffffff;
|
fill: #ffffff;
|
||||||
filter: drop-shadow(0 8px 18px rgba(15, 23, 42, 0.08));
|
shape-rendering: crispEdges;
|
||||||
stroke: #d6e0ee;
|
stroke: #222222;
|
||||||
stroke-width: 1;
|
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 {
|
.node-title {
|
||||||
fill: #0f172a;
|
dominant-baseline: middle;
|
||||||
font-size: 15px;
|
fill: #111111;
|
||||||
font-weight: 700;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-subtitle {
|
.module-node.root .node-title {
|
||||||
fill: #64748b;
|
font-size: 17px;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-badge-bg {
|
.module-label {
|
||||||
fill: #f8fafc;
|
fill: #111111;
|
||||||
stroke: #e2e8f0;
|
font-size: 16px;
|
||||||
stroke-width: 1;
|
font-weight: 600;
|
||||||
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-badge-text {
|
.figure-caption {
|
||||||
fill: #475569;
|
fill: #111111;
|
||||||
font-size: 11px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,5 +370,9 @@ async function exportPng() {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-controls {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
function readComponent(name) {
|
||||||
|
return readFileSync(resolve(currentDir, name), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('app module diagram view exposes shared editor controls', () => {
|
||||||
|
const source = readComponent('AppModuleDiagramView.vue')
|
||||||
|
|
||||||
|
assert.equal(source.includes('sourceMode'), true)
|
||||||
|
assert.equal(source.includes('outlineText'), true)
|
||||||
|
assert.equal(source.includes('layoutOptions'), true)
|
||||||
|
assert.equal(source.includes('update:sourceMode'), true)
|
||||||
|
assert.equal(source.includes('update:outlineText'), true)
|
||||||
|
assert.equal(source.includes('update:layoutOptions'), true)
|
||||||
|
assert.equal(source.includes('自动蓝图'), true)
|
||||||
|
assert.equal(source.includes('缩进结构'), true)
|
||||||
|
assert.equal(source.includes('标准'), true)
|
||||||
|
assert.equal(source.includes('宽松'), true)
|
||||||
|
assert.equal(source.includes('紧凑'), true)
|
||||||
|
assert.equal(source.includes('横向'), true)
|
||||||
|
assert.equal(source.includes('竖向'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app module diagram view supports svg and png export from the same graph', () => {
|
||||||
|
const source = readComponent('AppModuleDiagramView.vue')
|
||||||
|
|
||||||
|
assert.equal(source.includes('exportSvg'), true)
|
||||||
|
assert.equal(source.includes('exportPng'), true)
|
||||||
|
assert.equal(source.includes('buildAppModuleExportSvg(graph.value)'), true)
|
||||||
|
assert.equal(source.includes('saveBlob'), true)
|
||||||
|
assert.equal(source.includes('system-module-diagram.svg'), true)
|
||||||
|
assert.equal(source.includes('system-module-diagram.png'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app module diagram view renders outline warnings next to the editor', () => {
|
||||||
|
const source = readComponent('AppModuleDiagramView.vue')
|
||||||
|
|
||||||
|
assert.equal(source.includes('outlineWarnings'), true)
|
||||||
|
assert.equal(source.includes('module-warning-list'), true)
|
||||||
|
assert.equal(source.includes('v-for="warning in outlineWarnings"'), true)
|
||||||
|
})
|
||||||
80
RuoYi-Vue/easycode-web/src/utils/appModuleExport.js
Normal file
80
RuoYi-Vue/easycode-web/src/utils/appModuleExport.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
function escapeXml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shortText(value, maxLength) {
|
||||||
|
const text = String(value ?? '')
|
||||||
|
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFontSize(node, fallback) {
|
||||||
|
return Number(node.fontSize) || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVerticalText(node) {
|
||||||
|
const x = node.width / 2
|
||||||
|
const startY = verticalTextY(node)
|
||||||
|
return `
|
||||||
|
<text class="module-label" x="${x}" y="${startY}" font-size="${nodeFontSize(node, 16)}">
|
||||||
|
${verticalChars(node.title).map((char, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : 18}">${escapeXml(char)}</tspan>`).join('')}
|
||||||
|
</text>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHorizontalText(node) {
|
||||||
|
return `
|
||||||
|
<text class="node-title" x="${node.width / 2}" y="${node.height / 2}" font-size="${nodeFontSize(node, node.kind === 'root' ? 17 : 16)}">${escapeXml(shortText(node.title, 18))}</text>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node) {
|
||||||
|
const className = ['module-node', node.kind, node.lane].filter(Boolean).join(' ')
|
||||||
|
return `
|
||||||
|
<g class="${escapeXml(className)}" transform="translate(${node.x}, ${node.y})">
|
||||||
|
<rect class="module-node-box" width="${node.width}" height="${node.height}" fill="#ffffff" stroke="#222222" stroke-width="1" />
|
||||||
|
${node.vertical ? renderVerticalText(node) : renderHorizontalText(node)}
|
||||||
|
</g>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdge(edge) {
|
||||||
|
return `
|
||||||
|
<path class="tree-edge" d="${escapeXml(edge.path)}" fill="none" stroke="#222222" stroke-width="1.2" vector-effect="non-scaling-stroke" />`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppModuleExportSvg(graph = {}) {
|
||||||
|
const canvas = graph.canvas || {}
|
||||||
|
const width = Math.max(1, Number(canvas.width) || 1)
|
||||||
|
const height = Math.max(1, Number(canvas.height) || 1)
|
||||||
|
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
|
||||||
|
const edges = Array.isArray(graph.edges) ? graph.edges : []
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<style>
|
||||||
|
.tree-edge{fill:none;stroke:#222;stroke-width:1.2;shape-rendering:crispEdges}
|
||||||
|
.module-node-box{fill:#fff;stroke:#222;stroke-width:1;shape-rendering:crispEdges}
|
||||||
|
.node-title{fill:#111;font-family:SimSun,"宋体","Microsoft YaHei",Arial,sans-serif;font-size:16px;font-weight:600;text-anchor:middle;dominant-baseline:middle}
|
||||||
|
.module-node.root .node-title{font-size:17px}
|
||||||
|
.module-label{fill:#111;font-family:SimSun,"宋体","Microsoft YaHei",Arial,sans-serif;font-size:16px;font-weight:600;text-anchor:middle}
|
||||||
|
.figure-caption{fill:#111;font-family:SimSun,"宋体","Microsoft YaHei",Arial,sans-serif;font-size:14px;text-anchor:middle}
|
||||||
|
</style>
|
||||||
|
<rect width="100%" height="100%" fill="#ffffff" />
|
||||||
|
<g>${edges.map(renderEdge).join('')}</g>
|
||||||
|
<g>${nodes.map(renderNode).join('')}</g>
|
||||||
|
<text class="figure-caption" x="${width / 2}" y="${height - 24}">${escapeXml(graph.caption)}</text>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
114
RuoYi-Vue/easycode-web/src/utils/appModuleExport.test.mjs
Normal file
114
RuoYi-Vue/easycode-web/src/utils/appModuleExport.test.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { buildAppModuleExportSvg } from './appModuleExport.js'
|
||||||
|
|
||||||
|
test('buildAppModuleExportSvg renders a pure image-loadable SVG', () => {
|
||||||
|
const svg = buildAppModuleExportSvg({
|
||||||
|
canvas: { width: 640, height: 420 },
|
||||||
|
caption: '系统功能结构图',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
title: 'BookManage',
|
||||||
|
kind: 'root',
|
||||||
|
lane: 'root',
|
||||||
|
x: 190,
|
||||||
|
y: 18,
|
||||||
|
width: 260,
|
||||||
|
height: 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frontend:catalog',
|
||||||
|
title: '图书目录',
|
||||||
|
kind: 'module',
|
||||||
|
lane: 'frontend',
|
||||||
|
vertical: true,
|
||||||
|
x: 300,
|
||||||
|
y: 198,
|
||||||
|
width: 48,
|
||||||
|
height: 138
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'root->frontend:catalog',
|
||||||
|
path: 'M 320 60 V 88 H 324 V 198'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(svg, /^<\?xml version="1.0" encoding="UTF-8"\?>/)
|
||||||
|
assert.equal(svg.includes('xmlns="http://www.w3.org/2000/svg"'), true)
|
||||||
|
assert.equal(svg.includes('width="640"'), true)
|
||||||
|
assert.equal(svg.includes('height="420"'), true)
|
||||||
|
assert.equal(svg.includes('data-v-'), false)
|
||||||
|
assert.equal(svg.includes('<foreignObject'), false)
|
||||||
|
assert.equal(svg.includes('BookManage'), true)
|
||||||
|
assert.equal(svg.includes('<tspan'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAppModuleExportSvg escapes text and path values', () => {
|
||||||
|
const svg = buildAppModuleExportSvg({
|
||||||
|
canvas: { width: 320, height: 240 },
|
||||||
|
caption: 'A&B<图>',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
title: '图书 & 借阅 <系统>',
|
||||||
|
kind: 'root',
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 200,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'edge',
|
||||||
|
path: 'M 1 1 L 2 2"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(svg.includes('图书 & 借阅 <系统>'), true)
|
||||||
|
assert.equal(svg.includes('A&B<图>'), true)
|
||||||
|
assert.equal(svg.includes('d="M 1 1 L 2 2""'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAppModuleExportSvg uses graph node font sizes', () => {
|
||||||
|
const svg = buildAppModuleExportSvg({
|
||||||
|
canvas: { width: 640, height: 420 },
|
||||||
|
caption: '系统功能结构图',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
title: '学生社团管理系统',
|
||||||
|
kind: 'root',
|
||||||
|
lane: 'root',
|
||||||
|
x: 190,
|
||||||
|
y: 18,
|
||||||
|
width: 260,
|
||||||
|
height: 42,
|
||||||
|
fontSize: 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'group_1:module_1',
|
||||||
|
title: '用户管理',
|
||||||
|
kind: 'module',
|
||||||
|
lane: 'group_1',
|
||||||
|
vertical: true,
|
||||||
|
x: 300,
|
||||||
|
y: 198,
|
||||||
|
width: 54,
|
||||||
|
height: 154,
|
||||||
|
fontSize: 18
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(svg.includes('font-size="19"'), true)
|
||||||
|
assert.equal(svg.includes('font-size="18"'), true)
|
||||||
|
assert.equal(svg.includes('学生社团管理系统'), true)
|
||||||
|
assert.equal(svg.includes('<foreignObject'), false)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user