Angular9-高级教程-三-

63 阅读27分钟

Angular9 高级教程(三)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

八、SportsStore:订单和结账

在本章中,我继续向我在第七章中创建的 SportsStore 应用添加特性。我添加了对购物车和结账流程的支持,并用来自 RESTful web 服务的数据替换了虚拟数据。

准备示例应用

本章不需要准备,继续使用第七章中的 SportsStore 项目。要启动 RESTful web 服务,请打开命令提示符并在SportsStore文件夹中运行以下命令:

npm run json

打开第二个命令提示符,在SportsStore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:

ng serve --open

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建购物车

用户需要一个购物车,产品可以放入其中,并用于开始结帐过程。在接下来的小节中,我将向应用添加一个购物车,并将其集成到商店中,以便用户可以选择他们想要的产品。

创建购物车模型

购物车功能的起点是一个新的模型类,它将用于收集用户选择的产品。我在src/app/model文件夹中添加了一个名为cart.model.ts的文件,并用它来定义清单 8-1 中所示的类。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable()
export class Cart {
    public lines: CartLine[] = [];
    public itemCount: number = 0;
    public cartPrice: number = 0;

    addLine(product: Product, quantity: number = 1) {
        let line = this.lines.find(line => line.product.id == product.id);
        if (line != undefined) {
            line.quantity += quantity;
        } else {
            this.lines.push(new CartLine(product, quantity));
        }
        this.recalculate();
    }

    updateQuantity(product: Product, quantity: number) {
        let line = this.lines.find(line => line.product.id == product.id);
        if (line != undefined) {
            line.quantity = Number(quantity);
        }
        this.recalculate();
    }

    removeLine(id: number) {
        let index = this.lines.findIndex(line => line.product.id == id);
        this.lines.splice(index, 1);
        this.recalculate();
    }

    clear() {
        this.lines = [];
        this.itemCount = 0;
        this.cartPrice = 0;
    }

    private recalculate() {
        this.itemCount = 0;
        this.cartPrice = 0;
        this.lines.forEach(l => {
            this.itemCount += l.quantity;
            this.cartPrice += (l.quantity * l.product.price);
        })
    }
}

export class CartLine {

    constructor(public product: Product,
        public quantity: number) {}

    get lineTotal() {
        return this.quantity * this.product.price;
    }
}

Listing 8-1.The Contents of the cart.model.ts File in the src/app/model Folder

单个产品选择被表示为一组CartLine对象,每个对象包含一个Product对象和一个数量。Cart类跟踪已经被选中的商品的总数和它们的总价格。

在整个应用中应该使用一个单独的Cart对象,确保应用的任何部分都可以访问用户的产品选择。为了实现这一点,我将使Cart成为一个服务,这意味着 Angular 将负责创建一个Cart类的实例,并在需要创建一个具有Cart构造函数参数的组件时使用它。这是 Angular 依赖注入特性的另一个用途,可用于在整个应用中共享对象,这将在第 19 和 20 章中详细描述。已经应用于清单中的Cart类的@Injectable装饰器表明这个类将被用作服务。

Note

严格地说,只有当一个类有自己的构造函数参数需要解析时,才需要使用@Injectable装饰器,但是无论如何应用它都是一个好主意,因为它提供了一个信号,表明这个类打算用作服务。

清单 8-2 将Cart类注册为模型特征模块类的providers属性中的服务。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";

@NgModule({
    providers: [ProductRepository, StaticDataSource, Cart]
})
export class ModelModule { }

Listing 8-2.Registering the Cart as a Service in the model.module.ts File in the src/app/model Folder

创建购物车摘要组件

组件是 angle 应用的基本构建块,因为它们允许轻松创建代码和内容的离散单元。SportsStore 应用将在页面的标题区域向用户显示他们的产品选择摘要,我将通过创建一个组件来实现这一点。我在src/app/store文件夹中添加了一个名为cartSummary.component.ts的文件,并用它来定义清单 8-3 中所示的组件。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";

@Component({
    selector: "cart-summary",
    templateUrl: "cartSummary.component.html"
})
export class CartSummaryComponent {

    constructor(public cart: Cart) { }
}

Listing 8-3.The Contents of the cartSummary.component.ts File in the src/app/store Folder

当 Angular 需要创建这个组件的实例时,它必须提供一个Cart对象作为构造函数参数,使用我在上一节中配置的服务,将Cart类添加到特性模块的providers属性中。服务的默认行为意味着一个单独的Cart对象将被创建并在整个应用中共享,尽管有不同的服务行为可用(如第二十章所述)。

为了给组件提供模板,我在组件类文件所在的文件夹中创建了一个名为cartSummary.component.html的 HTML 文件,并添加了清单 8-4 中所示的标记。

<div class="float-right">
  <small>
    Your cart:
    <span *ngIf="cart.itemCount > 0">
      {{ cart.itemCount }} item(s)
      {{ cart.cartPrice | currency:"USD":"symbol":"2.2-2" }}
    </span>
    <span *ngIf="cart.itemCount == 0">
      (empty)
    </span>
  </small>
  <button class="btn btn-sm bg-dark text-white"
      [disabled]="cart.itemCount == 0">
    <i class="fa fa-shopping-cart"></i>
  </button>
</div>

Listing 8-4.The Contents of the cartSummary.component.html File in the src/app/store Folder

该模板使用其组件提供的Cart对象来显示购物车中的商品数量和总费用。还有一个按钮,当我在本章后面将它添加到应用时,它将启动结帐过程。

Tip

清单 8-4 中的按钮元素是使用字体 Awesome 定义的类来设计的,字体 Awesome 是第七章的package.json文件中的一个包。这个开源包为 web 应用中的图标提供了出色的支持,包括我在 SportsStore 应用中需要的购物车。详见 http://fontawesome.io

清单 8-5 向 store 特性模块注册新组件,为下一节使用它做准备。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent],
    exports: [StoreComponent]
})
export class StoreModule { }

Listing 8-5.Registering the Component in the store.module.ts File in the src/app/store Folder

将购物车整合到商店中

商店组件是将购物车和购物车小部件集成到应用中的关键。清单 8-6 更新了商店组件,这样它的构造函数就有了一个Cart参数,并定义了一个将产品添加到购物车的方法。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";
import { Cart } from "../model/cart.model";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository,
                private cart: Cart) { }

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageCount(): number {
        return Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage)
    }

    addProductToCart(product: Product) {
        this.cart.addLine(product);
    }
}

Listing 8-6.Adding Cart Support in the store.component.ts File in the src/app/store Folder

