iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 26
0
Modern Web

用Angular打造完整後台系列 第 26

day26 IndexModule

簡述

通常開設商店都會有收入跟費用,
現在業主打開後台,
都會希望在第一時間看到一些數據,
也就是常聽到的 Dashboard

功能

有出現哪些資訊要看各產業,Demo這邊舉了兩個例子:

index

  • 前三個月的收入。
  • 單月銷售量的前五商品。

檔案結構

-src
  |-app
    |-cms
       |-index
            |-rank-order
                   |-rank-order.component.html
                   |-rank-order.component.css
                   |-rank-order.component.ts
            |-rank-product
                |-rank-product.component.html
                |-rank-product.component.css
                |-rank-product.component.ts
           |-index-routing.module.ts
           |-index.component.css
           |-index.component.html
           |-index.component.ts
           |-index.module.ts

實作

圖表的套件使用ngx-charts,請先安裝

參考至 https://github.com/swimlane/ngx-charts

ngx-charts的Demo網:
https://swimlane.github.io/ngx-charts/#/ngx-charts/bar-vertical

(一) 流程

通常圖表的套件都是用canvas繪製的,
在RWD會有個困擾,
就是瀏覽的時候如果任意拉長寬高,
canvas繪製的圖表不會自適應。

所以我們需要先做一層 目前螢幕的寬高
以及要給 canvas的寬高

index.component.ts

import {
  Component,
  OnInit,
  HostListener,
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef
} from "@angular/core";
import { BreakpointObserver } from "@angular/cdk/layout";

