feat: support module diagram outline layouts
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user