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

34 KiB
Raw Permalink Blame History

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:

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:

node easycode-web/src/utils/useCaseDiagram.test.mjs

Expected: ERR_MODULE_NOT_FOUND for easycode-web/src/utils/useCaseDiagram.js.

  • Step 3: Commit the failing test
git add easycode-web/src/utils/useCaseDiagram.test.mjs
git commit -m "test: add use case diagram utility coverage"

Task 2: Use Case Diagram Utility

Files:

  • Create: easycode-web/src/utils/useCaseDiagram.js

  • Test: easycode-web/src/utils/useCaseDiagram.test.mjs

  • Step 1: Implement pure parser, layout, and export utility

Create easycode-web/src/utils/useCaseDiagram.js with these exports and helper responsibilities:

const MIN_CANVAS_WIDTH = 980
const MIN_CANVAS_HEIGHT = 560
const ACTOR_X = 96
const PRIMARY_X = 360
const CHILD_X = 690
const TOP_Y = 76
const PRIMARY_GAP_Y = 118
const CHILD_GAP_Y = 58
const OVAL_WIDTH = 172
const OVAL_HEIGHT = 48

export const USE_CASE_EXAMPLE_DSL = `用户
浏览商品
  搜索商品
  查看商品详情
  筛选商品
加入购物车
  选择规格
  修改数量
直接购买
  选择规格
提交订单
  选择收货地址
  选择优惠券
支付订单
  选择支付方式
  输入支付密码
确认收货
  查看物流
评价商品
  上传图片
  打分`

function cleanText(value, fallback = '') {
  const text = String(value ?? '').trim()
  return text || fallback
}

function parseJson(value, fallback = {}) {
  if (!value) return fallback
  if (typeof value === 'object') return value
  try {
    const parsed = JSON.parse(value)
    return parsed && typeof parsed === 'object' ? parsed : fallback
  } catch (error) {
    return fallback
  }
}

function countIndent(line) {
  const match = String(line ?? '').match(/^[\t ]*/u)
  return match ? match[0].replace(/\t/gu, '  ').length : 0
}

function makeUseCaseId(index) {
  return `use_case_${index + 1}`
}

function menuTitle(menu) {
  return cleanText(menu?.name || menu?.title || menu?.code)
}

function uniqueItems(items, max = 8) {
  const seen = new Set()
  return items
    .map((item) => cleanText(item))
    .filter((item) => {
      if (!item || seen.has(item)) return false
      seen.add(item)
      return true
    })
    .slice(0, max)
}

Implement buildDefaultUseCaseDsl(project):

export function buildDefaultUseCaseDsl(project = {}) {
  const blueprint = parseJson(project.appBlueprint, {})
  const frontendMenus = uniqueItems((Array.isArray(blueprint.frontendMenus) ? blueprint.frontendMenus : []).map(menuTitle), 5)
  const adminMenus = uniqueItems((Array.isArray(blueprint.adminMenus) ? blueprint.adminMenus : []).map(menuTitle), 5)

  if (!frontendMenus.length && !adminMenus.length) {
    return USE_CASE_EXAMPLE_DSL
  }

  const lines = ['用户']
  frontendMenus.forEach((name) => {
    lines.push(name)
    lines.push(`  查看${name}`)
    lines.push(`  提交${name}`)
  })
  if (adminMenus.length) {
    lines.push('后台管理')
    adminMenus.forEach((name) => {
      lines.push(`  ${name}`)
    })
  }
  return lines.join('\n')
}

Implement parseUseCaseDsl(dsl) with this behavior:

export function parseUseCaseDsl(dsl = '') {
  const actors = [{ id: 'actor_1', name: '用户' }]
  const useCases = []
  const primaryUseCases = []
  const relations = []
  const warnings = []
  const lines = String(dsl || '').split(/\r?\n/u)
  let actorSet = false
  let currentPrimary = null

  lines.forEach((rawLine, index) => {
    if (!cleanText(rawLine)) return
    const lineNumber = index + 1
    const indent = countIndent(rawLine)
    const text = cleanText(rawLine)
    const actorMatch = text.match(/^角色[:]\s*(.+)$/u)
    const arrowMatch = text.match(/^(.+?)\s*->\s*(.+)$/u)

    if (actorMatch && indent === 0) {
      actors[0].name = cleanText(actorMatch[1], '用户')
      actorSet = true
      return
    }

    if (!actorSet && indent === 0 && !arrowMatch && useCases.length === 0) {
      actors[0].name = text
      actorSet = true
      return
    }

    const rawName = arrowMatch ? cleanText(arrowMatch[2]) : text
    const optional = rawName.endsWith('?')
    const name = optional ? cleanText(rawName.slice(0, -1)) : rawName

    if (!actorSet && indent > 0) {
      warnings.push(`第 ${lineNumber} 行缺少参与者,已使用默认参与者“用户”`)
      actorSet = true
    }

    const useCase = {
      id: makeUseCaseId(useCases.length),
      name,
      optional,
      lineNumber,
      parentId: '',
      kind: indent >= 2 && currentPrimary ? 'child' : 'primary'
    }
    useCases.push(useCase)

    if (useCase.kind === 'primary') {
      currentPrimary = useCase
      primaryUseCases.push(useCase)
      relations.push({ id: `relation_${relations.length + 1}`, type: 'association', source: actors[0].id, target: useCase.id, label: '' })
      return
    }

    useCase.parentId = currentPrimary.id
    relations.push({
      id: `relation_${relations.length + 1}`,
      type: optional ? 'extend' : 'include',
      source: optional ? useCase.id : currentPrimary.id,
      target: optional ? currentPrimary.id : useCase.id,
      label: optional ? '<<extend>>' : '<<include>>'
    })
  })

  if (!useCases.length) {
    warnings.push('请至少添加一个用例')
  }
  if (!actorSet) {
    warnings.push('缺少参与者,已使用默认参与者“用户”')
  }

  return {
    actors,
    useCases,
    primaryUseCases,
    relations,
    warnings,
    stats: {
      actorCount: actors.length,
      useCaseCount: useCases.length,
      primaryUseCaseCount: primaryUseCases.length,
      relationCount: relations.length
    }
  }
}

Implement layout helpers and buildUseCaseDiagram(parsed, options):

function shortText(value, max = 14) {
  const chars = Array.from(cleanText(value))
  return chars.length > max ? `${chars.slice(0, max - 1).join('')}...` : chars.join('')
}

function primaryHeight(primary, children) {
  return Math.max(PRIMARY_GAP_Y, Math.max(1, children.length) * CHILD_GAP_Y + 24)
}

export function buildUseCaseDiagram(parsedUseCase, options = {}) {
  const parsed = typeof parsedUseCase === 'string' ? parseUseCaseDsl(parsedUseCase) : parsedUseCase
  const sourceActors = Array.isArray(parsed?.actors) ? parsed.actors : [{ id: 'actor_1', name: '用户' }]
  const sourceUseCases = Array.isArray(parsed?.useCases) ? parsed.useCases : []
  const sourcePrimary = Array.isArray(parsed?.primaryUseCases) ? parsed.primaryUseCases : sourceUseCases.filter((item) => item.kind !== 'child')
  const childrenByParent = new Map()
  sourceUseCases.filter((item) => item.kind === 'child').forEach((item) => {
    if (!childrenByParent.has(item.parentId)) childrenByParent.set(item.parentId, [])
    childrenByParent.get(item.parentId).push(item)
  })

  let cursorY = TOP_Y
  const positioned = []
  sourcePrimary.forEach((primary) => {
    const children = childrenByParent.get(primary.id) || []
    const blockHeight = primaryHeight(primary, children)
    const primaryY = cursorY + blockHeight / 2 - OVAL_HEIGHT / 2
    positioned.push({ ...primary, x: PRIMARY_X, y: primaryY, width: OVAL_WIDTH, height: OVAL_HEIGHT, text: shortText(primary.name), kind: 'primary' })
    children.forEach((child, childIndex) => {
      const childStart = cursorY + blockHeight / 2 - ((children.length - 1) * CHILD_GAP_Y) / 2
      positioned.push({ ...child, x: CHILD_X, y: childStart + childIndex * CHILD_GAP_Y - OVAL_HEIGHT / 2, width: OVAL_WIDTH, height: OVAL_HEIGHT, text: shortText(child.name), kind: 'child' })
    })
    cursorY += blockHeight
  })

  const canvasHeight = Math.max(MIN_CANVAS_HEIGHT, cursorY + 72)
  const actors = sourceActors.map((actor) => ({
    ...actor,
    x: ACTOR_X,
    y: Math.max(TOP_Y + 120, canvasHeight / 2 - 48),
    width: 82,
    height: 98
  }))
  const useCaseMap = new Map(positioned.map((item) => [item.id, item]))
  const actorMap = new Map(actors.map((item) => [item.id, item]))
  const relations = (Array.isArray(parsed?.relations) ? parsed.relations : []).map((relation) => ({
    ...relation,
    ...relationPath(relation, useCaseMap, actorMap)
  })).filter((relation) => relation.path)

  return {
    title: cleanText(options.title, '用例图'),
    actors,
    useCases: positioned,
    relations,
    warnings: Array.isArray(parsed?.warnings) ? parsed.warnings : [],
    stats: {
      actorCount: actors.length,
      useCaseCount: positioned.length,
      primaryUseCaseCount: sourcePrimary.length,
      relationCount: relations.length
    },
    canvas: {
      width: MIN_CANVAS_WIDTH,
      height: canvasHeight
    }
  }
}

