Files
yidaima/RuoYi-Vue/docs/superpowers/plans/2026-07-07-uml-class-diagram-center.md

46 KiB
Raw Permalink Blame History

UML Class 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 project-aware UML class diagram editor to the EasyCode diagram center, launched from 更多图表 > 生成 UML 类图.

Architecture: Implement a focused classDiagram utility that converts project database tables into editable DSL, parses that DSL into a deterministic graph, lays out class boxes and UML relations, and exports SVG. Wire the graph into DiagramCenterView.vue using the same DSL editor, save/restore, and SVG/PNG export pattern already used by use case, sequence, activity, state, organization, and data-flow diagrams.

Tech Stack: Vue 3 Composition API, Element Plus, plain SVG, Node node:test, no new runtime dependencies.


File Structure

  • Create easycode-web/src/utils/classDiagram.js: all class diagram DSL parsing, default DSL generation, deterministic layout, and SVG export.
  • Create easycode-web/src/utils/classDiagram.test.mjs: behavior tests for parsing, default generation, layout, warnings, and SVG export safety.
  • Modify easycode-web/src/views/DiagramCenterView.vue: import the utility, add class_diagram state/computed/template branch/save/load/export handlers, and reuse existing diagram workbench styles.
  • Modify easycode-web/src/views/diagramCenterView.test.mjs: source-level integration tests for dropdown exposure, direct route, save/restore payload, and export handlers.

Task 1: Class Diagram Utility Tests

Files:

  • Create: easycode-web/src/utils/classDiagram.test.mjs

  • Later implementation target: easycode-web/src/utils/classDiagram.js

  • Step 1: Write the failing utility test

Create easycode-web/src/utils/classDiagram.test.mjs with:

import test from 'node:test'
import assert from 'node:assert/strict'
import {
  CLASS_DIAGRAM_EXAMPLE_DSL,
  buildClassDiagram,
  buildClassDiagramExportSvg,
  buildDefaultClassDiagramDsl,
  parseClassDiagramDsl
} from './classDiagram.js'

const bookstoreDsl = `标题: 在线书店 UML 类图

class 用户 <<abstract>>
- id: Long (PK)
- username: String
- password: String
+ login(): Boolean

class 客户 extends 用户
- level: Integer
- balance: BigDecimal
+ placeOrder(items: List): Order

class 支付接口 <<interface>>
+ pay(): Boolean

class 微信支付 implements 支付接口
- appId: String
+ pay(): Boolean

class 订单
- id: Long (PK)
- totalAmount: BigDecimal
+ cancel(): void

class 订单明细
- id: Long (PK)
- quantity: Integer
- price: BigDecimal

客户 "1" -- "0..*" 订单 : 下单
订单 ..> 微信支付 : 使用
订单 o-- 支付接口 : 支付方式
订单 *-- 订单明细 : 明细`

test('parseClassDiagramDsl recognizes classes, members, stereotypes, and UML relations', () => {
  const parsed = parseClassDiagramDsl(bookstoreDsl)

  assert.equal(parsed.title, '在线书店 UML 类图')
  assert.equal(parsed.warnings.length, 0)
  assert.equal(parsed.classes.length, 6)
  assert.deepEqual(
    parsed.classes.slice(0, 4).map((item) => ({
      name: item.name,
      stereotype: item.stereotype,
      extendsName: item.extendsName,
      implementsNames: item.implementsNames
    })),
    [
      { name: '用户', stereotype: 'abstract', extendsName: '', implementsNames: [] },
      { name: '客户', stereotype: '', extendsName: '用户', implementsNames: [] },
      { name: '支付接口', stereotype: 'interface', extendsName: '', implementsNames: [] },
      { name: '微信支付', stereotype: '', extendsName: '', implementsNames: ['支付接口'] }
    ]
  )
  assert.deepEqual(
    parsed.classes.find((item) => item.name === '用户').attributes.map((item) => `${item.visibility} ${item.name}: ${item.type} ${item.meta}`),
    ['- id: Long PK', '- username: String ', '- password: String ']
  )
  assert.deepEqual(
    parsed.classes.find((item) => item.name === '客户').methods.map((item) => `${item.visibility} ${item.signature}`),
    ['+ placeOrder(items: List): Order']
  )
  assert.ok(parsed.relations.some((item) => item.type === 'inheritance' && item.source === '客户' && item.target === '用户'))
  assert.ok(parsed.relations.some((item) => item.type === 'implementation' && item.source === '微信支付' && item.target === '支付接口'))
  assert.ok(parsed.relations.some((item) => item.type === 'association' && item.source === '客户' && item.target === '订单' && item.sourceMultiplicity === '1' && item.targetMultiplicity === '0..*'))
  assert.ok(parsed.relations.some((item) => item.type === 'dependency' && item.label === '使用'))
  assert.ok(parsed.relations.some((item) => item.type === 'aggregation' && item.label === '支付方式'))
  assert.ok(parsed.relations.some((item) => item.type === 'composition' && item.label === '明细'))
})