为了完成购物车到商店组件的集成,清单 8-7 添加了将购物车汇总组件应用到商店组件的模板的元素,并为每个产品描述添加了一个按钮,该按钮带有调用addProductToCart方法的事件绑定。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
      <cart-summary></cart-summary>
    </div>
  </div>
  <div class="row">

    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
          class="btn btn-outline-primary btn-block"
          [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
    </div>

    <div class="col-9 p-2">
      <div *ngFor="let product of products" class="card m-1 p-1 bg-light">
        <h4>
          {{product.name}}
          <span class="badge badge-pill badge-primary float-right">
            {{ product.price | currency:"USD":"symbol":"2.2-2" }}
          </span>
        </h4>
        <div class="card-text bg-white p-1">
          {{product.description}}
          <button class="btn btn-success btn-sm float-right"
                  (click)="addProductToCart(product)">
            Add To Cart
          </button>
        </div>
      </div>

      <div class="form-inline float-left mr-1">
        <select class="form-control" [value]="productsPerPage"
                (change)="changePageSize($event.target.value)">
          <option value="3">3 per Page</option>
          <option value="4">4 per Page</option>
          <option value="6">6 per Page</option>
          <option value="8">8 per Page</option>
        </select>
      </div>

      <div class="btn-group float-right">
        <button *counter="let page of pageCount" (click)="changePage(page)"
            class="btn btn-outline-primary" [class.active]="page == selectedPage">
          {{page}}
        </button>
      </div>

    </div>
  </div>
</div>

Listing 8-7.Applying the Component in the store.component.html File in the src/app/store Folder

结果是为每个产品添加一个按钮,如图 8-1 所示。整个购物车过程尚未完成,但是您可以在页面顶部的购物车摘要中看到每次添加的效果。

img/421542_4_En_8_Fig1_HTML.jpg

图 8-1。

向 SportsStore 应用添加购物车支持

请注意,单击添加到购物车按钮之一会自动更新摘要组件的内容。发生这种情况是因为两个组件共享一个Cart对象,并且当 Angular 评估另一个组件中的数据绑定表达式时,一个组件所做的更改会被反映出来。

添加 URL 路由

大多数应用需要在不同的时间向用户显示不同的内容。在 SportsStore 应用中,当用户单击 Add To Cart 按钮时,他们应该看到所选产品的详细视图,并有机会开始结帐过程。

Angular 支持一个名为 URL 路由的特性,它使用浏览器显示的当前 URL 来选择显示给用户的组件。这种方法使得创建组件松散耦合的应用变得容易,并且不需要在应用的其他地方进行相应的修改就可以容易地进行更改。URL 路由也使得改变用户通过应用的路径变得容易。

对于 SportsStore 应用,我将添加对三个不同 URL 的支持,如表 8-1 中所述。这是一个简单的配置,但是路由系统有很多特性,在章节 25 到 27 中有详细描述。

表 8-1。

SportsStore 应用支持的 URL

|

统一资源定位器

|

描述

| | --- | --- | | /store | 该 URL 将显示产品列表。 | | /cart | 这个 URL 将详细显示用户的购物车。 | | /checkout | 此 URL 将显示结帐过程。 |

在接下来的小节中,我将为 SportsStore 购物车和订单结帐阶段创建占位符组件,然后使用 URL 路由将它们集成到应用中。一旦实现了 URL,我将返回组件并添加更多有用的特性。

创建购物车详细信息和结帐组件

在将 URL 路由添加到应用之前,我需要创建将由/cart/checkoutURL 显示的组件。我只需要一些基本的占位符内容就可以开始了,只是为了清楚地显示哪个组件。我首先在src/app/store文件夹中添加一个名为cartDetail.component.ts的文件,并定义清单 8-8 中所示的组件。

import { Component } from "@angular/core";

@Component({
    template: `<div><h3 class="bg-info p-1 text-white">Cart Detail Component</h3></div>`
})
export class CartDetailComponent {}

Listing 8-8.The Contents of the cartDetail.component.ts File in the src/app/store Folder

接下来,我在src/app/store文件夹中添加了一个名为checkout.component.ts的文件,并定义了清单 8-9 中所示的组件。

import { Component } from "@angular/core";

@Component({
    template: `<div><h3 class="bg-info p-1 text-white">Checkout Component</h3></div>`
})
export class CheckoutComponent { }

Listing 8-9.The Contents of the checkout.component.ts File in the src/app/store Folder

该组件遵循与购物车组件相同的模式,并显示一条占位符消息。清单 8-10 在商店功能模块中注册组件,并将它们添加到exports属性中,这意味着它们可以在应用的其他地方使用。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";
import { CartDetailComponent } from "./cartDetail.component";
import { CheckoutComponent } from "./checkout.component";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent,
        CartDetailComponent, CheckoutComponent],
    exports: [StoreComponent, CartDetailComponent, CheckoutComponent]
})
export class StoreModule { }

Listing 8-10.Registering Components in the store.module.ts File in the src/app/store Folder

创建和应用路由配置

现在我有了一系列要显示的组件,下一步是创建路由配置,告诉 Angular 如何将 URL 映射到组件中。一个 URL 到一个组件的每个映射被称为一个 URL 路由或者仅仅是一个路由。在第三部分中,我创建了更复杂的路由配置,我在一个单独的文件中定义路由,但是对于这个项目,我将遵循一个更简单的方法,在应用根模块的@NgModule装饰器中定义路由,如清单 8-11 所示。

Tip

Angular 路由特性要求 HTML 文档中有一个base元素,它提供了应用路由所依据的基本 URL。当我在第七章中创建 SportsStore 项目时,这个元素被ng new命令添加到了index.html文件中。如果忽略该元素,Angular 将报告错误,并且无法应用管线。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            { path: "store", component: StoreComponent },
            { path: "cart", component: CartDetailComponent },
            { path: "checkout", component: CheckoutComponent },
            { path: "**", redirectTo: "/store" }
        ])],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 8-11.Creating the Routing Configuration in the app.module.ts File in the src/app Folder

RouterModule.forRoot方法传递一组路由,每个路由将一个 URL 映射到一个组件。列表中的前三个路由匹配表 8-1 中的 URL。最后一个路由是一个通配符,它将任何其他 URL 重定向到/store,这将显示StoreComponent

当使用路由特性时,Angular 查找router-outlet元素,该元素定义了对应于当前 URL 的组件应该显示的位置。清单 8-12 用router-outlet元素替换根组件模板中的store元素。

import { Component } from "@angular/core";

@Component({
    selector: "app",
    template: "<router-outlet></router-outlet>"
})
export class AppComponent { }

Listing 8-12.Defining the Routing Target in the app.component.ts File in the src/app Folder

当您保存更改并且浏览器重新加载 HTML 文档时,Angular 将应用路由配置。浏览器窗口中显示的内容没有改变,但是如果您检查浏览器的 URL 栏,您将能够看到路由配置已经被应用,如图 8-2 所示。

img/421542_4_En_8_Fig2_HTML.jpg

图 8-2。

URL 路由的影响

在应用中导航

路由配置就绪后,就可以通过更改浏览器的 URL 来添加对组件间导航的支持了。URL 路由功能依赖于浏览器提供的 JavaScript API,这意味着用户不能简单地在浏览器的 URL 栏中键入目标 URL。相反,导航必须由应用来执行,要么在组件或其他构建块中使用 JavaScript 代码,要么在模板中向 HTML 元素添加属性。

当用户单击添加到购物车按钮之一时,应该显示购物车细节组件,这意味着应用应该导航到/cart URL。清单 8-13 向组件方法添加导航,当用户点击按钮时,组件方法被调用。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";
import { Cart } from "../model/cart.model";
import { Router } from "@angular/router";

@Component({
    selector: "store",
    templateUrl: "store.component.html"
})
export class StoreComponent {
    public selectedCategory = null;
    public productsPerPage = 4;
    public selectedPage = 1;

    constructor(private repository: ProductRepository,
        private cart: Cart,
        private router: Router) { }

    get products(): Product[] {
        let pageIndex = (this.selectedPage - 1) * this.productsPerPage
        return this.repository.getProducts(this.selectedCategory)
            .slice(pageIndex, pageIndex + this.productsPerPage);
    }

    get categories(): string[] {
        return this.repository.getCategories();
    }

    changeCategory(newCategory?: string) {
        this.selectedCategory = newCategory;
    }

    changePage(newPage: number) {
        this.selectedPage = newPage;
    }

    changePageSize(newSize: number) {
        this.productsPerPage = Number(newSize);
        this.changePage(1);
    }

    get pageCount(): number {
        return Math.ceil(this.repository
            .getProducts(this.selectedCategory).length / this.productsPerPage)
    }

    addProductToCart(product: Product) {
        this.cart.addLine(product);
        this.router.navigateByUrl("/cart");
    }
}

Listing 8-13.Navigating Using JavaScript in the store.component.ts File in the app/src/store Folder

构造函数有一个Router参数,它是 Angular 在创建组件的新实例时通过依赖注入特性提供的。在addProductToCart方法中,Router.navigateByUrl方法用于导航到/cart URL。

还可以通过向模板中的元素添加routerLink属性来完成导航。在清单 8-14 中,routerLink属性已经应用于购物车汇总组件模板中的购物车按钮。

<div class="float-right">
  <small>
    Your cart:
    <span *ngIf="cart.itemCount > 0">
      {{ cart.itemCount }} item(s)
      {{ cart.cartPrice | currency:"USD":"symbol":"2.2-2" }}
    </span>
    <span *ngIf="cart.itemCount == 0">
      (empty)
    </span>
  </small>
  <button class="btn btn-sm bg-dark text-white"
      [disabled]="cart.itemCount == 0" routerLink="/cart">
    <i class="fa fa-shopping-cart"></i>
  </button>
</div>

Listing 8-14.Adding Navigation in the cartSummary.component.html File in the src/app/store Folder

routerLink属性指定的值是单击button时应用将导航到的 URL。当购物车为空时,这个特殊的按钮被禁用,因此只有当用户将产品添加到购物车时,它才会执行导航。

为了增加对routerLink属性的支持,必须将RouterModule模块导入到特征模块中,如清单 8-15 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { StoreComponent } from "./store.component";
import { CounterDirective } from "./counter.directive";
import { CartSummaryComponent } from "./cartSummary.component";
import { CartDetailComponent } from "./cartDetail.component";
import { CheckoutComponent } from "./checkout.component";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [ModelModule, BrowserModule, FormsModule, RouterModule],
    declarations: [StoreComponent, CounterDirective, CartSummaryComponent,
        CartDetailComponent, CheckoutComponent],
    exports: [StoreComponent, CartDetailComponent, CheckoutComponent]
})
export class StoreModule { }

Listing 8-15.Importing the Router Module in the store.module.ts File in the src/app/store Folder

要查看导航的效果,请保存文件的更改,一旦浏览器重新加载了 HTML 文档,请单击“添加到购物车”按钮之一。浏览器将导航到/cart网址,如图 8-3 所示。

img/421542_4_En_8_Fig3_HTML.jpg

图 8-3。

使用 URL 路由

守卫路由

