46 KiB
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, addclass_diagramstate/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, /用户<script>/)
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, '&')
.replace(/</gu, '<')
.replace(/>/gu, '>')
.replace(/"/gu, '"')
.replace(/'/gu, ''')
}
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"><<${escapeSvg(item.stereotype)}>></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_diagrameditor 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">
<<{{ item.stereotype }}>>
</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.