基于NG-ZORRO的登录、菜单、多标签页页面架构

在一个后台管理系统中,用户和角色的管理都是必不可少的功能,基于NG-ZORRO实现一套基本的登录、菜单、多标签页的页面

 目录结构

可以分为登录、主页、错误页三大模块,根路由如下

const routes: Routes = [
  { path: 'login', loadChildren: () => import('./login/login.module').then(m => m.LoginModule) },
  {
    path: 'home',
    canActivate: [LoginGuard],
    resolve: {
      menu: MenuResolver
    },
    loadChildren: () => import('./homepage/homepage.module').then(m => m.HomepageModule)
  },
  { path: 'error', loadChildren: () => import('./error-page/error-page.module').then(m => m.ErrorPageModule) }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

跳转到主页home路由需要通过登录守卫,并且加载menu数据

@Injectable({
  providedIn: 'root'
})
export class MenuResolver implements Resolve<PurviewInfo[]> {

  constructor(private requestService: RequestService, private router: Router) { }

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<PurviewInfo[]> {
    return this.requestService.get('/admin/user/purview', undefined, () => {
      this.router.navigate(['/error/500']);
      return true;
    });
  }
}
@Injectable({
  providedIn: 'root'
})
export class LoginGuard implements CanActivate {

  constructor(private authService: AuthService, private router: Router) { }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    const isLoggedIn = this.authService.isLoggedIn();
    if (!isLoggedIn) {
      this.router.navigate(['/login']);
    }
    return isLoggedIn;
  }

}

登录

登录页面主要是表单提交,存储token的值

submitForm(): void {
    if (this.validateForm.valid) {
      const formValue = this.validateForm.value;
      const requestParam = {
        username: formValue.username,
        password: formValue.password
      };
      this.requestService.post('/auth/admin/login', requestParam, error => {
        const { client, errorMessage } = error;
        if (client) {
          // TODO: 改成表单error
          this.message.error(errorMessage);
        }
        return !client;
      }).subscribe({
        next: token => {
          const remember: boolean = formValue.remember;
          this.authService.setToken(token, remember);
          this.router.navigate(['/home']);
        }
      });
    } else {
      Object.values(this.validateForm.controls).forEach(control => {
        if (control.invalid) {
          control.markAsDirty();
          control.updateValueAndValidity({ onlySelf: true });
        }
      });
    }
  }

token可以存储在localStorage或者sessionStorage,取决于用户是否选择了免密登录。

缺点是没法控制token什么时候失效,有条件最好是后台设置cookie存储。

菜单

菜单根据权限进行加载

@Injectable({
  providedIn: 'root'
})
export class MenuService {

  constructor() { }

  leftMenuClick: EventEmitter<any> = new EventEmitter();

  private getAllTopMenus() {
    return [{
      key: 'admin',
      title: '系统管理'
    }];
  }

  private getAllLeftMenus() {
    return [{
      key: 'system',
      title: '管理员配置',
      icon: 'user',
      topKey: 'admin',
      children: [{
        key: 'user',
        title: '用户管理',
        component: AdminManageComponent
      }, {
        key: 'role',
        title: '角色管理',
        component: RoleManageComponent
      }]
    }];
  }

  /**
   * 根据权限获取菜单信息
   * 
   * @param purviewList 返回的权限信息
   * @returns 顶部和左侧菜单信息,顶部菜单topMenus中的项的key属性值与左侧菜单leftMenuMap的Map的key值对应
   */
  getMenus(purviewList: PurviewInfo[]): { topMenus: any[], leftMenuMap: Map<string, any[]> } {
    const allTopMenus = this.getAllTopMenus();
    const allLeftMenus = this.getAllLeftMenus();
    const topMenus: any[] = [];
    const leftMenuMap: Map<string, any[]> = new Map();

    // TODO: 根据权限点purviewList,对allTopMenus和allLeftMenus进行过滤
    ......

    return {
      leftMenuMap,
      topMenus
    }
  }
}

权限点数据系统存在差异,故省略了过滤的过程,最后返回需要展示的顶部菜单和左侧菜单数据。

leftMenu中子元素的component属性,对应的是需要加载的组件类

首页

首页分为三部分,顶部菜单,左侧菜单,tab页签。

三者的关系为:顶部菜单点击后变更对应的左侧菜单,左侧菜单点击后,加载对应的tab页签。

homepage

:host {
    display                : flex;
    text-rendering         : optimizeLegibility;
    -webkit-font-smoothing : antialiased;
    -moz-osx-font-smoothing: grayscale;
}

.app-layout {
    height: 100vh;
}

.menu-sidebar {
    position  : relative;
    z-index   : 10;
    min-height: 100vh;
    box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
}

nz-header {
    padding: 0;
    width  : 100%;
    z-index: 2;
}

