改造delon库的reuse-tab标签使其关联隐藏动态菜单Menu及tab切换时参数不丢失方案

马航
2023-12-01

  最近使用了erupt-web项目,非常感谢作者提供了功能强大、使用方便、配置灵活的工具:erupt项目

  在使用到其路由复用标签reuse-tab时,发现tab来回切换时,url上附带的参数会丢失,翻阅官方文档,有明确说明不能使用queryString作为参数:
ng-alain==>reuse-tab

不支持 QueryString 查询参数
复用采用URL来区分是否同一个页面,而 QueryString 查询参数很容易产生重复性误用,因此不支持查询参数,且在复用过程中会强制忽略掉 QueryString 部分。

  这就麻烦了!

  虽然是后台管理平台,但在我们的规划中,各个页面之间是可以互相跳转的,而且菜单一般是进入列表页面,通过点击列表页面上各行、各列的数据进行页面之间、弹出层的交互。

  比如说“用户管理”菜单下“用户列表”这个菜单,点开之后内容区域显示每个用户一行的概括数据,点击列表行末的操作菜单(比如查看详情)即打开新的一个操作内容tab页面,在这个页面里面,展示用户的各种详细信息。

  这种设计就必须要求新打开的tab页要从触发点击事件的页面中传递参数过来,而且tab来回切换、浏览器刷新等操作参数都不能丢失!

  于是就开始研究reuse-tab!

  erupt-web使用的delon库是8.8.0版本,虽然版本有点老,但胜在稳定,参考了博客园大佬的文章:【NG-Alain】组件Reuse-tab的前世今生,其中提到:

启用reuse-tab后的路由传参
  在任意的单页面web应用程序中,涉及到【路由】的项目都必须严格按照框架推荐的方式进行传参。
  而在ng-alain中,当reuse-tab被启用后(即便在启用之前),URL都不应该继续使用带有【?】的传参方式,无论【?】是在【#】之前还是之后。此规则是强制性要求,不遵守即必然出现错误,表现为参数会在页面切换或刷新后丢失。

  其中提到一种传参方式叫:矩阵参数(matrix parameters),简单来说就是将url中的?/&换成;来传参,于是尝试了一下:

let menu: Menu = this.menuSrv.getItem(menuCode);
let queryParams = {id:1,name:"张三"};
let link = menu.link;
for (let p in queryParams) {
    link += ';' + (p + '=' + queryParams[p])
}
console.log('url:', link);
this.router.navigateByUrl(link);

  路由确实跳转成功了,而且tab来回切换参数不会丢失,在跳转过来的页面中读取location.href自行解析参数即可。

  但是新的问题:新开的tab没有名字,使用的url作为tab名称,大佬也提到过:

在显示多标签式选项卡标题时,优先使用通过ReuseTabService.title设置并存储在ReuseTabService._titleCached数组内的标题,然后使用路由的data对象的title,否则使用菜单的text属性,最后直接显示URL。
根据此方法最后两行代码,如果设置mode的值为ReuseTabMatchModel.URL时,请务必满足前两个条件之一,否则只会显示URL。

  而我这里为了让所有动态URL都能复用,确实将menu的model值设为了ReuseTabMatchModel.URL。于是只好手动设置ReuseTabService.title = '标题’这种方式来设置tab标题。

  但即使是这样也还不够,一是左侧菜单没有关联打开,二是页面顶部面包屑导航栏是一片空白!而且手动设置tab名称的方法也不靠谱,毕竟erupt-web最终是要打包编译到erupt项目中的,java代码里面可没有手动设置tab名称的地方!

  所以转而就采用最朴素的方法:已知每一个新开的tab都是一个menu,从列表页中跳转的路由只是menu的link上附带了矩阵参数,那么如果我新开tab之前,向menu服务中动态新增一个隐藏的menu,link设置为带了矩阵参数的link,那么新开的tab不是变相的打开了一个menu么,那么关联展开菜单、导航栏不是都有了么?

  于是——首先将各种详情页菜单项作为隐藏菜单进行设置:

function generateTree(menus, pid): Menu[] {
    let result: Menu[] = [];
    menus.forEach((menu) => {
        if (menu.type === MenuTypeEnum.button || menu.type === MenuTypeEnum.api) {
            return;
        }
        if (menu.pid == pid) {
            let option: Menu = {
                text: menu.name,
                key: menu.code, 
                i18n: menu.name,
                hide: menu.status === MenuStatusEnum.HIDE,
                hideInBreadcrumb: false, //隐藏菜单时隐藏面包屑等:false
                icon: menu.icon || {
                    type: "icon",
                    value: "unordered-list"
                },
                link: generateMenuPath(menu.type, menu.value),
                children: generateTree(menus, menu.id)
            };
            result.push(option);
        }
    });
    return result;
}

  想法虽好,问题不少!delon-8.8.0这个版本的menu有个bug,当菜单之下的所有子菜单都是hide,页面菜单栏上的父菜单仍然表现为一个菜单组,点击是展开(虽然展开后其下并无任何可视子菜单),再点击是关闭。而我们期望父菜单(实际就是“用户管理列表”菜单)点击之后就直接跳转列表页的。

  后面的高版本官方是有fix了这个bug的:refactor(theme:menu): refactor MenuService #1507

  根据这个PR,是在LayoutDefaultNavComponent初始化时将menu进行了一次hide属性设置及过滤:

 ngOnInit(): void {
	 menuSrv.change.pipe(takeUntil(destroy$)).subscribe(data => {
	   menuSrv.visit(data, (i: Nav, _p, depth) => {
	     ...
	   });
	   //如果所有的子菜单都hide,则将父菜单也一并hide
	   this.fixHide(data);
	   //过滤隐藏的菜单
	   this.list = data.filter((w: Nav) => w._hidden !== true);
	   cdr.detectChanges();
	 });
	 
  private fixHide(ls: Nav[]): void {
    const inFn = (list: Nav[]): void => {
      for (const item of list) {
        if (item.children && item.children.length > 0) {
          inFn(item.children);
          if (!item._hidden) {
            item._hidden = item.children.every((v: Nav) => v._hidden);
          }
        }
      }
    };

    inFn(ls);
  }
    

  所以,像我这种“用户管理”——“用户列表”下面全部是隐藏菜单的情况,界面直接连“用户列表”都显示不出来,所以升级版本也不是解决这个问题的办法!

  在将delon的8.8.0的源码拉下来尝试修改了数次之后(ng-alain/delon/8.8.0),我注意到这段源码:

<ng-template #item let-i>
  <!-- link -->
  <a *ngIf="i._type <= 2" (click)="to(i)" [attr.data-id]="i.__id" class="sidebar-nav__item-link"
    [ngClass]="{'sidebar-nav__item-disabled': i.disabled}">
    <ng-container *ngIf="i._needIcon">
      <ng-container *ngIf="!collapsed">
        <ng-template [ngTemplateOutlet]="icon" [ngTemplateOutletContext]="{$implicit: i.icon}"></ng-template>
      </ng-container>
      <span *ngIf="collapsed" nz-tooltip nzTooltipPlacement="right" [nzTooltipTitle]="i.text">
        <ng-template [ngTemplateOutlet]="icon" [ngTemplateOutletContext]="{$implicit: i.icon}"></ng-template>
      </span>
    </ng-container>
    <span class="sidebar-nav__item-text" [innerHTML]="i._text"></span>
  </a>
  <!-- has children link -->
  <a *ngIf="i._type === 3" (click)="toggleOpen(i)" (mouseenter)="showSubMenu($event, i)" class="sidebar-nav__item-link">
    <ng-template [ngTemplateOutlet]="icon" [ngTemplateOutletContext]="{$implicit: i.icon}"></ng-template>
    <span class="sidebar-nav__item-text" [innerHTML]="i._text"></span>
    <i class="sidebar-nav__sub-arrow"></i>
  </a>
  <!-- badge -->
  <div *ngIf="i.badge" [attr.title]="i.badge" class="badge badge-{{i.badgeStatus}}" [class.badge-dot]="i.badgeDot">
    <em>{{i.badge}}</em>
  </div>
</ng-template>

  现在的问题实际上就是进入到i._type === 3 这个分支里面去了,所以我如果直接将源码这里加上是否子菜单全部是隐藏的判断,再重新打包编译,功能肯定是能实现的,只是这手段太勉强了。

  观察源码,发现_type属性的赋值,只在resume方法中,如下:

resume(callback?: (item: Menu, parentMenum: Menu | null, depth?: number) => void) {
    let i = 1;
    const shortcuts: Menu[] = [];
    this.visit(this.data, (item, parent, depth) => {
      ...

      item._type = item.externalLink ? 2 : 1;
      if (item.children && item.children.length > 0) {
        item._type = 3;
      }

      ...

      if (callback) callback(item, parent, depth);
    });
	...
  }

  resume方法只在add和切换i18n国际化方案的时候会调用,那我就计划在菜单add完成之后,重新遍历一次菜单,将所有子菜单为hide的父菜单的_type强制设置为2,使界面渲染时不进入i._type === 3的分支,代码在erupt-web的default.component.ts中,大致如下:

ngOnInit() {
        ...
        this.data.getMenu().subscribe(res => {
        	...
        	function generateTree(menus, pid): Menu[] {
        		...
        	}
        	...
        	let rootMenu: Menu[] = [{
                group: false,
                hideInBreadcrumb: true, //不生成导航栏
                text: "~",
                shortcutRoot: true,
                children: generateTree(res, null)
            }];
            this.menuSrv.add(rootMenu);
            console.log('菜单初始化完成!', this.menuSrv.menus)
            
            this.menuSrv.menus.forEach((m) => {
                this.resumeMenuType(m.children)
            });
            console.log('重设type之后的菜单:', this.menuSrv.menus)
        	
	})

private resumeMenuType(ls: Menu[]): void {
    const inFn = (list: Menu[]): void => {
        for (const item of list) {
            if (item.children && item.children.length > 0) {
                inFn(item.children);
                if (!item._hidden) {
                    let h = item.children.every((v: Menu) => v._hidden);
                    if (h === true) {
                    	//所有子菜单都是hidden,但菜单本身link不为空,则设置type=2
                        if (item.link && item.link.trim().length > 0) {
                            item._type = 2;
                        } else {
                            item._type = 3
                        }
                    }
                }
            }
        }
    };

    inFn(ls);
}

  以上操作,算是煞费苦心的绕过了delon-8.8.0的bug。项目启动起来,终于看到期望中的菜单了,欣喜若狂!

  带着兴奋的神情,继续计划的第二步:在新开tab之前,向menu服务中动态新增一个隐藏的menu,link设置为带了矩阵参数的link,主要代码如下:

newTabForMenu(menuCode: string, menuTitle: string, data: any, eruptName: string, primaryKeyName: string, primaryKeyVal: any) {
        let menu: Menu = this.menuSrv.getItem(menuCode);
        if (!menu) {
            menu = this.menuSrv.getHit(this.menuSrv.menus, menuTitle);
        }
        if (!menu) {
            this.msg.warning("无法找到此路由,请检查!");
            return;
        }
		
		//根据自身业务,构建矩阵参数
		// ...
		let queryParams = {id:1,name:'张三',age:30};
        let link = menu.link;
        for (let p in queryParams) {
            link += ';' + (p + '=' + queryParams[p])
        }
        //在当前menu对象的同级生成一个隐藏菜单,防止router无法找到路由
        const newHideMenuKey = '_hide_' + menu.key + '_' + new Date().getTime();
        let newHideMenu: Menu = {
            text: menu.text,
            key: newHideMenuKey,
            hideInBreadcrumb: false,
            i18n: menu.text,
            hide: true,
            link: link,
            __parent: menu.__parent
        };
        if (menu.__parent) {
            menu.__parent.children.push(newHideMenu);
        }
        this.menuSrv.setItem(newHideMenuKey, newHideMenu);
        console.log('新增隐藏菜单之后:', this.menuSrv.menus);
        
        //跳转到菜单路由
        this.router.navigate([newHideMenu.link]);

    }

  一气呵成!启动,试验,失败——url中的;=等字符竟然被自动编码了:

xx%2Fxxx%3Bid%3D1%3Bname%3D%E5%BC%A0%E4%B8%89%3Bage%3D30

  这一串当然与我们设置的原link相距甚远:

xx/xxx;id=1;name=张三;age=30

  所以reuse-tab自然的404了。

  为了解决这个问题,遍翻各资料站点:  这是说自定义URL序列化规则的方案  这是说使用router.navigate([‘/xxx/xxx’,{k:v,k:v}])的方案

  最后准备使用第二种方案,修改代码:

newTabForMenu(menuCode: string, menuTitle: string, data: any, eruptName: string, primaryKeyName: string, primaryKeyVal: any) {
	...
	let queryParams = {id:1,name:'张三',age:30};
	...
	//跳转到菜单路由,参数格式为:['/xxx/xxx',{k:v,k:v}]
    this.router.navigate([menu.link, queryParams]);
    
}

  这次再试,终于成功了!!!导航栏面包屑很完整,新开tab页菜单栏也关联打开了,终于完整实现了最初的构想!!!

  泪流满面!

  总结这次调整,其实就是使用矩阵参数+动态插入隐藏菜单两个核心技术点,但无论是从reuse-tab的使用、ng-alain的menu的源码实现以及angular的router的使用,方方面面,无一精通,无一不坑!处处全靠锲而不舍的精神慢慢磨出来的效果,所谓念念不忘必有回响,所谓走的人多了也就有了路…

 类似资料: