# 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`:

```js
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:

```powershell
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`:

```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:

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

Expected: PASS, six tests passing.

- [ ] **Step 5: Commit the outline parser**

Run:

```powershell
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:

```text
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`:

```js
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:

```powershell
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:

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

Replace the current fixed layout constants with:

```js
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`:

```js
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:

```js
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:

```js
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`:

```js
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`:

```js
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:

```js
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:

```powershell
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:

```powershell
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:

```text
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`:

```js
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:

```powershell
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:

```js
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:

```powershell
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`:

```js
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:

```powershell
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:

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

Replace props and add emits:

```js
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:

```js
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:

```js
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:

```js
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:

```vue
<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:

```vue
<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:

```scss
.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:

```powershell
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:

```powershell
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:

```text
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`:

```js
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:

```powershell
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:

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

Add state near `savedDiagrams`:

```js
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:

```vue
<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:

```js
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:

```js
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:

```powershell
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:

```powershell
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:

```text
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`:

```js
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`:

```js
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:

```powershell
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`:

```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:

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

Add route entries after the diagram-center route:

```js
{
  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:

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

Add the handler:

```js
function openModuleDiagramEditor(row) {
  if (!row?.projectId) return
  router.push(`/project/${row.projectId}/module-diagram`)
}
```

- [ ] **Step 7: Run standalone editor and project list tests**

Run:

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

Expected: PASS.

- [ ] **Step 8: Commit standalone editor route**

Run:

```powershell
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:

```text
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:

```powershell
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:

```powershell
cd easycode-web
npm run build
```

Expected: Vite build exits 0 and writes `dist`.

- [ ] **Step 3: Start the frontend dev server**

Run:

```powershell
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:

```text
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:

```text
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:

```text
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:

```powershell
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:

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