请记住,导航只能由应用来执行。如果您直接在浏览器的 URL 栏中更改 URL,浏览器将从 web 服务器请求您输入的 URL。响应 HTTP 请求的 Angular development 服务器将通过返回index.html的内容来响应任何与文件不对应的 URL。这通常是一个有用的行为,因为这意味着当单击浏览器的重新加载按钮时,您不会收到 HTTP 错误。但是,如果应用希望用户按照特定的路径在应用中导航,这可能会导致问题。

例如,如果您单击添加到购物车按钮之一,然后单击浏览器的重新加载按钮,HTTP 服务器将返回index.html文件的内容,Angular 将立即跳转到购物车详细信息组件,跳过应用中允许用户选择产品的部分。

对于一些应用,能够开始使用不同的 URL 是有意义的,但如果不是这样,那么 Angular 支持路由守卫,用于管理路由系统。

为了防止应用以/cart/order URL 开始,我在SportsStore/src/app文件夹中添加了一个名为storeFirst.guard.ts的文件,并定义了清单 8-16 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { StoreComponent } from "./store/store.component";

@Injectable()
export class StoreFirstGuard {
    private firstNavigation = true;

    constructor(private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): boolean {
        if (this.firstNavigation) {
            this.firstNavigation = false;
            if (route.component != StoreComponent) {
                this.router.navigateByUrl("/");
                return false;
            }
        }
        return true;
    }
}

Listing 8-16.The Contents of the storeFirst.guard.ts File in the src/app Folder

有不同的方法来保护路由,如第二十七章所述,这是一个防止路由被激活的保护的例子,它被实现为一个定义了canActivate方法的类。这个方法的实现使用 Angular 提供的描述将要导航到的路由的上下文对象,并检查目标组件是否是一个StoreComponent。如果这是第一次调用canActivate方法,并且将要使用一个不同的组件,那么Router.navigateByUrl方法用于导航到根 URL。

清单中应用了@Injectable装饰符,因为路由守卫是服务。清单 8-17 使用根模块的providers属性将守卫注册为服务,并使用canActivate属性守卫每条路由。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";
import { StoreFirstGuard } from "./storeFirst.guard";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            {
                path: "store", component: StoreComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "cart", component: CartDetailComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "checkout", component: CheckoutComponent,
                canActivate: [StoreFirstGuard]
            },
            { path: "**", redirectTo: "/store" }
        ])],
    providers: [StoreFirstGuard],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 8-17.Guarding Routes in the app.module.ts File in the src/app Folder

如果您现在点击“添加到购物车”按钮后重新加载浏览器,您将会看到浏览器自动返回到安全位置,如图 8-4 所示。

img/421542_4_En_8_Fig4_HTML.jpg

图 8-4。

守卫路由

完成购物车详细信息功能

既然应用有了导航支持,是时候完成详细描述用户购物车内容的视图了。清单 8-18 从 cart detail 组件中移除内联模板,在同一目录中指定一个外部模板,并向构造函数添加一个Cart参数,该参数可在模板中通过一个名为cart的属性进行访问。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";

@Component({
    templateUrl: "cartDetail.component.html"
})
export class CartDetailComponent {

    constructor(public cart: Cart) { }
}

Listing 8-18.Changing the Template in the cartDetail.component.ts File in the src/app/store Folder

为了完成购物车细节特性,我在src/app/store文件夹中创建了一个名为cartDetail.component.html的 HTML 文件,并添加了清单 8-19 中所示的内容。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
  <div class="row">
    <div class="col mt-2">
      <h2 class="text-center">Your Cart</h2>
      <table class="table table-bordered table-striped p-2">
        <thead>
          <tr>
            <th>Quantity</th>
            <th>Product</th>
            <th class="text-right">Price</th>
            <th class="text-right">Subtotal</th>
          </tr>
        </thead>
        <tbody>
          <tr *ngIf="cart.lines.length == 0">
            <td colspan="4" class="text-center">
              Your cart is empty
            </td>
          </tr>
          <tr *ngFor="let line of cart.lines">
            <td>
              <input type="number" class="form-control-sm"
                     style="width:5em"
                     [value]="line.quantity"
                     (change)="cart.updateQuantity(line.product,
                                $event.target.value)" />
            </td>
            <td>{{line.product.name}}</td>
            <td class="text-right">
                {{line.product.price | currency:"USD":"symbol":"2.2-2"}}
            </td>
            <td class="text-right">
                {{(line.lineTotal) | currency:"USD":"symbol":"2.2-2" }}
            </td>
            <td class="text-center">
              <button class="btn btn-sm btn-danger"
                      (click)="cart.removeLine(line.product.id)">
                Remove
              </button>
            </td>
          </tr>
        </tbody>
        <tfoot>
          <tr>
            <td colspan="3" class="text-right">Total:</td>
            <td class="text-right">
              {{cart.cartPrice | currency:"USD":"symbol":"2.2-2"}}
            </td>
          </tr>
        </tfoot>
      </table>
    </div>
  </div>
  <div class="row">
    <div class="col">
    <div class="text-center">
      <button class="btn btn-primary m-1" routerLink="/store">
          Continue Shopping
      </button>
      <button class="btn btn-secondary m-1" routerLink="/checkout"
              [disabled]="cart.lines.length == 0">
        Checkout
      </button>
    </div>
  </div>
</div>

Listing 8-19.The Contents of the cartDetail.component.html File in the src/app/store Folder

该模板显示一个表格,其中显示了用户的产品选择。对于每个产品,都有一个input元素可用于更改数量,还有一个 Remove 按钮可将其从购物车中删除。还有两个导航按钮,允许用户返回产品列表或继续结账过程,如图 8-5 所示。Angular 数据绑定和共享的Cart对象的结合意味着对购物车的任何更改都会立即生效,重新计算价格;如果您单击 Continue Shopping 按钮,这些更改将反映在产品列表上方显示的购物车摘要组件中。

img/421542_4_En_8_Fig5_HTML.jpg

图 8-5。

完成购物车详细信息功能

处理订单

能够收到顾客的订单是网上商店最重要的方面。在接下来的几节中,我将在应用的基础上添加对从用户处接收最终细节并检查它们的支持。为了保持过程简单,我将避免处理支付和履行平台,它们通常是后端服务,并不特定于 Angular 应用。

扩展模型

为了描述用户下的订单,我在src/app/model文件夹中添加了一个名为order.model.ts的文件,并定义了清单 8-20 中所示的代码。

import { Injectable } from "@angular/core";
import { Cart } from "./cart.model";

@Injectable()
export class Order {
    public id: number;
    public name: string;
    public address: string;
    public city: string;
    public state: string;
    public zip: string;
    public country: string;
    public shipped: boolean = false;

    constructor(public cart: Cart) { }

    clear() {
        this.id = null;
        this.name = this.address = this.city = null;
        this.state = this.zip = this.country = null;
        this.shipped = false;
        this.cart.clear();
    }
}

Listing 8-20.The Contents of the order.model.ts File in the src/app/model Folder

Order类将是另一个服务,这意味着整个应用将共享一个实例。当 Angular 创建Order对象时,它将检测Cart构造函数参数,并提供应用中其他地方使用的同一个Cart对象。

更新存储库和数据源

为了处理应用中的订单,我需要扩展存储库和数据源,以便它们可以接收Order对象。清单 8-21 向接收订单的数据源添加一个方法。因为这仍然是虚拟数据源,所以该方法只是从订单中产生一个 JSON 字符串,并将其写入 JavaScript 控制台。在下一节中,当我创建一个使用 HTTP 请求与 RESTful web 服务通信的数据源时,我将对这些对象做一些更有用的事情。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable, from } from "rxjs";
import { Order } from "./order.model";

@Injectable()
export class StaticDataSource {
    private products: Product[] = [
        new Product(1, "Product 1", "Category 1", "Product 1 (Category 1)", 100),
        new Product(2, "Product 2", "Category 1", "Product 2 (Category 1)", 100),
        new Product(3, "Product 3", "Category 1", "Product 3 (Category 1)", 100),
        new Product(4, "Product 4", "Category 1", "Product 4 (Category 1)", 100),
        new Product(5, "Product 5", "Category 1", "Product 5 (Category 1)", 100),
        new Product(6, "Product 6", "Category 2", "Product 6 (Category 2)", 100),
        new Product(7, "Product 7", "Category 2", "Product 7 (Category 2)", 100),
        new Product(8, "Product 8", "Category 2", "Product 8 (Category 2)", 100),
        new Product(9, "Product 9", "Category 2", "Product 9 (Category 2)", 100),
        new Product(10, "Product 10", "Category 2", "Product 10 (Category 2)", 100),
        new Product(11, "Product 11", "Category 3", "Product 11 (Category 3)", 100),
        new Product(12, "Product 12", "Category 3", "Product 12 (Category 3)", 100),
        new Product(13, "Product 13", "Category 3", "Product 13 (Category 3)", 100),
        new Product(14, "Product 14", "Category 3", "Product 14 (Category 3)", 100),
        new Product(15, "Product 15", "Category 3", "Product 15 (Category 3)", 100),
    ];

