feat: support module diagram outline layouts

This commit is contained in:
王鹏
2026-07-07 14:14:13 +08:00
parent 27aaa13fcc
commit 5603aea680
2 changed files with 504 additions and 159 deletions

View File

@@ -1,20 +1,71 @@
const SOURCE_X = 32
const FRONTEND_X = 360
const ADMIN_X = 688
const START_Y = 72
const NODE_WIDTH = 252
const NODE_HEIGHT = 108
const NODE_GAP_Y = 24
import { blueprintToModuleOutline, normalizeModuleOutline } from './appModuleOutline.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 }
}
function cleanText(value, fallback = '') {
const text = String(value ?? '').trim()
return text || fallback
}
function flagOn(value) {
return value === true || value === 1 || value === '1' || value === 'true'
}
function normalizeKey(value, fallback) {
const text = cleanText(value, fallback)
.toLowerCase()
@@ -36,165 +87,342 @@ function uniqueKey(value, fallbackPrefix, index, used) {
return candidate
}
function normalizeRoles(roles) {
const used = new Set()
return (Array.isArray(roles) ? roles : []).filter(Boolean).map((role, index) => {
const code = uniqueKey(role.code, 'role', index, used)
return {
code,
id: `role:${code}`,
title: cleanText(role.name, code),
subtitle: cleanText(role.description, '角色'),
kind: 'role',
lane: 'role',
badges: ['角色']
}
})
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 normalizeMenus(menus, lane, fallbackPrefix) {
const used = new Set()
return (Array.isArray(menus) ? menus : []).filter(Boolean).map((menu, index) => {
const code = uniqueKey(menu.code, fallbackPrefix, index, used)
const requiresLogin = flagOn(menu.requiresLogin)
const dataScope = cleanText(menu.dataScope, requiresLogin ? 'CURRENT_USER' : 'PUBLIC').toUpperCase()
const visibleRoles = Array.isArray(menu.visibleRoles)
? menu.visibleRoles.map((role) => normalizeKey(role, '')).filter(Boolean)
: []
function layoutConfig(options) {
return DENSITY_CONFIGS[options.density]
}
function fontConfig(options) {
return FONT_SIZE_CONFIGS[options.fontSize]
}
function sourceToOutline(source, options = {}) {
const input = source && typeof source === 'object' ? source : {}
if (Array.isArray(input.groups)) {
return normalizeModuleOutline(input)
}
return blueprintToModuleOutline(input, options)
}
function menuTitle(menu, fallback) {
return cleanText(menu?.name ?? menu?.title ?? menu?.code, fallback)
}
function normalizeBlueprintMenus(menus, lane, fallbackPrefix) {
const used = new Set()
return (Array.isArray(menus) ? menus : []).filter((menu) => {
return menu && typeof menu === 'object' && cleanText(menu.name ?? menu.title ?? menu.code)
}).map((menu, index) => {
const code = uniqueKey(menu.code ?? menu.name ?? menu.title, fallbackPrefix, index, used)
return {
code,
id: `${lane}:${code}`,
title: cleanText(menu.name, code),
subtitle: cleanText(menu.path, '/'),
kind: 'menu',
title: menuTitle(menu, code),
kind: 'module',
lane,
requiresLogin,
visibleRoles,
dataScope,
badges: [
lane === 'frontend' ? '前台' : '后台',
requiresLogin ? '需登录' : '公开',
dataScope
]
vertical: true
}
})
}
function shouldUsePublic(menu) {
return !menu.requiresLogin || menu.dataScope === 'PUBLIC'
}
function needsPublicAccess(menus) {
return menus.some(shouldUsePublic)
}
function needsLoggedInAccess(menus, roleIds) {
return menus.some((menu) => {
if (!menu.requiresLogin) return false
return !menu.visibleRoles.some((roleCode) => roleIds.has(`role:${roleCode}`))
})
}
function buildAccessNodes(menus, roleIds) {
const nodes = []
if (needsPublicAccess(menus)) {
nodes.push({
code: 'public',
id: 'access:public',
title: '公开访问',
subtitle: '无需登录',
kind: 'access',
lane: 'access',
badges: ['PUBLIC']
})
}
if (needsLoggedInAccess(menus, roleIds)) {
nodes.push({
code: 'logged_in',
id: 'access:logged_in',
title: '登录用户',
subtitle: '未限定角色',
kind: 'access',
lane: 'access',
badges: ['LOGIN']
})
}
return nodes
}
function applyLayout(nodes) {
const laneIndexes = new Map()
return nodes.map((node) => {
const layoutLane = node.lane === 'admin' ? 'admin' : node.lane === 'frontend' ? 'frontend' : 'source'
const index = laneIndexes.get(layoutLane) || 0
laneIndexes.set(layoutLane, index + 1)
const x = layoutLane === 'admin' ? ADMIN_X : layoutLane === 'frontend' ? FRONTEND_X : SOURCE_X
function normalizeOutlineItems(items, lane, fallbackPrefix) {
return (Array.isArray(items) ? items : []).filter(Boolean).map((item, index) => {
const code = `${fallbackPrefix}_${index + 1}`
return {
...node,
x,
y: START_Y + index * (NODE_HEIGHT + NODE_GAP_Y),
width: NODE_WIDTH,
height: NODE_HEIGHT
code,
id: `${lane}:${code}`,
title: cleanText(item.title ?? item.name ?? item.code, code),
kind: 'module',
lane,
vertical: true
}
})
}
function edgeLabel(menu) {
return menu.dataScope || (menu.requiresLogin ? 'LOGIN' : 'PUBLIC')
function groupWidth(itemCount, config) {
const moduleWidth = itemCount * config.moduleWidth + Math.max(itemCount - 1, 0) * config.moduleGap
return Math.max(config.groupWidth, moduleWidth)
}
function edgeId(source, target) {
return `${source}->${target}`
function centerX(node) {
return node.x + node.width / 2
}
function buildEdges(menus, roleIds) {
const edges = []
menus.forEach((menu) => {
const validRoleIds = menu.visibleRoles
.map((roleCode) => `role:${roleCode}`)
.filter((roleId) => roleIds.has(roleId))
function bottomY(node) {
return node.y + node.height
}
if (validRoleIds.length) {
validRoleIds.forEach((roleId) => {
edges.push({
id: edgeId(roleId, menu.id),
source: roleId,
target: menu.id,
label: edgeLabel(menu)
})
})
return
}
function treePath(source, target, branchY) {
const sourceX = centerX(source)
const targetX = centerX(target)
const sourceY = bottomY(source)
const targetY = target.y
const source = shouldUsePublic(menu) ? 'access:public' : 'access:logged_in'
edges.push({
id: edgeId(source, menu.id),
source,
target: menu.id,
label: edgeLabel(menu)
if (sourceX === targetX) {
return `M ${sourceX} ${sourceY} V ${targetY}`
}
return `M ${sourceX} ${sourceY} V ${branchY} H ${targetX} V ${targetY}`
}
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}`
}
function buildSections(outline, source = {}) {
const input = source && typeof source === 'object' ? source : {}
if (!Array.isArray(input.groups)) {
return [
{
key: 'admin',
title: '管理员端',
lane: 'admin',
items: normalizeBlueprintMenus(input.adminMenus, 'admin', 'admin_menu')
},
{
key: 'frontend',
title: '普通用户端',
lane: 'frontend',
items: normalizeBlueprintMenus(input.frontendMenus, 'frontend', 'frontend_menu')
}
].filter((section) => section.items.length)
}
return (Array.isArray(outline.groups) ? outline.groups : [])
.map((group, index) => {
const lane = `group_${index + 1}`
return {
key: lane,
title: cleanText(group.title, `模块${index + 1}`),
lane,
items: normalizeOutlineItems(group.items, lane, 'module')
}
})
})
return edges
.filter((section) => section.items.length)
}
export function buildAppModuleGraph(blueprint = {}) {
const roles = normalizeRoles(blueprint.roles)
const frontendMenus = normalizeMenus(blueprint.frontendMenus, 'frontend', 'frontend_menu')
const adminMenus = normalizeMenus(blueprint.adminMenus, 'admin', 'admin_menu')
const menus = [...frontendMenus, ...adminMenus]
const roleIds = new Set(roles.map((role) => role.id))
const accessNodes = buildAccessNodes(menus, roleIds)
const nodes = applyLayout([...accessNodes, ...roles, ...frontendMenus, ...adminMenus])
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 + config.groupGap)
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: buildEdges(menus, roleIds),
lanes: [
{ key: 'source', title: '访问角色', x: SOURCE_X, width: NODE_WIDTH },
{ key: 'frontend', title: '前台功能模块', x: FRONTEND_X, width: NODE_WIDTH },
{ key: 'admin', title: '后台功能模块', x: ADMIN_X, width: NODE_WIDTH }
]
edges,
groups,
options: graphOptions,
canvas: {
width: canvasWidth,
height: config.canvasHeight
}
}
}
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 + 80, y + 48)
}
}
}
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)
}

View File

@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { buildAppModuleGraph } from './appModuleGraph.js'
test('buildAppModuleGraph creates role and menu module nodes with access edges', () => {
test('buildAppModuleGraph creates a thesis-style module hierarchy from menus', () => {
const graph = buildAppModuleGraph({
roles: [
{ code: 'reader', name: '读者', description: '前台用户' },
@@ -38,29 +38,40 @@ test('buildAppModuleGraph creates role and menu module nodes with access edges',
]
})
assert.equal(graph.title, '系统模块设计')
assert.deepEqual(
graph.nodes.map((node) => ({ id: node.id, title: node.title, kind: node.kind, lane: node.lane })),
graph.groups.map((group) => ({ key: group.key, title: group.title, childIds: group.childIds })),
[
{ id: 'access:public', title: '公开访问', kind: 'access', lane: 'access' },
{ id: 'role:reader', title: '读者', kind: 'role', lane: 'role' },
{ id: 'role:admin', title: '管理员', kind: 'role', lane: 'role' },
{ id: 'frontend:catalog', title: '图书目录', kind: 'menu', lane: 'frontend' },
{ id: 'frontend:my_borrows', title: '我的借阅', kind: 'menu', lane: 'frontend' },
{ id: 'admin:book_admin', title: '图书管理', kind: 'menu', lane: 'admin' }
{ key: 'admin', title: '管理员端', childIds: ['admin:book_admin'] },
{ key: 'frontend', title: '普通用户端', childIds: ['frontend:catalog', 'frontend:my_borrows'] }
]
)
assert.deepEqual(
graph.edges.map((edge) => ({ source: edge.source, target: edge.target, label: edge.label })),
graph.nodes.map((node) => ({ id: node.id, title: node.title, kind: node.kind, lane: node.lane })),
[
{ source: 'access:public', target: 'frontend:catalog', label: 'PUBLIC' },
{ source: 'role:reader', target: 'frontend:my_borrows', label: 'CURRENT_USER' },
{ source: 'role:admin', target: 'admin:book_admin', label: 'ROLE' }
{ id: 'root', title: '系统模块设计', kind: 'root', lane: 'root' },
{ id: 'group:admin', title: '管理员端', kind: 'group', lane: 'admin' },
{ id: 'admin:book_admin', title: '图书管理', kind: 'module', lane: 'admin' },
{ id: 'group:frontend', title: '普通用户端', kind: 'group', lane: 'frontend' },
{ id: 'frontend:catalog', title: '图书目录', kind: 'module', lane: 'frontend' },
{ id: 'frontend:my_borrows', title: '我的借阅', kind: 'module', lane: 'frontend' }
]
)
assert.deepEqual(
graph.edges.map((edge) => ({ source: edge.source, target: edge.target })),
[
{ source: 'root', target: 'group:admin' },
{ source: 'group:admin', target: 'admin:book_admin' },
{ source: 'root', target: 'group:frontend' },
{ source: 'group:frontend', target: 'frontend:catalog' },
{ source: 'group:frontend', target: 'frontend:my_borrows' }
]
)
})
test('buildAppModuleGraph creates a logged-in access node for menus without explicit visible roles', () => {
test('buildAppModuleGraph keeps frontend-only projects centered and exportable', () => {
const graph = buildAppModuleGraph({
roles: [],
frontendMenus: [
@@ -76,7 +87,113 @@ test('buildAppModuleGraph creates a logged-in access node for menus without expl
adminMenus: []
})
assert.equal(graph.nodes[0].id, 'access:logged_in')
assert.equal(graph.edges[0].source, 'access:logged_in')
assert.equal(graph.edges[0].target, 'frontend:profile')
assert.deepEqual(graph.groups.map((group) => group.key), ['frontend'])
assert.equal(graph.nodes[0].id, 'root')
assert.equal(graph.nodes[1].id, 'group:frontend')
assert.equal(graph.nodes[2].id, 'frontend:profile')
assert.equal(graph.nodes[2].vertical, true)
assert.equal(graph.edges[0].source, 'root')
assert.equal(graph.edges[0].target, 'group:frontend')
assert.equal(graph.edges[1].source, 'group:frontend')
assert.equal(graph.edges[1].target, 'frontend:profile')
})
test('buildAppModuleGraph uses the project name as the root title', () => {
const graph = buildAppModuleGraph({
frontendMenus: [
{
code: 'catalog',
name: '图书目录'
}
],
adminMenus: []
}, {
projectName: '图书借阅系统'
})
assert.equal(graph.title, '图书借阅系统')
assert.equal(graph.nodes[0].title, '图书借阅系统')
})
test('buildAppModuleGraph skips invalid legacy menus while preserving code-only menus', () => {
const graph = buildAppModuleGraph({
adminMenus: [
null,
{},
{ name: ' ' },
{ code: 'settings' }
],
frontendMenus: []
})
assert.deepEqual(graph.groups.map((group) => group.childIds), [['admin:settings']])
assert.equal(graph.nodes.find((node) => node.id === 'admin:settings').title, 'settings')
})
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)
})