34 KiB
Use Case Diagram Center Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a dedicated 用例图 tab to EasyCode's diagram center with text DSL editing, UML SVG preview, project draft save/restore, and SVG/PNG export.
Architecture: Add a pure useCaseDiagram.js utility for DSL parsing, deterministic layout, and standalone SVG export. Integrate that utility into DiagramCenterView.vue using the same left-DSL/right-preview pattern already used by flowcharts and architecture diagrams, while persisting drafts through the existing project diagram API as diagramType: 'use_case'.
Tech Stack: Vue 3 Composition API, Element Plus, browser SVG/canvas export, Node node:test for focused frontend utility/view string tests.
File Structure
- Create:
easycode-web/src/utils/useCaseDiagram.js- Owns default DSL generation, DSL parsing, graph layout, SVG escaping, and export SVG serialization.
- Create:
easycode-web/src/utils/useCaseDiagram.test.mjs- Verifies parser, default DSL, layout, relation semantics, canvas growth, and export SVG behavior.
- Modify:
easycode-web/src/views/DiagramCenterView.vue- Adds
用例图tab, editor UI, preview SVG, save/restore logic, and export handlers.
- Adds
- Modify:
easycode-web/src/views/diagramCenterView.test.mjs- Verifies tab routing, save/restore wiring, and export handler wiring with source-level tests.
Do not change backend schema or project diagram APIs.
Task 1: Utility Tests First
Files:
-
Create:
easycode-web/src/utils/useCaseDiagram.test.mjs -
Test:
easycode-web/src/utils/useCaseDiagram.test.mjs -
Step 1: Write the failing utility test file
Create easycode-web/src/utils/useCaseDiagram.test.mjs with:
import test from 'node:test'
import assert from 'node:assert/strict'
import {
USE_CASE_EXAMPLE_DSL,
buildDefaultUseCaseDsl,
buildUseCaseDiagram,
buildUseCaseExportSvg,
parseUseCaseDsl
} from './useCaseDiagram.js'
const shoppingDsl = `用户
浏览商品
搜索商品
查看商品详情
筛选商品
加入购物车
选择规格
修改数量
提交订单
选择收货地址
选择优惠券
评价商品
上传图片?
打分`
test('parseUseCaseDsl parses screenshot-style actor, use cases, and include relations', () => {
const parsed = parseUseCaseDsl(shoppingDsl)
assert.equal(parsed.actors.length, 1)
assert.equal(parsed.actors[0].name, '用户')
assert.equal(parsed.primaryUseCases.length, 4)
assert.equal(parsed.useCases.length, 13)
assert.equal(parsed.relations.filter((relation) => relation.type === 'association').length, 4)
assert.equal(parsed.relations.filter((relation) => relation.type === 'include').length, 8)
assert.equal(parsed.relations.filter((relation) => relation.type === 'extend').length, 1)
assert.equal(parsed.warnings.length, 0)
assert.deepEqual(
parsed.primaryUseCases.map((item) => item.name),
['浏览商品', '加入购物车', '提交订单', '评价商品']
)
})
test('parseUseCaseDsl normalizes tabs to two-space indentation', () => {
const parsed = parseUseCaseDsl('用户\n浏览商品\n\t搜索商品')
assert.equal(parsed.primaryUseCases.length, 1)
assert.equal(parsed.useCases.find((item) => item.name === '搜索商品').parentId, 'use_case_1')
assert.equal(parsed.relations.some((relation) => relation.type === 'include'), true)
})
test('parseUseCaseDsl marks question-suffixed children as extend relations', () => {
const parsed = parseUseCaseDsl('用户\n支付订单\n 支付失败?')
assert.equal(parsed.relations.find((relation) => relation.type === 'extend').target, 'use_case_1')
assert.equal(parsed.useCases.find((item) => item.name === '支付失败').optional, true)
})
test('parseUseCaseDsl returns warnings for empty DSL and falls back to actor when missing', () => {
const empty = parseUseCaseDsl('')
assert.equal(empty.actors[0].name, '用户')
assert.equal(empty.useCases.length, 0)
assert.ok(empty.warnings.some((warning) => warning.includes('至少添加一个用例')))
const missingActor = parseUseCaseDsl(' 搜索商品')
assert.equal(missingActor.actors[0].name, '用户')
assert.equal(missingActor.primaryUseCases.length, 1)
assert.ok(missingActor.warnings.some((warning) => warning.includes('缺少参与者')))
})
test('buildDefaultUseCaseDsl creates project-aware starter content from blueprint menus', () => {
const dsl = buildDefaultUseCaseDsl({
projectName: '图书借阅系统',
appBlueprint: JSON.stringify({
frontendMenus: [{ name: '图书浏览' }, { title: '我的借阅' }],
adminMenus: [{ name: '图书管理' }, { code: 'borrowAudit' }]
})
})
assert.match(dsl, /用户/)
assert.match(dsl, /图书浏览/)
assert.match(dsl, /我的借阅/)
assert.match(dsl, /图书管理/)
assert.match(dsl, /borrowAudit/)
})
test('buildUseCaseDiagram lays out actor, ovals, relations, and grows canvas', () => {
const small = buildUseCaseDiagram(parseUseCaseDsl('用户\n浏览商品'))
const large = buildUseCaseDiagram(parseUseCaseDsl(shoppingDsl), { title: '商城用例图' })
assert.equal(large.title, '商城用例图')
assert.equal(large.stats.actorCount, 1)
assert.equal(large.stats.primaryUseCaseCount, 4)
assert.equal(large.stats.useCaseCount, 13)
assert.ok(large.canvas.height > small.canvas.height)
assert.ok(large.actors[0].x < large.useCases[0].x)
assert.ok(large.useCases.some((item) => item.kind === 'child'))
assert.ok(large.relations.some((relation) => relation.label === '<<include>>'))
assert.ok(large.relations.some((relation) => relation.label === '<<extend>>'))
})
test('buildUseCaseExportSvg serializes safe UML SVG without foreignObject', () => {
const diagram = buildUseCaseDiagram(parseUseCaseDsl('用户\n浏览<script>\n 查看详情'), { title: '安全用例图' })
const svg = buildUseCaseExportSvg(diagram)
assert.match(svg, /安全用例图/)
assert.match(svg, /<script>/)
assert.match(svg, /ellipse/)
assert.match(svg, /stroke-dasharray="5 4"/)
assert.doesNotMatch(svg, /<script>/)
assert.doesNotMatch(svg, /foreignObject/)
})
test('USE_CASE_EXAMPLE_DSL is valid default shopping example', () => {
const parsed = parseUseCaseDsl(USE_CASE_EXAMPLE_DSL)
assert.equal(parsed.warnings.length, 0)
assert.ok(parsed.primaryUseCases.length >= 6)
assert.ok(parsed.relations.some((relation) => relation.type === 'include'))
})
- Step 2: Run the utility test and verify RED
Run from repo root:
node easycode-web/src/utils/useCaseDiagram.test.mjs
Expected: ERR_MODULE_NOT_FOUND for easycode-web/src/utils/useCaseDiagram.js.
- Step 3: Commit the failing test
git add easycode-web/src/utils/useCaseDiagram.test.mjs
git commit -m "test: add use case diagram utility coverage"
Task 2: Use Case Diagram Utility
Files:
-
Create:
easycode-web/src/utils/useCaseDiagram.js -
Test:
easycode-web/src/utils/useCaseDiagram.test.mjs -
Step 1: Implement pure parser, layout, and export utility
Create easycode-web/src/utils/useCaseDiagram.js with these exports and helper responsibilities:
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)
}
Implement buildDefaultUseCaseDsl(project):
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')
}
Implement parseUseCaseDsl(dsl) with this behavior:
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
}
}
}
Implement layout helpers and buildUseCaseDiagram(parsed, options):
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(primary, children) {
return Math.max(PRIMARY_GAP_Y, Math.max(1, children.length) * CHILD_GAP_Y + 24)
}
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(primary, 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
}
}
}
Implement relationPath, escapeSvg, actor/ellipse/text render helpers, and buildUseCaseExportSvg(diagram) so export SVG:
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>`
}
The implementation must include no foreignObject, must escape XML special characters, and must use dashed arrows for include/extend relations.
- Step 2: Run the utility test and verify GREEN
Run:
node easycode-web/src/utils/useCaseDiagram.test.mjs
Expected: all tests pass.
- Step 3: Commit the utility
git add easycode-web/src/utils/useCaseDiagram.js easycode-web/src/utils/useCaseDiagram.test.mjs
git commit -m "feat: add use case diagram utility"
Task 3: Diagram Center Source Tests
Files:
-
Modify:
easycode-web/src/views/diagramCenterView.test.mjs -
Test:
easycode-web/src/views/diagramCenterView.test.mjs -
Step 1: Add failing source-level tests for use case integration
Append these tests to easycode-web/src/views/diagramCenterView.test.mjs:
test('diagram center exposes a dedicated use case diagram tab and direct route', () => {
const source = readView('DiagramCenterView.vue')
assert.equal(source.includes('label="use_case"'), true)
assert.match(source, />用例图</)
assert.match(source, /use_case:\s*'use_case'/)
assert.equal(source.includes("const useCaseDsl = ref('')"), true)
})
test('diagram center saves and restores use case diagram DSL payloads', () => {
const source = readView('DiagramCenterView.vue')
assert.equal(source.includes('function applySavedUseCaseDiagram'), true)
assert.equal(source.includes("savedDiagrams.value.find((item) => item.diagramType === 'use_case')"), true)
assert.equal(source.includes("diagramType: 'use_case'"), true)
assert.equal(source.includes('dsl: useCaseDsl.value'), true)
assert.equal(source.includes('graph: useCaseDiagram.value'), true)
})
test('diagram center wires use case SVG and PNG export handlers', () => {
const source = readView('DiagramCenterView.vue')
assert.equal(source.includes('buildUseCaseExportSvg'), true)
assert.equal(source.includes('function useCaseSvgText'), true)
assert.equal(source.includes('downloadUseCaseSvg'), true)
assert.equal(source.includes('downloadUseCasePng'), true)
assert.equal(source.includes('useCaseRelationPath'), true)
})
- Step 2: Run the view test and verify RED
Run:
node easycode-web/src/views/diagramCenterView.test.mjs
Expected: at least the new tests fail because DiagramCenterView.vue does not yet include use_case tab/source functions.
- Step 3: Commit the failing view tests
git add easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "test: add diagram center use case coverage"
Task 4: Diagram Center Integration
Files:
-
Modify:
easycode-web/src/views/DiagramCenterView.vue -
Test:
easycode-web/src/views/diagramCenterView.test.mjs -
Test:
easycode-web/src/utils/useCaseDiagram.test.mjs -
Step 1: Import the utility exports
In the script imports of DiagramCenterView.vue, add:
import {
USE_CASE_EXAMPLE_DSL,
buildDefaultUseCaseDsl,
buildUseCaseDiagram,
buildUseCaseExportSvg,
parseUseCaseDsl
} from '@/utils/useCaseDiagram'
- Step 2: Add the diagram tab
In the el-radio-group for diagram kinds, add this button next to flowchart:
<el-radio-button label="use_case">用例图</el-radio-button>
- Step 3: Add the use case editor template branch
Add this v-else-if branch before three_line_table:
<section v-else-if="activeDiagram === 'use_case'" class="use-case-editor">
<div class="use-case-toolbar">
<div>
<strong>{{ useCaseDiagram.title }}</strong>
<span>{{ useCaseStatsText }}</span>
</div>
<div class="toolbar">
<el-button :icon="Download" :disabled="!useCaseDiagram.useCases.length" @click="downloadUseCaseSvg">SVG</el-button>
<el-button :icon="Picture" :disabled="!useCaseDiagram.useCases.length" @click="downloadUseCasePng">PNG</el-button>
</div>
</div>
<div class="use-case-workbench">
<aside class="use-case-dsl-panel">
<div class="use-case-dsl-header">
<div>
<strong>用例图 DSL</strong>
<span>首行写参与者,顶格写主用例,缩进 2 个空格写 include 子用例,子用例末尾加 ? 表示 extend。</span>
</div>
<div class="use-case-dsl-actions">
<el-button :icon="Delete" text @click="clearUseCaseDsl">清空</el-button>
<el-button :icon="DocumentCopy" text @click="loadUseCaseExample">加载示例</el-button>
<el-button :icon="Refresh" text @click="regenerateUseCaseDsl">换一换</el-button>
</div>
</div>
<div class="use-case-grammar">
示例:用户 / 浏览商品 / 两空格搜索商品。支持“角色: 用户”和“用户 -> 浏览商品”的兼容写法。
</div>
<el-input
v-model="useCaseDsl"
class="use-case-dsl-input"
type="textarea"
resize="none"
:autosize="{ minRows: 20, maxRows: 34 }"
spellcheck="false"
/>
<div class="use-case-dsl-footer">
<span v-if="parsedUseCase.warnings.length">{{ parsedUseCase.warnings.length }} 条格式提示</span>
<span v-else>DSL 已就绪</span>
<el-button type="primary" :icon="MagicStick" @click="refreshUseCasePreview">生成预览</el-button>
</div>
</aside>
<section class="use-case-preview-panel">
<div v-if="parsedUseCase.warnings.length" class="use-case-warnings">
<span v-for="warning in parsedUseCase.warnings.slice(0, 3)" :key="warning">{{ warning }}</span>
</div>
<div class="use-case-preview-scroll">
<svg
class="use-case-svg"
:viewBox="`0 0 ${useCaseDiagram.canvas.width} ${useCaseDiagram.canvas.height}`"
:style="{ width: `${useCaseDiagram.canvas.width}px`, height: `${useCaseDiagram.canvas.height}px` }"
>
<defs>
<marker id="use-case-arrow-preview" 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 class="use-case-svg-title" :x="useCaseDiagram.canvas.width / 2" y="32">
{{ useCaseDiagram.title }}
</text>
<g class="use-case-relation-layer">
<g v-for="relation in useCaseDiagram.relations" :key="relation.id">
<path
class="use-case-relation"
:class="{ 'is-dependency': relation.type !== 'association' }"
:d="useCaseRelationPath(relation)"
:marker-end="relation.type === 'association' ? null : 'url(#use-case-arrow-preview)'"
/>
<text
v-if="relation.label"
class="use-case-relation-label"
:x="relation.labelX"
:y="relation.labelY"
>
{{ relation.label }}
</text>
</g>
</g>
<g class="use-case-actor-layer">
<g v-for="actor in useCaseDiagram.actors" :key="actor.id" class="use-case-actor">
<circle :cx="actor.x + actor.width / 2" :cy="actor.y + 12" r="10" />
<path :d="useCaseActorPath(actor)" />
<text :x="actor.x + actor.width / 2" :y="actor.y + actor.height">{{ actor.name }}</text>
</g>
</g>
<g class="use-case-node-layer">
<g v-for="item in useCaseDiagram.useCases" :key="item.id" class="use-case-node">
<ellipse
:cx="item.x + item.width / 2"
:cy="item.y + item.height / 2"
:rx="item.width / 2"
:ry="item.height / 2"
/>
<text :x="item.x + item.width / 2" :y="item.y + item.height / 2">{{ item.text }}</text>
</g>
</g>
</svg>
</div>
</section>
</div>
</section>
- Step 4: Add state, computed values, and route normalization
Add use_case to the diagram aliases:
const DIAGRAM_ALIASES = {
er: 'er',
'module-diagram': 'module',
module: 'module',
architecture: 'architecture',
flowchart: 'flowchart',
use_case: 'use_case',
usecase: 'use_case',
three_line_table: 'three_line_table',
overview: 'overview',
ai: 'ai'
}
Add state and computed values near flowchartDsl:
const useCaseDsl = ref('')
const parsedUseCase = computed(() => parseUseCaseDsl(useCaseDsl.value))
const useCaseDiagram = computed(() => buildUseCaseDiagram(parsedUseCase.value, {
title: `${project.projectName || '项目'}用例图`
}))
const useCaseStatsText = computed(() => {
const stats = useCaseDiagram.value.stats
return `${stats.actorCount} 参与者 · ${stats.useCaseCount} 用例`
})
- Step 5: Add load/save/restore logic
In applySavedDrafts(), add:
const savedUseCase = savedDiagrams.value.find((item) => item.diagramType === 'use_case')
if (savedUseCase) {
applySavedUseCaseDiagram(savedUseCase)
} else {
useCaseDsl.value = buildDefaultUseCaseDsl(project)
}
In loadSavedDiagram(diagram), add before AI handling:
if (diagram.diagramType === 'use_case') {
activeDiagram.value = 'use_case'
applySavedUseCaseDiagram(diagram)
return
}
Add:
function applySavedUseCaseDiagram(diagram) {
const payload = parseJson(diagram.diagramJson, {})
useCaseDsl.value = typeof payload.dsl === 'string'
? payload.dsl
: buildDefaultUseCaseDsl(project)
}
In currentDiagramPayload(), add before three_line_table:
if (activeDiagram.value === 'use_case') {
const existing = savedDiagrams.value.find((item) => item.diagramType === 'use_case')
return {
diagramId: existing?.diagramId,
diagramType: 'use_case',
title: '用例图',
description: 'DSL 驱动的 UML 用例图草稿',
diagramJson: JSON.stringify({
dsl: useCaseDsl.value,
graph: useCaseDiagram.value
})
}
}
- Step 6: Add use case actions and export handlers
Add:
function clearUseCaseDsl() {
useCaseDsl.value = ''
}
function loadUseCaseExample() {
useCaseDsl.value = USE_CASE_EXAMPLE_DSL
ElMessage.success('示例 DSL 已加载')
}
function regenerateUseCaseDsl() {
useCaseDsl.value = buildDefaultUseCaseDsl(project)
ElMessage.success('已根据当前项目重新生成用例图 DSL')
}
function refreshUseCasePreview() {
if (!useCaseDiagram.value.useCases.length) {
ElMessage.warning('请先在 DSL 中添加用例')
return
}
ElMessage.success('预览已更新')
}
function useCaseSvgText() {
return buildUseCaseExportSvg(useCaseDiagram.value)
}
function downloadUseCaseSvg() {
const text = useCaseSvgText()
if (!text) return
saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${useCaseDiagram.value.title || '用例图'}.svg`)
}
async function downloadUseCasePng() {
const text = useCaseSvgText()
if (!text) return
const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const image = new Image()
image.decoding = 'async'
try {
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = reject
image.src = url
})
const canvas = document.createElement('canvas')
canvas.width = useCaseDiagram.value.canvas.width
canvas.height = useCaseDiagram.value.canvas.height
const context = canvas.getContext('2d')
context.fillStyle = '#ffffff'
context.fillRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0)
const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
if (pngBlob) {
saveBlob(pngBlob, `${useCaseDiagram.value.title || '用例图'}.png`)
}
} finally {
URL.revokeObjectURL(url)
}
}
Add SVG helper functions:
function useCaseRelationPath(relation) {
return relation.path || ''
}
function useCaseActorPath(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}`
}
- Step 7: Add scoped styles for the use case workspace
Add styles mirroring the flowchart editor class structure:
.use-case-editor {
display: grid;
min-height: 660px;
background: #ffffff;
}
.use-case-toolbar,
.use-case-dsl-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.use-case-toolbar {
min-height: 68px;
padding: 14px 18px;
border-bottom: 1px solid #e7ecf3;
}
.use-case-toolbar strong,
.use-case-toolbar span {
display: block;
}
.use-case-toolbar strong {
color: #172033;
font-size: 16px;
}
.use-case-toolbar span,
.use-case-dsl-footer span {
margin-top: 4px;
color: #667085;
font-size: 13px;
}
.use-case-workbench {
display: grid;
grid-template-columns: minmax(340px, 38%) minmax(0, 1fr);
min-height: 592px;
}
.use-case-dsl-panel {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
padding: 14px;
border-right: 1px solid #e7ecf3;
background: #fbfdff;
}
.use-case-dsl-header {
display: grid;
gap: 10px;
}
.use-case-dsl-header strong,
.use-case-dsl-header span {
display: block;
}
.use-case-dsl-header strong {
color: #172033;
font-size: 15px;
}
.use-case-dsl-header span {
margin-top: 4px;
color: #667085;
font-size: 12px;
line-height: 1.5;
}
.use-case-dsl-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.use-case-dsl-actions :deep(.el-button) {
margin-left: 0;
}
.use-case-grammar {
padding: 9px 10px;
border: 1px solid #dbeafe;
border-radius: 6px;
background: #eff6ff;
color: #475569;
font-size: 12px;
line-height: 1.6;
}
.use-case-dsl-input {
flex: 1;
}
.use-case-dsl-input :deep(.el-textarea__inner) {
min-height: 460px !important;
border-color: #d9e0ea;
border-radius: 6px;
color: #172033;
font-family: Consolas, "Courier New", "Microsoft YaHei", monospace;
font-size: 14px;
line-height: 1.65;
}
.use-case-preview-panel {
display: grid;
min-width: 0;
align-content: start;
background: #f8fafc;
}
.use-case-warnings {
display: grid;
gap: 4px;
padding: 10px 18px;
border-bottom: 1px solid #fde68a;
background: #fffbeb;
color: #92400e;
font-size: 12px;
}
.use-case-preview-scroll {
min-height: 592px;
overflow: auto;
padding: 24px;
}
.use-case-svg {
display: block;
background: #ffffff;
border: 1px solid #d9e0ea;
border-radius: 6px;
font-family: "Microsoft YaHei", Arial, sans-serif;
}
.use-case-svg-title {
fill: #111111;
font-size: 18px;
font-weight: 700;
text-anchor: middle;
}
.use-case-relation {
fill: none;
stroke: #222222;
stroke-width: 1.3;
}
.use-case-relation.is-dependency {
stroke-dasharray: 5 4;
}
.use-case-relation-label {
fill: #111111;
font-size: 11px;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 4px;
text-anchor: middle;
}
.use-case-actor circle,
.use-case-actor path,
.use-case-node ellipse {
fill: #ffffff;
stroke: #222222;
stroke-width: 1.4;
}
.use-case-actor path {
fill: none;
}
.use-case-actor text,
.use-case-node text {
fill: #111111;
font-size: 13px;
text-anchor: middle;
dominant-baseline: middle;
}
In the existing @media (max-width: 980px) block, include .use-case-workbench, .use-case-dsl-panel, and .use-case-toolbar with the same responsive behavior as flowchart/architecture.
- Step 8: Run view and utility tests and verify GREEN
Run:
node easycode-web/src/utils/useCaseDiagram.test.mjs
node easycode-web/src/views/diagramCenterView.test.mjs
Expected: all tests pass.
- Step 9: Commit diagram center integration
git add easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "feat: add use case diagram center tab"
Task 5: Build Verification
Files:
-
Verify:
easycode-web/package.json -
Verify:
easycode-web/src/utils/useCaseDiagram.test.mjs -
Verify:
easycode-web/src/views/diagramCenterView.test.mjs -
Step 1: Run focused tests
Run:
node easycode-web/src/utils/useCaseDiagram.test.mjs
node easycode-web/src/views/diagramCenterView.test.mjs
Expected: both commands pass with no assertion failures.
- Step 2: Run frontend build
Run:
cd easycode-web
npm run build
Expected: Vite production build completes successfully.
- Step 3: Inspect changed files
Run from repo root:
git status --short
git diff --stat HEAD
Expected: only planned use case utility/view files and plan/spec commits are in scope for this feature. Existing unrelated dirty files may still appear and must not be reverted.
- Step 4: Commit final verification notes only if code changed after Task 4
If Task 5 required code changes, commit them:
git add easycode-web/src/utils/useCaseDiagram.js easycode-web/src/utils/useCaseDiagram.test.mjs easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "fix: stabilize use case diagram verification"
If no code changed, do not create an empty commit.
Self-Review
- Spec coverage: The plan covers a dedicated
use_casetab, DSL editor, UML SVG preview, save/restore throughdiagramJson, SVG/PNG export, default project/sample DSL, parser warnings, and focused tests. - Placeholder scan: No task contains unresolved placeholder language or vague implementation instructions without concrete behavior.
- Type consistency: The plan consistently uses
useCaseDsl,parsedUseCase,useCaseDiagram,buildDefaultUseCaseDsl,parseUseCaseDsl,buildUseCaseDiagram, andbuildUseCaseExportSvg.