feat: add use case diagram utility
This commit is contained in:
387
RuoYi-Vue/easycode-web/src/utils/useCaseDiagram.js
Normal file
387
RuoYi-Vue/easycode-web/src/utils/useCaseDiagram.js
Normal file
@@ -0,0 +1,387 @@
|
||||
const MIN_CANVAS_WIDTH = 980
|
||||
const MIN_CANVAS_HEIGHT = 560
|
||||
const ACTOR_X = 96
|
||||
const PRIMARY_X = 360
|
||||
const CHILD_X = 690
|
||||
const TOP_Y = 76
|
||||
const PRIMARY_GAP_Y = 118
|
||||
const CHILD_GAP_Y = 58
|
||||
const OVAL_WIDTH = 172
|
||||
const OVAL_HEIGHT = 48
|
||||
|
||||
export const USE_CASE_EXAMPLE_DSL = `用户
|
||||
浏览商品
|
||||
搜索商品
|
||||
查看商品详情
|
||||
筛选商品
|
||||
加入购物车
|
||||
选择规格
|
||||
修改数量
|
||||
直接购买
|
||||
选择规格
|
||||
提交订单
|
||||
选择收货地址
|
||||
选择优惠券
|
||||
支付订单
|
||||
选择支付方式
|
||||
输入支付密码
|
||||
确认收货
|
||||
查看物流
|
||||
评价商品
|
||||
上传图片
|
||||
打分`
|
||||
|
||||
function cleanText(value, fallback = '') {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function parseJson(value, fallback = {}) {
|
||||
if (!value) return fallback
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : fallback
|
||||
} catch (error) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function countIndent(line) {
|
||||
const match = String(line ?? '').match(/^[\t ]*/u)
|
||||
return match ? match[0].replace(/\t/gu, ' ').length : 0
|
||||
}
|
||||
|
||||
function makeUseCaseId(index) {
|
||||
return `use_case_${index + 1}`
|
||||
}
|
||||
|
||||
function menuTitle(menu) {
|
||||
return cleanText(menu?.name || menu?.title || menu?.code)
|
||||
}
|
||||
|
||||
function uniqueItems(items, max = 8) {
|
||||
const seen = new Set()
|
||||
return items
|
||||
.map((item) => cleanText(item))
|
||||
.filter((item) => {
|
||||
if (!item || seen.has(item)) return false
|
||||
seen.add(item)
|
||||
return true
|
||||
})
|
||||
.slice(0, max)
|
||||
}
|
||||
|
||||
export function buildDefaultUseCaseDsl(project = {}) {
|
||||
const blueprint = parseJson(project.appBlueprint, {})
|
||||
const frontendMenus = uniqueItems((Array.isArray(blueprint.frontendMenus) ? blueprint.frontendMenus : []).map(menuTitle), 5)
|
||||
const adminMenus = uniqueItems((Array.isArray(blueprint.adminMenus) ? blueprint.adminMenus : []).map(menuTitle), 5)
|
||||
|
||||
if (!frontendMenus.length && !adminMenus.length) {
|
||||
return USE_CASE_EXAMPLE_DSL
|
||||
}
|
||||
|
||||
const lines = ['用户']
|
||||
frontendMenus.forEach((name) => {
|
||||
lines.push(name)
|
||||
lines.push(` 查看${name}`)
|
||||
lines.push(` 提交${name}`)
|
||||
})
|
||||
if (adminMenus.length) {
|
||||
lines.push('后台管理')
|
||||
adminMenus.forEach((name) => {
|
||||
lines.push(` ${name}`)
|
||||
})
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function parseUseCaseDsl(dsl = '') {
|
||||
const actors = [{ id: 'actor_1', name: '用户' }]
|
||||
const useCases = []
|
||||
const primaryUseCases = []
|
||||
const relations = []
|
||||
const warnings = []
|
||||
const lines = String(dsl || '').split(/\r?\n/u)
|
||||
let actorSet = false
|
||||
let currentPrimary = null
|
||||
|
||||
lines.forEach((rawLine, index) => {
|
||||
if (!cleanText(rawLine)) return
|
||||
const lineNumber = index + 1
|
||||
const indent = countIndent(rawLine)
|
||||
const text = cleanText(rawLine)
|
||||
const actorMatch = text.match(/^角色[::]\s*(.+)$/u)
|
||||
const arrowMatch = text.match(/^(.+?)\s*->\s*(.+)$/u)
|
||||
|
||||
if (actorMatch && indent === 0) {
|
||||
actors[0].name = cleanText(actorMatch[1], '用户')
|
||||
actorSet = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!actorSet && indent === 0 && !arrowMatch && useCases.length === 0) {
|
||||
actors[0].name = text
|
||||
actorSet = true
|
||||
return
|
||||
}
|
||||
|
||||
const rawName = arrowMatch ? cleanText(arrowMatch[2]) : text
|
||||
const optional = rawName.endsWith('?')
|
||||
const name = optional ? cleanText(rawName.slice(0, -1)) : rawName
|
||||
|
||||
if (!actorSet && indent > 0) {
|
||||
warnings.push(`第 ${lineNumber} 行缺少参与者,已使用默认参与者“用户”`)
|
||||
actorSet = true
|
||||
}
|
||||
|
||||
const useCase = {
|
||||
id: makeUseCaseId(useCases.length),
|
||||
name,
|
||||
optional,
|
||||
lineNumber,
|
||||
parentId: '',
|
||||
kind: indent >= 2 && currentPrimary ? 'child' : 'primary'
|
||||
}
|
||||
useCases.push(useCase)
|
||||
|
||||
if (useCase.kind === 'primary') {
|
||||
currentPrimary = useCase
|
||||
primaryUseCases.push(useCase)
|
||||
relations.push({
|
||||
id: `relation_${relations.length + 1}`,
|
||||
type: 'association',
|
||||
source: actors[0].id,
|
||||
target: useCase.id,
|
||||
label: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
useCase.parentId = currentPrimary.id
|
||||
relations.push({
|
||||
id: `relation_${relations.length + 1}`,
|
||||
type: optional ? 'extend' : 'include',
|
||||
source: optional ? useCase.id : currentPrimary.id,
|
||||
target: optional ? currentPrimary.id : useCase.id,
|
||||
label: optional ? '<<extend>>' : '<<include>>'
|
||||
})
|
||||
})
|
||||
|
||||
if (!useCases.length) {
|
||||
warnings.push('请至少添加一个用例')
|
||||
}
|
||||
if (!actorSet) {
|
||||
warnings.push('缺少参与者,已使用默认参与者“用户”')
|
||||
}
|
||||
|
||||
return {
|
||||
actors,
|
||||
useCases,
|
||||
primaryUseCases,
|
||||
relations,
|
||||
warnings,
|
||||
stats: {
|
||||
actorCount: actors.length,
|
||||
useCaseCount: useCases.length,
|
||||
primaryUseCaseCount: primaryUseCases.length,
|
||||
relationCount: relations.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shortText(value, max = 14) {
|
||||
const chars = Array.from(cleanText(value))
|
||||
return chars.length > max ? `${chars.slice(0, max - 1).join('')}...` : chars.join('')
|
||||
}
|
||||
|
||||
function primaryHeight(children) {
|
||||
return Math.max(PRIMARY_GAP_Y, Math.max(1, children.length) * CHILD_GAP_Y + 24)
|
||||
}
|
||||
|
||||
function useCaseLeft(item) {
|
||||
return { x: item.x, y: item.y + item.height / 2 }
|
||||
}
|
||||
|
||||
function useCaseRight(item) {
|
||||
return { x: item.x + item.width, y: item.y + item.height / 2 }
|
||||
}
|
||||
|
||||
function actorAnchor(actor) {
|
||||
return { x: actor.x + actor.width, y: actor.y + 44 }
|
||||
}
|
||||
|
||||
function relationPath(relation, useCaseMap, actorMap) {
|
||||
const sourceUseCase = useCaseMap.get(relation.source)
|
||||
const targetUseCase = useCaseMap.get(relation.target)
|
||||
const sourceActor = actorMap.get(relation.source)
|
||||
|
||||
if (sourceActor && targetUseCase) {
|
||||
const source = actorAnchor(sourceActor)
|
||||
const target = useCaseLeft(targetUseCase)
|
||||
return {
|
||||
path: `M ${source.x} ${source.y} L ${target.x} ${target.y}`,
|
||||
labelX: (source.x + target.x) / 2,
|
||||
labelY: (source.y + target.y) / 2 - 8
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceUseCase && targetUseCase) {
|
||||
const source = relation.type === 'extend' ? useCaseLeft(sourceUseCase) : useCaseRight(sourceUseCase)
|
||||
const target = relation.type === 'extend' ? useCaseRight(targetUseCase) : useCaseLeft(targetUseCase)
|
||||
const midX = (source.x + target.x) / 2
|
||||
return {
|
||||
path: `M ${source.x} ${source.y} L ${midX} ${source.y} L ${midX} ${target.y} L ${target.x} ${target.y}`,
|
||||
labelX: midX,
|
||||
labelY: (source.y + target.y) / 2 - 8
|
||||
}
|
||||
}
|
||||
|
||||
return { path: '', labelX: 0, labelY: 0 }
|
||||
}
|
||||
|
||||
export function buildUseCaseDiagram(parsedUseCase, options = {}) {
|
||||
const parsed = typeof parsedUseCase === 'string' ? parseUseCaseDsl(parsedUseCase) : parsedUseCase
|
||||
const sourceActors = Array.isArray(parsed?.actors) ? parsed.actors : [{ id: 'actor_1', name: '用户' }]
|
||||
const sourceUseCases = Array.isArray(parsed?.useCases) ? parsed.useCases : []
|
||||
const sourcePrimary = Array.isArray(parsed?.primaryUseCases)
|
||||
? parsed.primaryUseCases
|
||||
: sourceUseCases.filter((item) => item.kind !== 'child')
|
||||
const childrenByParent = new Map()
|
||||
|
||||
sourceUseCases.filter((item) => item.kind === 'child').forEach((item) => {
|
||||
if (!childrenByParent.has(item.parentId)) childrenByParent.set(item.parentId, [])
|
||||
childrenByParent.get(item.parentId).push(item)
|
||||
})
|
||||
|
||||
let cursorY = TOP_Y
|
||||
const positioned = []
|
||||
sourcePrimary.forEach((primary) => {
|
||||
const children = childrenByParent.get(primary.id) || []
|
||||
const blockHeight = primaryHeight(children)
|
||||
const primaryY = cursorY + blockHeight / 2 - OVAL_HEIGHT / 2
|
||||
positioned.push({
|
||||
...primary,
|
||||
x: PRIMARY_X,
|
||||
y: primaryY,
|
||||
width: OVAL_WIDTH,
|
||||
height: OVAL_HEIGHT,
|
||||
text: shortText(primary.name),
|
||||
kind: 'primary'
|
||||
})
|
||||
children.forEach((child, childIndex) => {
|
||||
const childStart = cursorY + blockHeight / 2 - ((children.length - 1) * CHILD_GAP_Y) / 2
|
||||
positioned.push({
|
||||
...child,
|
||||
x: CHILD_X,
|
||||
y: childStart + childIndex * CHILD_GAP_Y - OVAL_HEIGHT / 2,
|
||||
width: OVAL_WIDTH,
|
||||
height: OVAL_HEIGHT,
|
||||
text: shortText(child.name),
|
||||
kind: 'child'
|
||||
})
|
||||
})
|
||||
cursorY += blockHeight
|
||||
})
|
||||
|
||||
const canvasHeight = Math.max(MIN_CANVAS_HEIGHT, cursorY + 72)
|
||||
const actors = sourceActors.map((actor) => ({
|
||||
...actor,
|
||||
x: ACTOR_X,
|
||||
y: Math.max(TOP_Y + 120, canvasHeight / 2 - 48),
|
||||
width: 82,
|
||||
height: 98
|
||||
}))
|
||||
const useCaseMap = new Map(positioned.map((item) => [item.id, item]))
|
||||
const actorMap = new Map(actors.map((item) => [item.id, item]))
|
||||
const relations = (Array.isArray(parsed?.relations) ? parsed.relations : [])
|
||||
.map((relation) => ({
|
||||
...relation,
|
||||
...relationPath(relation, useCaseMap, actorMap)
|
||||
}))
|
||||
.filter((relation) => relation.path)
|
||||
|
||||
return {
|
||||
title: cleanText(options.title, '用例图'),
|
||||
actors,
|
||||
useCases: positioned,
|
||||
relations,
|
||||
warnings: Array.isArray(parsed?.warnings) ? parsed.warnings : [],
|
||||
stats: {
|
||||
actorCount: actors.length,
|
||||
useCaseCount: positioned.length,
|
||||
primaryUseCaseCount: sourcePrimary.length,
|
||||
relationCount: relations.length
|
||||
},
|
||||
canvas: {
|
||||
width: MIN_CANVAS_WIDTH,
|
||||
height: canvasHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeSvg(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''')
|
||||
}
|
||||
|
||||
function renderRelationSvg(relation) {
|
||||
const marker = relation.type === 'association' ? '' : ' marker-end="url(#use-case-arrow)"'
|
||||
const dash = relation.type === 'association' ? '' : ' stroke-dasharray="5 4"'
|
||||
const label = relation.label
|
||||
? `<text x="${relation.labelX}" y="${relation.labelY}" text-anchor="middle" font-size="11" fill="#111111" paint-order="stroke" stroke="#ffffff" stroke-width="4">${escapeSvg(relation.label)}</text>`
|
||||
: ''
|
||||
return `<path d="${relation.path}" fill="none" stroke="#222222" stroke-width="1.3"${dash}${marker}/>${label}`
|
||||
}
|
||||
|
||||
function actorPath(actor) {
|
||||
const cx = actor.x + actor.width / 2
|
||||
const headBottom = actor.y + 23
|
||||
const bodyBottom = actor.y + 56
|
||||
return `M ${cx} ${headBottom} L ${cx} ${bodyBottom} M ${cx - 24} ${actor.y + 36} L ${cx + 24} ${actor.y + 36} M ${cx} ${bodyBottom} L ${cx - 22} ${actor.y + 82} M ${cx} ${bodyBottom} L ${cx + 22} ${actor.y + 82}`
|
||||
}
|
||||
|
||||
function renderActorSvg(actor) {
|
||||
const cx = actor.x + actor.width / 2
|
||||
return `<g>
|
||||
<circle cx="${cx}" cy="${actor.y + 12}" r="10" fill="#ffffff" stroke="#222222" stroke-width="1.4"/>
|
||||
<path d="${actorPath(actor)}" fill="none" stroke="#222222" stroke-width="1.4"/>
|
||||
<text x="${cx}" y="${actor.y + actor.height}" text-anchor="middle" dominant-baseline="middle" font-size="13" fill="#111111">${escapeSvg(actor.name)}</text>
|
||||
</g>`
|
||||
}
|
||||
|
||||
function renderUseCaseSvg(item) {
|
||||
const cx = item.x + item.width / 2
|
||||
const cy = item.y + item.height / 2
|
||||
return `<g><desc>${escapeSvg(item.name)}</desc>
|
||||
<ellipse cx="${cx}" cy="${cy}" rx="${item.width / 2}" ry="${item.height / 2}" fill="#ffffff" stroke="#222222" stroke-width="1.4"/>
|
||||
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle" font-size="13" fill="#111111">${escapeSvg(item.text)}</text>
|
||||
</g>`
|
||||
}
|
||||
|
||||
export function buildUseCaseExportSvg(diagram = {}) {
|
||||
const width = Math.max(1, Number(diagram.canvas?.width) || MIN_CANVAS_WIDTH)
|
||||
const height = Math.max(1, Number(diagram.canvas?.height) || MIN_CANVAS_HEIGHT)
|
||||
const actors = Array.isArray(diagram.actors) ? diagram.actors : []
|
||||
const useCases = Array.isArray(diagram.useCases) ? diagram.useCases : []
|
||||
const relations = Array.isArray(diagram.relations) ? diagram.relations : []
|
||||
const title = escapeSvg(cleanText(diagram.title, '用例图'))
|
||||
|
||||
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}" font-family="Microsoft YaHei, Arial, sans-serif">
|
||||
<defs>
|
||||
<marker id="use-case-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L8,3 z" fill="#222222"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="#ffffff"/>
|
||||
<text x="${width / 2}" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#111111">${title}</text>
|
||||
${relations.map(renderRelationSvg).join('\n ')}
|
||||
${actors.map(renderActorSvg).join('\n ')}
|
||||
${useCases.map(renderUseCaseSvg).join('\n ')}
|
||||
</svg>`
|
||||
}
|
||||
Reference in New Issue
Block a user