    getProducts(): Observable<Product[]> {
        return from([this.products]);
    }

    saveOrder(order: Order): Observable<Order> {
        console.log(JSON.stringify(order));
        return from([order]);
    }
}

Listing 8-21.Handling Orders in the static.datasource.ts File in the src/app/model Folder

为了管理订单,我在src/app/model文件夹中添加了一个名为order.repository.ts的文件,并用它来定义清单 8-22 中所示的类。目前订单库中只有一个方法,但是当我创建管理特性时,我会在第九章中添加更多的功能。

Tip

您不必为应用中的每个模型类型使用不同的存储库,但我经常这样做,因为负责多个模型类型的单个类可能会变得复杂且难以维护。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Order } from "./order.model";
import { StaticDataSource } from "./static.datasource";

@Injectable()
export class OrderRepository {
    private orders: Order[] = [];

    constructor(private dataSource: StaticDataSource) {}

    getOrders(): Order[] {
        return this.orders;
    }

    saveOrder(order: Order): Observable<Order> {
        return this.dataSource.saveOrder(order);
    }
}

Listing 8-22.The Contents of the order.repository.ts File in the src/app/model Folder

更新功能模块

清单 8-23 使用模型特征模块的providers属性将Order类和新的存储库注册为服务。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";

@NgModule({
    providers: [ProductRepository, StaticDataSource, Cart,
                Order, OrderRepository]
})
export class ModelModule { }

Listing 8-23.Registering Services in the model.module.ts File in the src/app/model Folder

收集订单详细信息

下一步是从用户那里收集完成订单所需的详细信息。Angular 包含了处理 HTML 表单和验证其内容的内置指令。清单 8-24 准备 checkout 组件,切换到外部模板,接收Order对象作为构造函数参数,并提供一些额外的支持来帮助模板。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { OrderRepository } from "../model/order.repository";
import { Order } from "../model/order.model";

@Component({
    templateUrl: "checkout.component.html",
    styleUrls: ["checkout.component.css"]
})
export class CheckoutComponent {
    orderSent: boolean = false;
    submitted: boolean = false;

    constructor(public repository: OrderRepository,
                public order: Order) {}

    submitOrder(form: NgForm) {
        this.submitted = true;
        if (form.valid) {
            this.repository.saveOrder(this.order).subscribe(order => {
                this.order.clear();
                this.orderSent = true;
                this.submitted = false;
            });
        }
    }
}

Listing 8-24.Preparing for a Form in the checkout.component.ts File in the src/app/store Folder

当用户提交表单时,submitOrder方法将被调用,表单由一个NgForm对象表示。如果表单包含的数据有效,那么Order对象将被传递给存储库的saveOrder方法,购物车和订单中的数据将被重置。

@Component decorator 的styleUrls属性用于指定一个或多个 CSS 样式表,这些样式表应该应用于组件模板中的内容。为了给用户输入 HTML 表单元素的值提供验证反馈,我在src/app/store文件夹中创建了一个名为checkout.component.css的文件,并定义了清单 8-25 中所示的样式。

input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }

Listing 8-25.The Contents of the checkout.component.css File in the src/app/store Folder

Angular 将元素添加到ng-dirtyng-validng-valid类中,以指示它们的验证状态。在第十四章中描述了全套的验证类,但是清单 8-25 中样式的效果是在有效的input元素周围添加一个绿色边框,在无效的元素周围添加一个红色边框。

拼图的最后一块是组件的模板,它为用户提供填充一个Order对象的属性所需的表单域,如清单 8-26 所示。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
    </div>
  </div>
</div>

<div *ngIf="orderSent" class="m-2 text-center">
  <h2>Thanks!</h2>
  <p>Thanks for placing your order.</p>
  <p>We'll ship your goods as soon as possible.</p>
  <button class="btn btn-primary" routerLink="/store">Return to Store</button>
</div>
<form *ngIf="!orderSent" #form="ngForm" novalidate
      (ngSubmit)="submitOrder(form)" class="m-2">
  <div class="form-group">
    <label>Name</label>
    <input class="form-control" #name="ngModel" name="name"
            [(ngModel)]="order.name" required />
    <span *ngIf="submitted && name.invalid" class="text-danger">
      Please enter your name
    </span>
  </div>
  <div class="form-group">
    <label>Address</label>
    <input class="form-control" #address="ngModel" name="address"
            [(ngModel)]="order.address" required />
    <span *ngIf="submitted && address.invalid" class="text-danger">
      Please enter your address
    </span>
  </div>
  <div class="form-group">
    <label>City</label>
    <input class="form-control" #city="ngModel" name="city"
            [(ngModel)]="order.city" required />
    <span *ngIf="submitted && city.invalid" class="text-danger">
      Please enter your city
    </span>
  </div>
  <div class="form-group">
    <label>State</label>
    <input class="form-control" #state="ngModel" name="state"
            [(ngModel)]="order.state" required />
    <span *ngIf="submitted && state.invalid" class="text-danger">
      Please enter your state
    </span>
  </div>
  <div class="form-group">
    <label>Zip/Postal Code</label>
    <input class="form-control" #zip="ngModel" name="zip"
            [(ngModel)]="order.zip" required />
    <span *ngIf="submitted && zip.invalid" class="text-danger">
      Please enter your zip/postal code
    </span>
  </div>
  <div class="form-group">
    <label>Country</label>
    <input class="form-control" #country="ngModel" name="country"
            [(ngModel)]="order.country" required />
    <span *ngIf="submitted && country.invalid" class="text-danger">
      Please enter your country
    </span>
  </div>
  <div class="text-center">
    <button class="btn btn-secondary m-1" routerLink="/cart">Back</button>
    <button class="btn btn-primary m-1" type="submit">Complete Order</button>
  </div>
</form>

Listing 8-26.The Contents of the checkout.component.html File in the src/app/store Folder

该模板中的forminput元素使用 Angular 特性来确保用户为每个字段提供值,如果用户在没有完成表单的情况下单击 Complete Order 按钮,它们会提供视觉反馈。这种反馈一部分来自应用清单 8-25 中定义的样式,一部分来自在用户试图提交无效表单之前保持隐藏的span元素。

Tip

要求值只是 Angular 验证表单域的方式之一,正如我在第十四章中解释的,你也可以很容易地添加你自己的自定义验证。

要查看该过程,从产品列表开始,单击其中一个添加到购物车按钮,将产品添加到购物车。点击 Checkout 按钮,你会看到如图 8-6 所示的 HTML 表单。单击 Complete Order 按钮,无需在任何input元素中输入文本,您将看到验证反馈消息。填写表格并单击“完成订单”按钮;您将看到如图所示的确认消息。

img/421542_4_En_8_Fig6_HTML.jpg

图 8-6。

完成订单

如果您查看浏览器的 JavaScript 控制台,您会看到订单的 JSON 表示,如下所示:

{"cart":
    {"lines":[
        {"product":{"id":1,"name":"Product 1","category":"Category 1",
         "description":"Product 1 (Category 1)","price":100},"quantity":1}],
         "itemCount":1,"cartPrice":100},
    "shipped":false,
    "name":"Joe Smith","address":"123 Main Street",
    "city":"Smallville","state":"NY","zip":"10036","country":"USA"
}

使用 RESTful Web 服务

既然基本的 SportsStore 功能已经就绪,是时候用一个从 RESTful web 服务获取数据的数据源来替换这个虚拟数据源了,这个 RESTful web 服务是在第七章的项目设置期间创建的。

为了创建数据源,我在src/app/model文件夹中添加了一个名为rest.datasource.ts的文件,并添加了清单 8-27 中所示的代码。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
  baseUrl: string;

  constructor(private http: HttpClient) {
    this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
  }

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.baseUrl + "products");
  }

  saveOrder(order: Order): Observable<Order> {
    return this.http.post<Order>(this.baseUrl + "orders", order);
  }
}

Listing 8-27.The Contents of the rest.datasource.ts File in the src/app/model Folder

Angular 提供了一个名为HttpClient的内置服务,用于发出 HTTP 请求。RestDataSource构造函数接收HttpClient服务,并使用浏览器提供的全局location对象来确定请求将被发送到的 URL,即加载应用的主机上的端口 3500。

RestDataSource类定义的方法对应于由静态数据源定义的方法,但是使用第二十四章中描述的HttpClient服务来实现。

Tip

当通过 HTTP 获取数据时,网络拥塞或服务器负载可能会延迟请求,使用户只能看到没有数据的应用。在第二十七章中,我解释了如何配置路由系统来防止这个问题。

应用数据源

为了完成本章,我将通过重新配置应用来应用 RESTful 数据源,这样从虚拟数据到 REST 数据的切换是通过对单个文件的更改来完成的。清单 8-28 改变了模型特征模块中数据源服务的行为。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource }]
})
export class ModelModule { }

Listing 8-28.Changing the Service Configuration in the model.module.ts File in the src/app/model Folder

