Angular中臺前端管理系統ng-matero快速上手教學

發表於2023-09-22

現有管理系統方案

我認為,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設計風格,可以解決更多業務場景需要的元件。

cover

這裡列舉一些業務場景

  • 登入認證
  • 許可權管理 (ngx-permissions)
  • 國際化
  • 主題系統
  • HTTP攔截器
  • RTL支援
  • 深色模式

做一個簡單文章管理效果

現在,我們要做一個demo,由於是快速上手,篇幅不會太大,效果如下。

chrome-capture-2023-8-1349e5e4afdd3cd615.gif

就把個人資訊文章列表刪除文章登入處理jwt給做了,然後說明一下Angular官方的專案組織,及ng-matero簡單國際化,更詳細的教學後面大家一起學習哈。

前置要求

  • 擁有Node.js執行時環境,並安裝Angular腳手架工具
  • 學習過前端框架,並知道宣告式開發正規化

接下來的教學,我會盡量寫的是,門檻到沒有接觸Angular框架的同學進行學習。

開始

先放上官方的文件地址,簡介 - NG-MATERO (gitbook.io)

官方給出了2種方式進行使用,一種是建立一個Angular專案,然後使用ng add形式快速配置Angular安裝。但這種方式得要Angular安裝版本與作者提供的版本支援!這裡我就直接用另一種方式,克隆 Starter 倉庫。

官方的教程寫的很詳細了,這裡安裝不做演示,就用圖片掩蓋過去了

Pasted-image-20230913195604e690ec254ad30155.png
Pasted-image-20230914145920c06379b38d8b229c.png

專案結構作者是採用的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是寫在核心模組攔截器裡面。

Drawing-2023-09-14-10.57.26.excalidraw568083c887e690c0.png

那就對應建立一個攔截器,也建立一個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中,做類似的配置。

Drawing-2023-09-14-11.29.30.excalidraw85a4e4b3cc4878c3.png

接下來就是根模組建立依賴

Drawing-2023-09-14-11.31.31.excalidrawe0f7692a0954fb45.png

現在可以進行依賴注入了,透過在建構函式中,變數前面寫上@Inject(GEEK_PC_API)即可

登入服務

src\app\routes\sessions\login\login.component.ts元件檔案中可以看出,登入的介面請求部分在AuthServiceLoginService,順其自然修改一下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.tssrc\app\routes\sessions\login\login.component.ts注意函式引數,否則構建不透過。(TypeScript語言特性)

Drawing-2023-09-14-14.54.16.excalidrawce3224eb1cb1e256.png

然後要在介面的地方,建立一個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;
}

登入介面

修改了登入服務之後,接著就是來看登入元件部分了

Drawing-2023-09-14-15.00.54.excalidrawb0840a87808ba108.png

先來修改一下模板,要去掉原來的記住我欄位,然後將使用者名稱、密碼改成手機、驗證碼。

// 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(請求時請求頭攜帶)。

Drawing-2023-09-14-15.22.52.excalidraw1d320e44988d486c.png

開啟網路工具,其實是可以看到請求頭沒有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,開啟瀏覽器開發工具,發現多了一個跨域問題

Drawing-2023-09-14-17.15.29.excalidraw5cf1bdb48032e05f.png

這裡的大白話意思是,請求允許Cookie時,響應頭必須有Access-Control-Allow-Credentialstrue欄位資訊,否則觸發了跨域(CORS)。

因為請求攜帶Token是在攔截器裡,那麼回來再看看TokenInterceptor

原來請求攜帶Token的時候,同時也允許Cookie

Drawing-2023-09-14-17.30.28.excalidrawe2a7790b3f62d8f7.png

這裡不用覆蓋了,萬一後續後端介面又多了一個呢?也許老介面必須要用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

效果就是這樣
Pasted-image-20230915100423410da19e0fab0a2e.png

重新整理一下頁面,你會發現沒有新增加的文章選單
Pasted-image-202309151010429ffdbbbabe98e87f.png

這要修改src\assets\data\menu.json檔案,因為現在的介面請求,是透過這個檔案,來表示選單功能的
Pasted-image-20230915101746d91804e9564af242.png

{
  "menu": [
    {
      "route": "article",
      "name": "article",
      "type": "sub",
      "icon": "description",
      "children": [
        {
          "route": "list",
          "name": "list",
          "type": "link"
        }
      ]
    },
    ...
  ]
}
這裡選單需要配置語言,最後一小節我們再來做。

可以看到頁面出來了

Pasted-image-202309151019318d07c151d6aa8bf1.png

然後改動元件吧,這裡我要用到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),
        },
      ],
    },
  ];
}

到此,我們最初目的就差不多了,就差最後的語言設定了。

Pasted-image-2023091510313666b045c01701e0f8.png

配置語言

所有的語言配置都在src\assets\i18n下面,開啟中文的zh-CN.json檔案,看看有什麼關聯。

Drawing-2023-09-15-10.35.20.excalidraw5e3651372a59de75.png

可以看到,選單文字的話,是在menu.xx形式下。然後如果元件模板檔案中使用國際化的話,就是使用translate管道運算子,對應語言json檔案的鍵值對。

那就改一下對應語言的檔案

Drawing-2023-09-15-11.01.22.excalidrawd2205741d3cefab7.png

這樣就成功了

Drawing-2023-09-15-11.03.31.excalidraw469a8c4ef183d544.png

這裡就把選單部分給弄了哈,由於本教學屬於快速上手,篇幅儘量還是得小一些,其他的部分可以大家額外花時間去弄。

現在就和#做一個簡單文章管理效果的效果圖片一致啦。

最後

Angular還是挺有趣的,提供的官方方案特別的齊全,像路由、伺服器渲染、網路請求、表單校驗、PWA等等,特別是依賴注入寫起來就像 後端人員寫前端 這樣一種感覺。

但是這幾年Angular的興趣度前幾年在下滑,State of JavaScript 2022: Front-end Frameworks (stateofjs.com)的調查問卷當中,去年開始有所提升了。我個人對這個框架挺感興趣的,特別是設計風格,比如模組服務元件守衛攔截管道概念等等,可以說是我ReactVue用久了,一個有個性的前端框架吧。

需要程式碼的同學,我上傳了gitee上了,有需要可以克隆學習哦。

ng-matero-quick-start · 乾坤道長/share-blog-code - 碼雲 - 開源中國 (gitee.com)

相關文章