Implement relationPath, escapeSvg, actor/ellipse/text render helpers, and buildUseCaseExportSvg(diagram) so export SVG:

export function buildUseCaseExportSvg(diagram = {}) {
  const width = Math.max(1, Number(diagram.canvas?.width) || MIN_CANVAS_WIDTH)
  const height = Math.max(1, Number(diagram.canvas?.height) || MIN_CANVAS_HEIGHT)
  const actors = Array.isArray(diagram.actors) ? diagram.actors : []
  const useCases = Array.isArray(diagram.useCases) ? diagram.useCases : []
  const relations = Array.isArray(diagram.relations) ? diagram.relations : []
  const title = escapeSvg(cleanText(diagram.title, '用例图'))

  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="Microsoft YaHei, Arial, sans-serif">
  <defs>
    <marker id="use-case-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
      <path d="M0,0 L0,6 L8,3 z" fill="#222222"/>
    </marker>
  </defs>
  <rect width="100%" height="100%" fill="#ffffff"/>
  <text x="${width / 2}" y="32" text-anchor="middle" font-size="18" font-weight="700" fill="#111111">${title}</text>
  ${relations.map(renderRelationSvg).join('\n  ')}
  ${actors.map(renderActorSvg).join('\n  ')}
  ${useCases.map(renderUseCaseSvg).join('\n  ')}
</svg>`
}

The implementation must include no foreignObject, must escape XML special characters, and must use dashed arrows for include/extend relations.

  • Step 2: Run the utility test and verify GREEN

Run:

node easycode-web/src/utils/useCaseDiagram.test.mjs

Expected: all tests pass.

  • Step 3: Commit the utility
git add easycode-web/src/utils/useCaseDiagram.js easycode-web/src/utils/useCaseDiagram.test.mjs
git commit -m "feat: add use case diagram utility"

Task 3: Diagram Center Source Tests

Files:

  • Modify: easycode-web/src/views/diagramCenterView.test.mjs

  • Test: easycode-web/src/views/diagramCenterView.test.mjs

  • Step 1: Add failing source-level tests for use case integration

Append these tests to easycode-web/src/views/diagramCenterView.test.mjs:

test('diagram center exposes a dedicated use case diagram tab and direct route', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('label="use_case"'), true)
  assert.match(source, />用例图</)
  assert.match(source, /use_case:\s*'use_case'/)
  assert.equal(source.includes("const useCaseDsl = ref('')"), true)
})

test('diagram center saves and restores use case diagram DSL payloads', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('function applySavedUseCaseDiagram'), true)
  assert.equal(source.includes("savedDiagrams.value.find((item) => item.diagramType === 'use_case')"), true)
  assert.equal(source.includes("diagramType: 'use_case'"), true)
  assert.equal(source.includes('dsl: useCaseDsl.value'), true)
  assert.equal(source.includes('graph: useCaseDiagram.value'), true)
})

test('diagram center wires use case SVG and PNG export handlers', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('buildUseCaseExportSvg'), true)
  assert.equal(source.includes('function useCaseSvgText'), true)
  assert.equal(source.includes('downloadUseCaseSvg'), true)
  assert.equal(source.includes('downloadUseCasePng'), true)
  assert.equal(source.includes('useCaseRelationPath'), true)
})
  • Step 2: Run the view test and verify RED

Run:

node easycode-web/src/views/diagramCenterView.test.mjs

Expected: at least the new tests fail because DiagramCenterView.vue does not yet include use_case tab/source functions.

  • Step 3: Commit the failing view tests
git add easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "test: add diagram center use case coverage"

Task 4: Diagram Center Integration

Files:

  • Modify: easycode-web/src/views/DiagramCenterView.vue

  • Test: easycode-web/src/views/diagramCenterView.test.mjs

  • Test: easycode-web/src/utils/useCaseDiagram.test.mjs

  • Step 1: Import the utility exports

In the script imports of DiagramCenterView.vue, add:

import {
  USE_CASE_EXAMPLE_DSL,
  buildDefaultUseCaseDsl,
  buildUseCaseDiagram,
  buildUseCaseExportSvg,
  parseUseCaseDsl
} from '@/utils/useCaseDiagram'
  • Step 2: Add the diagram tab

In the el-radio-group for diagram kinds, add this button next to flowchart:

<el-radio-button label="use_case">用例图</el-radio-button>
  • Step 3: Add the use case editor template branch

Add this v-else-if branch before three_line_table:

<section v-else-if="activeDiagram === 'use_case'" class="use-case-editor">
  <div class="use-case-toolbar">
    <div>
      <strong>{{ useCaseDiagram.title }}</strong>
      <span>{{ useCaseStatsText }}</span>
    </div>
    <div class="toolbar">
      <el-button :icon="Download" :disabled="!useCaseDiagram.useCases.length" @click="downloadUseCaseSvg">SVG</el-button>
      <el-button :icon="Picture" :disabled="!useCaseDiagram.useCases.length" @click="downloadUseCasePng">PNG</el-button>
    </div>
  </div>

  <div class="use-case-workbench">
    <aside class="use-case-dsl-panel">
      <div class="use-case-dsl-header">
        <div>
          <strong>用例图 DSL</strong>
          <span>首行写参与者顶格写主用例缩进 2 个空格写 include 子用例子用例末尾加 ? 表示 extend</span>
        </div>
        <div class="use-case-dsl-actions">
          <el-button :icon="Delete" text @click="clearUseCaseDsl">清空</el-button>
          <el-button :icon="DocumentCopy" text @click="loadUseCaseExample">加载示例</el-button>
          <el-button :icon="Refresh" text @click="regenerateUseCaseDsl">换一换</el-button>
        </div>
      </div>

      <div class="use-case-grammar">
        示例用户 / 浏览商品 / 两空格搜索商品支持角色: 用户用户 -> 浏览商品的兼容写法
      </div>

      <el-input
        v-model="useCaseDsl"
        class="use-case-dsl-input"
        type="textarea"
        resize="none"
        :autosize="{ minRows: 20, maxRows: 34 }"
        spellcheck="false"
      />

      <div class="use-case-dsl-footer">
        <span v-if="parsedUseCase.warnings.length">{{ parsedUseCase.warnings.length }} 条格式提示</span>
        <span v-else>DSL 已就绪</span>
        <el-button type="primary" :icon="MagicStick" @click="refreshUseCasePreview">生成预览</el-button>
      </div>
    </aside>

    <section class="use-case-preview-panel">
      <div v-if="parsedUseCase.warnings.length" class="use-case-warnings">
        <span v-for="warning in parsedUseCase.warnings.slice(0, 3)" :key="warning">{{ warning }}</span>
      </div>

      <div class="use-case-preview-scroll">
        <svg
          class="use-case-svg"
          :viewBox="`0 0 ${useCaseDiagram.canvas.width} ${useCaseDiagram.canvas.height}`"
          :style="{ width: `${useCaseDiagram.canvas.width}px`, height: `${useCaseDiagram.canvas.height}px` }"
        >
          <defs>
            <marker id="use-case-arrow-preview" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
              <path d="M0,0 L0,6 L8,3 z" fill="#222222" />
            </marker>
          </defs>
          <rect width="100%" height="100%" fill="#ffffff" />
          <text class="use-case-svg-title" :x="useCaseDiagram.canvas.width / 2" y="32">
            {{ useCaseDiagram.title }}
          </text>

          <g class="use-case-relation-layer">
            <g v-for="relation in useCaseDiagram.relations" :key="relation.id">
              <path
                class="use-case-relation"
                :class="{ 'is-dependency': relation.type !== 'association' }"
                :d="useCaseRelationPath(relation)"
                :marker-end="relation.type === 'association' ? null : 'url(#use-case-arrow-preview)'"
              />
              <text
                v-if="relation.label"
                class="use-case-relation-label"
                :x="relation.labelX"
                :y="relation.labelY"
              >
                {{ relation.label }}
              </text>
            </g>
          </g>

          <g class="use-case-actor-layer">
            <g v-for="actor in useCaseDiagram.actors" :key="actor.id" class="use-case-actor">
              <circle :cx="actor.x + actor.width / 2" :cy="actor.y + 12" r="10" />
              <path :d="useCaseActorPath(actor)" />
              <text :x="actor.x + actor.width / 2" :y="actor.y + actor.height">{{ actor.name }}</text>
            </g>
          </g>

          <g class="use-case-node-layer">
            <g v-for="item in useCaseDiagram.useCases" :key="item.id" class="use-case-node">
              <ellipse
                :cx="item.x + item.width / 2"
                :cy="item.y + item.height / 2"
                :rx="item.width / 2"
                :ry="item.height / 2"
              />
              <text :x="item.x + item.width / 2" :y="item.y + item.height / 2">{{ item.text }}</text>
            </g>
          </g>
        </svg>
      </div>
    </section>
  </div>
</section>
  • Step 4: Add state, computed values, and route normalization

Add use_case to the diagram aliases:

const DIAGRAM_ALIASES = {
  er: 'er',
  'module-diagram': 'module',
  module: 'module',
  architecture: 'architecture',
  flowchart: 'flowchart',
  use_case: 'use_case',
  usecase: 'use_case',
  three_line_table: 'three_line_table',
  overview: 'overview',
  ai: 'ai'
}

Add state and computed values near flowchartDsl:

const useCaseDsl = ref('')

const parsedUseCase = computed(() => parseUseCaseDsl(useCaseDsl.value))
const useCaseDiagram = computed(() => buildUseCaseDiagram(parsedUseCase.value, {
  title: `${project.projectName || '项目'}用例图`
}))
const useCaseStatsText = computed(() => {
  const stats = useCaseDiagram.value.stats
  return `${stats.actorCount} 参与者 · ${stats.useCaseCount} 用例`
})
  • Step 5: Add load/save/restore logic

In applySavedDrafts(), add:

const savedUseCase = savedDiagrams.value.find((item) => item.diagramType === 'use_case')
if (savedUseCase) {
  applySavedUseCaseDiagram(savedUseCase)
} else {
  useCaseDsl.value = buildDefaultUseCaseDsl(project)
}

In loadSavedDiagram(diagram), add before AI handling:

if (diagram.diagramType === 'use_case') {
  activeDiagram.value = 'use_case'
  applySavedUseCaseDiagram(diagram)
  return
}

Add:

function applySavedUseCaseDiagram(diagram) {
  const payload = parseJson(diagram.diagramJson, {})
  useCaseDsl.value = typeof payload.dsl === 'string'
    ? payload.dsl
    : buildDefaultUseCaseDsl(project)
}

In currentDiagramPayload(), add before three_line_table:

if (activeDiagram.value === 'use_case') {
  const existing = savedDiagrams.value.find((item) => item.diagramType === 'use_case')
  return {
    diagramId: existing?.diagramId,
    diagramType: 'use_case',
    title: '用例图',
    description: 'DSL 驱动的 UML 用例图草稿',
    diagramJson: JSON.stringify({
      dsl: useCaseDsl.value,
      graph: useCaseDiagram.value
    })
  }
}
  • Step 6: Add use case actions and export handlers

Add:

function clearUseCaseDsl() {
  useCaseDsl.value = ''
}

function loadUseCaseExample() {
  useCaseDsl.value = USE_CASE_EXAMPLE_DSL
  ElMessage.success('示例 DSL 已加载')
}

function regenerateUseCaseDsl() {
  useCaseDsl.value = buildDefaultUseCaseDsl(project)
  ElMessage.success('已根据当前项目重新生成用例图 DSL')
}

function refreshUseCasePreview() {
  if (!useCaseDiagram.value.useCases.length) {
    ElMessage.warning('请先在 DSL 中添加用例')
    return
  }
  ElMessage.success('预览已更新')
}

function useCaseSvgText() {
  return buildUseCaseExportSvg(useCaseDiagram.value)
}

function downloadUseCaseSvg() {
  const text = useCaseSvgText()
  if (!text) return
  saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${useCaseDiagram.value.title || '用例图'}.svg`)
}

