1151 lines
34 KiB
Markdown
1151 lines
34 KiB
Markdown
# 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, /<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:
|
||
|
||
```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`.
|