test('buildDefaultClassDiagramDsl creates classes and inferred associations from database tables', () => {
  const dsl = buildDefaultClassDiagramDsl(
    { projectName: '在线书店' },
    {
      tables: [
        {
          tableName: 'book',
          tableComment: '图书',
          columns: [
            { columnName: 'id', columnType: 'bigint', columnComment: '图书ID', isPk: '1', isRequired: '1' },
            { columnName: 'title', columnType: 'varchar(100)', columnComment: '标题' },
            { columnName: 'category_id', columnType: 'bigint', columnComment: '分类ID' },
            { columnName: 'price', columnType: 'decimal(10,2)', columnComment: '价格' },
            { columnName: 'create_time', columnType: 'datetime', columnComment: '创建时间' }
          ]
        },
        {
          tableName: 'book_category',
          tableComment: '图书分类',
          columns: [
            { columnName: 'id', columnType: 'bigint', columnComment: '分类ID', isPk: '1' },
            { columnName: 'name', columnType: 'varchar(50)', columnComment: '名称' }
          ]
        }
      ],
      erDiagram: {
        relations: [
          { source: 'book', target: 'book_category', label: '所属分类', type: 'many-to-one' }
        ]
      }
    }
  )

  assert.match(dsl, /标题: 在线书店 UML 类图/)
  assert.match(dsl, /class 图书/)
  assert.match(dsl, /- id: Long \(PK\)/)
  assert.match(dsl, /- title: String/)
  assert.match(dsl, /- price: BigDecimal/)
  assert.match(dsl, /- createTime: LocalDateTime/)
  assert.match(dsl, /图书 "0\.\.\*" -- "1" 图书分类 : 所属分类/)
})

test('parseClassDiagramDsl keeps valid content while reporting malformed lines and missing relation targets', () => {
  const parsed = parseClassDiagramDsl(`标题: 错误类图
class 用户
- id Long
坏行
用户 -- 订单 : 下单`)

  assert.equal(parsed.classes.length, 1)
  assert.ok(parsed.warnings.some((warning) => warning.includes('第 3 行')))
  assert.ok(parsed.warnings.some((warning) => warning.includes('第 4 行')))
  assert.ok(parsed.warnings.some((warning) => warning.includes('关系目标不存在:订单')))
})

test('buildClassDiagram lays out class compartments and relation paths', () => {
  const diagram = buildClassDiagram(parseClassDiagramDsl(bookstoreDsl), { title: '在线书店类图' })

  assert.equal(diagram.title, '在线书店类图')
  assert.equal(diagram.stats.classCount, 6)
  assert.ok(diagram.stats.relationCount >= 6)
  assert.ok(diagram.canvas.width >= 1100)
  assert.ok(diagram.canvas.height >= 620)
  assert.ok(diagram.classes.every((item) => item.width >= 210 && item.height >= 112))
  assert.ok(diagram.relations.every((item) => item.path))
})

test('buildClassDiagramExportSvg serializes safe UML class SVG without foreignObject', () => {
  const diagram = buildClassDiagram(parseClassDiagramDsl(`标题: 安全类图
class 用户<script>
- name: String
+ login(): Boolean`))
  const svg = buildClassDiagramExportSvg(diagram)

  assert.match(svg, /安全类图/)
  assert.match(svg, /用户&lt;script&gt;/)
  assert.match(svg, /login\(\): Boolean/)
  assert.doesNotMatch(svg, /<script>/)
  assert.doesNotMatch(svg, /foreignObject/)
})

test('CLASS_DIAGRAM_EXAMPLE_DSL is a valid class diagram example', () => {
  const parsed = parseClassDiagramDsl(CLASS_DIAGRAM_EXAMPLE_DSL)

  assert.equal(parsed.warnings.length, 0)
  assert.ok(parsed.classes.length >= 4)
  assert.ok(parsed.relations.length >= 3)
})
  • Step 2: Run the utility test to verify it fails

Run:

cd easycode-web
node --test src/utils/classDiagram.test.mjs

Expected: FAIL with Cannot find module .../classDiagram.js.

Task 2: Class Diagram Utility Implementation

Files:

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

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

  • Step 1: Add the class diagram utility

Create easycode-web/src/utils/classDiagram.js with these exported functions and constants:

const MIN_CANVAS_WIDTH = 1100
const MIN_CANVAS_HEIGHT = 620
const CLASS_WIDTH = 230
const CLASS_GAP_X = 92
const CLASS_GAP_Y = 90
const TOP_Y = 74
const LEFT_X = 56
const HEADER_HEIGHT = 46
const MEMBER_ROW_HEIGHT = 20
const SECTION_PADDING = 14

export const CLASS_DIAGRAM_EXAMPLE_DSL = `标题: 在线书店 UML 类图

class 用户 <<abstract>>
- id: Long (PK)
- username: String
- password: String
+ login(): Boolean

class 客户 extends 用户
- level: Integer
- balance: BigDecimal
+ placeOrder(items: List): Order

class 支付接口 <<interface>>
+ pay(): Boolean

class 微信支付 implements 支付接口
- appId: String
+ pay(): Boolean

class 订单
- id: Long (PK)
- totalAmount: BigDecimal
+ cancel(): void

class 订单明细
- id: Long (PK)
- quantity: Integer
- price: BigDecimal

客户 "1" -- "0..*" 订单 : 下单
订单 ..> 微信支付 : 使用
订单 o-- 支付接口 : 支付方式
订单 *-- 订单明细 : 明细`

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

function safeId(value, prefix = 'class') {
  return `${prefix}_${String(value || '').replace(/[^\p{L}\p{N}_-]+/gu, '_')}`
}

function camelName(value) {
  return cleanText(value)
    .replace(/_([a-zA-Z0-9])/gu, (_, char) => char.toUpperCase())
    .replace(/^[A-Z]/u, (char) => char.toLowerCase())
}

function tableDisplayName(table) {
  return cleanText(table?.tableComment || table?.comment || table?.tableName || table?.name || table?.id, '数据表')
}

function tableRawName(table) {
  return cleanText(table?.tableName || table?.name || table?.id)
}