async function downloadUseCasePng() {
  const text = useCaseSvgText()
  if (!text) return
  const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const image = new Image()
  image.decoding = 'async'
  try {
    await new Promise((resolve, reject) => {
      image.onload = resolve
      image.onerror = reject
      image.src = url
    })
    const canvas = document.createElement('canvas')
    canvas.width = useCaseDiagram.value.canvas.width
    canvas.height = useCaseDiagram.value.canvas.height
    const context = canvas.getContext('2d')
    context.fillStyle = '#ffffff'
    context.fillRect(0, 0, canvas.width, canvas.height)
    context.drawImage(image, 0, 0)
    const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
    if (pngBlob) {
      saveBlob(pngBlob, `${useCaseDiagram.value.title || '用例图'}.png`)
    }
  } finally {
    URL.revokeObjectURL(url)
  }
}

Add SVG helper functions:

function useCaseRelationPath(relation) {
  return relation.path || ''
}

function useCaseActorPath(actor) {
  const cx = actor.x + actor.width / 2
  const headBottom = actor.y + 23
  const bodyBottom = actor.y + 56
  return `M ${cx} ${headBottom} L ${cx} ${bodyBottom} M ${cx - 24} ${actor.y + 36} L ${cx + 24} ${actor.y + 36} M ${cx} ${bodyBottom} L ${cx - 22} ${actor.y + 82} M ${cx} ${bodyBottom} L ${cx + 22} ${actor.y + 82}`
}
  • Step 7: Add scoped styles for the use case workspace