nz-content {
    padding: 24px;
    overflow: auto;
}
<nz-layout class="app-layout">
    <nz-sider class="menu-sidebar" nzCollapsible nzWidth="256px" nzBreakpoint="md" [(nzCollapsed)]="isCollapsed"
        [nzTrigger]="null">
        <app-left-menu [(collapsed)]="isCollapsed" [leftMenus]="leftMenus"></app-left-menu>
    </nz-sider>
    <nz-layout>
        <nz-header>
            <app-top-menu [(collapsed)]="isCollapsed" [topMenus]="topMenus" [selected]="topKey"
                (selectedChange)="onSelectedChange($event)"></app-top-menu>
        </nz-header>
        <nz-content>
            <app-content-tabs></app-content-tabs>
        </nz-content>
    </nz-layout>
</nz-layout>
@Component({
  selector: 'app-homepage',
  templateUrl: './homepage.component.html',
  styleUrls: ['./homepage.component.css']
})
export class HomepageComponent implements OnInit {

  isCollapsed = false;

  topMenus: any[] = [];
  leftMenus: any[] = [];
  topKey!: string;

  private leftMenuMap!: Map<string, any>;

  constructor(private router: ActivatedRoute, private menuService: MenuService) { }

  ngOnInit(): void {
    const resolve = this.router.snapshot.data;
    const menu = resolve['menu'] as PurviewInfo[];
    const { leftMenuMap, topMenus } = this.menuService.getMenus(menu);
    this.leftMenuMap = leftMenuMap;
    this.topMenus = topMenus;
    if (topMenus.length === 0) {
      this.isCollapsed = true;
    } else {
      this.topKey = topMenus[0].key;
      this.leftMenus = this.getLeftMenu(this.topKey);
    }
  }

  getLeftMenu(key: string) {
    const leftMenus = this.leftMenuMap?.get(key) || [];
    return leftMenus;
  }

  onSelectedChange(key: string) {
    if (key === this.topKey) {
      return;
    }
    this.leftMenus = this.getLeftMenu(key);
  }
}

top-menu

.app-header {
    position  : relative;
    height    : 64px;
    padding   : 0;
    background: #fff;
    box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
}

.menu-div {
    display : inline-block;
    position: absolute;
}

.header-menu {
    line-height: 64px;
}

.header-trigger {
    height    : 64px;
    padding   : 20px 24px;
    font-size : 20px;
    cursor    : pointer;
    transition: all .3s, padding 0s;
}

.trigger:hover {
    color: #1890ff;
}
<div class="app-header">
    <span class="header-trigger" (click)="collapsed = !collapsed">
        <span class="trigger" nz-icon [nzType]="collapsed ? 'menu-unfold' : 'menu-fold'"></span>
    </span>
    <div class="menu-div">
        <ul nz-menu nzMode="horizontal" class="header-menu">
            <li nz-menu-item *ngFor="let item of topMenus" [nzSelected]="selected === item.key" (click)="onMenuClick(item.key)">{{item.title}}</li>
        </ul>
    </div>
</div>
@Component({
  selector: 'app-top-menu',
  templateUrl: './top-menu.component.html',
  styleUrls: ['./top-menu.component.css']
})
export class TopMenuComponent implements OnInit {

  private _collapsed!: boolean;

  private _selected!: string;

  @Input()
  topMenus: any[] = [];

  @Output()
  collapsedChange: EventEmitter<boolean> = new EventEmitter();

  @Output()
  selectedChange: EventEmitter<string> = new EventEmitter();

  constructor() { }

  ngOnInit(): void {
  }

  @Input()
  public get collapsed(): boolean {
    return this._collapsed;
  }

  @Input()
  public get selected(): string {
    return this._selected;
  }

  public set selected(selected: string) {
    if (this._selected === selected) {
      return;
    }
    this._selected = selected;
    this.selectedChange.emit(this._selected);
  }

  public set collapsed(collapsed: boolean) {
    if (this._collapsed === collapsed) {
      return;
    }
    this._collapsed = collapsed;
    this.collapsedChange.emit(this._collapsed);
  }

  onMenuClick(key: string) {
    this.selected = key;
  }

}

left-menu

.sidebar-logo {
    position    : relative;
    height      : 64px;
    padding-left: 24px;
    overflow    : hidden;
    line-height : 64px;
    background  : #001529;
    transition  : all .3s;
}

.sidebar-logo img {
    display       : inline-block;
    height        : 32px;
    width         : 32px;
    vertical-align: middle;
}

.sidebar-logo h1 {
    display       : inline-block;
    margin        : 0 0 0 20px;
    color         : #fff;
    font-weight   : 600;
    font-size     : 14px;
    font-family   : Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
    vertical-align: middle;
}
<div class="sidebar-logo">
    <a href="https://ng.ant.design/" target="_blank">
        <img src="https://ng.ant.design/assets/img/logo.svg" alt="logo">
        <h1>Ant Design Of Angular</h1>
    </a>