imports属性用于声明对HttpClientModule特性模块的依赖,该模块提供了在清单 8-27 中使用的HttpClient服务。对providers属性的更改告诉 Angular,当它需要用StaticDataSource构造函数参数创建一个类的实例时,它应该使用RestDataSource来代替。由于两个对象定义了相同的方法,动态 JavaScript 类型系统意味着替换是无缝的。当所有更改都已保存,浏览器重新加载应用时,您将看到虚拟数据已被通过 HTTP 获得的数据所替换,如图 8-7 所示。

img/421542_4_En_8_Fig7_HTML.jpg

图 8-7。

使用 RESTful web 服务

如果您经历选择产品和结帐的过程,您可以看到数据源已经通过导航到以下 URL 将订单写入 web 服务:

http://localhost:3500/db

这将显示数据库的全部内容,包括订单的集合。您将不能请求/orders URL,因为它需要认证,我将在下一章中设置认证。

Tip

请记住,当您停止服务器并使用npm run json命令再次启动它时,RESTful web 服务提供的数据会被重置。

摘要

在本章中,我继续向 SportsStore 应用添加特性,添加对用户可以放入产品的购物车和完成购物过程的结帐过程的支持。为了完成这一章,我用一个向 RESTful web 服务发送 HTTP 请求的数据源替换了伪数据源。在下一章中,我将创建允许管理 SportsStore 数据的管理特性。

九、SportsStore:管理

在本章中,我将继续通过添加管理功能来构建 SportsStore 应用。需要访问管理功能的用户相对较少,因此在不太可能使用管理代码和内容时,强制所有用户下载这些代码和内容是一种浪费。相反,我将把管理特性放在一个单独的模块中,只在需要时才加载。

准备示例应用

本章不需要准备,继续使用第八章中的 SportsStore 项目。要启动 RESTful web 服务,请打开命令提示符并在SportsStore文件夹中运行以下命令:

npm run json

打开第二个命令提示符,在SportsStore文件夹中运行以下命令,启动开发工具和 HTTP 服务器:

ng serve --open

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

创建模块

创建功能模块的过程与您在前面章节中看到的模式相同。关键的区别在于,应用的任何其他部分都不依赖于模块或它包含的类,这一点很重要,否则会破坏模块的动态加载,并导致 JavaScript 模块加载管理代码,即使它没有被使用。

管理特性的起点是身份验证,这将确保只有授权用户才能管理应用。我在src/app/admin文件夹中创建了一个名为auth.component.ts的文件,并用它来定义清单 9-1 中所示的组件。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Router } from "@angular/router";

@Component({
    templateUrl: "auth.component.html"
})
export class AuthComponent {
    public username: string;
    public password: string;
    public errorMessage: string;

    constructor(private router: Router) {}

    authenticate(form: NgForm) {
        if (form.valid) {
            // perform authentication
            this.router.navigateByUrl("/admin/main");
        } else {
            this.errorMessage = "Form Data Invalid";
        }
    }
}

Listing 9-1.The Content of the auth.component.ts File in the src/app/admin Folder

该组件定义了用户名和密码的属性,这些属性将用于验证用户,一个errorMessage属性将用于在出现问题时显示消息,一个authenticate方法将执行验证过程(但目前不做任何事情)。

为了给组件提供模板,我在src/app/admin文件夹中创建了一个名为auth.component.html的文件,并添加了清单 9-2 中所示的内容。

<div class="bg-info p-2 text-center text-white">
  <h3>SportsStore Admin</h3>
</div>
<div class="bg-danger mt-2 p-2 text-center text-white"
     *ngIf="errorMessage != null">
  {{errorMessage}}
</div>
<div class="p-2">
  <form novalidate #form="ngForm" (ngSubmit)="authenticate(form)">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control" name="username"
             [(ngModel)]="username" required />
    </div>
    <div class="form-group">
      <label>Password</label>
      <input class="form-control" type="password" name="password"
             [(ngModel)]="password" required />
    </div>
    <div class="text-center">
      <button class="btn btn-secondary m-1" routerLink="/">Go back</button>
      <button class="btn btn-primary m-1" type="submit">Log In</button>
    </div>
  </form>
</div>

Listing 9-2.The Content of the auth.component.html File in the src/app/admin Folder

该模板包含一个 HTML 表单,该表单对组件的属性使用双向数据绑定表达式。有一个提交表单的按钮,一个导航回根 URL 的按钮,以及一个只有在显示错误消息时才可见的div元素。

为了创建管理特性的占位符,我在src/app/admin文件夹中添加了一个名为admin.component.ts的文件,并定义了清单 9-3 中所示的组件。

import { Component } from "@angular/core";

@Component({
    templateUrl: "admin.component.html"
})
export class AdminComponent {}

Listing 9-3.The Contents of the admin.component.ts File in the src/app/admin Folder

该组件目前不包含任何功能。为了给组件提供一个模板,我在src/app/admin文件夹中添加了一个名为admin.component.html的文件,占位符内容如清单 9-4 所示。

<div class="bg-info p-2 text-white">
  <h3>Placeholder for Admin Features</h3>
</div>

Listing 9-4.The Contents of the admin.component.html File in the src/app/admin Folder

为了定义特性模块,我在src/app/admin文件夹中添加了一个名为admin.module.ts的文件,并添加了清单 9-5 中所示的代码。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    { path: "main", component: AdminComponent },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    declarations: [AuthComponent, AdminComponent]
})
export class AdminModule { }

Listing 9-5.The Contents of the admin.module.ts File in the src/app/admin Folder

RouterModule.forChild方法用于定义功能模块的路由配置,然后包含在模块的imports属性中。

动态加载的模块必须是自包含的,并且包含 Angular 需要的所有信息,包括支持的路由 URL 和它们显示的组件。如果应用的任何其他部分依赖于该模块,那么它将与应用代码的其余部分一起包含在 JavaScript 包中,这意味着所有用户都必须为他们不会使用的功能下载代码和资源。

但是,允许动态加载的模块声明对应用主要部分的依赖。这个模块依赖于数据模型模块中的功能,它已经被添加到模块的imports中,以便组件可以访问模型类和存储库。

配置 URL 路由系统

动态加载的模块通过路由配置进行管理,当应用导航到特定的 URL 时,路由配置会触发加载过程。清单 9-6 扩展了应用的路由配置,因此/admin URL 将加载管理功能模块。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { StoreModule } from "./store/store.module";
import { StoreComponent } from "./store/store.component";
import { CheckoutComponent } from "./store/checkout.component";
import { CartDetailComponent } from "./store/cartDetail.component";
import { RouterModule } from "@angular/router";
import { StoreFirstGuard } from "./storeFirst.guard";

