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`.
|