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

1261 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```js
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:
```powershell
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:
```js
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:
```powershell
cd easycode-web
node --test src/utils/classDiagram.test.mjs
```
Expected: PASS, 6 tests.
- [ ] **Step 3: Run adjacent diagram utility tests**
Run:
```powershell
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`:
```js
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:
```powershell
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:
```js
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:
```js
const classDiagramDsl = ref('')
```
Near the existing parsed diagram computed values, add:
```js
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:
```vue
<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:
```js
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:
```js
if (diagram.diagramType === 'class_diagram') {
activeDiagram.value = 'class_diagram'
applySavedClassDiagram(diagram)
return
}
```
Near `applySavedUseCaseDiagram`, add:
```js
function applySavedClassDiagram(diagram) {
const payload = parseJson(diagram.diagramJson, {})
classDiagramDsl.value = typeof payload.dsl === 'string'
? payload.dsl
: buildDefaultClassDiagramDsl(project, database.value)
}
```
In `currentDiagramPayload`, add:
```js
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:
```js
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:
```scss
.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:
```powershell
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:
```powershell
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:
```powershell
cd easycode-web
npm run build
```
Expected: Vite build exits 0.
- [ ] **Step 3: Inspect the final diff**
Run:
```powershell
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.