feat: add use case diagram utility

This commit is contained in:
王鹏
2026-07-07 14:57:27 +08:00
parent 326ebc8542
commit b7b5ab022c

View 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, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&apos;')
}
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>`
}