Files
yidaima/RuoYi-Vue/docs/superpowers/plans/2026-07-07-module-diagram-editor.md
2026-07-07 13:54:16 +08:00

61 KiB
Raw Permalink Blame History

Module Diagram Editor 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: Build a shared function module diagram engine with both project diagram-center integration and a standalone SQL2Doc-style indented-structure editor.

Architecture: Add a shared outline layer, evolve the existing SVG graph/export utilities, then reuse the same AppModuleDiagramView in the project diagram center and standalone editor. The diagram center stays project-aware and persists module diagram source/layout/graph data through the existing diagramJson; the standalone page can also run without a project as a local rendering/export tool.

Tech Stack: Vue 3, Vite, Element Plus, Node node:test, browser SVG/PNG export.


Scope Check

This is one cohesive frontend feature, not multiple independent subsystems. The two entry points share the same parser, graph model, renderer, and export path, so they should be implemented in one plan with shared utilities first.

File Structure

  • Create easycode-web/src/utils/appModuleOutline.js
    • Owns the shared outline model.
    • Parses indented text.
    • Converts app blueprints into outline text.
    • Normalizes labels and recoverable warnings.
  • Create easycode-web/src/utils/appModuleOutline.test.mjs
    • Unit tests for parsing, text serialization, and blueprint conversion.
  • Modify easycode-web/src/utils/appModuleGraph.js
    • Accepts either legacy blueprint input or normalized outline input.
    • Adds density, direction, and font-size options.
    • Keeps deterministic node and edge output.
  • Modify easycode-web/src/utils/appModuleGraph.test.mjs
    • Keeps current compatibility tests and adds outline/layout option coverage.
  • Modify easycode-web/src/utils/appModuleExport.js
    • Keeps standalone SVG generation.
    • Mirrors current visible font sizes and graph options in exported SVG.
  • Modify easycode-web/src/utils/appModuleExport.test.mjs
    • Adds export tests for option-aware SVG output.
  • Modify easycode-web/src/components/AppModuleDiagramView.vue
    • Becomes the shared diagram workspace component.
    • Accepts blueprint or outline text.
    • Emits source mode, outline text, and layout option changes.
    • Exposes SVG and PNG export controls.
  • Create easycode-web/src/components/appModuleDiagramView.test.mjs
    • Static source tests for component props, emits, mode controls, export controls, and warning rendering.
  • Modify easycode-web/src/views/DiagramCenterView.vue
    • Hosts AppModuleDiagramView in project-aware mode.
    • Loads saved module diagram payload from diagramJson.
    • Saves source mode, outline text, options, and graph data.
  • Create easycode-web/src/views/diagramCenterModuleView.test.mjs
    • Static source tests for module diagram persistence and binding.
  • Create easycode-web/src/views/ModuleDiagramEditorView.vue
    • Standalone two-pane editor matching the reference workflow.
    • Supports optional projectId.
  • Create easycode-web/src/views/moduleDiagramEditorView.test.mjs
    • Static source tests for standalone editor layout, project loading, local mode, save, and export.
  • Modify easycode-web/src/router/index.js
    • Adds authenticated project editor route and public/local standalone editor route.
  • Modify easycode-web/src/views/ProjectListView.vue
    • Adds a compact project action to open the project module diagram editor.
  • Modify easycode-web/src/views/projectListView.test.mjs
    • Static source test for the new compact action.

Global Execution Rules

  • The worktree already contains many unrelated modified and untracked files. Before each commit, run git diff --cached --name-status and stage only files listed in that task.
  • Follow TDD: write the failing test, run it and confirm the expected failure, implement the minimum, rerun the task tests.
  • Use the existing frontend test style: node src/path/file.test.mjs from easycode-web.
  • Use production build as final verification: npm run build from easycode-web.

Task 1: Outline Parser And Blueprint Conversion

Files:

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

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

  • Step 1: Write the failing outline utility tests

Create easycode-web/src/utils/appModuleOutline.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import {
  blueprintToModuleOutline,
  moduleOutlineToText,
  normalizeModuleOutline,
  parseModuleOutlineText
} from './appModuleOutline.js'

const studentClubText = `学生社团管理系统
  管理员端
    注册登录
    用户管理
    社团管理
    成员管理
    活动管理
    公告管理
    系统管理
  普通用户端
    注册登录
    社团浏览
    社团申请
    活动浏览
    活动报名
    个人中心`

test('parseModuleOutlineText parses the reference student club outline', () => {
  const result = parseModuleOutlineText(studentClubText)

  assert.deepEqual(result.warnings, [])
  assert.equal(result.outline.title, '学生社团管理系统')
  assert.deepEqual(result.outline.groups.map((group) => group.title), ['管理员端', '普通用户端'])
  assert.deepEqual(result.outline.groups[0].items.map((item) => item.title), [
    '注册登录',
    '用户管理',
    '社团管理',
    '成员管理',
    '活动管理',
    '公告管理',
    '系统管理'
  ])
  assert.deepEqual(result.outline.groups[1].items.map((item) => item.title), [
    '注册登录',
    '社团浏览',
    '社团申请',
    '活动浏览',
    '活动报名',
    '个人中心'
  ])
})

test('parseModuleOutlineText normalizes tabs as two-space indentation', () => {
  const result = parseModuleOutlineText('图书借阅系统\\n\\t管理端\\n\\t\\t图书管理')

  assert.equal(result.outline.title, '图书借阅系统')
  assert.equal(result.outline.groups[0].title, '管理端')
  assert.equal(result.outline.groups[0].items[0].title, '图书管理')
})

test('parseModuleOutlineText accepts imperfect indentation with warnings', () => {
  const result = parseModuleOutlineText('客户管理系统\\n客户模块\\n    客户列表\\n      客户详情')

  assert.equal(result.outline.title, '客户管理系统')
  assert.equal(result.outline.groups[0].title, '客户模块')
  assert.deepEqual(result.outline.groups[0].items.map((item) => item.title), ['客户列表', '客户详情'])
  assert.equal(result.warnings.some((warning) => warning.code === 'GROUP_WITHOUT_INDENT'), true)
})

test('parseModuleOutlineText returns empty outline warnings for blank input', () => {
  const result = parseModuleOutlineText('  \\n\\t')

  assert.equal(result.outline.title, '')
  assert.deepEqual(result.outline.groups, [])
  assert.equal(result.warnings.some((warning) => warning.code === 'EMPTY_OUTLINE'), true)
})

test('blueprintToModuleOutline maps project menus into standard groups', () => {
  const outline = blueprintToModuleOutline({
    projectName: '社团系统',
    adminMenus: [
      { code: 'users', name: '用户管理' },
      { code: 'clubs', title: '社团管理' }
    ],
    frontendMenus: [
      { code: 'browse', name: '社团浏览' },
      { code: 'profile' }
    ]
  }, {
    projectName: '学生社团管理系统'
  })

  assert.equal(outline.title, '学生社团管理系统')
  assert.deepEqual(outline.groups.map((group) => group.title), ['管理员端', '普通用户端'])
  assert.deepEqual(outline.groups[0].items.map((item) => item.title), ['用户管理', '社团管理'])
  assert.deepEqual(outline.groups[1].items.map((item) => item.title), ['社团浏览', 'profile'])
})

