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