function normalizeSqlType(type) {
  const text = String(type || '').toLowerCase()
  if (/bigint/u.test(text)) return 'Long'
  if (/int|tinyint|smallint/u.test(text)) return 'Integer'
  if (/decimal|numeric|double|float/u.test(text)) return 'BigDecimal'
  if (/datetime|timestamp/u.test(text)) return 'LocalDateTime'
  if (/\bdate\b/u.test(text)) return 'LocalDate'
  if (/time/u.test(text)) return 'LocalTime'
  if (/bool|bit/u.test(text)) return 'Boolean'
  if (/json/u.test(text)) return 'String'
  return 'String'
}

function isPrimaryKey(column) {
  return column?.isPk === '1' || column?.pk === true || column?.primaryKey === true || column?.columnKey === 'PRI'
}

function splitRelationLine(text) {
  const match = cleanText(text).match(/^(.+?)(?:\s+"([^"]+)")?\s+(--\|>|..\|>|<\|--|<\|..|..\>|o--|\*--|--|<--)\s+(?:"([^"]+)"\s+)?(.+?)(?:\s*[:]\s*(.+))?$/u)
  if (!match) return null
  return {
    source: cleanText(match[1]),
    sourceMultiplicity: cleanText(match[2]),
    operator: match[3],
    targetMultiplicity: cleanText(match[4]),
    target: cleanText(match[5]),
    label: cleanText(match[6])
  }
}

function relationType(operator) {
  if (operator === '--|>' || operator === '<|--') return 'inheritance'
  if (operator === '..|>' || operator === '<|..') return 'implementation'
  if (operator === '..>') return 'dependency'
  if (operator === 'o--') return 'aggregation'
  if (operator === '*--') return 'composition'
  return 'association'
}

function normalizeRelationDirection(relation) {
  if (relation.operator === '<|--' || relation.operator === '<|..') {
    return {
      ...relation,
      source: relation.target,
      target: relation.source,
      sourceMultiplicity: relation.targetMultiplicity,
      targetMultiplicity: relation.sourceMultiplicity
    }
  }
  return relation
}

function parseClassHeader(text, lineNumber, warnings) {
  const match = cleanText(text).match(/^class\s+(.+?)(?:\s+extends\s+(.+?))?(?:\s+implements\s+(.+?))?(?:\s+<<(.+?)>>)?$/iu)
  if (!match) {
    warnings.push(`第 ${lineNumber} 行类声明格式不正确:${text}`)
    return null
  }
  const rawName = cleanText(match[1])
  const name = rawName.replace(/\s*<<.+?>>\s*$/u, '').trim()
  const inlineStereo = rawName.match(/<<(.+?)>>/u)
  return {
    id: safeId(name),
    name,
    stereotype: cleanText(match[4] || inlineStereo?.[1]),
    extendsName: cleanText(match[2]),
    implementsNames: cleanText(match[3])
      ? match[3].split(/[,,、]/u).map((item) => cleanText(item)).filter(Boolean)
      : [],
    attributes: [],
    methods: [],
    lineNumber
  }
}