</div>
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="collapsed">
    <ng-container *ngFor="let menu of leftMenus">
        <li nz-submenu nzOpen [nzTitle]="menu.title" [nzIcon]="menu.icon">
            <ul>
                <li nz-menu-item *ngFor="let item of menu.children" (click)="onMenuClick(item)">
                    <a>{{item.title}}</a>
                </li>
            </ul>
        </li>
    </ng-container>
</ul>
@Component({
  selector: 'app-left-menu',
  templateUrl: './left-menu.component.html',
  styleUrls: ['./left-menu.component.css']
})
export class LeftMenuComponent implements OnInit {

  private _collapsed!: boolean;

  @Input()
  leftMenus: any[] = [];

  @Output()
  collapsedChange: EventEmitter<boolean> = new EventEmitter();

  constructor(private menuService: MenuService) { }

  ngOnInit(): void {
  }

  @Input()
  public get collapsed(): boolean {
    return this._collapsed;
  }

  public set collapsed(collapsed: boolean) {
    if (this._collapsed === collapsed) {
      return;
    }
    this._collapsed = collapsed;
    this.collapsedChange.emit(this._collapsed);
  }

  onMenuClick(menu: any) {
    this.menuService.leftMenuClick.emit(menu);
  }
}

content-tabs

:host {
    overflow: hidden;
    display : block;
}

.card-container ::ng-deep p {
    margin: 0;
}

.card-container ::ng-deep>.ant-tabs-card .ant-tabs-content {
    margin-top: -16px;
}

.card-container ::ng-deep>.ant-tabs-card .ant-tabs-content>.ant-tabs-tabpane {
    background: #fff;
    padding   : 16px;
    min-height: calc(100vh - 152px);
}

.card-container ::ng-deep>.ant-tabs-card>.ant-tabs-nav {
    height: 40px;
}

.card-container ::ng-deep>.ant-tabs-card>.ant-tabs-nav::before {
    display: none;
}

.card-container ::ng-deep>.ant-tabs-card .ant-tabs-tab {
    border-color: transparent;
    background  : transparent;
}

.card-container ::ng-deep>.ant-tabs-card .ant-tabs-tab-active {
    border-color: #fff;
    background  : #fff;
}
<div class="card-container">
    <nz-tabset [(nzSelectedIndex)]="selectedIndex" nzType="editable-card" [nzHideAdd]="true"
        (nzClose)="closeTab($event)">
        <nz-tab nzTitle="首页">
            <app-welcome></app-welcome>
        </nz-tab>
        <nz-tab *ngFor="let tab of tabs" [nzTitle]="tab.title" nzClosable>
            <app-content-page [menuData]="tab"></app-content-page>
        </nz-tab>
    </nz-tabset>
</div>
@Component({
  selector: 'app-content-tabs',
  templateUrl: './content-tabs.component.html',
  styleUrls: ['./content-tabs.component.css']
})
export class ContentTabsComponent implements OnInit {

  selectedIndex = 0;

  tabs: any[] = [];

  constructor(private menuService: MenuService) { }

  ngOnInit(): void {
    this.menuService.leftMenuClick.subscribe(tabInfo => {
      // 查看是否已经打开了这个菜单
      const index = this.tabs.findIndex(tab => tab.id === tabInfo.id) + 1;
      if (index && index !== this.selectedIndex) {
        this.selectedIndex = index;
      } else {
        this.newTab(tabInfo);
      }
    });
  }

  newTab(tabInfo: any): void {
    this.tabs.push(tabInfo);
    this.selectedIndex = this.tabs.length;
  }


  closeTab({ index }: { index: number }): void {
    this.tabs.splice(index - 1, 1);
  }

}

标签页

标签页根据点击传入的菜单数据,动态的加载组件

content-page

@Component({
  selector: 'app-content-page',
  template: `<ng-container #container></ng-container>`,
  styleUrls: ['./content-page.component.css']
})
export class ContentPageComponent implements OnInit, AfterViewInit {

  @Input()
  menuData: any;

  @ViewChild('container', { read: ViewContainerRef })
  container!: ViewContainerRef;

  constructor(private cd: ChangeDetectorRef) { }

  ngOnInit(): void {
  }

  ngAfterViewInit(): void {
    this.createComponent();
  }

  createComponent() {
    // 创建组件实例
    const { component, purview } = this.menuData;
    const componentRef = this.container.createComponent<ContentPage>(component);
    componentRef.instance.purview = purview;
    this.cd.detectChanges();
  }

  removeComponent() {
    // 移除组件
    this.container.remove();
  }
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值