@NgModule({
    imports: [BrowserModule, StoreModule,
        RouterModule.forRoot([
            {
                path: "store", component: StoreComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "cart", component: CartDetailComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "checkout", component: CheckoutComponent,
                canActivate: [StoreFirstGuard]
            },
            {
                path: "admin",
                loadChildren: () => import("./admin/admin.module")
                    .then(m => m.AdminModule),
                canActivate: [StoreFirstGuard]
            },
            { path: "**", redirectTo: "/store" }
        ])],
    providers: [StoreFirstGuard],
    declarations: [AppComponent],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 9-6.Configuring a Dynamically Loaded Module in the app.module.ts File in the src/app Folder

新的路径告诉 Angular,当应用导航到/admin URL 时,它应该从admin/admin.module.ts文件中加载一个由名为AdminModule的类定义的特性模块,其路径是相对于app.module.ts文件指定的。当 Angular 处理管理模块时,它会将包含的路由信息合并到整个路由集中,并完成导航。

导航到管理 URL

最后的准备步骤是为用户提供导航到/admin URL 的能力,以便加载管理功能模块并向用户显示其组件。清单 9-7 在商店组件的模板中添加了一个按钮来执行导航。

<div class="container-fluid">
  <div class="row">
    <div class="col bg-dark text-white">
      <a class="navbar-brand">SPORTS STORE</a>
      <cart-summary></cart-summary>
    </div>
  </div>
  <div class="row">

    <div class="col-3 p-2">
      <button class="btn btn-block btn-outline-primary" (click)="changeCategory()">
        Home
      </button>
      <button *ngFor="let cat of categories"
          class="btn btn-outline-primary btn-block"
          [class.active]="cat == selectedCategory" (click)="changeCategory(cat)">
        {{cat}}
      </button>
      <button class="btn btn-block btn-danger mt-5" routerLink="/admin">
        Admin
      </button>
    </div>

    <div class="col-9 p-2">

      <!-- ...elements omitted for brevity... -->

    </div>
  </div>
</div>

Listing 9-7.Adding a Navigation Button in the store.component.html File in the src/app/store Folder

要反映这些更改,请停止开发工具,并通过在SportsStore文件夹中运行以下命令来重新启动它们:

ng serve

使用浏览器导航至http://localhost:4200,并使用浏览器的 F12 开发工具查看加载应用时浏览器发出的网络请求。管理模块的文件将不会被加载,直到你点击管理按钮,此时 Angular 将请求文件并显示如图 9-1 所示的登录页面。

img/421542_4_En_9_Fig1_HTML.jpg

图 9-1。

使用动态加载的模块

在表单域中输入任意名称和密码,点击登录按钮,查看占位符内容,如图 9-2 所示。如果您将任何一个表单域留空,将会显示一条警告消息。

img/421542_4_En_9_Fig2_HTML.jpg

图 9-2。

占位符管理功能

实施身份验证

RESTful web 服务已经过配置,因此它要求对管理特性所要求的请求进行身份验证。在接下来的小节中,我将通过向 RESTful web 服务发送 HTTP 请求来添加对用户身份验证的支持。

了解认证系统

当 RESTful web 服务对用户进行身份验证时,它将返回一个 JSON Web 令牌(JWT ),应用必须将它包含在后续的 HTTP 请求中,以表明身份验证已经成功执行。您可以在 https://tools.ietf.org/html/rfc7519 阅读 JWT 规范,但是对于 SportsStore 应用来说,只需知道 Angular 应用可以通过向/login URL 发送 POST 请求来验证用户,包括在请求体中包含名称和密码属性的 JSON 格式的对象。我在第七章的申请中添加的验证码只有一组有效凭证,如表 9-1 所示。

表 9-1。

RESTful Web 服务支持的身份验证凭证

|

用户名

|

密码

| | --- | --- | | admin | secret |

正如我在第七章中提到的,您不应该在实际项目中硬编码凭证,但这是您在 SportsStore 应用中需要的用户名和密码。

如果正确的凭证被发送到/login URL,那么来自 RESTful web 服务的响应将包含一个 JSON 对象,如下所示:

{
  "success": true,
   "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLCJleHBpcmVz
           SW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-bHBtdWrz0312p_DG5tKypGv6cA
           NgOyzlg8"
}

success属性描述认证操作的结果,而token属性包含 JWT,它应该包含在使用Authorization HTTP 头的后续请求中,格式如下:

Authorization: Bearer<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiYWRtaW4iLC
    JleHBpcmVzSW4iOiIxaCIsImlhdCI6MTQ3ODk1NjI1Mn0.lJaDDrSu-
    bHBtdWrz0312p_DG5tKypGv6cANgOyzlg8>

我配置了服务器返回的 JWT 令牌,使它们在一小时后过期。如果向服务器发送了错误的凭证,那么响应中返回的 JSON 对象将只包含一个设置为falsesuccess属性,如下所示:

{
  "success": false
}

扩展数据源

RESTful 数据源将完成大部分工作,因为它负责向/login URL 发送认证请求,并在后续请求中包含 JWT。清单 9-8 向RestDataSource类添加了身份验证,并定义了一个变量,该变量将在获得 JWT 后存储它。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
    }

    getProducts(): Observable<Product[]> {
        return this.http.get<Product[]>(this.baseUrl + "products");
    }

    saveOrder(order: Order): Observable<Order> {
        return this.http.post<Order>(this.baseUrl + "orders", order);
    }

    authenticate(user: string, pass: string): Observable<boolean> {
        return this.http.post<any>(this.baseUrl + "login", {
            name: user, password: pass
        }).pipe(map(response => {
            this.auth_token = response.success ? response.token : null;
            return response.success;
        }));
    }
}

Listing 9-8.Adding Authentication in the rest.datasource.ts File in the src/app/model Folder

创建身份验证服务

我将创建一个可用于执行身份验证并确定应用是否已通过身份验证的服务,而不是直接向应用的其余部分公开数据源。我在src/app/model文件夹中添加了一个名为auth.service.ts的文件,并添加了清单 9-9 中所示的代码。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class AuthService {

    constructor(private datasource: RestDataSource) {}

    authenticate(username: string, password: string): Observable<boolean> {
        return this.datasource.authenticate(username, password);
    }

    get authenticated(): boolean {
        return this.datasource.auth_token != null;
    }

    clear() {
        this.datasource.auth_token = null;
    }
}

Listing 9-9.The Contents of the auth.service.ts File in the src/app/model Folder

authenticate方法接收用户的凭证,并将它们传递给数据源authenticate方法,如果认证过程成功,返回一个将产生trueObservable,否则返回falseauthenticated属性是一个 getter 专用属性,如果数据源已经获得了一个认证令牌,它将返回trueclear方法从数据源中移除令牌。

清单 9-10 向模型特征模块注册新服务。它还为RestDataSource类添加了一个providers条目,该类在前面的章节中仅被用作StaticDataSource类的替代品。由于AuthService类有一个RestDataSource构造函数参数,它需要在模块中有自己的条目。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";
import { AuthService } from "./auth.service";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource },
    RestDataSource, AuthService]
})
export class ModelModule { }

Listing 9-10.Configuring the Services in the model.module.ts File in the src/app/model Folder

启用身份验证

下一步是连接从用户处获取凭证的组件,以便它通过新服务执行身份验证,如清单 9-11 所示。

import { Component } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Router } from "@angular/router";
import { AuthService } from "../model/auth.service";

@Component({
    templateUrl: "auth.component.html"
})
export class AuthComponent {
    public username: string;
    public password: string;
    public errorMessage: string;

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

    authenticate(form: NgForm) {
        if (form.valid) {
            this.auth.authenticate(this.username, this.password)
                .subscribe(response => {
                    if (response) {
                        this.router.navigateByUrl("/admin/main");
                    }
                    this.errorMessage = "Authentication Failed";
                })
        } else {
            this.errorMessage = "Form Data Invalid";
        }
    }
}

Listing 9-11.Enabling Authentication in the auth.component.ts File in the src/app/admin Folder

为了防止应用直接导航到管理特性,这将导致 HTTP 请求在没有令牌的情况下被发送,我在src/app/admin文件夹中添加了一个名为auth.guard.ts的文件,并定义了清单 9-12 中所示的路由保护。

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, RouterStateSnapshot,
            Router } from "@angular/router";
import { AuthService } from "../model/auth.service";

@Injectable()
export class AuthGuard {

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

    canActivate(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): boolean {

        if (!this.auth.authenticated) {
            this.router.navigateByUrl("/admin/auth");
            return false;
        }
        return true;
    }
}

Listing 9-12.The Contents of the auth.guard.ts File in the src/app/admin Folder

清单 9-13 将路由保护应用于由管理功能模块定义的路由之一。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";
import { AuthGuard } from "./auth.guard";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    { path: "main", component: AdminComponent, canActivate: [AuthGuard] },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    providers: [AuthGuard],
    declarations: [AuthComponent, AdminComponent]
})
export class AdminModule {}

Listing 9-13.Guarding a Route in the admin.module.ts File in the src/app/admin Folder

要测试身份验证系统,请单击 Admin 按钮,输入一些凭证,然后单击 Log In 按钮。如果凭证是表 9-1 中的凭证,那么您将看到管理功能的占位符。如果您输入其他凭据,将会看到一条错误消息。图 9-3 说明了这两种结果。

Tip

令牌不会永久存储,因此如果可以,请在浏览器中重新加载应用以再次启动,并尝试一组不同的凭据。

img/421542_4_En_9_Fig3_HTML.jpg

图 9-3。

测试身份验证功能

扩展数据源和存储库

身份验证系统就绪后,下一步是扩展数据源,以便它可以发送经过身份验证的请求,并通过 order 和 product repository 类公开这些特性。清单 9-14 向包含认证令牌的数据源添加方法。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";
import { HttpHeaders } from '@angular/common/http';

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
    }

    getProducts(): Observable<Product[]> {
        return this.http.get<Product[]>(this.baseUrl + "products");
    }

    saveOrder(order: Order): Observable<Order> {
        return this.http.post<Order>(this.baseUrl + "orders", order);
    }

    authenticate(user: string, pass: string): Observable<boolean> {
        return this.http.post<any>(this.baseUrl + "login", {
            name: user, password: pass
        }).pipe(map(response => {
            this.auth_token = response.success ? response.token : null;
            return response.success;
        }));
    }

    saveProduct(product: Product): Observable<Product> {
        return this.http.post<Product>(this.baseUrl + "products",
            product, this.getOptions());
    }

    updateProduct(product): Observable<Product> {
        return this.http.put<Product>(`${this.baseUrl}products/${product.id}`,
            product, this.getOptions());
    }

    deleteProduct(id: number): Observable<Product> {
        return this.http.delete<Product>(`${this.baseUrl}products/${id}`,
            this.getOptions());
    }

    getOrders(): Observable<Order[]> {
        return this.http.get<Order[]>(this.baseUrl + "orders", this.getOptions());
    }

    deleteOrder(id: number): Observable<Order> {
        return this.http.delete<Order>(`${this.baseUrl}orders/${id}`,
            this.getOptions());
    }

    updateOrder(order: Order): Observable<Order> {
        return this.http.put<Order>(`${this.baseUrl}orders/${order.id}`,
            order, this.getOptions());
    }

    private getOptions() {
        return {
            headers: new HttpHeaders({
                "Authorization": `Bearer<${this.auth_token}>`
            })
        }
    }
}

