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.
|