@Component({
  selector: "app-index",
  templateUrl: "./index.component.html",
  styleUrls: ["./index.component.css"],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class IndexComponent implements OnInit, AfterViewInit {
  innerWidth = 0;
  innerHeight = 0;
  isDevice = "pc";

  constructor(
    private breakpointObserver: BreakpointObserver,
    private changeDetectorRef: ChangeDetectorRef,
  ) {}

  ngOnInit() {
    this.breakpointObserver.observe("(max-width: 1199px)")
    .subscribe(r => {
      this.isDevice = r.matches ? "mb" : "pc";
    });
  }

  ngAfterViewInit() {
    setInterval(() => {
      this.changeDetectorRef.markForCheck();
    }, 500);
    this.initSize();
  }

  @HostListener("window:resize", ["$event"]) onResize(event: Event) {
    this.initSize();
  }

  initSize() {
    this.innerWidth = window.innerWidth;
    this.innerHeight = window.innerHeight;
    this.reviseSize();
  }

  reviseSize() {
    if (this.isDevice === "pc") {
      this.innerWidth = this.innerWidth - 270;
      this.innerHeight = (this.innerHeight - 125) / 2;
    } else {
      this.innerWidth = this.innerWidth - 40;
      this.innerHeight = (this.innerHeight - 140) / 2;
    }
  }
}

說明幾個部分:

  • 當畫面任意拉伸,需要知道當前螢幕尺寸:
 @HostListener("window:resize", ["$event"]) onResize(event: Event) {
    this.initSize();
  }

--

  • breakpointObserver是Angular的CDK
    可以判斷目前的裝置是否符合我所設定的observe("(max-width: 1199px)")

但是breakpointObserver無法直接告訴我目前寬高尺寸,
所以才用@HostListener去監聽。

更多了解請參考至 https://ithelp.ithome.com.tw/articles/10197159

--

  • 版面配置的計算

有分兩種版面:
headerfootermenu各有它的尺寸。

一種為PC版:
indexsiz1

container 需要放置的寬度就必須要減掉
menu的寬度(230px),
以及兩邊留白的各20px(40px)。

container 需要放置的高度就必須要減掉
header的高度(50px),
以及footer的高度(35px)。
以及上下兩邊留白的各20px(40px)。

--

一種為MB版:
indexsize2

container 需要放置的寬度就必須要減掉兩邊留白的各20px(40px)。

container 需要放置的高度就必須要減掉
header的高度(65px),
以及footer的高度(35px)。
以及上下兩邊留白的各20px(40px)。

為什麼高度除以2呢?
那是因為Demo中舉了兩個例子,分別各佔剩餘版面的50%。

--

  • 改變Angular偵測策略

當畫面任意拉伸,所監聽到的尺寸事件一直變動,
如果沒有設定this.changeDetectorRef.markForCheck();
會一直出現紅字錯誤說明偵測上是沒有變動,但是變數卻被更改了。

想知道更多偵測原理,請參考至 https://ithelp.ithome.com.tw/articles/10209130

--

index.component.html

<div class="container flex straight-flex">
  <ng-container *ngIf="!!innerWidth && !!innerHeight">
    <app-rank-order [innerWidth]="innerWidth" [innerHeight]="innerHeight">
    </app-rank-order>
  </ng-container>
  <ng-container *ngIf="!!innerWidth && !!innerHeight">
    <app-rank-product [innerWidth]="innerWidth" [innerHeight]="innerHeight">
    </app-rank-product>
  </ng-container>
</div>
.container > * {
  flex: 1 1 50%;
}

(二) 前三個月的收入

rank-order.component.ts

...
import * as _moment from "moment";
import { Moment } from "moment";
const moment = (_moment as any).default ? (_moment as any).default : _moment;

interface IChartData {
  name: string;
  value: number;
}

interface IDateRange {
  startDate: Moment;
  endDate: Moment;
}

@Component({
  selector: "app-rank-order",
  templateUrl: "./rank-order.component.html",
  styleUrls: ["./rank-order.component.css"]
})
export class RankOrderComponent implements OnInit {
  @Input() innerWidth: number = 0;
  @Input() innerHeight: number = 0;
  rankOrders: IChartData[] = [];//畫面資料
  max = 3;                      //最多到前幾個月

  constructor(private dataService: DataService) {}

  /*開始跑流程 setDatas()為遞迴 */
  ngOnInit() {
    this.setDatas(0);
  }

  /*最多跑到max設置的圈數 圈數為0.1.2*/
  setDatas(tag: number) {
    if (tag <= this.max) {
      this.setOrders(tag, this.setDate(tag));
    }
  }

  /*根據第幾圈給予前幾個月的 時間區間 */
  setDate(lastIndex?: number): IDateRange {
    let obj = <IDateRange>{
      startDate: null,
      endDate: null
    };
    obj.startDate = moment()
      .month(moment().month() - lastIndex)
      .startOf("month");
    obj.endDate = moment()
      .month(moment().month() - lastIndex)
      .endOf("month");
    return obj;
  }

  /*
  根據前一個月或是前兩個月或是前三個月的
  已結單訂單來查詢資料(orders)。
   */
  setOrders(tag: number, range: IDateRange) {
    let url = this.dataService.setUrl("orders", [
      { key: "status", val: 5 },
      { key: "inserted_gte", val: range.startDate.valueOf() },
      { key: "inserted_lte", val: range.endDate.valueOf() }
    ]);
    this.dataService.getData(url).subscribe((data: IData) => {
      if (!data.errorcode && !!data.res) {
        let orders = <IOrder[]>data.res;
        this.settingOrder(tag, orders);
      } else {
        this.setDatas(++tag);
      }
    });
  }

  /*
  如果查詢完的資料為空,直接跑一下圈
  如果有查詢完的資料(orders),直接進行orders的計算
   */
  settingOrder(tag: number, orders: IOrder[]) {
    if (!!orders.length) {
      this.runPerOrder(tag, 0, orders);
    } else {
      this.setDatas(++tag);
    }
  }

  /*
  orders開始進行每張order的計算
  此function為遞迴
   */
  runPerOrder(tag: number, orderIndex: number, orders: IOrder[]) {
    let leng = orders.length;
    if (orderIndex < leng) {
      this.setPerOrder(tag, orderIndex, orders);
    } else {
      //計算此圈月份orders的合計,進入塞資料
      this.setRankOrders(tag, orders);
    }
  }

  /*每張訂單的購物車明細*/
  setPerOrder(tag: number, orderIndex: number, orders: IOrder[]) {
    let order = orders[orderIndex];
    let carUrl = this.dataService.setUrl("cars", [
      { key: "orderId", val: order.id },
      { key: "_expand", val: "product" }
    ]);
    this.dataService.getData(carUrl).subscribe((carData: IData) => {
      if (!carData.errorcode && !!carData.res) {
        let cars = <IOrderCar[]>carData.res;
        let sum = this.sumCars(cars);
        order["sum"] = sum;
      } else {
        order["sum"] = 0;
      }
      this.runPerOrder(tag, ++orderIndex, orders);
    });
  }

 /*每張訂單的購物車總和*/
  sumCars(cars: IOrderCar[]): number {
    let sum = 0;
    if (!!cars && !!cars.length) {
      cars.forEach((car: IOrderCar) => {
        sum += car.product.price * car.amount;
      });
    }
    return sum;
  }

  /*裝載資料*/
  setRankOrders(tag: number, orders: IOrder[]) {
    let total = orders
      .map((order: IOrder) => {
        return order["sum"];
      })
      .reduce((a: number, c: number) => {
        return a + c;
      }, 0);
    let obj = <IChartData>{
      name: this.setRankName(tag),
      value: total
    };
    this.rankOrders.push(obj);
    this.setDatas(++tag);
  }

  /*每筆資料的項目名稱 如:7月、8月..*/
  setRankName(tag: number): string {
    let nowMonth = moment().month() + 1;
    let tagMonth = nowMonth - tag;
    return `${tagMonth}`;
  }
}

注意的是有兩個遞迴function,
因為異步加載的關係,
一定要等上一個結果回應後才能進行下一個。

--

rank-order.component.html

<ngx-charts-bar-horizontal
  *ngIf="!!rankOrders && !!rankOrders.length"
  [view]="[innerWidth, innerHeight]"
  [results]="rankOrders"
  [xAxis]="true"
  [yAxis]="true"
  [barPadding]="40"
  [xAxisLabel]="'revenue' | translate"
  [yAxisLabel]="'month' | translate"
  [showXAxisLabel]="true"
  [showYAxisLabel]="true"
>
</ngx-charts-bar-horizontal>

(三) 單月銷售量的前五商品

rank-product.component.ts

...
import * as _moment from "moment";
import { Moment } from "moment";
const moment = (_moment as any).default ? (_moment as any).default : _moment;

interface IChartData {
  name: string;
  value: number;
}

interface IDateRange {
  startDate: Moment;
  endDate: Moment;
}

interface IAmountProduct {
  productName: string;
  totalAmount: number;
}

@Component({
  selector: "app-rank-product",
  templateUrl: "./rank-product.component.html",
  styleUrls: ["./rank-product.component.css"]
})
export class RankProductComponent implements OnInit {
  @Input() innerWidth: number = 0;
  @Input() innerHeight: number = 0;
  rankProducts: IChartData[] = [];//畫面資料
  //每件商品的銷售量
  sumProducts: { [key: number]: number } = {};
  max = 5;  //最多幾個商品

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.setDatas();
  }

  /*目前月份的時間區間 */
  setDate(): IDateRange {
    let obj = <IDateRange>{
      startDate: null,
      endDate: null
    };
    obj.startDate = moment().startOf("month");
    obj.endDate = moment()
      .startOf("month")
      .add(1, "months");
    return obj;
  }

  /*開始跑流程 */
  setDatas() {
    this.setOrders(this.setDate());
  }

  /*
  根據當月的已結單訂單來查詢資料(orders)。
  */
  setOrders(range: IDateRange) {
    let url = this.dataService.setUrl("orders", [
      { key: "status", val: 5 },
      { key: "inserted_gte", val: range.startDate.valueOf() },
      { key: "inserted_lte", val: range.endDate.valueOf() }
    ]);
    this.dataService.getData(url).subscribe((data: IData) => {
      if (!data.errorcode && !!data.res) {
        let orders = <IOrder[]>data.res;
        this.runPerOrder(0, orders);
      }
    });
  }

  /*
  orders開始進行每張order的計算
  此function為遞迴
  */
  runPerOrder(orderIndex: number, orders: IOrder[]) {
    let leng = orders.length;
    if (orderIndex < leng) {
      this.setPerOrder(orderIndex, orders);
    } else {
      this.setRankProducts();
    }
  }

  /*每張訂單的購物車明細*/
  setPerOrder(orderIndex: number, orders: IOrder[]) {
    let order = orders[orderIndex];
    let carUrl = this.dataService.setUrl("cars", [
      { key: "orderId", val: order.id },
      { key: "_expand", val: "product" }
    ]);
    this.dataService.getData(carUrl).subscribe((carData: IData) => {
      if (!carData.errorcode && !!carData.res) {
        let cars = <IOrderCar[]>carData.res;
        this.setPerProduct(cars);
      }
      this.runPerOrder(++orderIndex, orders);
    });
  }

  /*確認每個產品在每張訂單的銷售量*/
  setPerProduct(cars: IOrderCar[]) {
    cars.forEach((car: IOrderCar) => {
      if (!this.sumProducts[car.product.name]) {
        this.sumProducts[car.product.name] = 0;
      }

      this.sumProducts[car.product.name] = 
      this.sumProducts[car.product.name] + car.amount;

    });
  }

  /*裝載資料*/
  setRankProducts() {
    let arrs: IAmountProduct[] = [];
    arrs = this.sortRankProducts(arrs);
    if (!!arrs && !!arrs.length) {
      this.rankProducts = [];

      let max = arrs.length - 1 < this.max ? 
      arrs.length - 1 : this.max;

      for (let i = 0; i <= max; i++) {
        this.rankProducts.push({
          name: arrs[i].productName,
          value: arrs[i].totalAmount
        });
      }
    }
  }

 /*所有商品的銷售量排序*/
  sortRankProducts(arrs: IAmountProduct[]): IAmountProduct[] {
    let keys = Object.keys(this.sumProducts);
    keys.forEach((k: string) => {
      let obj = <IAmountProduct>{
        productName: k,
        totalAmount: this.sumProducts[k]
      };
      arrs.push(obj);
    });
    arrs.sort((a, b) => {
      if (a.totalAmount < b.totalAmount) {
        return 1;
      }
      if (a.totalAmount > b.totalAmount) {
        return -1;
      }
      return 0;
    });
    return arrs;
  }
}

--

rank-product.component.ts

<ngx-charts-bar-vertical
  *ngIf="!!rankProducts && !!rankProducts.length"
  [view]="[innerWidth, innerHeight]"
  [results]="rankProducts"
  [xAxis]="true"
  [yAxis]="true"
  [barPadding]="80"
  [xAxisLabel]="'product_name' | translate"
  [yAxisLabel]="'product_amount' | translate"
  [showXAxisLabel]="true"
  [showYAxisLabel]="true"
>
</ngx-charts-bar-vertical>

範例碼

此為完整專案範例碼,連線方式為json-server。

https://stackblitz.com/edit/ngcms-json-server

Start

一開始會跳出提示視窗顯示fail為正常,
請先從範例專案裡下載或是複製db.json到本地端,
並下指令:

json-server db.json

json-server開啟成功後請連結此網址:
https://ngcms-json-server.stackblitz.io/cms?token=bc6e113d26ce620066237d5e43f14690


上一篇
day25 SearchModule(二):應用
下一篇
day27 Firebase(一)
系列文
用Angular打造完整後台30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言