Listing 9-14.Adding New Operations in the rest.datasource.ts File in the src/app/model Folder

清单 9-15 向产品存储库类添加了新方法,允许创建、更新或删除产品。saveProduct方法负责创建和更新产品,这是一种在使用由组件管理的单个对象时工作良好的方法,您将在本章后面看到演示。清单还将构造函数参数的类型更改为RestDataSource

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
//import { StaticDataSource } from "./static.datasource";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class ProductRepository {
    private products: Product[] = [];
    private categories: string[] = [];

    constructor(private dataSource: RestDataSource) {
        dataSource.getProducts().subscribe(data => {
            this.products = data;
            this.categories = data.map(p => p.category)
                .filter((c, index, array) => array.indexOf(c) == index).sort();
        });
    }

    getProducts(category: string = null): Product[] {
        return this.products
            .filter(p => category == null || category == p.category);
    }

    getProduct(id: number): Product {
        return this.products.find(p => p.id == id);
    }

    getCategories(): string[] {
        return this.categories;
    }

    saveProduct(product: Product) {
        if (product.id == null || product.id == 0) {
            this.dataSource.saveProduct(product)
                .subscribe(p => this.products.push(p));
        } else {
            this.dataSource.updateProduct(product)
                .subscribe(p => {
                    this.products.splice(this.products.
                        findIndex(p => p.id == product.id), 1, product);
                });
        }
    }

    deleteProduct(id: number) {
        this.dataSource.deleteProduct(id).subscribe(p => {
            this.products.splice(this.products.
                findIndex(p => p.id == id), 1);
        })
    }
}

Listing 9-15.Adding New Operations in the product.repository.ts File in the src/app/model Folder

清单 9-16 对订单存储库进行了相应的更改,添加了允许修改和删除订单的方法。

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Order } from "./order.model";
//import { StaticDataSource } from "./static.datasource";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class OrderRepository {
    private orders: Order[] = [];
    private loaded: boolean = false;

    constructor(private dataSource: RestDataSource) { }

    loadOrders() {
        this.loaded = true;
        this.dataSource.getOrders()
            .subscribe(orders => this.orders = orders);
    }

    getOrders(): Order[] {
        if (!this.loaded) {
            this.loadOrders();
        }
        return this.orders;
    }

    saveOrder(order: Order): Observable<Order> {
        return this.dataSource.saveOrder(order);
    }

    updateOrder(order: Order) {
        this.dataSource.updateOrder(order).subscribe(order => {
            this.orders.splice(this.orders.
                findIndex(o => o.id == order.id), 1, order);
        });
    }

    deleteOrder(id: number) {
        this.dataSource.deleteOrder(id).subscribe(order => {
            this.orders.splice(this.orders.findIndex(o => id == o.id), 1);
        });
    }
}

Listing 9-16.Adding New Operations in the order.repository.ts File in the src/app/model Folder

订单存储库定义了一个loadOrders方法,该方法从存储库中获取订单,并确保在执行身份验证之前不会将请求发送到 RESTful web 服务。

创建管理功能结构

现在,身份验证系统已经就绪,存储库提供了完整的操作,我可以创建显示管理特性的结构了,这是通过构建现有的 URL 路由配置来创建的。表 9-2 列出了我将支持的 URL 以及每个 URL 将呈现给用户的功能。

表 9-2。

管理功能的 URL

|

名字

|

描述

| | --- | --- | | /admin/main/products | 导航到此 URL 将在一个表中显示所有产品,以及允许编辑或删除现有产品和创建新产品的按钮。 | | /admin/main/products/create | 导航到这个 URL 将向用户呈现一个用于创建新产品的空编辑器。 | | /admin/main/products/edit/1 | 导航到该 URL 将向用户呈现一个填充的编辑器,用于编辑现有产品。 | | /admin/main/orders | 导航到这个 URL 将向用户显示一个表中的所有订单,以及标记已发货订单和通过删除取消订单的按钮。 |

创建占位符组件

我发现向 Angular 项目添加特性的最简单方法是定义具有占位符内容的组件,并围绕它们构建应用的结构。一旦结构就位,我就返回组件并详细实现特性。对于管理特性,我首先将一个名为productTable.component.ts的文件添加到src/app/admin文件夹中,并定义清单 9-17 中所示的组件。该组件将负责显示产品列表,以及编辑和删除它们或创建新产品所需的按钮。

import { Component } from "@angular/core";

@Component({
    template: `<div class="bg-info p-2 text-white">
                <h3>Product Table Placeholder</h3>
              </div>`
})
export class ProductTableComponent {}

Listing 9-17.The Contents of the productTable.component.ts File in the src/app/admin Folder

我在src/app/admin文件夹中添加了一个名为productEditor.component.ts的文件,并用它来定义清单 9-18 中所示的组件,这将用于允许用户输入创建或编辑组件所需的细节。

import { Component } from "@angular/core";

@Component({
    template: `<div class="bg-warning p-2 text-white">
                <h3>Product Editor Placeholder</h3>
              </div>`
})
export class ProductEditorComponent { }

Listing 9-18.The Contents of the productEditor.component.ts File in the src/app/admin Folder

为了创建负责管理客户订单的组件,我在src/app/admin文件夹中添加了一个名为orderTable.component.ts的文件,并添加了清单 9-19 中所示的代码。

import { Component } from "@angular/core";

@Component({
    template: `<div class="bg-primary p-2 text-white">
                <h3>Order Table Placeholder</h3>
              </div>`
})
export class OrderTableComponent { }

Listing 9-19.The Contents of the orderTable.component.ts File in the src/app/admin Folder

准备通用内容和功能模块

上一节中创建的组件将负责特定的特性。为了将这些特性结合在一起并允许用户在它们之间导航,我需要修改占位符组件的模板,我一直使用这个模板来演示成功的身份验证尝试的结果。我用清单 9-20 中所示的元素替换了占位符内容。

<div class="container-fluid">
    <div class="row">
        <div class="col bg-dark text-white">
            <a class="navbar-brand">SPORTS STORE</a>
        </div>
    </div>
    <div class="row mt-2">
        <div class="col-3">
            <button class="btn btn-outline-info btn-block"
                    routerLink="/admin/main/products"
                    routerLinkActive="active">
                Products
            </button>
            <button class="btn btn-outline-info btn-block"
                    routerLink="/admin/main/orders"
                    routerLinkActive="active">
                Orders
            </button>
            <button class="btn btn-outline-danger btn-block" (click)="logout()">
                Logout
            </button>
        </div>
        <div class="col-9">
            <router-outlet></router-outlet>
        </div>
    </div>
</div>

Listing 9-20.Replacing the Content in the admin.component.html File in the src/app/admin Folder

该模板包含一个router-outlet元素,用于显示前一节中的组件。还有一些按钮将应用导航到/admin/main/products/admin/main/ordersURL,这将选择产品或订单功能。这些按钮使用routerLinkActive属性,当由routerLink属性指定的路由激活时,该属性用于将元素添加到 CSS 类中。

该模板还包含一个Logout按钮,该按钮有一个事件绑定,该事件绑定的目标是一个名为logout的方法。清单 9-21 将该方法添加到组件中,该组件使用认证服务来移除不记名令牌并将应用导航到默认 URL。

import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { AuthService } from "../model/auth.service";

@Component({
    templateUrl: "admin.component.html"
})
export class AdminComponent {

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

    logout() {
        this.auth.clear();
        this.router.navigateByUrl("/");
    }
}

Listing 9-21.Implementing the Logout Method in the admin.component.ts File in the src/app/admin Folder

清单 9-22 启用将用于每个管理特性的占位符组件,并扩展 URL 路由配置以实现来自表 9-2 的 URL。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { AuthComponent } from "./auth.component";
import { AdminComponent } from "./admin.component";
import { AuthGuard } from "./auth.guard";
import { ProductTableComponent } from "./productTable.component";
import { ProductEditorComponent } from "./productEditor.component";
import { OrderTableComponent } from "./orderTable.component";

let routing = RouterModule.forChild([
    { path: "auth", component: AuthComponent },
    {
        path: "main", component: AdminComponent, canActivate: [AuthGuard],
        children: [
            { path: "products/:mode/:id", component: ProductEditorComponent },
            { path: "products/:mode", component: ProductEditorComponent },
            { path: "products", component: ProductTableComponent },
            { path: "orders", component: OrderTableComponent },
            { path: "**", redirectTo: "products" }
        ]
    },
    { path: "**", redirectTo: "auth" }
]);

@NgModule({
    imports: [CommonModule, FormsModule, routing],
    providers: [AuthGuard],
    declarations: [AuthComponent, AdminComponent,
        ProductTableComponent, ProductEditorComponent, OrderTableComponent]
})
export class AdminModule {}