test('moduleOutlineToText serializes normalized outlines with two-space levels', () => {
  const outline = normalizeModuleOutline({
    title: '学生社团管理系统',
    groups: [
      { title: '管理员端', items: [{ title: '用户管理' }] },
      { title: '普通用户端', items: [{ title: '社团浏览' }] }
    ]
  })

  assert.equal(moduleOutlineToText(outline), '学生社团管理系统\\n  管理员端\\n    用户管理\\n  普通用户端\\n    社团浏览')
})
  • Step 2: Run the outline tests and verify RED

Run:

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

Expected: FAIL with ERR_MODULE_NOT_FOUND or a named export error for appModuleOutline.js.

  • Step 3: Implement the outline utility

Create easycode-web/src/utils/appModuleOutline.js:

const DEFAULT_ROOT_TITLE = '系统模块设计'
const DEFAULT_ADMIN_GROUP_TITLE = '管理员端'
const DEFAULT_FRONTEND_GROUP_TITLE = '普通用户端'
const FALLBACK_GROUP_TITLE = '未分组模块'

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

function normalizeTabs(line) {
  return String(line ?? '').replace(/\t/g, '  ')
}

function leadingSpaces(line) {
  const match = normalizeTabs(line).match(/^ */)
  return match ? match[0].length : 0
}

function lineText(line) {
  return normalizeTabs(line).trim()
}

function warning(code, line, message) {
  return { code, line, message }
}

function normalizeItem(item) {
  const title = cleanText(item?.title ?? item?.name ?? item?.code)
  return title ? { title } : null
}

export function normalizeModuleOutline(outline = {}) {
  const title = cleanText(outline.title ?? outline.projectName)
  const groups = (Array.isArray(outline.groups) ? outline.groups : [])
    .map((group) => {
      const groupTitle = cleanText(group?.title ?? group?.name ?? group?.code)
      const items = (Array.isArray(group?.items) ? group.items : [])
        .map(normalizeItem)
        .filter(Boolean)
      return groupTitle ? { title: groupTitle, items } : null
    })
    .filter(Boolean)

  return { title, groups }
}

export function parseModuleOutlineText(text = '') {
  const warnings = []
  const rows = String(text ?? '')
    .split(/\r?\n/)
    .map((raw, index) => ({
      raw: normalizeTabs(raw),
      text: lineText(raw),
      line: index + 1,
      indent: leadingSpaces(raw)
    }))
    .filter((row) => row.text)

  if (!rows.length) {
    return {
      outline: { title: '', groups: [] },
      warnings: [warning('EMPTY_OUTLINE', 0, '请输入系统名称和模块结构')]
    }
  }

  const root = rows[0]
  const groups = []
  let currentGroup = null

  rows.slice(1).forEach((row) => {
    if (row.indent < 2) {
      warnings.push(warning('GROUP_WITHOUT_INDENT', row.line, '二级模块建议缩进 2 个空格'))
      currentGroup = { title: row.text, items: [] }
      groups.push(currentGroup)
      return
    }

    if (row.indent < 4) {
      currentGroup = { title: row.text, items: [] }
      groups.push(currentGroup)
      return
    }

    if (!currentGroup) {
      warnings.push(warning('LEAF_WITHOUT_GROUP', row.line, '功能节点缺少上级模块,已放入未分组模块'))
      currentGroup = { title: FALLBACK_GROUP_TITLE, items: [] }
      groups.push(currentGroup)
    }

    currentGroup.items.push({ title: row.text })
  })

  return {
    outline: normalizeModuleOutline({
      title: root.text,
      groups
    }),
    warnings
  }
}

function menuTitle(menu, fallback) {
  return cleanText(menu?.name ?? menu?.title ?? menu?.code, fallback)
}

function menusToItems(menus = [], fallbackPrefix) {
  return (Array.isArray(menus) ? menus : [])
    .filter(Boolean)
    .map((menu, index) => ({ title: menuTitle(menu, `${fallbackPrefix}${index + 1}`) }))
    .filter((item) => item.title)
}

export function blueprintToModuleOutline(blueprint = {}, options = {}) {
  const title = cleanText(options.projectName ?? blueprint.projectName, DEFAULT_ROOT_TITLE)
  const adminItems = menusToItems(blueprint.adminMenus, '后台功能')
  const frontendItems = menusToItems(blueprint.frontendMenus, '前台功能')
  const groups = []

  if (adminItems.length) {
    groups.push({ title: DEFAULT_ADMIN_GROUP_TITLE, items: adminItems })
  }
  if (frontendItems.length) {
    groups.push({ title: DEFAULT_FRONTEND_GROUP_TITLE, items: frontendItems })
  }

  return normalizeModuleOutline({ title, groups })
}

export function moduleOutlineToText(outline = {}) {
  const normalized = normalizeModuleOutline(outline)
  if (!normalized.title && !normalized.groups.length) return ''

  const lines = []
  if (normalized.title) {
    lines.push(normalized.title)
  }
  normalized.groups.forEach((group) => {
    lines.push(`  ${group.title}`)
    group.items.forEach((item) => {
      lines.push(`    ${item.title}`)
    })
  })
  return lines.join('\n')
}
  • Step 4: Run the outline tests and verify GREEN

Run:

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

Expected: PASS, six tests passing.

  • Step 5: Commit the outline parser

Run:

git add -- easycode-web/src/utils/appModuleOutline.js easycode-web/src/utils/appModuleOutline.test.mjs
git diff --cached --name-status
git commit -m "feat: add module outline parser"

Expected staged files:

A	easycode-web/src/utils/appModuleOutline.js
A	easycode-web/src/utils/appModuleOutline.test.mjs

Task 2: Graph Engine Outline Input And Layout Options

Files:

  • Modify: easycode-web/src/utils/appModuleGraph.test.mjs

  • Modify: easycode-web/src/utils/appModuleGraph.js

  • Step 1: Add failing graph tests for outline and options

Append these tests to easycode-web/src/utils/appModuleGraph.test.mjs:

test('buildAppModuleGraph accepts a normalized outline source', () => {
  const graph = buildAppModuleGraph({
    title: '学生社团管理系统',
    groups: [
      { title: '管理员端', items: [{ title: '用户管理' }, { title: '社团管理' }] },
      { title: '普通用户端', items: [{ title: '社团浏览' }] }
    ]
  })

  assert.equal(graph.title, '学生社团管理系统')
  assert.deepEqual(
    graph.groups.map((group) => ({ key: group.key, title: group.title, childIds: group.childIds })),
    [
      { key: 'group_1', title: '管理员端', childIds: ['group_1:module_1', 'group_1:module_2'] },
      { key: 'group_2', title: '普通用户端', childIds: ['group_2:module_1'] }
    ]
  )
  assert.equal(graph.nodes.find((node) => node.id === 'group_1:module_1').title, '用户管理')
})

test('buildAppModuleGraph density options change spacing predictably', () => {
  const outline = {
    title: '学生社团管理系统',
    groups: [
      { title: '管理员端', items: [{ title: '用户管理' }, { title: '社团管理' }, { title: '活动管理' }] },
      { title: '普通用户端', items: [{ title: '社团浏览' }, { title: '活动报名' }, { title: '个人中心' }] }
    ]
  }

  const compact = buildAppModuleGraph(outline, { density: 'compact' })
  const relaxed = buildAppModuleGraph(outline, { density: 'relaxed' })

  assert.equal(compact.options.density, 'compact')
  assert.equal(relaxed.options.density, 'relaxed')
  assert.ok(relaxed.canvas.width > compact.canvas.width)
  assert.ok(relaxed.nodes.find((node) => node.kind === 'module').height > compact.nodes.find((node) => node.kind === 'module').height)
})

