Files
urbanLifeline/urbanLifelineWeb/packages/shared/src/utils/route/route-generator.ts
2025-12-13 14:13:31 +08:00

858 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @description 动态路由生成器工具类
* @author yslg
* @since 2025-12-12
*
* 说明:此文件提供路由生成的通用方法,各个 web 服务可以使用这些方法生成自己的路由
*/
import type { RouteRecordRaw } from 'vue-router'
import type { TbSysViewDTO } from '@/types'
// 为了代码可读性,创建类型别名
type SysMenu = TbSysViewDTO
// 视图类型常量(对应后端的 type 字段)
const ViewType = {
MENU: 1, // 菜单
PAGE: 2, // 页面
BUTTON: 3 // 按钮
} as const
/**
* 路由生成器配置
*/
export interface RouteGeneratorConfig {
/**
* 布局组件映射表
* key: 布局名称value: 组件加载函数
*/
layoutMap: Record<string, () => Promise<any>>
/**
* 视图组件加载器
* 用于动态加载视图组件
*/
viewLoader: (componentPath: string) => Promise<any> | null
/**
* 静态路由列表(可选)
* 用于将静态路由转换为菜单项
*/
staticRoutes?: RouteRecordRaw[]
/**
* 404 组件路径(可选)
*/
notFoundComponent?: () => Promise<any>
}
/**
* 根据菜单生成路由配置
* @param menus 用户菜单列表
* @param config 路由生成器配置
* @returns Vue Router路由配置数组
*/
export function generateRoutes(
menus: SysMenu[],
config: RouteGeneratorConfig
): RouteRecordRaw[] {
if (!menus || menus.length === 0) {
return []
}
const routes: RouteRecordRaw[] = []
const pageRoutes: RouteRecordRaw[] = []
// 构建菜单树
const menuTree = buildMenuTree(menus, config.staticRoutes)
// 生成路由
menuTree.forEach(menu => {
const route = generateRouteFromMenu(menu, config, true)
if (route) {
routes.push(route)
// 递归提取所有 PAGE 类型的子菜单
extractPageChildren(route, pageRoutes, config)
}
})
// 将 PAGE 类型的路由添加到路由列表
routes.push(...pageRoutes)
return routes
}
/**
* 递归提取路由中的 PAGE 类型子菜单
*/
function extractPageChildren(
route: any,
pageRoutes: RouteRecordRaw[],
config: RouteGeneratorConfig
) {
// 检查当前路由是否有 PAGE 类型的子菜单
if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) {
route.meta.pageChildren.forEach((pageMenu: SysMenu) => {
const pageRoute = generateRouteFromMenu(pageMenu, config, true)
if (pageRoute) {
pageRoutes.push(pageRoute)
} else {
console.error(`[路由生成] 生成独立PAGE路由失败: ${pageMenu.name}`)
}
})
// 清理临时数据
delete route.meta.pageChildren
}
// 递归检查子路由
if (route.children && Array.isArray(route.children)) {
route.children.forEach((childRoute: any) => {
extractPageChildren(childRoute, pageRoutes, config)
})
}
}
/**
* 根据单个菜单生成路由
* @param menu 菜单对象
* @param config 路由生成器配置
* @param isTopLevel 是否是顶层菜单
* @returns 路由配置
*/
function generateRouteFromMenu(
menu: SysMenu,
config: RouteGeneratorConfig,
isTopLevel = true
): RouteRecordRaw | null {
// 跳过按钮类型
if (menu.type === ViewType.BUTTON) {
return null
}
// 跳过静态路由(已经在 router 中定义,不需要再次添加)
if (menu.component === '__STATIC_ROUTE__') {
return null
}
const route: any = {
path: menu.url || `/${menu.viewId}`,
name: menu.viewId,
meta: {
title: menu.name,
icon: menu.icon,
menuId: menu.viewId,
parentId: menu.parentId,
orderNum: menu.orderNum,
type: menu.type,
hideInMenu: false,
requiresAuth: true,
}
}
// 检查是否指定了布局(只有顶层菜单才使用布局)
const layout = isTopLevel ? (menu as any).layout : null
const hasChildren = menu.children && menu.children.length > 0
// 检查 component 是否是布局组件
const isComponentLayout = menu.component && (
config.layoutMap[menu.component] ||
(typeof menu.component === 'string' && menu.component.includes('Layout'))
)
// 确定路由组件
if (layout && config.layoutMap[layout]) {
// 如果指定了布局,使用指定的布局
route.component = config.layoutMap[layout]
} else if (isComponentLayout && hasChildren && isTopLevel && menu.component) {
// 如果 component 是布局组件且有子菜单,使用该布局组件作为父路由组件
route.component = config.layoutMap[menu.component]
} else if (hasChildren && isTopLevel) {
// 如果有子菜单但没有指定布局,根据菜单类型选择默认布局
if (menu.type === ViewType.MENU && !menu.parentId) {
route.component = config.layoutMap['SidebarLayout']
} else {
route.component = config.layoutMap['BasicLayout']
}
} else {
// 没有子菜单,也没有指定布局,使用具体的页面组件
if (menu.component) {
const component = config.viewLoader(menu.component)
if (component) {
route.component = component
} else {
// 组件加载失败,使用 404
route.component = config.notFoundComponent || (() => import('vue').then(({ h }) => ({
default: {
render() { return h('div', '404') }
}
})))
}
} else {
// 使用路由占位组件
route.component = () => import('vue').then(({ h, resolveComponent }) => ({
default: {
render() {
const RouterView = resolveComponent('RouterView')
return h(RouterView)
}
}
}))
}
}
// 处理子路由
if (layout && config.layoutMap[layout] && menu.component && isTopLevel) {
// 如果指定了布局,将页面组件作为子路由
const component = config.viewLoader(menu.component)
route.children = [{
path: '',
name: `${menu.viewId}_page`,
component: component || route.component,
meta: route.meta
}]
// 如果还有其他子菜单,继续添加
if (hasChildren) {
const pageChildren: SysMenu[] = []
const normalChildren: SysMenu[] = []
menu.children!.forEach((child: SysMenu) => {
if (child.type === ViewType.PAGE) {
pageChildren.push(child)
} else {
normalChildren.push(child)
}
})
// 添加普通子菜单
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, config, false)
if (childRoute) {
route.children!.push(childRoute)
}
})
// PAGE 类型的菜单保存到 meta
if (pageChildren.length > 0) {
route.meta.pageChildren = pageChildren
}
}
} else if (hasChildren) {
// 处理有子菜单的情况
route.children = []
// 分离 PAGE 类型的子菜单和普通子菜单
const pageChildren: SysMenu[] = []
const normalChildren: SysMenu[] = []
menu.children!.forEach((child: SysMenu) => {
if (child.type === ViewType.PAGE) {
pageChildren.push(child)
} else {
normalChildren.push(child)
}
})
// 如果当前菜单有组件且有普通子菜单,创建默认子路由
if (menu.component && !isComponentLayout && normalChildren.length > 0) {
const component = config.viewLoader(menu.component)
route.children!.push({
path: '',
name: `${menu.viewId}_page`,
component: component || route.component,
meta: {
...route.meta,
}
})
}
// 只将普通子菜单加入 children
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, config, false)
if (childRoute) {
route.children!.push(childRoute)
}
})
// PAGE 类型的菜单保存到 meta
if (pageChildren.length > 0) {
route.meta.pageChildren = pageChildren
}
// 自动重定向到第一个有URL的子菜单
if (!route.redirect && route.children.length > 0) {
const firstChildWithUrl = findFirstMenuWithUrl(normalChildren)
if (firstChildWithUrl?.url) {
route.redirect = firstChildWithUrl.url
}
}
}
return route
}
/**
* 查找第一个有URL的菜单
*/
function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
for (const menu of menus) {
if (menu.type !== ViewType.BUTTON) {
if (menu.url) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findFirstMenuWithUrl(menu.children)
if (found) return found
}
}
}
return null
}
/**
* 将静态路由转换为菜单项
*/
function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] {
const menus: SysMenu[] = []
routes.forEach(route => {
if (route.children && route.children.length > 0) {
route.children.forEach(child => {
if (child.meta?.menuType !== undefined) {
const menu: SysMenu = {
viewId: child.name as string || child.path.replace(/\//g, '-'),
parentId: '0',
name: child.meta.title as string || child.name as string,
url: route.path,
type: child.meta.menuType as number,
orderNum: (child.meta.orderNum as number) || -1,
component: '__STATIC_ROUTE__',
}
menus.push(menu)
}
})
} else if (route.meta?.menuType !== undefined) {
const menu: SysMenu = {
viewId: route.name as string || route.path.replace(/\//g, '-'),
parentId: '0',
name: route.meta.title as string || route.name as string,
url: route.path,
type: route.meta.menuType as number,
orderNum: (route.meta.orderNum as number) || -1,
component: '__STATIC_ROUTE__',
}
menus.push(menu)
}
})
return menus
}
/**
* 构建菜单树结构
* @param menus 菜单列表
* @param staticRoutes 静态路由列表
* @returns 菜单树
*/
export function buildMenuTree(
menus: SysMenu[],
staticRoutes?: RouteRecordRaw[]
): SysMenu[] {
// 将静态路由转换为菜单项
const staticMenus = staticRoutes ? convertRoutesToMenus(staticRoutes) : []
// 合并动态菜单和静态菜单
const allMenus = [...staticMenus, ...menus]
if (allMenus.length === 0) {
return []
}
const menuMap = new Map<string, SysMenu>()
const rootMenus: SysMenu[] = []
const maxDepth = allMenus.length
// 创建菜单映射
allMenus.forEach(menu => {
if (menu.viewId) {
menuMap.set(menu.viewId, { ...menu, children: [] })
}
})
// 循环构建树结构
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false
allMenus.forEach(menu => {
if (!menu.viewId) return
const menuNode = menuMap.get(menu.viewId)
if (!menuNode) return
if (isNodeInTree(menuNode, rootMenus)) {
return
}
if (!menu.parentId || menu.parentId === '0' || menu.parentId === '') {
if (!isNodeInTree(menuNode, rootMenus)) {
rootMenus.push(menuNode)
hasChanges = true
}
} else {
const parent = menuMap.get(menu.parentId)
if (parent && isNodeInTree(parent, rootMenus)) {
if (!parent.children) {
parent.children = []
}
if (!parent.children.includes(menuNode)) {
parent.children.push(menuNode)
hasChanges = true
}
}
}
})
if (!hasChanges) {
break
}
}
// 排序
const sortMenus = (menus: SysMenu[]): SysMenu[] => {
return menus
.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
.map(menu => ({
...menu,
children: menu.children ? sortMenus(menu.children) : []
}))
}
return sortMenus(rootMenus)
}
/**
* 检查节点是否已经在树中
*/
function isNodeInTree(node: SysMenu, tree: SysMenu[]): boolean {
for (const treeNode of tree) {
if (treeNode.viewId === node.viewId) {
return true
}
if (treeNode.children && isNodeInTree(node, treeNode.children)) {
return true
}
}
return false
}
/**
* 根据权限过滤菜单
*/
export function filterMenusByPermissions(
menus: SysMenu[],
permissions: string[]
): SysMenu[] {
if (!menus || menus.length === 0) {
return []
}
return menus
.filter(() => true) // 暂时返回true后续可根据实际需求过滤
.map(menu => {
if (menu.children && menu.children.length > 0) {
return {
...menu,
children: filterMenusByPermissions(menu.children, permissions)
}
}
return menu
})
}
/**
* 查找路由路径对应的菜单
*/
export function findMenuByPath(menus: SysMenu[], path: string): SysMenu | null {
for (const menu of menus) {
if (menu.url === path) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path)
if (found) {
return found
}
}
}
return null
}
/**
* 获取菜单路径数组(面包屑导航用)
*/
export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
const path: SysMenu[] = []
function findPath(menuList: SysMenu[]): boolean {
for (const menu of menuList) {
path.push(menu)
if (menu.viewId === targetMenuId) {
return true
}
if (menu.children && menu.children.length > 0) {
if (findPath(menu.children)) {
return true
}
}
path.pop()
}
return false
}
findPath(menus)
return path
}
/**
* 获取第一个可访问的菜单URL用于登录后跳转
*/
export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null {
if (!menus || menus.length === 0) {
return null
}
const firstMenu = findFirstMenuWithUrl(menus)
return firstMenu?.url || '/home'
}
/**
* 从 LocalStorage 加载用户视图数据
* @param storageKey localStorage 的 key默认为 'loginDomain'
* @param viewsPath 视图数据在对象中的路径,默认为 'userViews'
* @returns 视图列表,如果不存在返回 null
*/
export function loadViewsFromStorage(
storageKey: string = 'loginDomain',
viewsPath: string = 'userViews'
): TbSysViewDTO[] | null {
try {
const dataStr = localStorage.getItem(storageKey)
if (!dataStr) {
console.log(`[路由工具] LocalStorage 中没有 ${storageKey}`)
return null
}
const data = JSON.parse(dataStr)
// 支持嵌套路径,如 'user.views'
const paths = viewsPath.split('.')
let views = data
for (const path of paths) {
views = views?.[path]
}
if (views && Array.isArray(views) && views.length > 0) {
console.log(`[路由工具] 从 LocalStorage 加载视图,数量: ${views.length}`)
return views
}
console.log(`[路由工具] ${storageKey} 中没有 ${viewsPath} 或数据为空`)
return null
} catch (error) {
console.error('[路由工具] 从 LocalStorage 加载视图失败:', error)
return null
}
}
/**
* 生成简化的路由配置(用于直接添加到 router
* 相比 generateRoutes这个方法生成的路由更适合动态添加到现有路由树
*
* @param views 视图列表
* @param config 路由生成器配置
* @param options 额外选项
* @returns 路由配置数组
*/
export interface GenerateSimpleRoutesOptions {
/**
* 是否作为根路由的子路由(路径去掉前导 /
*/
asRootChildren?: boolean
/**
* iframe 类型视图的占位组件
*/
iframePlaceholder?: () => Promise<any>
/**
* 是否启用详细日志
*/
verbose?: boolean
}
export function generateSimpleRoutes(
views: TbSysViewDTO[],
config: RouteGeneratorConfig,
options: GenerateSimpleRoutesOptions = {}
): RouteRecordRaw[] {
const {
asRootChildren = false,
iframePlaceholder,
verbose = false
} = options
if (!views || views.length === 0) {
if (verbose) console.warn('[路由生成] 视图列表为空')
return []
}
if (verbose) {
console.log('[路由生成] 开始生成路由,视图数量:', views.length)
}
// 构建视图树
const viewTree = buildMenuTree(views)
if (verbose) {
console.log('[路由生成] 构建视图树,根节点数量:', viewTree.length)
}
const routes: RouteRecordRaw[] = []
// 遍历根节点,生成路由
viewTree.forEach(view => {
const route = generateSimpleRoute(view, config, {
asRootChild: asRootChildren,
iframePlaceholder,
verbose
})
if (route) {
routes.push(route)
if (verbose) {
console.log('[路由生成] 已生成路由:', {
path: route.path,
name: route.name,
hasComponent: !!route.component,
childrenCount: route.children?.length || 0
})
}
} else if (verbose) {
console.warn('[路由生成] 跳过无效视图:', view.name)
}
})
return routes
}
/**
* 从单个视图生成简化路由
*/
function generateSimpleRoute(
view: TbSysViewDTO,
config: RouteGeneratorConfig,
options: {
asRootChild?: boolean
iframePlaceholder?: () => Promise<any>
verbose?: boolean
} = {}
): RouteRecordRaw | null {
const { asRootChild = false, iframePlaceholder, verbose = false } = options
// 验证必要字段
if (!view.viewId) {
if (verbose) console.error('[路由生成] 视图缺少 viewId:', view)
return null
}
// 判断是否是 iframe 类型
const isIframe = (view as any).viewType === 'iframe' || !!(view as any).iframeUrl
// 处理路径和组件
let routePath = view.url || `/${view.viewId}`
let component: any
if (isIframe) {
// iframe 类型使用占位组件用于显示iframe内容
// 路由路径使用 url 字段(应该设置为不冲突的路径,如 /app/workcase
component = iframePlaceholder || (() => import('vue').then(({ h }) => ({
default: {
render() { return h('div', { class: 'iframe-placeholder' }, 'Loading...') }
}
})))
} else if (view.component) {
// route 类型:加载实际组件
component = config.viewLoader(view.component)
if (!component) {
if (verbose) console.warn('[路由生成] 组件加载失败:', view.component, '使用占位组件')
// 使用占位组件,避免路由无效
const errorMsg = `组件加载失败: ${view.component}`
component = () => import('vue').then(({ h }) => ({
default: {
render() {
return h('div', {
style: { padding: '20px', color: 'red' }
}, errorMsg)
}
}
}))
}
}
// 根路径的子路由去掉前导斜杠
if (asRootChild && routePath.startsWith('/')) {
routePath = routePath.substring(1)
}
const hasChildren = view.children && view.children.length > 0
if (verbose) {
console.log('[路由生成] 视图信息:', {
viewId: view.viewId,
name: view.name,
url: view.url,
component: view.component,
isIframe,
hasChildren,
childrenCount: view.children?.length || 0
})
}
const route: any = {
path: routePath,
name: view.viewId,
meta: {
title: view.name || view.viewId,
icon: view.icon,
menuId: view.viewId,
orderNum: view.orderNum,
requiresAuth: true,
isIframe,
iframeUrl: (view as any).iframeUrl
}
}
// 根据 component 和 children 的情况处理
if (component && hasChildren) {
// 有组件且有子视图:组件作为空路径子路由
route.component = component
route.children = [
{
path: '',
name: `${view.viewId}_page`,
component: component,
meta: route.meta
}
]
// 添加其他子路由
view.children!.forEach(childView => {
const childRoute = generateSimpleRoute(childView, config, {
asRootChild: false,
iframePlaceholder,
verbose
})
if (childRoute) {
route.children.push(childRoute)
}
})
} else if (component && !hasChildren) {
// 只有组件,没有子视图
route.component = component
} else if (!component && hasChildren) {
// 没有组件,只有子视图(路由容器)
route.component = () => import('vue').then(({ h, resolveComponent }) => ({
default: {
render() {
const RouterView = resolveComponent('RouterView')
return h(RouterView)
}
}
}))
route.children = []
// 添加子路由
view.children!.forEach(childView => {
const childRoute = generateSimpleRoute(childView, config, {
asRootChild: false,
iframePlaceholder,
verbose
})
if (childRoute) {
route.children.push(childRoute)
}
})
// 重定向到第一个子路由
if (route.children.length > 0) {
const firstChild = route.children[0]
route.redirect = firstChild.path
}
} else {
// 既没有组件也没有子视图
if (verbose) {
console.warn('[路由生成] 视图既无组件也无子视图:', view.name)
}
return null
}
// 处理layout如果视图指定了layout且不是作为Root的子路由且有有效组件需要包裹layout
const viewLayout = (view as any).layout
if (viewLayout && !asRootChild && route.component && config.layoutMap[viewLayout]) {
if (verbose) {
console.log('[路由生成] 为视图添加布局:', view.name, '布局:', viewLayout, '路径:', routePath)
}
// 创建layout路由将原路由的组件作为其子路由
const layoutRoute: RouteRecordRaw = {
path: routePath,
name: view.viewId,
component: config.layoutMap[viewLayout],
meta: {
...route.meta,
layout: viewLayout // 标记使用的布局
},
children: [
{
path: '',
name: `${view.viewId}_content`,
component: route.component,
meta: route.meta
}
]
}
// 如果原路由有其他children子视图也添加到layout路由的children中
if (route.children && route.children.length > 0) {
// 跳过第一个空路径的子路由(如果存在)
const otherChildren = route.children.filter((child: any) => child.path !== '')
if (otherChildren.length > 0) {
layoutRoute.children!.push(...otherChildren)
}
}
if (verbose) {
console.log('[路由生成] Layout路由生成完成:', {
path: layoutRoute.path,
name: layoutRoute.name,
childrenCount: layoutRoute.children?.length
})
}
return layoutRoute
}
return route
}