Listing 9-22.Configuring the Feature Module in the admin.module.ts File in the src/app/admin Folder

单独的路由可以使用children属性来扩展,该属性用于定义以嵌套的router-outlet元素为目标的路由,我在第二十五章中对此进行了描述。正如您将看到的,组件可以从 Angular 获得活动路由的详细信息,因此它们可以调整自己的行为。路由可以包括路由参数,例如:mode:id,它们匹配任何 URL 段,并且可以用来向组件提供信息,这些信息可以用来改变它们的行为。

保存所有更改后,点击 Admin 按钮,使用密码secret验证为admin。你会看到新的布局,如图 9-4 所示。点击产品和订单按钮将改变清单 9-20 中router-outlet元素显示的组件。点击注销按钮将退出管理区。

img/421542_4_En_9_Fig4_HTML.jpg

图 9-4。

行政布局结构

实现产品功能

呈现给用户的初始管理功能将是一个产品列表,能够创建新产品以及删除或编辑现有产品。清单 9-23 从产品表组件中删除了占位符内容,并添加了实现该特性所需的逻辑。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    templateUrl: "productTable.component.html"
})
export class ProductTableComponent {

    constructor(private repository: ProductRepository) { }

    getProducts(): Product[] {
        return this.repository.getProducts();
    }

    deleteProduct(id: number) {
        this.repository.deleteProduct(id);
    }
}

Listing 9-23.Replacing Content in the productTable.component.ts File in the src/app/admin Folder

组件方法提供了对存储库中产品的访问,并允许删除产品。其他操作将由编辑器组件处理,该组件将使用组件模板中的路由 URL 来激活。为了提供模板,我在src/app/admin文件夹中添加了一个名为productTable.component.html的文件,并添加了清单 9-24 中所示的标记。

<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>ID</th><th>Name</th><th>Category</th><th>Price</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr *ngFor="let p of getProducts()">
            <td>{{p.id}}</td>
            <td>{{p.name}}</td>
            <td>{{p.category}}</td>
            <td>{{p.price | currency:"USD":"symbol":"2.2-2"}}</td>
            <td>
                <button class="btn btn-sm btn-warning m-1"
                        [routerLink]="['/admin/main/products/edit', p.id]">
                    Edit
                </button>
                <button class="btn btn-sm btn-danger" (click)="deleteProduct(p.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>
<button class="btn btn-primary" routerLink="/admin/main/products/create">
    Create New Product
</button>

Listing 9-24.The Contents of the productTable.component.html File in the src/app/admin Folder

该模板包含一个表,该表使用ngFor指令为组件的getProducts方法返回的每个产品生成一行。每一行都包含一个调用组件的delete方法的删除按钮,还包含一个导航到指向编辑器组件的 URL 的编辑按钮。编辑器组件也是“创建新产品”按钮的目标,尽管使用了不同的 URL。

实现产品编辑器

组件可以接收关于当前路由 URL 的信息,并相应地调整它们的行为。编辑器组件需要使用这个特性来区分创建新组件和编辑现有组件的请求。清单 9-25 向编辑器组件添加了创建或编辑产品所需的功能。

import { Component } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { ProductRepository } from "../model/product.repository";

@Component({
    templateUrl: "productEditor.component.html"
})
export class ProductEditorComponent {
    editing: boolean = false;
    product: Product = new Product();

    constructor(private repository: ProductRepository,
                private router: Router,
                activeRoute: ActivatedRoute) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        if (this.editing) {
            Object.assign(this.product,
                repository.getProduct(activeRoute.snapshot.params["id"]));
        }
    }

    save(form: NgForm) {
        this.repository.saveProduct(this.product);
        this.router.navigateByUrl("/admin/main/products");
    }
}

Listing 9-25.Adding Functionality in the productEditor.component.ts File in the src/app/admin Folder

Angular 在创建 component 类的新实例时会提供一个ActivatedRoute对象作为构造函数参数,这个对象可以用来检查激活的 route。在这种情况下,组件会判断是应该编辑还是应该创建产品,如果是编辑,就从存储库中检索当前的详细信息。还有一个save方法,它使用存储库来保存用户所做的更改。

为了给组件提供模板,我在src/app/admin文件夹中添加了一个名为productEditor.component.html的文件,并添加了清单 9-26 中所示的标记。

<div class="bg-primary p-2 text-white" [class.bg-warning]="editing"
     [class.text-dark]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
</div>
<form novalidate #form="ngForm" (ngSubmit)="save(form)">
    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name" [(ngModel)]="product.name" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category" [(ngModel)]="product.category" />
    </div>
    <div class="form-group">
        <label>Description</label>
        <textarea class="form-control" name="description"
                  [(ngModel)]="product.description">
        </textarea>
    </div>
    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price" [(ngModel)]="product.price" />
    </div>
    <button type="submit" class="btn btn-primary m-1" [class.btn-warning]="editing">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary" routerLink="/admin/main/products">
        Cancel
    </button>
</form>

Listing 9-26.The Contents of the productEditor.component.html File in the src/app/admin Folder

该模板包含一个表单,其中包含由Product模型类定义的属性字段,除了由 RESTful web 服务自动分配的id属性。

表单中的元素调整其外观,以区分编辑和创建功能。要查看该组件如何工作,请进行身份验证以访问管理功能,然后单击出现在产品表下的“创建新产品”按钮。填写表单,单击 Create 按钮,新产品将被发送到 RESTful web 服务,在那里它将被分配一个 ID 属性并显示在 product 表中,如图 9-5 所示。

img/421542_4_En_9_Fig5_HTML.jpg

图 9-5。

创造新产品

编辑过程以类似的方式工作。点击其中一个编辑按钮,查看当前的详细信息,使用表单字段对其进行编辑,点击保存按钮保存更改,如图 9-6 所示。

img/421542_4_En_9_Fig6_HTML.jpg

图 9-6。

编辑现有产品

实施订单功能

订单管理功能既漂亮又简单。它需要一个列出订单集的表,以及将 shipped 属性设置为 true 或完全删除订单的按钮。清单 9-27 用支持这些操作所需的逻辑替换组件中的占位符内容。

import { Component } from "@angular/core";
import { Order } from "../model/order.model";
import { OrderRepository } from "../model/order.repository";

@Component({
    templateUrl: "orderTable.component.html"
})
export class OrderTableComponent {
    includeShipped = false;

    constructor(private repository: OrderRepository) {}

    getOrders(): Order[] {
        return this.repository.getOrders()
            .filter(o => this.includeShipped || !o.shipped);
    }

    markShipped(order: Order) {
        order.shipped = true;
        this.repository.updateOrder(order);
    }

    delete(id: number) {
        this.repository.deleteOrder(id);
    }
}

Listing 9-27.Adding Operations in the orderTable.component.ts File in the src/app/admin Folder

除了提供将订单标记为已发货和删除订单的方法之外,该组件还定义了一个getOrders方法,该方法允许根据名为includeShipped的属性值来包含或排除已发货的订单。这个属性在模板中使用,我通过将一个名为orderTable.component.html的文件添加到src/app/admin文件夹中来创建这个模板,其标记如清单 9-28 所示。

<div class="form-check">
    <label class="form-check-label">
    <input type="checkbox" class="form-check-input" [(ngModel)]="includeShipped"/>
        Display Shipped Orders
    </label>
</div>
<table class="table table-sm">
    <thead>
        <tr><th>Name</th><th>Zip</th><th colspan="2">Cart</th><th></th></tr>
    </thead>
    <tbody>
        <tr *ngIf="getOrders().length == 0">
            <td colspan="5">There are no orders</td>
        </tr>
        <ng-template ngFor let-o [ngForOf]="getOrders()">
            <tr>
                <td>{{o.name}}</td><td>{{o.zip}}</td>
                <th>Product</th><th>Quantity</th>
                <td>
                    <button class="btn btn-warning m-1" (click)="markShipped(o)">
                        Ship
                    </button>
                    <button class="btn btn-danger" (click)="delete(o.id)">
                        Delete
                    </button>
                </td>
            </tr>
            <tr *ngFor="let line of o.cart.lines">
                <td colspan="2"></td>
                <td>{{line.product.name}}</td>
                <td>{{line.quantity}}</td>
            </tr>
        </ng-template>
    </tbody>
</table>

Listing 9-28.The Contents of the orderTable.component.html File in the src/app/admin Folder

请记住,RESTful web 服务提供的数据在每次流程启动时都会被重置,这意味着您必须使用购物车并结帐来创建订单。完成后,您可以使用管理工具的 Orders 部分检查和管理它们,如图 9-7 所示。

img/421542_4_En_9_Fig7_HTML.jpg

图 9-7。

管理订单

摘要

在本章中,我创建了一个动态加载的 Angular 特征模块,其中包含管理产品目录和处理订单所需的管理工具。在下一章中,我将完成 SportsStore 应用,并准备将其部署到生产环境中。