現有管理系統方案
我認為,Angular
還在維護的中臺前端框架,大多屬於冷門型。熱門的管理系統方案,我一直都是推薦Ant Design Pro
的,畢竟一直在持續地維護更新。
我在做管理系統的時候,由於好奇Angular
,索性就學習了Angular
,因為之前都是用React
寫的,想換換框架體驗體驗,同時也想用Material
設計風格,就來到了本文主題。
ngx-admin
,GitHub達到了24.7k star,但是最近已經不怎麼更新了,最高支援Angular 14
。ng-admin
,Github有4k star,可以說不在本討論的範圍內,這個angular1的老牌管理系統ng-zerro
,為ant-design for angular
,由社群進行維護,Github的star數量為8.6k,大公司也在使用,支援最新Angular 16
。為UI元件庫,但antd提供了佈局元件,方便快速搭建皮膚,也僅是如此。blur-admin
,也是angular 1
的老牌管理系統方案,屬於ngx-admin
的前身。primeng
,支援最新的Angular 16
,GitHub上的star數量為8.4k,但是這個為UI元件庫,現成的模板在Template
中尋找。coreui-free-angular-admin-template
,GitHub上的star數量為1.6k,支援最新的angular 16
。特點
基於Angular Material 搭建的中後臺管理框架。,同時還有作者編寫的@ng-matero/extensions
元件庫,也是採用Material設計風格,可以解決更多業務場景需要的元件。
這裡列舉一些業務場景
- 登入認證
- 許可權管理 (
ngx-permissions
) - 國際化
- 主題系統
- HTTP攔截器
- RTL支援。
- 深色模式
做一個簡單文章管理效果
現在,我們要做一個demo,由於是快速上手,篇幅不會太大,效果如下。
就把個人資訊、文章列表、刪除文章、登入、處理jwt給做了,然後說明一下Angular
官方的專案組織,及ng-matero
簡單國際化,更詳細的教學後面大家一起學習哈。
前置要求
- 擁有
Node.js
執行時環境,並安裝Angular
腳手架工具 - 學習過前端框架,並知道宣告式開發正規化
接下來的教學,我會盡量寫的是,門檻到沒有接觸Angular
框架的同學進行學習。
開始
先放上官方的文件地址,簡介 - NG-MATERO (gitbook.io)。
官方給出了2種方式進行使用,一種是建立一個Angular
專案,然後使用ng add
形式快速配置Angular安裝。但這種方式得要Angular
安裝版本與作者提供的版本支援!這裡我就直接用另一種方式,克隆 Starter 倉庫。
官方的教程寫的很詳細了,這裡安裝不做演示,就用圖片掩蓋過去了
專案結構作者是採用的Angular
官方推薦的方式進行組織,Angular - 工作區和專案檔案結構。簡要的說明一下重要的地方。
app
根模組,啟動由Angular開發的Web應用程式。app/core
核心模組,一般是只用一次,只有根模組匯入的服務集app/routes
路由模組,對應頁面的元件,及子路由的模組。app/share
共享模組,一般存放重複使用的業務元件集environments
,存放環境變數的值,具體類似於env
檔案,比如開發環境與生產環境的取值。
本教學大概會花費20-30分鐘的時間學習
選用API
這裡就選用黑馬程式設計師 - 極客園介面,介面文件。
首先看到環境變數的地方,就索性把api介面寫進去。後面接下來就是將環境變數的值,進行依賴注入。(生產環境中,會把這裡匯出的物件,替換成environment.prod.ts
檔案中的物件)
// src\environments\environment.ts
export const environment = {
production: false,
baseUrl: '',
useHash: false,
geekPcApi: 'http://toutiao.itheima.net/v1_0',
};
開啟根模組(src\app\app.module.ts
)可以看到,baseUrl
就是在這裡進行Provider
的,進行可以發現InjectionToken
是寫在核心模組的攔截器裡面。
那就對應建立一個攔截器,也建立一個InjectionToken
,把geek
部落格園api進行依賴注入。終端進入src\app\core\interceptors
,然後使用Angular
腳手架。
ng g interceptor geek-pc-api
然後改寫一下
// src\app\core\interceptors\geek-pc-api.interceptor.ts
import { Injectable, InjectionToken } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
export const GEEK_PC_API = new InjectionToken<string>('geek-pc-api'); // 後面作為提供商(Provider)
@Injectable()
export class GeekPcApiInterceptor implements HttpInterceptor {
constructor() {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request);
}
}
統一一下攔截器的匯出,記得在src\app\core\interceptors\index.ts
中,做類似的配置。
接下來就是根模組中建立依賴
現在可以進行依賴注入了,透過在建構函式中,變數前面寫上@Inject(GEEK_PC_API)
即可
登入服務
從src\app\routes\sessions\login\login.component.ts
元件檔案中可以看出,登入的介面請求部分在AuthService
和LoginService
,順其自然修改一下LoginService
。
登入介面文件寫的很詳細了,這裡不再描述介面資料部分
// src\app\core\authentication\login.service.ts
import { Inject, Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { GeekUser, Token, User } from './interface';
import { Menu } from '@core';
import { map } from 'rxjs/operators';
import { GEEK_PC_API } from '@core/interceptors/geek-pc-api.interceptor';
import { of } from 'rxjs';
import { IResponse } from 'app/geek/interface/response';
@Injectable({
providedIn: 'root',
})
export class LoginService {
constructor(protected http: HttpClient, @Inject(GEEK_PC_API) private geekPcApi: string) {}
// 不轉換資料的話,重新命名interface.ts的access_token為token也行,但是TokenService記得也要改,這裡索性直接轉換了。
login(mobile: string, code: string) {
return this.http
.post<IResponse<Pick<Token, 'refresh_token'> & { token: string }>>(
`${this.geekPcApi}/authorizations`,
{
mobile,
code,
}
)
.pipe(
map(res => res.data),
map((token): Token => ({ access_token: token.token, refresh_token: token.refresh_token }))
);
}
// 實際開發中,這個需要更改
refresh(params: Record<string, any>) {
return this.http.post<Token>('/auth/refresh', params);
}
// 這裡退出操作不需要呼叫後端api,索性直接返回觀察者物件就好了
logout() {
return of(true);
}
// 實際開發應該統一User,這裡是為了方便展示,所以索性新定義GeekUser,然後二次轉換為User
me() {
return this.http.get<IResponse<GeekUser>>(`${this.geekPcApi}/user`).pipe(
map(res => res.data),
map(
(geek): User => ({
id: geek.id,
avatar: geek.photo,
email: 'demo@gmail.com',
name: geek.name,
})
)
);
}
// 實際開發中,不同許可權應當對應不同目錄,本api其實就一個角色,所以不用改
menu() {
return this.http.get<{ menu: Menu[] }>('/me/menu').pipe(map(res => res.menu));
}
}
原來的rememberMe
相關欄位就都可以刪掉,這裡不需要了。
在src\app\core\authentication\auth.service.ts
和src\app\routes\sessions\login\login.component.ts
注意函式引數,否則構建不透過。(TypeScript
語言特性)
然後要在介面的地方,建立一個geek部落格園的使用者型別
// src\app\core\authentication\interface.ts
export interface GeekUser {
id: string; // 必須 使用者id
name: string; // 必須 使用者名稱
photo: string; // 必須 使用者頭像
is_media: string; //必須 是否是自媒體,0-否,1-是
intro: string; //必須 簡介
certi: string; //必須 自媒體認證說明
art_count: string; //必須 釋出文章數
follow_count: string; //必須 關注的數目
fans_count: string; //必須 fans_count
like_count: string; //必須 被點贊數
}
...
最後還缺少IResponse
型別,這裡需要建立一個特性模組,用來處理與geek部落格園相關的業務操作,終端進入src/app
目錄下。
ng g m geek
這樣就建立了一個geek
目錄,然後還有一個Angular模組。建立一個interface
目錄,然後再建立一個api介面格式型別,命名為response.ts
檔案。
// src\app\geek\interface\response.ts
export interface IResponse<T> {
data: T;
message: string;
}
登入介面
修改了登入服務之後,接著就是來看登入元件部分了
先來修改一下模板,要去掉原來的記住我欄位,然後將使用者名稱、密碼改成手機、驗證碼。
// src\app\routes\sessions\login\login.component.html
<div class="d-flex w-full h-full">
<mat-card class="m-auto" style="max-width: 380px">
<mat-card-header class="m-b-24">
<mat-card-title>{{ 'login_title' | translate }}!</mat-card-title>
</mat-card-header>
<mat-card-content>
<form class="form-field-full" [formGroup]="loginForm">
<mat-form-field appearance="outline">
<mat-label>手機號</mat-label>
<input matInput placeholder="ng-matero" formControlName="mobile" required />
<mat-error *ngIf="mobile.invalid">
<span *ngIf="mobile.errors?.required">請輸入有效的手機號 </span>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>驗證碼:</mat-label>
<input matInput placeholder="ng-matero" formControlName="code" required />
<mat-error *ngIf="code.invalid">
<span *ngIf="code.errors?.required">請輸入有效的驗證碼 </span>
</mat-error>
</mat-form-field>
<button
class="w-full m-y-16"
mat-raised-button
color="primary"
[disabled]="!!loginForm.invalid"
[loading]="isSubmitting"
(click)="login()"
>
{{ 'login' | translate }}
</button>
</form>
</mat-card-content>
</mat-card>
</div>
{{ 'login' | translate }}
這種是使用了管道,也就是對應國際化業務。那麼這裡就先,全部寫成中文了,原來的地方不動。
然後回到元件檔案,對應的資料進行設定。
// src\app\routes\sessions\login\login.component.ts
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { filter } from 'rxjs/operators';
import { AuthService } from '@core/authentication';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
})
export class LoginComponent {
isSubmitting = false;
loginForm = this.fb.nonNullable.group({
mobile: ['13911111111', [Validators.required, Validators.pattern(/^[\d]{11}$/)]],
code: ['246810', [Validators.required, Validators.pattern(/^[\d]{6}$/)]],
});
constructor(private fb: FormBuilder, private router: Router, private auth: AuthService) {}
get mobile() {
return this.loginForm.get('mobile')!;
}
get code() {
return this.loginForm.get('code')!;
}
login() {
this.isSubmitting = true;
this.auth
.login(this.mobile.value, this.code.value)
.pipe(filter(authenticated => authenticated))
// 這裡必須要有箭頭函式,否則this代表的物件不同
.subscribe({
complete: () => {
this.router.navigateByUrl('/');
},
error: (errorRes: HttpErrorResponse) => {
this.isSubmitting = false;
},
});
}
}
點選登入,前端拿到了token
,進入皮膚後又退回了登入頁,提示沒有未傳token
(請求時請求頭攜帶)。
開啟網路工具,其實是可以看到請求頭沒有Authorization
欄位,來看看請求攜帶token
是在哪裡處理的?
在app/core
核心模組中,有我們最開始#選用API做的攔截器,這裡顧名思義就是針對前端的網路請求,做類似Axios
的請求、響應、錯誤前後處理函式的功能。
找到TokenService
,這裡就是請求時要不要攜帶token的地方,進行改寫。
// src\app\core\interceptors\token-interceptor.ts
import { Inject, Injectable, Optional, inject } from '@angular/core';
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { TokenService } from '@core/authentication';
import { BASE_URL } from './base-url-interceptor';
import { GEEK_PC_API } from '@core/interceptors/geek-pc-api.interceptor';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
...
constructor(
private tokenService: TokenService,
private router: Router,
@Optional() @Inject(GEEK_PC_API) private geekPcApi: string,
@Optional() @Inject(BASE_URL) private baseUrl?: string
) {}
...
private shouldAppendToken(url: string) {
return !this.hasHttpScheme(url) || this.includeBaseUrl(url) || this.includeGeekPcApi(url);
}
...
private includeGeekPcApi(url: string) {
if (!this.geekPcApi) {
return false;
}
const geek = this.geekPcApi.replace(/\/$/, '');
return new RegExp(`^${geek}`, 'i').test(url);
}
}
然後再點選登入,這一回頁面上,顯示了一個Unkown Error
,開啟瀏覽器開發工具,發現多了一個跨域問題。
這裡的大白話意思是,請求允許Cookie時,響應頭必須有Access-Control-Allow-Credentials
為true
欄位資訊,否則觸發了跨域(CORS
)。
因為請求攜帶Token
是在攔截器裡,那麼回來再看看TokenInterceptor
。
原來請求攜帶Token
的時候,同時也允許Cookie
了
這裡不用覆蓋了,萬一後續後端介面又多了一個呢?也許老介面必須要用Cookie
,為了不必要麻煩,就把最開始寫的GeekPcApiInterceptor
完善吧。
// src\app\core\interceptors\geek-pc-api.interceptor.ts
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { IResponse } from 'app/geek/interface/response';
export const GEEK_PC_API = new InjectionToken<string>('geek-pc-api'); // 後面作為提供商(Provider)
@Injectable()
export class GeekPcApiInterceptor implements HttpInterceptor {
// provider從app模組
constructor(@Optional() @Inject(GEEK_PC_API) private geekPcApi?: string) {}
// http請求是否是當前的api
hasScheme = (url: string) => this.geekPcApi && new RegExp(this.geekPcApi, 'i').test(url);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (this.hasScheme(request.url)) {
this.beforeRequestEach(request);
return next // 複製請求並設定 withCredentials 為 false
.handle(request.clone({ withCredentials: false }))
.pipe(tap({ next: this.afterRequestEach }));
}
return next.handle(request);
}
// 請求前的鉤子函式
beforeRequestEach = (request: HttpRequest<unknown>) => {
console.log('request is : ', request);
};
// 請求後的鉤子函式,簡化版,詳細可以做成Axios那樣
afterRequestEach = (response: HttpEvent<IResponse<unknown>>) => {
console.log('response is : ', response);
return response;
};
}
現在可以正常的進去,並能看到個人資訊了。
文章服務
在app/geek
目錄下建立services
目錄,然後終端進入建立文章服務。
ng g s article
就做獲取列表和刪除文章,其他的業務場景,各位同學可以根據介面文件完成哈。
// src\app\geek\service\article.service.ts
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { GEEK_PC_API } from '@core/interceptors/geek-pc-api.interceptor';
import { IResponse } from 'app/geek/interface/response';
import { ArticleList } from 'app/geek/interface/article';
@Injectable({
providedIn: 'root',
})
export class ArticleService {
constructor(@Inject(GEEK_PC_API) private geekPcAPi: string, private httpClient: HttpClient) {}
/**
* 獲取文章列表
*/
findMany(page: number, perPage: number) {
return this.httpClient.get<IResponse<ArticleList>>(`${this.geekPcAPi}/mp/articles`, {
params: {
per_page: perPage,
page,
},
});
}
/**
* 刪除文章
*/
delete(id: string) {
return this.httpClient.delete(`${this.geekPcAPi}/mp/articles/${id}`);
}
}
// src\app\geek\interface\article.ts
export interface ArticleList {
page: number;
per_page: number;
results: Article[];
total_count: number;
}
export interface Article {
id: string;
title: string;
status: string;
comment_count: string;
pubdate: string;
cover: {
type: string;
images: string;
};
like_count: number;
read_count: string;
}
這裡是直接在根模組提供的,也可以讓geek
特性模組來提供,不過這樣就必須,匯入模組、匯出服務。
文章列表
要建立一個新的路由,這裡使用作者提供的新增路由工具。
ng g ng-matero:module article
然後是建立頁面元件了
ng g ng-matero:page list -m=article
效果就是這樣
重新整理一下頁面,你會發現沒有新增加的文章選單。
這要修改src\assets\data\menu.json
檔案,因為現在的介面請求,是透過這個檔案,來表示選單功能的。
{
"menu": [
{
"route": "article",
"name": "article",
"type": "sub",
"icon": "description",
"children": [
{
"route": "list",
"name": "list",
"type": "link"
}
]
},
...
]
}
這裡選單需要配置語言,最後一小節我們再來做。
可以看到頁面出來了
然後改動元件吧,這裡我要用到Angular Material 元件庫和作者提供的Angular Material Extensions library。
// src\app\routes\article\list\list.component.html
<page-header></page-header>
<mtx-grid
[data]="listData"
[columns]="listColumns"
[length]="listTotal"
[pageOnFront]="false"
[pageIndex]="page"
[pageSize]="perPage"
[pageSizeOptions]="[5, 10, 20]"
(page)="changePage($event)"
[cellTemplate]="{ cover: coverTpl, status: statusTpl }"
>
</mtx-grid>
<ng-template #coverTpl let-row let-index="index" let-col="colDef">
<img src="{{ row.cover.images[0] }}" width="{{ 80 }}" />
</ng-template>
<ng-template #statusTpl let-row let-index="index" let-col="colDef">
<mat-chip *ngIf="row.status === 0">稽核失敗</mat-chip>
<mat-chip *ngIf="row.status === 1">待稽核</mat-chip>
<mat-chip *ngIf="row.status === 2">稽核透過</mat-chip>
</ng-template>
let-
屬於模板微語法,用來獲取迴圈時的變數。具體可以看一下這篇文章,【Angular學習】關於模板輸入變數(let-變數)的理解 - 掘金 (juejin.cn)。為什麼資料表格要
[pageOnFront]="false"
呢?作者原始碼的大白話意思是,為true
代表data
指定的資料大小,決定表格的資料總數量。false
就可以使用length
來同步後端的資料總數量。
// src\app\routes\article\list\list.component.ts
import { Component, OnInit } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MtxGridColumn } from '@ng-matero/extensions/grid';
import { Article, ArticleList } from 'app/geek/interface/article';
import { ArticleService } from 'app/geek/service/article.service';
@Component({
selector: 'app-article-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss'],
})
export class ArticleListComponent implements OnInit {
constructor(private articleService: ArticleService, private snack: MatSnackBar) {}
ngOnInit() {
this.getPage();
}
/**
* 刪除文章
* @param id
*/
deleteArticle(id: string) {
this.articleService.delete(id).subscribe(() => {
this.snack.open('刪除成功', '確認', {
duration: 2000,
});
this.getPage();
});
}
/**
* 呼叫頁面請求資料
*/
getPage() {
this.articleService.findMany(this.page + 1, this.perPage).subscribe(list => {
this.listData = list.data.results;
this.listTotal = list.data.total_count;
});
}
/**
* 頁面切換,當下一頁,或者是改變每頁數量
* @param p
*/
changePage(p: PageEvent) {
this.page = p.pageIndex;
this.perPage = p.pageSize;
this.getPage();
}
page = 0;
perPage = 10;
listData: Article[] = [];
listTotal = 0;
listColumns: MtxGridColumn[] = [
{ header: '封面', field: 'cover', width: '120px' },
{ header: '文章標題', field: 'title', width: '220px' },
{ header: '狀態', field: 'status' },
{ header: '評論數量', field: 'comment_count' },
{ header: '釋出時間', field: 'pubdate' },
{ header: '閱讀數量', field: 'read_count' },
{ header: '點贊數量', field: 'like_count' },
{
header: '操作',
field: 'tool',
type: 'button',
buttons: [
{
type: 'icon',
text: 'edit',
icon: 'edit',
tooltip: '編輯',
click: () => alert('沒有實現哦'),
},
{
type: 'icon',
text: 'delete',
icon: 'delete',
tooltip: '刪除',
color: 'warn',
pop: {
title: '確認要刪除嗎?',
},
click: (rowData: Article) => this.deleteArticle(rowData.id),
},
],
},
];
}
到此,我們最初目的就差不多了,就差最後的語言設定了。
配置語言
所有的語言配置都在src\assets\i18n
下面,開啟中文的zh-CN.json
檔案,看看有什麼關聯。
可以看到,選單文字的話,是在menu.xx
形式下。然後如果元件模板檔案中使用國際化的話,就是使用translate
管道運算子,對應語言json
檔案的鍵值對。
那就改一下對應語言的檔案
這樣就成功了
這裡就把選單部分給弄了哈,由於本教學屬於快速上手,篇幅儘量還是得小一些,其他的部分可以大家額外花時間去弄。
現在就和#做一個簡單文章管理效果的效果圖片一致啦。
最後
Angular
還是挺有趣的,提供的官方方案特別的齊全,像路由、伺服器渲染、網路請求、表單校驗、PWA
等等,特別是依賴注入寫起來就像 後端人員寫前端 這樣一種感覺。
但是這幾年Angular
的興趣度前幾年在下滑,State of JavaScript 2022: Front-end Frameworks (stateofjs.com)的調查問卷當中,去年開始有所提升了。我個人對這個框架挺感興趣的,特別是設計風格,比如模組、服務、元件、守衛、攔截、管道概念等等,可以說是我React
、Vue
用久了,一個有個性的前端框架吧。
需要程式碼的同學,我上傳了gitee
上了,有需要可以克隆學習哦。
ng-matero-quick-start · 乾坤道長/share-blog-code - 碼雲 - 開源中國 (gitee.com)