Add styles mirroring the flowchart editor class structure:

.use-case-editor {
  display: grid;
  min-height: 660px;
  background: #ffffff;
}

.use-case-toolbar,
.use-case-dsl-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}

.use-case-toolbar {
  min-height: 68px;
  padding: 14px 18px;
  border-bottom: 1px solid #e7ecf3;
}

.use-case-toolbar strong,
.use-case-toolbar span {
  display: block;
}

.use-case-toolbar strong {
  color: #172033;
  font-size: 16px;
}

.use-case-toolbar span,
.use-case-dsl-footer span {
  margin-top: 4px;
  color: #667085;
  font-size: 13px;
}

.use-case-workbench {
  display: grid;
  grid-template-columns: minmax(340px, 38%) minmax(0, 1fr);
  min-height: 592px;
}

.use-case-dsl-panel {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: 12px;
  padding: 14px;
  border-right: 1px solid #e7ecf3;
  background: #fbfdff;
}

.use-case-dsl-header {
  display: grid;
  gap: 10px;
}

.use-case-dsl-header strong,
.use-case-dsl-header span {
  display: block;
}

.use-case-dsl-header strong {
  color: #172033;
  font-size: 15px;
}

.use-case-dsl-header span {
  margin-top: 4px;
  color: #667085;
  font-size: 12px;
  line-height: 1.5;
}