test('buildAppModuleGraph font size option is stored on graph and nodes', () => {
  const graph = buildAppModuleGraph({
    title: '学生社团管理系统',
    groups: [{ title: '管理员端', items: [{ title: '用户管理' }] }]
  }, {
    fontSize: 'large'
  })

  assert.equal(graph.options.fontSize, 'large')
  assert.equal(graph.nodes.find((node) => node.kind === 'module').fontSize, 18)
  assert.equal(graph.nodes.find((node) => node.kind === 'root').fontSize, 19)
})

test('buildAppModuleGraph vertical direction produces a valid graph', () => {
  const graph = buildAppModuleGraph({
    title: '学生社团管理系统',
    groups: [
      { title: '管理员端', items: [{ title: '用户管理' }] },
      { title: '普通用户端', items: [{ title: '社团浏览' }] }
    ]
  }, {
    direction: 'vertical'
  })

  assert.equal(graph.options.direction, 'vertical')
  assert.equal(graph.nodes[0].id, 'root')
  assert.equal(graph.edges.length, 4)
  assert.ok(graph.canvas.height > 420)
})
  • Step 2: Run the graph tests and verify RED

Run:

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

Expected: FAIL because buildAppModuleGraph still treats groups input as a legacy blueprint and does not expose options.

  • Step 3: Update graph implementation imports and option configs

At the top of easycode-web/src/utils/appModuleGraph.js, add:

import { blueprintToModuleOutline, normalizeModuleOutline } from './appModuleOutline.js'

Replace the current fixed layout constants with:

const CANVAS_MIN_WIDTH = 640
const DEFAULT_ROOT_TITLE = '系统模块设计'
const CAPTION = '系统功能结构图'

const DENSITY_CONFIGS = {
  compact: {
    canvasHeight: 390,
    rootWidth: 230,
    rootHeight: 40,
    groupWidth: 112,
    groupHeight: 38,
    moduleWidth: 44,
    moduleHeight: 124,
    moduleGap: 10,
    groupGap: 72,
    rootY: 16,
    rootBranchY: 78,
    groupY: 98,
    moduleBranchY: 160,
    moduleY: 180
  },
  standard: {
    canvasHeight: 420,
    rootWidth: 260,
    rootHeight: 42,
    groupWidth: 128,
    groupHeight: 40,
    moduleWidth: 48,
    moduleHeight: 138,
    moduleGap: 14,
    groupGap: 96,
    rootY: 18,
    rootBranchY: 88,
    groupY: 110,
    moduleBranchY: 178,
    moduleY: 198
  },
  relaxed: {
    canvasHeight: 460,
    rootWidth: 280,
    rootHeight: 46,
    groupWidth: 140,
    groupHeight: 44,
    moduleWidth: 54,
    moduleHeight: 154,
    moduleGap: 18,
    groupGap: 124,
    rootY: 20,
    rootBranchY: 100,
    groupY: 126,
    moduleBranchY: 202,
    moduleY: 224
  }
}

const FONT_SIZE_CONFIGS = {
  small: { root: 16, group: 15, module: 15, caption: 13 },
  standard: { root: 17, group: 16, module: 16, caption: 14 },
  large: { root: 19, group: 18, module: 18, caption: 15 }
}

Add these helpers below uniqueKey:

function normalizedOptions(options = {}) {
  const density = DENSITY_CONFIGS[options.density] ? options.density : 'standard'
  const direction = options.direction === 'vertical' ? 'vertical' : 'horizontal'
  const fontSize = FONT_SIZE_CONFIGS[options.fontSize] ? options.fontSize : 'standard'
  return { density, direction, fontSize }
}

function layoutConfig(options) {
  return DENSITY_CONFIGS[options.density]
}

function fontConfig(options) {
  return FONT_SIZE_CONFIGS[options.fontSize]
}

function sourceToOutline(source, options = {}) {
  if (Array.isArray(source?.groups)) {
    return normalizeModuleOutline(source)
  }
  return blueprintToModuleOutline(source, options)
}
  • Step 4: Replace section building with outline sections

Replace normalizeMenus and buildSections with this compatibility-preserving implementation:

function normalizeOutlineItems(items, lane, fallbackPrefix) {
  const used = new Set()
  return (Array.isArray(items) ? items : []).filter(Boolean).map((item, index) => {
    const code = uniqueKey(item.code ?? item.title, fallbackPrefix, index, used)
    return {
      code,
      id: `${lane}:${code}`,
      title: cleanText(item.title ?? item.name, code),
      kind: 'module',
      lane,
      vertical: true
    }
  })
}

function buildSections(outline, source = {}) {
  if (!Array.isArray(source?.groups)) {
    return [
      { key: 'admin', title: '管理员端', lane: 'admin', items: normalizeOutlineItems(outline.groups.find((group) => group.title === '管理员端')?.items, 'admin', 'admin_menu') },
      { key: 'frontend', title: '普通用户端', lane: 'frontend', items: normalizeOutlineItems(outline.groups.find((group) => group.title === '普通用户端')?.items, 'frontend', 'frontend_menu') }
    ].filter((section) => section.items.length)
  }

  return (Array.isArray(outline.groups) ? outline.groups : [])
    .map((group, index) => ({
      key: `group_${index + 1}`,
      title: cleanText(group.title, `模块${index + 1}`),
      lane: `group_${index + 1}`,
      items: normalizeOutlineItems(group.items, `group_${index + 1}`, 'module')
    }))
    .filter((section) => section.items.length)
}

This keeps legacy blueprint callers on admin/frontend section keys while outline callers use deterministic group_1, group_2, and so on.

  • Step 5: Update horizontal layout to use config and options

Inside buildAppModuleGraph, replace the current setup with:

export function buildAppModuleGraph(source = {}, options = {}) {
  const graphOptions = normalizedOptions(options)
  const config = layoutConfig(graphOptions)
  const fonts = fontConfig(graphOptions)
  const outline = sourceToOutline(source, options)
  const rootTitle = cleanText(outline.title, DEFAULT_ROOT_TITLE)
  const sections = buildSections(outline, source)

  if (!sections.length) {
    return {
      title: rootTitle,
      caption: CAPTION,
      nodes: [],
      edges: [],
      groups: [],
      options: graphOptions,
      canvas: {
        width: CANVAS_MIN_WIDTH,
        height: config.canvasHeight
      }
    }
  }

  if (graphOptions.direction === 'vertical') {
    return buildVerticalGraph(rootTitle, sections, graphOptions, config, fonts)
  }

  return buildHorizontalGraph(rootTitle, sections, graphOptions, config, fonts)
}

Add buildHorizontalGraph by moving the existing body into a function and replacing constants with config values. Every node should set fontSize:

function buildHorizontalGraph(rootTitle, sections, graphOptions, config, fonts) {
  const sectionWidths = sections.map((section) => groupWidth(section.items.length, config))
  const sectionsWidth = sectionWidths.reduce((sum, width) => sum + width, 0) + config.groupGap * Math.max(sections.length - 1, 0)
  const canvasWidth = Math.max(CANVAS_MIN_WIDTH, sectionsWidth + 96)
  const root = {
    id: 'root',
    title: rootTitle,
    kind: 'root',
    lane: 'root',
    x: (canvasWidth - config.rootWidth) / 2,
    y: config.rootY,
    width: config.rootWidth,
    height: config.rootHeight,
    fontSize: fonts.root
  }

  const nodes = [root]
  const edges = []
  const groups = []
  let sectionX = (canvasWidth - sectionsWidth) / 2

  sections.forEach((section, sectionIndex) => {
    const width = sectionWidths[sectionIndex]
    const groupNode = {
      id: `group:${section.key}`,
      title: section.title,
      kind: 'group',
      lane: section.lane,
      x: sectionX + (width - config.groupWidth) / 2,
      y: config.groupY,
      width: config.groupWidth,
      height: config.groupHeight,
      fontSize: fonts.group
    }
    const moduleRowWidth = section.items.length * config.moduleWidth + Math.max(section.items.length - 1, 0) * config.moduleGap
    const moduleStartX = sectionX + (width - moduleRowWidth) / 2
    const childIds = section.items.map((item) => item.id)

    groups.push({
      key: section.key,
      title: section.title,
      nodeId: groupNode.id,
      childIds,
      x: sectionX,
      y: config.groupY,
      width,
      height: config.moduleY + config.moduleHeight - config.groupY
    })
    nodes.push(groupNode)
    edges.push({
      id: `root->${groupNode.id}`,
      source: root.id,
      target: groupNode.id,
      path: treePath(root, groupNode, config.rootBranchY)
    })

    section.items.forEach((item, itemIndex) => {
      const moduleNode = {
        ...item,
        x: moduleStartX + itemIndex * (config.moduleWidth + config.moduleGap),
        y: config.moduleY,
        width: config.moduleWidth,
        height: config.moduleHeight,
        fontSize: fonts.module
      }
      nodes.push(moduleNode)
      edges.push({
        id: `${groupNode.id}->${moduleNode.id}`,
        source: groupNode.id,
        target: moduleNode.id,
        path: treePath(groupNode, moduleNode, config.moduleBranchY)
      })
    })

    sectionX += width + config.groupGap
  })

  return {
    title: root.title,
    caption: CAPTION,
    nodes,
    edges,
    groups,
    options: graphOptions,
    canvas: {
      width: canvasWidth,
      height: config.canvasHeight
    }
  }
}

Update groupWidth:

function groupWidth(itemCount, config) {
  const moduleWidth = itemCount * config.moduleWidth + Math.max(itemCount - 1, 0) * config.moduleGap
  return Math.max(config.groupWidth, moduleWidth)
}
  • Step 6: Add vertical graph implementation

Add this minimal deterministic vertical layout:

function buildVerticalGraph(rootTitle, sections, graphOptions, config, fonts) {
  const groupBlockHeight = Math.max(config.groupHeight, config.moduleHeight / 2)
  const groupGap = config.groupGap / 2
  const moduleGap = config.moduleGap
  const leftX = 48
  const groupX = leftX + config.rootWidth + 80
  const moduleX = groupX + config.groupWidth + 80
  const startY = 36
  const root = {
    id: 'root',
    title: rootTitle,
    kind: 'root',
    lane: 'root',
    x: leftX,
    y: startY,
    width: config.rootWidth,
    height: config.rootHeight,
    fontSize: fonts.root
  }

  const nodes = [root]
  const edges = []
  const groups = []
  let y = startY

  sections.forEach((section) => {
    const moduleColumnHeight = section.items.length * config.groupHeight + Math.max(section.items.length - 1, 0) * moduleGap
    const blockHeight = Math.max(groupBlockHeight, moduleColumnHeight)
    const groupNode = {
      id: `group:${section.key}`,
      title: section.title,
      kind: 'group',
      lane: section.lane,
      x: groupX,
      y,
      width: config.groupWidth,
      height: config.groupHeight,
      fontSize: fonts.group
    }
    nodes.push(groupNode)
    edges.push({
      id: `root->${groupNode.id}`,
      source: root.id,
      target: groupNode.id,
      path: horizontalTreePath(root, groupNode)
    })

    const childIds = []
    section.items.forEach((item, itemIndex) => {
      const moduleNode = {
        ...item,
        x: moduleX,
        y: y + itemIndex * (config.groupHeight + moduleGap),
        width: config.groupWidth,
        height: config.groupHeight,
        vertical: false,
        fontSize: fonts.module
      }
      childIds.push(moduleNode.id)
      nodes.push(moduleNode)
      edges.push({
        id: `${groupNode.id}->${moduleNode.id}`,
        source: groupNode.id,
        target: moduleNode.id,
        path: horizontalTreePath(groupNode, moduleNode)
      })
    })

    groups.push({
      key: section.key,
      title: section.title,
      nodeId: groupNode.id,
      childIds,
      x: groupX,
      y,
      width: moduleX + config.groupWidth - groupX,
      height: blockHeight
    })
    y += blockHeight + groupGap
  })

  return {
    title: root.title,
    caption: CAPTION,
    nodes,
    edges,
    groups,
    options: graphOptions,
    canvas: {
      width: moduleX + config.groupWidth + 80,
      height: Math.max(config.canvasHeight, y + 48)
    }
  }
}

function horizontalTreePath(source, target) {
  const sourceX = source.x + source.width
  const sourceY = source.y + source.height / 2
  const targetX = target.x
  const targetY = target.y + target.height / 2
  const branchX = sourceX + (targetX - sourceX) / 2
  return `M ${sourceX} ${sourceY} H ${branchX} V ${targetY} H ${targetX}`
}
  • Step 7: Run graph tests and existing outline tests

Run:

cd easycode-web
node src/utils/appModuleOutline.test.mjs
node src/utils/appModuleGraph.test.mjs

Expected: PASS for both files. If legacy tests fail only on group labels, update expected Chinese labels from 管理后台/用户前台 to 管理员端/普通用户端 because the confirmed design requires the reference wording.

  • Step 8: Commit graph engine changes

Run:

git add -- easycode-web/src/utils/appModuleGraph.js easycode-web/src/utils/appModuleGraph.test.mjs
git diff --cached --name-status
git commit -m "feat: support module diagram outline layouts"

Expected staged files:

M	easycode-web/src/utils/appModuleGraph.js
M	easycode-web/src/utils/appModuleGraph.test.mjs

Task 3: Export SVG Options And Shared Diagram Component Controls

Files:

  • Modify: easycode-web/src/utils/appModuleExport.test.mjs

  • Modify: easycode-web/src/utils/appModuleExport.js

  • Create: easycode-web/src/components/appModuleDiagramView.test.mjs

  • Modify: easycode-web/src/components/AppModuleDiagramView.vue

  • Step 1: Add failing export tests

Append to easycode-web/src/utils/appModuleExport.test.mjs:

test('buildAppModuleExportSvg uses graph node font sizes', () => {
  const svg = buildAppModuleExportSvg({
    canvas: { width: 640, height: 420 },
    caption: '系统功能结构图',
    nodes: [
      {
        id: 'root',
        title: '学生社团管理系统',
        kind: 'root',
        lane: 'root',
        x: 190,
        y: 18,
        width: 260,
        height: 42,
        fontSize: 19
      },
      {
        id: 'group_1:module_1',
        title: '用户管理',
        kind: 'module',
        lane: 'group_1',
        vertical: true,
        x: 300,
        y: 198,
        width: 54,
        height: 154,
        fontSize: 18
      }
    ],
    edges: []
  })

  assert.equal(svg.includes('font-size="19"'), true)
  assert.equal(svg.includes('font-size="18"'), true)
  assert.equal(svg.includes('学生社团管理系统'), true)
  assert.equal(svg.includes('<foreignObject'), false)
})
  • Step 2: Run export tests and verify RED

Run:

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

Expected: FAIL because exported text does not yet use per-node font-size attributes.

  • Step 3: Update export text renderers

In easycode-web/src/utils/appModuleExport.js, update text renderers:

