1261 lines
46 KiB
Markdown
1261 lines
46 KiB
Markdown
# 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, /用户<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:
|
||
|
||
```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, '&')
|
||
.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:
|
||
|
||
```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">
|
||
<<{{ 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:
|
||
|
||
```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.
|