.use-case-dsl-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.use-case-dsl-actions :deep(.el-button) {
  margin-left: 0;
}

.use-case-grammar {
  padding: 9px 10px;
  border: 1px solid #dbeafe;
  border-radius: 6px;
  background: #eff6ff;
  color: #475569;
  font-size: 12px;
  line-height: 1.6;
}

.use-case-dsl-input {
  flex: 1;
}

.use-case-dsl-input :deep(.el-textarea__inner) {
  min-height: 460px !important;
  border-color: #d9e0ea;
  border-radius: 6px;
  color: #172033;
  font-family: Consolas, "Courier New", "Microsoft YaHei", monospace;
  font-size: 14px;
  line-height: 1.65;
}

.use-case-preview-panel {
  display: grid;
  min-width: 0;
  align-content: start;
  background: #f8fafc;
}

.use-case-warnings {
  display: grid;
  gap: 4px;
  padding: 10px 18px;
  border-bottom: 1px solid #fde68a;
  background: #fffbeb;
  color: #92400e;
  font-size: 12px;
}

.use-case-preview-scroll {
  min-height: 592px;
  overflow: auto;
  padding: 24px;
}

.use-case-svg {
  display: block;
  background: #ffffff;
  border: 1px solid #d9e0ea;
  border-radius: 6px;
  font-family: "Microsoft YaHei", Arial, sans-serif;
}

