diff --git a/RuoYi-Vue/easycode-web/src/utils/useCaseDiagram.js b/RuoYi-Vue/easycode-web/src/utils/useCaseDiagram.js new file mode 100644 index 0000000..52aa275 --- /dev/null +++ b/RuoYi-Vue/easycode-web/src/utils/useCaseDiagram.js @@ -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 ? '<>' : '<>' + }) + }) + + 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, ''') +} + +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 + ? `${escapeSvg(relation.label)}` + : '' + return `${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 ` + + + ${escapeSvg(actor.name)} + ` +} + +function renderUseCaseSvg(item) { + const cx = item.x + item.width / 2 + const cy = item.y + item.height / 2 + return `${escapeSvg(item.name)} + + ${escapeSvg(item.text)} + ` +} + +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 ` + + + + + + + + ${title} + ${relations.map(renderRelationSvg).join('\n ')} + ${actors.map(renderActorSvg).join('\n ')} + ${useCases.map(renderUseCaseSvg).join('\n ')} +` +}