function nodeFontSize(node, fallback) {
  return Number(node.fontSize) || fallback
}

function renderVerticalText(node) {
  const x = node.width / 2
  const startY = verticalTextY(node)
  return `
      <text class="module-label" x="${x}" y="${startY}" font-size="${nodeFontSize(node, 16)}">
        ${verticalChars(node.title).map((char, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : 18}">${escapeXml(char)}</tspan>`).join('')}
      </text>`
}

function renderHorizontalText(node) {
  return `
      <text class="node-title" x="${node.width / 2}" y="${node.height / 2}" font-size="${nodeFontSize(node, node.kind === 'root' ? 17 : 16)}">${escapeXml(shortText(node.title, 18))}</text>`
}

Keep the existing CSS fallback styles. Do not add foreignObject.

  • Step 4: Run export tests and verify GREEN

Run:

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

Expected: PASS.

  • Step 5: Add static component tests

Create easycode-web/src/components/appModuleDiagramView.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'

const currentDir = dirname(fileURLToPath(import.meta.url))

function readComponent(name) {
  return readFileSync(resolve(currentDir, name), 'utf8')
}

test('app module diagram view exposes shared editor controls', () => {
  const source = readComponent('AppModuleDiagramView.vue')

  assert.equal(source.includes('sourceMode'), true)
  assert.equal(source.includes('outlineText'), true)
  assert.equal(source.includes('layoutOptions'), true)
  assert.equal(source.includes('update:sourceMode'), true)
  assert.equal(source.includes('update:outlineText'), true)
  assert.equal(source.includes('update:layoutOptions'), true)
  assert.equal(source.includes('自动蓝图'), true)
  assert.equal(source.includes('缩进结构'), true)
  assert.equal(source.includes('标准'), true)
  assert.equal(source.includes('宽松'), true)
  assert.equal(source.includes('紧凑'), true)
  assert.equal(source.includes('横向'), true)
  assert.equal(source.includes('竖向'), true)
})

test('app module diagram view supports svg and png export from the same graph', () => {
  const source = readComponent('AppModuleDiagramView.vue')

  assert.equal(source.includes('exportSvg'), true)
  assert.equal(source.includes('exportPng'), true)
  assert.equal(source.includes('buildAppModuleExportSvg(graph.value)'), true)
  assert.equal(source.includes('saveBlob'), true)
  assert.equal(source.includes('system-module-diagram.svg'), true)
  assert.equal(source.includes('system-module-diagram.png'), true)
})

test('app module diagram view renders outline warnings next to the editor', () => {
  const source = readComponent('AppModuleDiagramView.vue')

  assert.equal(source.includes('outlineWarnings'), true)
  assert.equal(source.includes('module-warning-list'), true)
  assert.equal(source.includes('v-for="warning in outlineWarnings"'), true)
})
  • Step 6: Run component static tests and verify RED

Run:

cd easycode-web
node src/components/appModuleDiagramView.test.mjs

Expected: FAIL because the component has not yet added editor props, emits, and SVG export.

  • Step 7: Update AppModuleDiagramView.vue props, emits, and graph source

In the script section, add imports:

import { Download, Picture } from '@element-plus/icons-vue'
import {
  blueprintToModuleOutline,
  moduleOutlineToText,
  parseModuleOutlineText
} from '@/utils/appModuleOutline'

Replace props and add emits:

const props = defineProps({
  blueprint: {
    type: Object,
    default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
  },
  projectName: {
    type: String,
    default: ''
  },
  sourceMode: {
    type: String,
    default: 'blueprint'
  },
  outlineText: {
    type: String,
    default: ''
  },
  layoutOptions: {
    type: Object,
    default: () => ({ density: 'standard', direction: 'horizontal', fontSize: 'standard' })
  },
  editable: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['update:sourceMode', 'update:outlineText', 'update:layoutOptions', 'graph-change'])

Add computed state:

const blueprintOutline = computed(() => blueprintToModuleOutline(props.blueprint, { projectName: props.projectName }))
const blueprintOutlineText = computed(() => moduleOutlineToText(blueprintOutline.value))
const activeOutlineText = computed(() => props.sourceMode === 'outline' ? props.outlineText : blueprintOutlineText.value)
const parsedOutline = computed(() => parseModuleOutlineText(activeOutlineText.value))
const outlineWarnings = computed(() => parsedOutline.value.warnings || [])
const graph = computed(() => buildAppModuleGraph(parsedOutline.value.outline, props.layoutOptions))

Add model update helpers:

function setSourceMode(value) {
  emit('update:sourceMode', value)
  if (value === 'outline' && !props.outlineText) {
    emit('update:outlineText', blueprintOutlineText.value)
  }
}

function updateOutlineText(value) {
  emit('update:outlineText', value)
}

function updateLayoutOption(key, value) {
  emit('update:layoutOptions', {
    ...props.layoutOptions,
    [key]: value
  })
}

Add SVG export:

function exportSvg() {
  const svgText = buildAppModuleExportSvg(graph.value)
  saveBlob(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }), 'system-module-diagram.svg')
}
  • Step 8: Update component template and styles

Replace the toolbar template with controls:

<div class="module-toolbar">
  <div class="module-summary">
    <span>节点{{ graph.nodes.length }}</span>
    <span>连线{{ graph.edges.length }}</span>
  </div>
  <div class="module-controls">
    <el-radio-group :model-value="sourceMode" size="small" @change="setSourceMode">
      <el-radio-button label="blueprint">自动蓝图</el-radio-button>
      <el-radio-button label="outline">缩进结构</el-radio-button>
    </el-radio-group>
    <el-radio-group :model-value="layoutOptions.density || 'standard'" size="small" @change="(value) => updateLayoutOption('density', value)">
      <el-radio-button label="compact">紧凑</el-radio-button>
      <el-radio-button label="standard">标准</el-radio-button>
      <el-radio-button label="relaxed">宽松</el-radio-button>
    </el-radio-group>
    <el-radio-group :model-value="layoutOptions.direction || 'horizontal'" size="small" @change="(value) => updateLayoutOption('direction', value)">
      <el-radio-button label="horizontal">横向</el-radio-button>
      <el-radio-button label="vertical">竖向</el-radio-button>
    </el-radio-group>
    <el-select :model-value="layoutOptions.fontSize || 'standard'" size="small" class="module-font-select" @change="(value) => updateLayoutOption('fontSize', value)">
      <el-option label="小" value="small" />
      <el-option label="标准" value="standard" />
      <el-option label="大" value="large" />
    </el-select>
    <el-button :icon="Download" @click="exportSvg">SVG</el-button>
    <el-button :icon="Picture" @click="exportPng">PNG</el-button>
  </div>
</div>

If editable is true, render an editor pane above the SVG:

<div v-if="editable" class="module-editor-pane">
  <el-input
    :model-value="activeOutlineText"
    type="textarea"
    :rows="12"
    spellcheck="false"
    @input="updateOutlineText"
  />
  <div v-if="outlineWarnings.length" class="module-warning-list">
    <el-tag v-for="warning in outlineWarnings" :key="`${warning.code}-${warning.line}`" type="warning" effect="plain">
       {{ warning.line || 1 }} {{ warning.message }}
    </el-tag>
  </div>
</div>

Add styles:

.module-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
}

.module-font-select {
  width: 92px;
}

.module-editor-pane {
  display: grid;
  gap: 10px;
  padding: 16px 18px;
  border-bottom: 1px solid #e5e7eb;
  background: #fbfdff;
}

.module-warning-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
  • Step 9: Run component/export/graph tests

Run:

cd easycode-web
node src/utils/appModuleOutline.test.mjs
node src/utils/appModuleGraph.test.mjs
node src/utils/appModuleExport.test.mjs
node src/components/appModuleDiagramView.test.mjs

Expected: PASS for all four files.

  • Step 10: Commit export and component changes

Run:

git add -- easycode-web/src/utils/appModuleExport.js easycode-web/src/utils/appModuleExport.test.mjs easycode-web/src/components/AppModuleDiagramView.vue easycode-web/src/components/appModuleDiagramView.test.mjs
git diff --cached --name-status
git commit -m "feat: add module diagram editor controls"

Expected staged files:

M	easycode-web/src/utils/appModuleExport.js
M	easycode-web/src/utils/appModuleExport.test.mjs
M	easycode-web/src/components/AppModuleDiagramView.vue
A	easycode-web/src/components/appModuleDiagramView.test.mjs

Task 4: Project Diagram Center Save And Restore

Files:

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

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

  • Step 1: Add failing diagram center static tests

Create easycode-web/src/views/diagramCenterModuleView.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'

const currentDir = dirname(fileURLToPath(import.meta.url))

function readView(name) {
  return readFileSync(resolve(currentDir, name), 'utf8')
}

test('diagram center binds module diagram source and layout state', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('const moduleDiagramSourceMode = ref'), true)
  assert.equal(source.includes('const moduleDiagramOutlineText = ref'), true)
  assert.equal(source.includes('const moduleDiagramLayoutOptions = ref'), true)
  assert.equal(source.includes('v-model:source-mode="moduleDiagramSourceMode"'), true)
  assert.equal(source.includes('v-model:outline-text="moduleDiagramOutlineText"'), true)
  assert.equal(source.includes('v-model:layout-options="moduleDiagramLayoutOptions"'), true)
  assert.equal(source.includes(':editable="true"'), true)
})

test('diagram center saves module diagram source, outline, layout, and graph data', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('function moduleDiagramPayload'), true)
  assert.equal(source.includes('sourceMode: moduleDiagramSourceMode.value'), true)
  assert.equal(source.includes('outlineText: moduleDiagramOutlineText.value'), true)
  assert.equal(source.includes('layoutOptions: moduleDiagramLayoutOptions.value'), true)
  assert.equal(source.includes('graph: moduleDiagramGraph.value'), true)
  assert.equal(source.includes('diagramJson: JSON.stringify(moduleDiagramPayload())'), true)
})

test('diagram center restores saved module diagram payload', () => {
  const source = readView('DiagramCenterView.vue')

  assert.equal(source.includes('function applySavedModuleDiagram'), true)
  assert.equal(source.includes("savedDiagrams.value.find((item) => item.diagramType === 'module')"), true)
  assert.equal(source.includes('moduleDiagramSourceMode.value = payload.sourceMode'), true)
  assert.equal(source.includes('moduleDiagramOutlineText.value = payload.outlineText'), true)
  assert.equal(source.includes('moduleDiagramLayoutOptions.value ='), true)
})
  • Step 2: Run diagram center test and verify RED

Run:

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

Expected: FAIL because module diagram state is not yet in DiagramCenterView.vue.

  • Step 3: Add imports and module diagram state

In DiagramCenterView.vue, import:

import { buildAppModuleGraph } from '@/utils/appModuleGraph'
import { blueprintToModuleOutline, moduleOutlineToText, parseModuleOutlineText } from '@/utils/appModuleOutline'

Add state near savedDiagrams:

const moduleDiagramSourceMode = ref('blueprint')
const moduleDiagramOutlineText = ref('')
const moduleDiagramLayoutOptions = ref({ density: 'standard', direction: 'horizontal', fontSize: 'standard' })
const moduleDiagramGraph = computed(() => {
  const text = moduleDiagramSourceMode.value === 'outline'
    ? moduleDiagramOutlineText.value
    : moduleOutlineToText(blueprintToModuleOutline(appBlueprint.value, { projectName: project.projectName }))
  return buildAppModuleGraph(parseModuleOutlineText(text).outline, moduleDiagramLayoutOptions.value)
})
  • Step 4: Bind module component in template

Replace the module view component usage with:

<AppModuleDiagramView
  v-else-if="activeDiagram === 'module'"
  v-model:source-mode="moduleDiagramSourceMode"
  v-model:outline-text="moduleDiagramOutlineText"
  v-model:layout-options="moduleDiagramLayoutOptions"
  :blueprint="appBlueprint"
  :project-name="project.projectName"
  :editable="true"
/>
  • Step 5: Add save and restore helpers

Add helpers:

function defaultModuleOutlineText() {
  return moduleOutlineToText(blueprintToModuleOutline(appBlueprint.value, { projectName: project.projectName }))
}

function moduleDiagramPayload() {
  const outlineText = moduleDiagramSourceMode.value === 'outline'
    ? moduleDiagramOutlineText.value
    : defaultModuleOutlineText()
  return {
    sourceMode: moduleDiagramSourceMode.value,
    outlineText,
    layoutOptions: moduleDiagramLayoutOptions.value,
    graph: moduleDiagramGraph.value,
    projectName: project.projectName
  }
}

function applySavedModuleDiagram() {
  const saved = savedDiagrams.value.find((item) => item.diagramType === 'module')
  const payload = parseJson(saved?.diagramJson, null)
  if (!payload || typeof payload !== 'object') {
    moduleDiagramSourceMode.value = 'blueprint'
    moduleDiagramOutlineText.value = defaultModuleOutlineText()
    moduleDiagramLayoutOptions.value = { density: 'standard', direction: 'horizontal', fontSize: 'standard' }
    return
  }
  moduleDiagramSourceMode.value = payload.sourceMode === 'outline' ? 'outline' : 'blueprint'
  moduleDiagramOutlineText.value = typeof payload.outlineText === 'string' ? payload.outlineText : defaultModuleOutlineText()
  moduleDiagramLayoutOptions.value = {
    density: payload.layoutOptions?.density || 'standard',
    direction: payload.layoutOptions?.direction || 'horizontal',
    fontSize: payload.layoutOptions?.fontSize || 'standard'
  }
}

Call applySavedModuleDiagram() at the end of applySavedDrafts() after erDraft and AI handling.

Update currentDiagramPayload() module branch:

if (activeDiagram.value === 'module') {
  const existing = savedDiagrams.value.find((item) => item.diagramType === 'module')
  return {
    diagramId: existing?.diagramId,
    diagramType: 'module',
    title: '功能模块图',
    description: '系统功能模块图草稿',
    diagramJson: JSON.stringify(moduleDiagramPayload())
  }
}
  • Step 6: Run diagram center and utility tests

Run:

cd easycode-web
node src/views/diagramCenterModuleView.test.mjs
node src/utils/appModuleOutline.test.mjs
node src/utils/appModuleGraph.test.mjs
node src/components/appModuleDiagramView.test.mjs

Expected: PASS for all four files.

  • Step 7: Commit diagram center integration

Run:

git add -- easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterModuleView.test.mjs
git diff --cached --name-status
git commit -m "feat: persist project module diagrams"

Expected staged files:

M	easycode-web/src/views/DiagramCenterView.vue
A	easycode-web/src/views/diagramCenterModuleView.test.mjs

Task 5: Standalone Module Diagram Editor Route

Files:

  • Create: easycode-web/src/views/moduleDiagramEditorView.test.mjs

  • Create: easycode-web/src/views/ModuleDiagramEditorView.vue

  • Modify: easycode-web/src/router/index.js

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

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

  • Step 1: Add failing standalone editor static tests

Create easycode-web/src/views/moduleDiagramEditorView.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'

const currentDir = dirname(fileURLToPath(import.meta.url))
const srcDir = resolve(currentDir, '..')

function readSource(relativePath) {
  return readFileSync(resolve(srcDir, relativePath), 'utf8')
}

test('standalone module diagram editor page exists and embeds the shared component', () => {
  assert.equal(existsSync(resolve(srcDir, 'views/ModuleDiagramEditorView.vue')), true)
  const source = readSource('views/ModuleDiagramEditorView.vue')

  assert.equal(source.includes("import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'"), true)
  assert.equal(source.includes('module-editor-layout'), true)
  assert.equal(source.includes('module-editor-side'), true)
  assert.equal(source.includes('module-editor-canvas'), true)
  assert.equal(source.includes('v-model:source-mode="sourceMode"'), true)
  assert.equal(source.includes('v-model:outline-text="outlineText"'), true)
  assert.equal(source.includes('v-model:layout-options="layoutOptions"'), true)
  assert.equal(source.includes(':editable="false"'), true)
})

test('standalone module diagram editor supports optional project loading and saving', () => {
  const source = readSource('views/ModuleDiagramEditorView.vue')

  assert.equal(source.includes('const projectId = computed'), true)
  assert.equal(source.includes('getProject(projectId.value)'), true)
  assert.equal(source.includes('listProjectDiagrams(projectId.value)'), true)
  assert.equal(source.includes('saveProjectDiagram(projectId.value'), true)
  assert.equal(source.includes('function editorDiagramPayload'), true)
  assert.equal(source.includes('sourceMode: sourceMode.value'), true)
  assert.equal(source.includes('outlineText: outlineText.value'), true)
  assert.equal(source.includes('layoutOptions: layoutOptions.value'), true)
})

test('router exposes project and local module diagram editor routes', () => {
  const source = readSource('router/index.js')

  assert.equal(source.includes("import ModuleDiagramEditorView from '@/views/ModuleDiagramEditorView.vue'"), true)
  assert.equal(source.includes("path: '/module-diagram'"), true)
  assert.equal(source.includes("path: '/project/:projectId/module-diagram'"), true)
  assert.equal(source.includes("name: 'module-diagram-editor'"), true)
})
  • Step 2: Extend project list test for compact action

Append to easycode-web/src/views/projectListView.test.mjs:

test('project list exposes compact module diagram action', () => {
  const source = readView('ProjectListView.vue')

  assert.equal(source.includes('el-tooltip content="模块图"'), true)
  assert.equal(source.includes('aria-label="模块图"'), true)
  assert.equal(source.includes('openModuleDiagramEditor'), true)
  assert.equal(source.includes("`/project/${row.projectId}/module-diagram`"), true)
})
  • Step 3: Run standalone editor tests and verify RED

Run:

cd easycode-web
node src/views/moduleDiagramEditorView.test.mjs
node src/views/projectListView.test.mjs

Expected: FAIL because page, routes, and action do not exist yet.

  • Step 4: Create standalone editor view

Create easycode-web/src/views/ModuleDiagramEditorView.vue:

<template>
  <section class="page module-diagram-editor-page" v-loading="loading">
    <div class="panel module-editor-shell">
      <div class="panel-header">
        <div>
          <h1 class="panel-title">功能模块图编辑器</h1>
          <p class="muted">{{ project.projectName || '输入缩进结构,生成论文风格功能模块图。' }}</p>
        </div>
        <div class="toolbar">
          <el-button v-if="projectId" @click="router.push(`/project/${projectId}/diagrams`)">返回图表中心</el-button>
          <el-button v-if="projectId" type="primary" :loading="saving" @click="saveEditorDiagram">保存到项目</el-button>
        </div>
      </div>

      <div class="module-editor-layout">
        <aside class="module-editor-side">
          <div class="module-editor-side-header">
            <strong>缩进结构</strong>
            <el-button text type="primary" @click="useBlueprintOutline">使用项目蓝图</el-button>
          </div>
          <el-input
            v-model="outlineText"
            type="textarea"
            :rows="20"
            spellcheck="false"
            placeholder="第一行写系统名;第二层缩进 2 个空格;第三层及以下作为功能节点。"
          />
        </aside>

        <main class="module-editor-canvas">
          <AppModuleDiagramView
            v-model:source-mode="sourceMode"
            v-model:outline-text="outlineText"
            v-model:layout-options="layoutOptions"
            :blueprint="appBlueprint"
            :project-name="project.projectName"
            :editable="false"
          />
        </main>
      </div>
    </div>
  </section>
</template>

<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'
import { getProject, listProjectDiagrams, saveProjectDiagram } from '@/api/project'
import { blueprintToModuleOutline, moduleOutlineToText, parseModuleOutlineText } from '@/utils/appModuleOutline'
import { buildAppModuleGraph } from '@/utils/appModuleGraph'

const route = useRoute()
const router = useRouter()
const projectId = computed(() => route.params.projectId || route.query.projectId || '')
const loading = ref(false)
const saving = ref(false)
const project = reactive({})
const savedDiagrams = ref([])
const sourceMode = ref('outline')
const outlineText = ref('学生社团管理系统\n  管理员端\n    注册登录\n    用户管理\n    社团管理\n  普通用户端\n    注册登录\n    社团浏览\n    活动报名')
const layoutOptions = ref({ density: 'standard', direction: 'horizontal', fontSize: 'standard' })

const appBlueprint = computed(() => parseJson(project.appBlueprint, { roles: [], frontendMenus: [], adminMenus: [] }))
const currentGraph = computed(() => buildAppModuleGraph(parseModuleOutlineText(outlineText.value).outline, layoutOptions.value))

function parseJson(value, fallback) {
  if (!value) return fallback
  if (typeof value === 'object') return value
  try {
    return JSON.parse(value)
  } catch (error) {
    return fallback
  }
}

function useBlueprintOutline() {
  outlineText.value = moduleOutlineToText(blueprintToModuleOutline(appBlueprint.value, { projectName: project.projectName }))
  sourceMode.value = 'outline'
}

function editorDiagramPayload() {
  return {
    sourceMode: sourceMode.value,
    outlineText: outlineText.value,
    layoutOptions: layoutOptions.value,
    graph: currentGraph.value,
    projectName: project.projectName
  }
}

async function loadEditorData() {
  if (!projectId.value) return
  loading.value = true
  try {
    const [projectResult, diagramsResult] = await Promise.all([
      getProject(projectId.value),
      listProjectDiagrams(projectId.value)
    ])
    Object.assign(project, projectResult || {})
    savedDiagrams.value = Array.isArray(diagramsResult) ? diagramsResult : []
    const saved = savedDiagrams.value.find((item) => item.diagramType === 'module')
    const payload = parseJson(saved?.diagramJson, null)
    if (payload?.outlineText) {
      sourceMode.value = 'outline'
      outlineText.value = payload.outlineText
      layoutOptions.value = {
        density: payload.layoutOptions?.density || 'standard',
        direction: payload.layoutOptions?.direction || 'horizontal',
        fontSize: payload.layoutOptions?.fontSize || 'standard'
      }
    } else {
      useBlueprintOutline()
    }
  } catch (error) {
    ElMessage.error(error.message || '加载模块图失败')
  } finally {
    loading.value = false
  }
}

async function saveEditorDiagram() {
  if (!projectId.value) return
  const existing = savedDiagrams.value.find((item) => item.diagramType === 'module')
  saving.value = true
  try {
    const saved = await saveProjectDiagram(projectId.value, {
      diagramId: existing?.diagramId,
      diagramType: 'module',
      title: '功能模块图',
      description: '系统功能模块图草稿',
      diagramJson: JSON.stringify(editorDiagramPayload())
    })
    if (saved?.diagramId) {
      savedDiagrams.value = savedDiagrams.value.filter((item) => item.diagramId !== saved.diagramId).concat(saved)
    }
    ElMessage.success('功能模块图已保存')
  } catch (error) {
    ElMessage.error(error.message || '保存模块图失败')
  } finally {
    saving.value = false
  }
}

onMounted(loadEditorData)
</script>

<style scoped lang="scss">
.module-diagram-editor-page {
  display: grid;
  gap: 18px;
}

.module-editor-shell {
  overflow: hidden;
}

.module-editor-layout {
  display: grid;
  grid-template-columns: 340px minmax(0, 1fr);
  min-height: 620px;
}

.module-editor-side {
  display: grid;
  align-content: start;
  gap: 12px;
  padding: 18px;
  border-right: 1px solid #e7ecf3;
  background: #fbfdff;
}

.module-editor-side-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.module-editor-side-header strong {
  color: #172033;
  font-size: 15px;
}

.module-editor-canvas {
  min-width: 0;
  background: #ffffff;
}

@media (max-width: 980px) {
  .module-editor-layout {
    grid-template-columns: 1fr;
  }

  .module-editor-side {
    border-right: 0;
    border-bottom: 1px solid #e7ecf3;
  }
}
</style>
  • Step 5: Add routes

In easycode-web/src/router/index.js, import:

import ModuleDiagramEditorView from '@/views/ModuleDiagramEditorView.vue'

Add route entries after the diagram-center route:

{
  path: '/module-diagram',
  name: 'module-diagram-editor-local',
  component: ModuleDiagramEditorView
},
{
  path: '/project/:projectId/module-diagram',
  name: 'module-diagram-editor',
  component: ModuleDiagramEditorView,
  meta: { requiresAuth: true }
}
  • Step 6: Add project list compact action

In ProjectListView.vue, import Connection from @element-plus/icons-vue.

Add an action button in the operation column:

<el-tooltip content="模块图" placement="top">
  <el-button
    class="project-icon-button"
    :icon="Connection"
    circle
    aria-label="模块图"
    @click="openModuleDiagramEditor(row)"
  />
</el-tooltip>

Add the handler:

function openModuleDiagramEditor(row) {
  if (!row?.projectId) return
  router.push(`/project/${row.projectId}/module-diagram`)
}
  • Step 7: Run standalone editor and project list tests

Run:

cd easycode-web
node src/views/moduleDiagramEditorView.test.mjs
node src/views/projectListView.test.mjs

Expected: PASS.

  • Step 8: Commit standalone editor route

Run:

git add -- easycode-web/src/views/ModuleDiagramEditorView.vue easycode-web/src/views/moduleDiagramEditorView.test.mjs easycode-web/src/router/index.js easycode-web/src/views/ProjectListView.vue easycode-web/src/views/projectListView.test.mjs
git diff --cached --name-status
git commit -m "feat: add standalone module diagram editor"

Expected staged files:

A	easycode-web/src/views/ModuleDiagramEditorView.vue
A	easycode-web/src/views/moduleDiagramEditorView.test.mjs
M	easycode-web/src/router/index.js
M	easycode-web/src/views/ProjectListView.vue
M	easycode-web/src/views/projectListView.test.mjs

Task 6: Final Verification And Manual Browser Check

Files:

  • Existing frontend files only.

  • Step 1: Run targeted unit and static tests

Run:

cd easycode-web
node src/utils/appModuleOutline.test.mjs
node src/utils/appModuleGraph.test.mjs
node src/utils/appModuleExport.test.mjs
node src/components/appModuleDiagramView.test.mjs
node src/views/diagramCenterModuleView.test.mjs
node src/views/moduleDiagramEditorView.test.mjs
node src/views/projectListView.test.mjs

Expected: PASS for all files.

  • Step 2: Run frontend production build

Run:

cd easycode-web
npm run build

Expected: Vite build exits 0 and writes dist.

  • Step 3: Start the frontend dev server

Run:

cd easycode-web
npm run dev

Expected: Vite reports a local URL such as http://localhost:5173/.

  • Step 4: Browser-check standalone local editor

Open:

http://localhost:5173/module-diagram

Expected:

  • The page loads without login.

  • Left panel shows indented text.

  • Right panel shows a black-and-white module diagram.

  • Density, direction, and font-size controls update the graph.

  • SVG and PNG export buttons download files.

  • Step 5: Browser-check project diagram center

Open a real project route:

http://localhost:5173/project/<projectId>/diagrams

Expected:

  • The 功能模块图 tab renders the project blueprint.

  • Switching to 缩进结构 enables text edits.

  • Saving current diagram stores the module diagram payload.

  • Reloading the route restores source mode, outline text, and layout options.

  • Step 6: Browser-check project shortcut

Open:

http://localhost:5173/projects

Expected:

  • Each project row has a compact 模块图 icon action.

  • Clicking it opens /project/<projectId>/module-diagram.

  • Step 7: Stop dev server and inspect git state

Stop the dev server with Ctrl+C. Then run:

git status --short

Expected:

  • Only intentional module diagram files are modified or committed.

  • Unrelated pre-existing worktree changes remain untouched.

  • Step 8: Final commit if verification required fixes

If final verification required fixes, stage only fixed module diagram files:

git add -- easycode-web/src/utils/appModuleOutline.js easycode-web/src/utils/appModuleOutline.test.mjs easycode-web/src/utils/appModuleGraph.js easycode-web/src/utils/appModuleGraph.test.mjs easycode-web/src/utils/appModuleExport.js easycode-web/src/utils/appModuleExport.test.mjs easycode-web/src/components/AppModuleDiagramView.vue easycode-web/src/components/appModuleDiagramView.test.mjs easycode-web/src/views/DiagramCenterView.vue easycode-web/src/views/diagramCenterModuleView.test.mjs easycode-web/src/views/ModuleDiagramEditorView.vue easycode-web/src/views/moduleDiagramEditorView.test.mjs easycode-web/src/router/index.js easycode-web/src/views/ProjectListView.vue easycode-web/src/views/projectListView.test.mjs
git diff --cached --name-status
git commit -m "fix: verify module diagram editor"

Expected: only module diagram implementation files appear in the staged list.

Self-Review

  • Spec coverage:
    • Shared outline parser: Task 1.
    • Blueprint conversion: Task 1.
    • Graph layout density/direction/font size: Task 2.
    • SVG export parity: Task 3.
    • Shared component controls: Task 3.
    • Diagram center save/restore: Task 4.
    • Standalone editor with optional project: Task 5.
    • Project shortcut: Task 5.
    • Final build/browser verification: Task 6.
  • Placeholder scan:
    • No placeholder markers or open-ended validation steps remain.
    • Each code-changing task has explicit code snippets and exact commands.
  • Type consistency:
    • State names are consistent across tasks: sourceMode, outlineText, layoutOptions, moduleDiagramSourceMode, moduleDiagramOutlineText, moduleDiagramLayoutOptions.
    • Payload keys are consistent: sourceMode, outlineText, layoutOptions, graph, projectName.