.use-case-svg-title {
  fill: #111111;
  font-size: 18px;
  font-weight: 700;
  text-anchor: middle;
}

.use-case-relation {
  fill: none;
  stroke: #222222;
  stroke-width: 1.3;
}

.use-case-relation.is-dependency {
  stroke-dasharray: 5 4;
}

.use-case-relation-label {
  fill: #111111;
  font-size: 11px;
  paint-order: stroke;
  stroke: #ffffff;
  stroke-width: 4px;
  text-anchor: middle;
}

.use-case-actor circle,
.use-case-actor path,
.use-case-node ellipse {
  fill: #ffffff;
  stroke: #222222;
  stroke-width: 1.4;
}

.use-case-actor path {
  fill: none;
}

.use-case-actor text,
.use-case-node text {
  fill: #111111;
  font-size: 13px;
  text-anchor: middle;
  dominant-baseline: middle;
}

In the existing @media (max-width: 980px) block, include .use-case-workbench, .use-case-dsl-panel, and .use-case-toolbar with the same responsive behavior as flowchart/architecture.

  • Step 8: Run view and utility tests and verify GREEN

Run:

node easycode-web/src/utils/useCaseDiagram.test.mjs
node easycode-web/src/views/diagramCenterView.test.mjs

Expected: all tests pass.

  • Step 9: Commit diagram center integration
git add easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "feat: add use case diagram center tab"

Task 5: Build Verification

Files:

  • Verify: easycode-web/package.json

  • Verify: easycode-web/src/utils/useCaseDiagram.test.mjs

  • Verify: easycode-web/src/views/diagramCenterView.test.mjs

  • Step 1: Run focused tests

Run:

node easycode-web/src/utils/useCaseDiagram.test.mjs
node easycode-web/src/views/diagramCenterView.test.mjs

Expected: both commands pass with no assertion failures.

  • Step 2: Run frontend build

Run:

cd easycode-web
npm run build

Expected: Vite production build completes successfully.

  • Step 3: Inspect changed files

Run from repo root:

git status --short
git diff --stat HEAD

Expected: only planned use case utility/view files and plan/spec commits are in scope for this feature. Existing unrelated dirty files may still appear and must not be reverted.

  • Step 4: Commit final verification notes only if code changed after Task 4

If Task 5 required code changes, commit them:

git add easycode-web/src/utils/useCaseDiagram.js easycode-web/src/utils/useCaseDiagram.test.mjs easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterView.test.mjs
git commit -m "fix: stabilize use case diagram verification"

If no code changed, do not create an empty commit.


Self-Review

  • Spec coverage: The plan covers a dedicated use_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.