Files
yidaima/RuoYi-Vue/docs/superpowers/plans/2026-07-07-use-case-diagram-center.md

1151 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
- 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:
```js
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, /&lt;script&gt;/)
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:
```bash
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**
```bash
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:
```js
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)`:
```js
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:
```js
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)`:
```js
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:
```js
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:
```bash
node easycode-web/src/utils/useCaseDiagram.test.mjs
```
Expected: all tests pass.
- [ ] **Step 3: Commit the utility**
```bash
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`:
```js
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:
```bash
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**
```bash
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:
```js
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`:
```vue
<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`:
```vue
<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:
```js
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`:
```js
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:
```js
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:
```js
if (diagram.diagramType === 'use_case') {
activeDiagram.value = 'use_case'
applySavedUseCaseDiagram(diagram)
return
}
```
Add:
```js
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`:
```js
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:
```js
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:
```js
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:
```scss
.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:
```bash
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**
```bash
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:
```bash
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:
```bash
cd easycode-web
npm run build
```
Expected: Vite production build completes successfully.
- [ ] **Step 3: Inspect changed files**
Run from repo root:
```bash
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:
```bash
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_case` tab, DSL editor, UML SVG preview, save/restore through `diagramJson`, 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`, and `buildUseCaseExportSvg`.