function parseAttribute(text) {
  const match = cleanText(text).match(/^([+\-#])\s*([^:()]+)\s*[:]\s*([^(]+?)(?:\s*\((.+?)\))?$/u)
  if (!match) return null
  return {
    visibility: match[1],
    name: cleanText(match[2]),
    type: cleanText(match[3]),
    meta: cleanText(match[4])
  }
}

function parseMethod(text) {
  const match = cleanText(text).match(/^([+\-#])?\s*([^\s].*?\([^)]*\)(?:\s*[:]\s*.+)?)$/u)
  if (!match || !/\(/u.test(match[2])) return null
  return {
    visibility: cleanText(match[1], '+'),
    signature: cleanText(match[2]).replace(/\s*\s*/gu, ': ')
  }
}

export function parseClassDiagramDsl(dsl = '') {
  const classes = []
  const relations = []
  const warnings = []
  let title = 'UML 类图'
  let currentClass = null

  String(dsl || '').split(/\r?\n/u).forEach((rawLine, index) => {
    const text = cleanText(rawLine)
    if (!text || text.startsWith('#')) return
    const lineNumber = index + 1
    const titleMatch = text.match(/^(标题|title)[:]\s*(.+)$/iu)
    if (titleMatch) {
      title = cleanText(titleMatch[2], title)
      return
    }
    if (/^class\s+/iu.test(text)) {
      currentClass = parseClassHeader(text, lineNumber, warnings)
      if (currentClass) classes.push(currentClass)
      return
    }
    const relation = splitRelationLine(text)
    if (relation) {
      relations.push({ id: `relation_${relations.length + 1}`, ...normalizeRelationDirection({ ...relation, type: relationType(relation.operator) }) })
      currentClass = null
      return
    }
    const attribute = parseAttribute(text)
    if (attribute && currentClass) {
      currentClass.attributes.push(attribute)
      return
    }
    const method = parseMethod(text)
    if (method && currentClass) {
      currentClass.methods.push(method)
      return
    }
    warnings.push(`第 ${lineNumber} 行格式不正确:${text}`)
  })

  classes.forEach((item) => {
    if (item.extendsName) {
      relations.push({ id: `relation_${relations.length + 1}`, type: 'inheritance', source: item.name, target: item.extendsName, label: '', sourceMultiplicity: '', targetMultiplicity: '' })
    }
    item.implementsNames.forEach((target) => {
      relations.push({ id: `relation_${relations.length + 1}`, type: 'implementation', source: item.name, target, label: '', sourceMultiplicity: '', targetMultiplicity: '' })
    })
  })

  const classNames = new Set(classes.map((item) => item.name))
  relations.forEach((relation) => {
    if (!classNames.has(relation.source)) warnings.push(`关系来源不存在:${relation.source}`)
    if (!classNames.has(relation.target)) warnings.push(`关系目标不存在:${relation.target}`)
  })

  if (!classes.length) warnings.push('请至少添加一个类')
  return { title, classes, relations, warnings, stats: { classCount: classes.length, relationCount: relations.length } }
}

function relationMultiplicityFromType(type) {
  return /many|多|0\.\.\*/iu.test(type || '') ? ['0..*', '1'] : ['1', '1']
}

function inferColumnRelation(table, column, tableByName, displayByRawName) {
  const name = cleanText(column?.columnName || column?.name)
  if (!name.endsWith('_id') || name === 'id') return null
  const targetRaw = name.slice(0, -3)
  const exact = tableByName.get(targetRaw)
  const suffix = [...tableByName.keys()].find((key) => key.endsWith(`_${targetRaw}`) || targetRaw.endsWith(key))
  const target = exact || tableByName.get(suffix)
  if (!target) return null
  return {
    source: tableDisplayName(table),
    target: tableDisplayName(target),
    label: cleanText(column?.columnComment || column?.comment || '关联'),
    sourceMultiplicity: '0..*',
    targetMultiplicity: '1'
  }
}

export function buildDefaultClassDiagramDsl(project = {}, database = {}) {
  const tables = Array.isArray(database?.tables) ? database.tables : []
  if (!tables.length) return CLASS_DIAGRAM_EXAMPLE_DSL
  const tableByName = new Map(tables.map((table) => [tableRawName(table), table]).filter(([name]) => name))
  const lines = [`标题: ${cleanText(project.projectName, '项目')} UML 类图`, '']
  tables.forEach((table) => {
    const className = tableDisplayName(table)
    lines.push(`class ${className}`)
    ;(Array.isArray(table.columns) ? table.columns : []).forEach((column) => {
      const name = camelName(column?.columnName || column?.name)
      if (!name) return
      const type = normalizeSqlType(column?.columnType || column?.type || column?.dataType)
      const meta = isPrimaryKey(column) ? ' (PK)' : ''
      lines.push(`- ${name}: ${type}${meta}`)
    })
    lines.push('')
  })
  const relationKeys = new Set()
  const pushRelation = (source, sourceMultiplicity, targetMultiplicity, target, label) => {
    if (!source || !target || source === target) return
    const key = `${source}->${target}:${label}`
    if (relationKeys.has(key)) return
    relationKeys.add(key)
    lines.push(`${source} "${sourceMultiplicity || '0..*'}" -- "${targetMultiplicity || '1'}" ${target} : ${label || '关联'}`)
  }
  ;(Array.isArray(database?.erDiagram?.relations) ? database.erDiagram.relations : []).forEach((relation) => {
    const sourceTable = tableByName.get(relation.source) || tableByName.get(relation.sourceTable)
    const targetTable = tableByName.get(relation.target) || tableByName.get(relation.targetTable)
    const [sourceMultiplicity, targetMultiplicity] = relationMultiplicityFromType(relation.type)
    pushRelation(tableDisplayName(sourceTable), sourceMultiplicity, targetMultiplicity, tableDisplayName(targetTable), relation.label)
  })
  tables.forEach((table) => {
    ;(Array.isArray(table.columns) ? table.columns : []).forEach((column) => {
      const relation = inferColumnRelation(table, column, tableByName)
      if (relation) pushRelation(relation.source, relation.sourceMultiplicity, relation.targetMultiplicity, relation.target, relation.label)
    })
  })
  return lines.join('\n').trimEnd()
}

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

function classHeight(item) {
  return HEADER_HEIGHT + SECTION_PADDING + Math.max(1, item.attributes.length) * MEMBER_ROW_HEIGHT + SECTION_PADDING + Math.max(1, item.methods.length) * MEMBER_ROW_HEIGHT + SECTION_PADDING
}

function relationPath(source, target) {
  const sourceCenter = { x: source.x + source.width / 2, y: source.y + source.height / 2 }
  const targetCenter = { x: target.x + target.width / 2, y: target.y + target.height / 2 }
  const horizontal = Math.abs(sourceCenter.x - targetCenter.x) >= Math.abs(sourceCenter.y - targetCenter.y)
  if (horizontal) {
    const sourceRight = sourceCenter.x < targetCenter.x
    const start = { x: sourceRight ? source.x + source.width : source.x, y: sourceCenter.y }
    const end = { x: sourceRight ? target.x : target.x + target.width, y: targetCenter.y }
    const midX = (start.x + end.x) / 2
    return { path: `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`, labelX: midX, labelY: (start.y + end.y) / 2 - 8 }
  }
  const sourceBelow = sourceCenter.y < targetCenter.y
  const start = { x: sourceCenter.x, y: sourceBelow ? source.y + source.height : source.y }
  const end = { x: targetCenter.x, y: sourceBelow ? target.y : target.y + target.height }
  const midY = (start.y + end.y) / 2
  return { path: `M ${start.x} ${start.y} L ${start.x} ${midY} L ${end.x} ${midY} L ${end.x} ${end.y}`, labelX: (start.x + end.x) / 2, labelY: midY - 8 }
}

export function buildClassDiagram(parsedClassDiagram, options = {}) {
  const parsed = typeof parsedClassDiagram === 'string' ? parseClassDiagramDsl(parsedClassDiagram) : parsedClassDiagram
  const sourceClasses = Array.isArray(parsed?.classes) ? parsed.classes : []
  const columns = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(Math.max(1, sourceClasses.length)))))
  const rowHeights = []
  const classes = sourceClasses.map((item, index) => {
    const row = Math.floor(index / columns)
    const column = index % columns
    const height = classHeight(item)
    rowHeights[row] = Math.max(rowHeights[row] || 0, height)
    return { ...item, x: LEFT_X + column * (CLASS_WIDTH + CLASS_GAP_X), y: 0, width: CLASS_WIDTH, height, displayName: shortText(item.name, 18) }
  })
  classes.forEach((item, index) => {
    const row = Math.floor(index / columns)
    item.y = TOP_Y + rowHeights.slice(0, row).reduce((sum, value) => sum + value + CLASS_GAP_Y, 0)
  })
  const classMap = new Map(classes.map((item) => [item.name, item]))
  const relations = (Array.isArray(parsed?.relations) ? parsed.relations : [])
    .map((relation) => {
      const source = classMap.get(relation.source)
      const target = classMap.get(relation.target)
      if (!source || !target) return null
      return { ...relation, ...relationPath(source, target) }
    })
    .filter(Boolean)
  const canvasWidth = Math.max(MIN_CANVAS_WIDTH, LEFT_X * 2 + columns * CLASS_WIDTH + (columns - 1) * CLASS_GAP_X)
  const canvasHeight = Math.max(MIN_CANVAS_HEIGHT, TOP_Y + rowHeights.reduce((sum, value) => sum + value + CLASS_GAP_Y, 0) + 40)
  return {
    title: cleanText(options.title, parsed?.title || 'UML 类图'),
    description: cleanText(options.description, '从数据库表结构生成的 UML 类图'),
    classes,
    relations,
    warnings: Array.isArray(parsed?.warnings) ? parsed.warnings : [],
    stats: { classCount: classes.length, relationCount: relations.length },
    canvas: { width: canvasWidth, height: canvasHeight }
  }
}

function escapeSvg(value) {
  return String(value ?? '')
    .replace(/&/gu, '&amp;')
    .replace(/</gu, '&lt;')
    .replace(/>/gu, '&gt;')
    .replace(/"/gu, '&quot;')
    .replace(/'/gu, '&apos;')
}

function renderClassSvg(item) {
  const attributeLines = item.attributes.length ? item.attributes : [{ visibility: '', name: '属性', type: '', meta: '' }]
  const methodLines = item.methods.length ? item.methods : [{ visibility: '', signature: '方法()' }]
  const attributeStart = item.y + HEADER_HEIGHT + 22
  const methodTop = item.y + HEADER_HEIGHT + SECTION_PADDING + attributeLines.length * MEMBER_ROW_HEIGHT
  return `<g class="class-node">
    <rect x="${item.x}" y="${item.y}" width="${item.width}" height="${item.height}" fill="#ffffff" stroke="#1f2937" stroke-width="1.4"/>
    <line x1="${item.x}" y1="${item.y + HEADER_HEIGHT}" x2="${item.x + item.width}" y2="${item.y + HEADER_HEIGHT}" stroke="#1f2937" stroke-width="1.1"/>
    <line x1="${item.x}" y1="${methodTop}" x2="${item.x + item.width}" y2="${methodTop}" stroke="#1f2937" stroke-width="1.1"/>
    ${item.stereotype ? `<text x="${item.x + item.width / 2}" y="${item.y + 17}" text-anchor="middle" font-size="11" fill="#64748b">&lt;&lt;${escapeSvg(item.stereotype)}&gt;&gt;</text>` : ''}
    <text x="${item.x + item.width / 2}" y="${item.y + (item.stereotype ? 35 : 27)}" text-anchor="middle" font-size="14" font-weight="700" fill="#111111">${escapeSvg(item.displayName)}</text>
    ${attributeLines.map((attribute, index) => `<text x="${item.x + 12}" y="${attributeStart + index * MEMBER_ROW_HEIGHT}" font-size="12" fill="#1f2937">${escapeSvg(`${attribute.visibility ? `${attribute.visibility} ` : ''}${shortText(attribute.name, 18)}${attribute.type ? `: ${attribute.type}` : ''}${attribute.meta ? ` (${attribute.meta})` : ''}`)}</text>`).join('\n    ')}
    ${methodLines.map((method, index) => `<text x="${item.x + 12}" y="${methodTop + 22 + index * MEMBER_ROW_HEIGHT}" font-size="12" fill="#1f2937">${escapeSvg(`${method.visibility ? `${method.visibility} ` : ''}${shortText(method.signature, 24)}`)}</text>`).join('\n    ')}
  </g>`
}

function markerForRelation(type) {
  if (type === 'inheritance') return 'url(#class-inheritance)'
  if (type === 'implementation') return 'url(#class-implementation)'
  if (type === 'dependency') return 'url(#class-dependency)'
  return ''
}

function renderRelationSvg(relation) {
  const marker = markerForRelation(relation.type)
  const dash = relation.type === 'implementation' || relation.type === 'dependency' ? ' stroke-dasharray="6 5"' : ''
  const markerAttr = marker ? ` marker-end="${marker}"` : ''
  const diamond = relation.type === 'aggregation' || relation.type === 'composition'
    ? `<circle cx="${relation.labelX}" cy="${relation.labelY + 8}" r="4" fill="${relation.type === 'composition' ? '#1f2937' : '#ffffff'}" stroke="#1f2937"/>`
    : ''
  const label = relation.label ? `<text x="${relation.labelX}" y="${relation.labelY}" text-anchor="middle" font-size="11" fill="#475569" paint-order="stroke" stroke="#ffffff" stroke-width="4">${escapeSvg(relation.label)}</text>` : ''
  return `<g class="class-relation"><path d="${relation.path}" fill="none" stroke="#1f2937" stroke-width="1.3"${dash}${markerAttr}/>${diamond}${label}</g>`
}

export function buildClassDiagramExportSvg(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 classes = Array.isArray(diagram.classes) ? diagram.classes : []
  const relations = Array.isArray(diagram.relations) ? diagram.relations : []
  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="class-inheritance" markerWidth="12" markerHeight="10" refX="10" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#ffffff" stroke="#1f2937"/></marker>
    <marker id="class-implementation" markerWidth="12" markerHeight="10" refX="10" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#ffffff" stroke="#1f2937"/></marker>
    <marker id="class-dependency" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto"><path d="M0,0 L0,6 L8,3 z" fill="#1f2937"/></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">${escapeSvg(diagram.title || 'UML 类图')}</text>
  ${relations.map(renderRelationSvg).join('\n  ')}
  ${classes.map(renderClassSvg).join('\n  ')}
</svg>`
}
  • Step 2: Run the utility test to verify it passes

Run:

cd easycode-web
node --test src/utils/classDiagram.test.mjs

Expected: PASS, 6 tests.

  • Step 3: Run adjacent diagram utility tests

Run:

cd easycode-web
node --test src/utils/useCaseDiagram.test.mjs src/utils/sequenceDiagram.test.mjs src/utils/activityDiagram.test.mjs src/utils/stateDiagram.test.mjs src/utils/dataFlowDiagram.test.mjs src/utils/organizationDiagram.test.mjs

Expected: PASS. This checks the new utility did not require shared helper changes.

Task 3: Diagram Center Source Tests

Files:

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

  • Later implementation target: easycode-web/src/views/DiagramCenterView.vue

  • Step 1: Add failing view wiring tests

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

test('diagram center exposes UML class diagram in the more charts dropdown and direct route', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('<el-dropdown-item command="class_diagram" aria-label="生成 UML 类图">生成 UML 类图</el-dropdown-item>'), true)
  assert.match(source, /class_diagram:\s*'class_diagram'/)
  assert.equal(source.includes("const classDiagramDsl = ref('')"), true)
  assert.equal(source.includes("v-else-if=\"activeDiagram === 'class_diagram'\""), true)
  assert.equal(source.includes('UML 类图 DSL'), true)
  assert.equal(source.includes('v-model="classDiagramDsl"'), true)
})

test('diagram center wires UML class diagram parsing, saving, restoring, and exports', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes("from '@/utils/classDiagram'"), true)
  assert.equal(source.includes('const parsedClassDiagram = computed(() => parseClassDiagramDsl(classDiagramDsl.value))'), true)
  assert.equal(source.includes('const classDiagram = computed(() => buildClassDiagram(parsedClassDiagram.value'), true)
  assert.equal(source.includes('const classDiagramStatsText = computed'), true)
  assert.equal(source.includes('function applySavedClassDiagram'), true)
  assert.equal(source.includes("savedDiagrams.value.find((item) => item.diagramType === 'class_diagram')"), true)
  assert.equal(source.includes("diagramType: 'class_diagram'"), true)
  assert.equal(source.includes('dsl: classDiagramDsl.value'), true)
  assert.equal(source.includes('graph: classDiagram.value'), true)
  assert.equal(source.includes('function classDiagramSvgText'), true)
  assert.equal(source.includes('downloadClassDiagramSvg'), true)
  assert.equal(source.includes('downloadClassDiagramPng'), true)
})
  • Step 2: Run the view test to verify it fails

Run:

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

Expected: FAIL because classDiagramDsl, the class_diagram branch, and export/save handlers do not exist yet.

Task 4: Diagram Center Integration

Files:

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

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

  • Step 1: Import class diagram utility

Add this import after the organization/data-flow diagram imports:

import {
  CLASS_DIAGRAM_EXAMPLE_DSL,
  buildClassDiagram,
  buildClassDiagramExportSvg,
  buildDefaultClassDiagramDsl,
  parseClassDiagramDsl
} from '@/utils/classDiagram'
  • Step 2: Add class diagram state and computed graph

Near the existing DSL refs, add:

const classDiagramDsl = ref('')

Near the existing parsed diagram computed values, add:

const parsedClassDiagram = computed(() => parseClassDiagramDsl(classDiagramDsl.value))
const classDiagram = computed(() => buildClassDiagram(parsedClassDiagram.value, {
  title: `${project.projectName || '项目'} UML 类图`
}))
const classDiagramStatsText = computed(() => {
  const stats = classDiagram.value.stats
  return `${stats.classCount} 类 · ${stats.relationCount} 关系`
})
  • Step 3: Add the class_diagram editor branch

Insert the branch after the sequence editor and before other secondary UML branches:

<section v-else-if="activeDiagram === 'class_diagram'" class="class-diagram-editor">
  <div class="class-diagram-toolbar">
    <div>
      <strong>{{ classDiagram.title }}</strong>
      <span>{{ classDiagramStatsText }}</span>
    </div>
    <div class="toolbar">
      <el-button :icon="Download" :disabled="!classDiagram.classes.length" @click="downloadClassDiagramSvg">SVG</el-button>
      <el-button :icon="Picture" :disabled="!classDiagram.classes.length" @click="downloadClassDiagramPng">PNG</el-button>
    </div>
  </div>

  <div class="class-diagram-workbench">
    <aside class="class-diagram-dsl-panel">
      <div class="class-diagram-dsl-header">
        <div>
          <strong>UML 类图 DSL</strong>
          <span>每个 class 块描述类属性和方法关系行支持继承实现依赖聚合组合和关联</span>
        </div>
        <div class="class-diagram-dsl-actions">
          <el-button :icon="Delete" text @click="clearClassDiagramDsl">清空</el-button>
          <el-button :icon="DocumentCopy" text @click="loadClassDiagramExample">加载示例</el-button>
          <el-button :icon="Refresh" text @click="regenerateClassDiagramDsl">换一换</el-button>
        </div>
      </div>

      <div class="class-diagram-grammar">
        示例class 客户 extends 用户- id: Long (PK)+ login(): Boolean客户 "1" -- "0..*" 订单 : 下单
      </div>

      <el-input
        v-model="classDiagramDsl"
        class="class-diagram-dsl-input"
        type="textarea"
        resize="none"
        :autosize="{ minRows: 22, maxRows: 36 }"
        spellcheck="false"
      />

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

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

      <div class="class-diagram-preview-scroll">
        <svg
          class="class-diagram-svg"
          :viewBox="`0 0 ${classDiagram.canvas.width} ${classDiagram.canvas.height}`"
          :style="{ width: `${classDiagram.canvas.width}px`, height: `${classDiagram.canvas.height}px` }"
        >
          <defs>
            <marker id="class-inheritance-preview" markerWidth="12" markerHeight="10" refX="10" refY="5" orient="auto">
              <path d="M0,0 L10,5 L0,10 Z" fill="#ffffff" stroke="#1f2937" />
            </marker>
            <marker id="class-implementation-preview" markerWidth="12" markerHeight="10" refX="10" refY="5" orient="auto">
              <path d="M0,0 L10,5 L0,10 Z" fill="#ffffff" stroke="#1f2937" />
            </marker>
            <marker id="class-dependency-preview" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto">
              <path d="M0,0 L0,6 L8,3 z" fill="#1f2937" />
            </marker>
          </defs>
          <rect width="100%" height="100%" fill="#ffffff" />
          <text class="class-diagram-svg-title" :x="classDiagram.canvas.width / 2" y="32">
            {{ classDiagram.title }}
          </text>

          <g class="class-relation-layer">
            <g v-for="relation in classDiagram.relations" :key="relation.id" class="class-relation">
              <path
                class="class-relation-path"
                :class="{ 'is-dashed': relation.type === 'implementation' || relation.type === 'dependency' }"
                :d="relation.path"
                :marker-end="classDiagramMarkerEnd(relation)"
              />
              <circle
                v-if="relation.type === 'aggregation' || relation.type === 'composition'"
                :cx="relation.labelX"
                :cy="relation.labelY + 8"
                r="4"
                class="class-relation-diamond"
                :class="{ 'is-filled': relation.type === 'composition' }"
              />
              <text v-if="relation.label" class="class-relation-label" :x="relation.labelX" :y="relation.labelY">
                {{ relation.label }}
              </text>
            </g>
          </g>

          <g class="class-node-layer">
            <g v-for="item in classDiagram.classes" :key="item.id" class="class-node">
              <rect class="class-node-box" :x="item.x" :y="item.y" :width="item.width" :height="item.height" />
              <line class="class-node-divider" :x1="item.x" :y1="item.y + 46" :x2="item.x + item.width" :y2="item.y + 46" />
              <line class="class-node-divider" :x1="item.x" :y1="classDiagramMethodDividerY(item)" :x2="item.x + item.width" :y2="classDiagramMethodDividerY(item)" />
              <text v-if="item.stereotype" class="class-node-stereotype" :x="item.x + item.width / 2" :y="item.y + 17">
                &lt;&lt;{{ item.stereotype }}&gt;&gt;
              </text>
              <text class="class-node-title" :x="item.x + item.width / 2" :y="item.y + (item.stereotype ? 35 : 27)">
                {{ item.displayName }}
              </text>
              <text
                v-for="(attribute, index) in classDiagramAttributes(item)"
                :key="`${item.id}-attr-${index}`"
                class="class-node-member"
                :x="item.x + 12"
                :y="item.y + 68 + index * 20"
              >
                {{ attribute }}
              </text>
              <text
                v-for="(method, index) in classDiagramMethods(item)"
                :key="`${item.id}-method-${index}`"
                class="class-node-member"
                :x="item.x + 12"
                :y="classDiagramMethodDividerY(item) + 22 + index * 20"
              >
                {{ method }}
              </text>
            </g>
          </g>
        </svg>
      </div>
    </section>
  </div>
</section>
  • Step 4: Add restore/load/save handlers

In applySavedDrafts, after use case or sequence restoration, add:

const savedClassDiagram = savedDiagrams.value.find((item) => item.diagramType === 'class_diagram')
if (savedClassDiagram) {
  applySavedClassDiagram(savedClassDiagram)
} else {
  classDiagramDsl.value = buildDefaultClassDiagramDsl(project, database.value)
}

In loadSavedDiagram, before generic fallback, add:

if (diagram.diagramType === 'class_diagram') {
  activeDiagram.value = 'class_diagram'
  applySavedClassDiagram(diagram)
  return
}

Near applySavedUseCaseDiagram, add:

function applySavedClassDiagram(diagram) {
  const payload = parseJson(diagram.diagramJson, {})
  classDiagramDsl.value = typeof payload.dsl === 'string'
    ? payload.dsl
    : buildDefaultClassDiagramDsl(project, database.value)
}

In currentDiagramPayload, add:

if (activeDiagram.value === 'class_diagram') {
  const existing = savedDiagrams.value.find((item) => item.diagramType === 'class_diagram')
  return {
    diagramId: existing?.diagramId,
    diagramType: 'class_diagram',
    title: 'UML 类图',
    description: '从数据库表结构生成的 UML 类图草稿',
    diagramJson: JSON.stringify({
      dsl: classDiagramDsl.value,
      graph: classDiagram.value
    })
  }
}
  • Step 5: Add editor actions and export handlers

Near the other DSL actions, add:

function clearClassDiagramDsl() {
  classDiagramDsl.value = ''
}

function loadClassDiagramExample() {
  classDiagramDsl.value = CLASS_DIAGRAM_EXAMPLE_DSL
  ElMessage.success('示例 DSL 已加载')
}

function regenerateClassDiagramDsl() {
  classDiagramDsl.value = buildDefaultClassDiagramDsl(project, database.value)
  ElMessage.success('已根据当前项目重新生成 UML 类图 DSL')
}

function refreshClassDiagramPreview() {
  if (!classDiagram.value.classes.length) {
    ElMessage.warning('请先在 DSL 中添加类')
    return
  }
  if (parsedClassDiagram.value.warnings.length) {
    ElMessage.warning('DSL 还有格式提示,请修正后再使用')
    return
  }
  ElMessage.success('预览已更新')
}

function classDiagramMarkerEnd(relation) {
  if (relation.type === 'inheritance') return 'url(#class-inheritance-preview)'
  if (relation.type === 'implementation') return 'url(#class-implementation-preview)'
  if (relation.type === 'dependency') return 'url(#class-dependency-preview)'
  return ''
}

function classDiagramMethodDividerY(item) {
  const attributes = Array.isArray(item.attributes) && item.attributes.length ? item.attributes : [{}]
  return item.y + 46 + 14 + attributes.length * 20
}

function classDiagramAttributes(item) {
  const attributes = Array.isArray(item.attributes) && item.attributes.length ? item.attributes : [{ visibility: '', name: '属性', type: '', meta: '' }]
  return attributes.map((attribute) => `${attribute.visibility ? `${attribute.visibility} ` : ''}${attribute.name}${attribute.type ? `: ${attribute.type}` : ''}${attribute.meta ? ` (${attribute.meta})` : ''}`)
}

function classDiagramMethods(item) {
  const methods = Array.isArray(item.methods) && item.methods.length ? item.methods : [{ visibility: '', signature: '方法()' }]
  return methods.map((method) => `${method.visibility ? `${method.visibility} ` : ''}${method.signature}`)
}

function classDiagramSvgText() {
  return buildClassDiagramExportSvg(classDiagram.value)
}

function downloadClassDiagramSvg() {
  const text = classDiagramSvgText()
  if (!text) return
  saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${classDiagram.value.title || 'UML 类图'}.svg`)
}

async function downloadClassDiagramPng() {
  const text = classDiagramSvgText()
  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 = classDiagram.value.canvas.width
    canvas.height = classDiagram.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, `${classDiagram.value.title || 'UML 类图'}.png`)
    }
  } finally {
    URL.revokeObjectURL(url)
  }
}
  • Step 6: Add scoped styles

Reuse the existing sequence/use-case style pattern by appending these selector groups:

.class-diagram-editor {
  display: grid;
  min-height: 680px;
  background: #ffffff;
}

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

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

.class-diagram-workbench {
  display: grid;
  grid-template-columns: minmax(360px, 40%) minmax(0, 1fr);
  min-height: 612px;
}

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

.class-diagram-dsl-header,
.class-diagram-dsl-actions,
.class-diagram-warnings {
  display: grid;
  gap: 10px;
}

.class-diagram-dsl-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

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

.class-diagram-dsl-input {
  flex: 1;
}

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

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

.class-diagram-warnings {
  padding: 10px 18px;
  border-bottom: 1px solid #fde68a;
  background: #fffbeb;
  color: #92400e;
  font-size: 12px;
}

.class-diagram-preview-scroll {
  min-height: 612px;
  overflow: auto;
  padding: 24px;
}

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

.class-diagram-svg-title,
.class-node-title,
.class-relation-label,
.class-node-member,
.class-node-stereotype {
  text-anchor: middle;
}

.class-diagram-svg-title {
  fill: #111111;
  font-size: 18px;
  font-weight: 700;
}

.class-node-box {
  fill: #ffffff;
  stroke: #1f2937;
  stroke-width: 1.4;
}

.class-node-divider,
.class-relation-path {
  fill: none;
  stroke: #1f2937;
}

.class-relation-path {
  stroke-width: 1.3;
}

.class-relation-path.is-dashed {
  stroke-dasharray: 6 5;
}

.class-relation-diamond {
  fill: #ffffff;
  stroke: #1f2937;
}

.class-relation-diamond.is-filled {
  fill: #1f2937;
}

.class-relation-label {
  fill: #475569;
  font-size: 11px;
  paint-order: stroke;
  stroke: #ffffff;
  stroke-width: 4px;
}

.class-node-title {
  fill: #111111;
  font-size: 14px;
  font-weight: 700;
}

.class-node-stereotype {
  fill: #64748b;
  font-size: 11px;
}

.class-node-member {
  fill: #1f2937;
  font-size: 12px;
  text-anchor: start;
}

Also add .class-diagram-workbench and .class-diagram-dsl-panel to the existing mobile media-query groups that currently include .architecture-workbench, .flowchart-workbench, .use-case-workbench, and .sequence-workbench.

  • Step 7: Run the view test to verify it passes

Run:

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

Expected: PASS.

Task 5: Full Verification

Files:

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

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

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

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

  • Step 1: Run focused Node tests

Run:

cd easycode-web
node --test src/utils/classDiagram.test.mjs src/views/diagramCenterView.test.mjs src/views/diagramCenterSequenceView.test.mjs src/views/diagramCenterModuleView.test.mjs

Expected: PASS.

  • Step 2: Run a production build

Run:

cd easycode-web
npm run build

Expected: Vite build exits 0.

  • Step 3: Inspect the final diff

Run:

git diff -- easycode-web/src/utils/classDiagram.js easycode-web/src/utils/classDiagram.test.mjs easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterView.test.mjs

Expected: Only class diagram utility, class diagram tests, and scoped diagram center integration changes are present.