在一个后台管理系统中,用户和角色的管理都是必不可少的功能,基于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();
}
}