iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 22
0
Modern Web

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

day22 ProductModule(二):Image

簡述

開始實作商品管理的上傳圖片、預覽圖片以及相關功能。

功能

productlist

  • 商品新增修改

在商品新增修改上有出現一個上傳圖片的功能,
我們會把上傳圖片的功能拉出來做一個小組件,
在商品修增修改時將會出此小組件。

檔案結構

-src
  |-app
    |-cms
       |-product
            ...
            |-list
                |-dialog-detail
                   |-dialog-detail.component.html
                   |-dialog-detail.component.css
                   |-dialog-detail.component.ts
                |-file-uploader
                   |-file-uploader.component.html
                   |-file-uploader.component.css
                   |-file-uploader.component.ts
                |-list.component.html
                |-list.component.css
                |-list.component.ts
           |-product-routing.module.ts
           |-product.component.ts
           |-product.module.ts

實作

(一) 上傳圖片子組件

imagefileview

file-uploader.component.ts

export class FileUploaderComponent implements OnInit {
  @Input() parent: FormGroup;
  imageSrc: string = "null";
  activeColor: string = "green";
  baseColor: string = "#ccc";
  overlayColor: string = "rgba(255,255,255,0.5)";
  dragging: boolean = false;
  loaded: boolean = false;
  imageLoaded: boolean = false;

  ngOnInit() {
    if (!!this.parent) {
      this.imageSrc = this.parent.value.file;
    }
  }

  handleDragEnter() {
    this.dragging = true;
  }

  handleDragLeave() {
    this.dragging = false;
  }

  handleDrop(e) {
    e.preventDefault();
    this.dragging = false;
    this.handleInputChange(e);
  }

  handleImageLoad() {
    this.imageLoaded = true;
  }

  handleInputChange(e) {
    let file = e.dataTransfer ? 
    e.dataTransfer.files[0] : e.target.files[0];

    let pattern = /image-*/;
    let reader = new FileReader();
    if (!file.type.match(pattern)) {
      alert("invalid format");
      return;
    }
    this.loaded = false;
    reader.onload = this._handleReaderLoaded.bind(this);
    reader.readAsDataURL(file);
  }

  _handleReaderLoaded(e) {
    let reader = e.target;
    this.imageSrc = reader.result;
    this.loaded = true;
    this.parent.patchValue({
      file: this.imageSrc
    });
  }

  cancel() {
    this.imageSrc = "";
  }
}

這裡要注意的是,@Input() parent: FormGroup;
有來自於父組件的表單綁定跟驗證!

  • 先判斷是否有舊資料,有的話一開始就要塞資料。
  • Drag & Drop 的function代表可以直接拖曳圖片。
  • 圖片上傳完後會觸發handleInputChange(),做一些簡單的驗證格式。
  • 最後在更新父組件的file控件
    this.parent.patchValue({file: this.imageSrc});

--

file-uploader.component.html

<div class="item-wrapper two pink" [formGroup]="parent">
  <div>
    <span>*{{ "upload_img" | translate }}</span>
  </div>
  <div class="flex straight-flex uploader-box">
    <div>
      <label
        class="uploader"
        ondragover="return false;"
        [class.loaded]="loaded"
        [style.outlineColor]="dragging ? activeColor : baseColor"
        (dragenter)="handleDragEnter()"
        (dragleave)="handleDragLeave()"
        (drop)="handleDrop($event)"
      >
        <i
          class="icon icon-upload"
          [style.color]="
            dragging
              ? imageSrc.length > 0
                ? overlayColor
                : activeColor
              : imageSrc.length > 0
              ? overlayColor
              : baseColor
          "
        ></i>

        <img [src]="imageSrc" (load)="handleImageLoad()" 
        [class.loaded]="imageLoaded" />
      </label>
    </div>
    <div>
      <input type="file" name="file" accept="image/*" 
      (change)="handleInputChange($event)" />
    </div>
    <div>
      <button (click)="cancel()">
        {{ "clear" | translate }}
      </button>
    </div>
  </div>
</div>

--

file-uploader.component.css

.uploader input {
  display: none;
}

.uploader {
  -webkit-align-items: center;
  align-items: center;
  background-color: #efefef;
  background-color: rgba(0, 0, 0, 0.02);
  cursor: pointer;
  display: -webkit-flex;
  display: flex;
  height: 150px;
  -webkit-justify-content: center;
  justify-content: center;
  outline: 1px dashed #ccc;
  position: relative;
  width: 150px;
}

.uploader img,
.uploader .icon {
  pointer-events: none;
}

.uploader,
.uploader .icon {
  -webkit-transition: all 100ms ease-in;
  -moz-transition: all 100ms ease-in;
  -ms-transition: all 100ms ease-in;
  -o-transition: all 100ms ease-in;
  transition: all 100ms ease-in;
}

.uploader .icon {
  color: #eee;
  color: rgba(0, 0, 0, 0.2);
  font-size: 5em;
}

.uploader img {
  left: 50%;
  opacity: 0;
  max-height: 100%;
  max-width: 100%;
  position: absolute;
  top: 50%;
  -webkit-transition: all 300ms ease-in;
  -moz-transition: all 300ms ease-in;
  -ms-transition: all 300ms ease-in;
  -o-transition: all 300ms ease-in;
  transition: all 300ms ease-in;
  -webkit-transform: translate(-50%, -50%);
  -moz-transform: translate(-50%, -50%);
  -ms-transform: translate(-50%, -50%);
  -o-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
}

.uploader img.loaded {
  opacity: 1;
}

.uploader-box > *:not(:last-child) {
  margin-bottom: 5px;
}

參考至 https://stackblitz.com/edit/angular-image-upload-base64?file=app%2Ffile-uploader.component.ts
參考至 https://medium.com/@amcdnl/file-uploads-with-angular-reactive-forms-960fd0b34cb5


(二) 商品新增/修改

productupdate

dialog-detail.component.ts

export interface IDetailProduct {
  product: IProduct;
  types: IType[];
}

@Component(...))
export class DialogProductDetailComponent implements OnInit {
  PRODUCTSTATUS = PRODUCTSTATUS;
  form: FormGroup;

  constructor(
    public dialogRef: MatDialogRef<DialogProductDetailComponent>,
    @Inject(MAT_DIALOG_DATA) public data: IDetailProduct,
    private fb: FormBuilder
  ) {}

  ngOnInit() {
    if (!!this.data && !!this.data.types) {
      this.createForm();
      if (!!this.data.product) {
        this.editForm();
      }
    }
  }

  createForm() {
    let obj = {
      name: ["", [Validators.required]],
      price: [
        "", 
        [
          Validators.required, 
          ValidationService.integerValidator
        ]
      ],
      typeId: [
        "", 
        [
          Validators.required, 
          ValidationService.numberOnlyValidator
        ]
      ],
      status: ["", [Validators.required]],
      file: ["", [Validators.required]]
    };
    this.form = this.fb.group(obj);
  }

  editForm() {
    let product = this.data.product;
    this.form.patchValue({
      name: product.name || "",
      price: product.price.toString() || "",
      typeId: product.typeId.toString() || "",
      status: product.status.toString() || "",
      file: product.file || ""
    });
  }

  getDetailData(): IProduct {
    return <IProduct>{
      name: this.form.value.name,
      price: +this.form.value.price,
      typeId: +this.form.value.typeId,
      status: +this.form.value.status,
      file: this.form.value.file
    };
  }

  onNoClick(): void {
    this.dialogRef.close();
  }

  onEnter() {
    this.dialogRef.close(this.getDetailData());
  }
}

--

dialog-detail.component.html

<form [formGroup]="form">
  <div mat-dialog-title class="flex center">
    <mat-icon svgIcon="alert"></mat-icon>
    <span *ngIf="!data.product"> 
      {{ "alert_product_insert" | translate }} 
    </span>
    <span *ngIf="!!data.product"> 
      {{ "alert_product_update" | translate }} 
    </span>
  </div>
  <div mat-dialog-content>
    <div class="item-wrapper two pink">
      <div>
        <span> *{{ "name" | translate }} </span>
      </div>
      <div>
        <input
          type="text"
          formControlName="name"
          [placeholder]="'import_name' | translate"
          required
        />
      </div>
      <validation-messages [control]="form.controls.name">
      </validation-messages>
    </div>
    <div class="item-wrapper two pink">
      <div>
        <span> *{{ "product_price" | translate }} </span>
      </div>
      <div>
        <input
          type="text"
          formControlName="price"
          [placeholder]="'import_coin' | translate"
          required
        />
      </div>
      <validation-messages [control]="form.controls.price">
      </validation-messages>
    </div>

    <div class="item-wrapper two pink">
      <div>
        <span>*{{ "status" | translate }}</span>
      </div>
      <div>
        <select name="se_status" id="se_status"
         formControlName="status">
          <option value="" disabled>
            {{ "select" | translate }}
          </option>
          <option *ngFor="let status of PRODUCTSTATUS" 
          [value]="status.id">
          {{status.name | translate}}
          </option>
        </select>
      </div>
      <validation-messages [control]="form.controls.status">
      </validation-messages>
    </div>

    <div class="item-wrapper two pink">
      <div>
        <span>*{{ "product_type" | translate }}</span>
      </div>
      <div>
        <select name="se_type" id="se_type" formControlName="typeId">
          <option value="" disabled>{{ "select" | translate }}</option>
          <option *ngFor="let type of data.types"
           [value]="type.id">
            {{type.name | translate}}
           </option>
        </select>
      </div>
      <validation-messages [control]="form.controls.typeId">
      </validation-messages>
    </div>

    <file-uploader [parent]="form"></file-uploader>
  </div>
  <div mat-dialog-actions class="flex center">
    <button (click)="onNoClick()" class="button pb radius-5" 
    style="margin-right:10px;">
      {{ "cancel" | translate }}
    </button>
    <button
      (click)="onEnter()"
      [disabled]="!form.valid"
      [ngClass]="{ disable: !form.valid }"
      class="button pb pink radius-5"
    >
      {{ "enter" | translate }}
    </button>
  </div>
</form>

--

list.component.ts

export class ProductListComponent implements OnInit {
  ...
  types: IType[] = [];

  constructor(...) {}

  /*初始 */
  init() {
    if (!this.tab.pageObj) {
      this.tab.pageObj = <IPage>{
        pageIndex: 0,
        pageSize: PAGESIZE,
        length: 0
      };
    }
    this.setDatas(true);
    this.setTypes();
  }

  /*裝所有types資料 */
  setTypes() {
    let url = this.dataService.setUrl("types");
    this.dataService.getData(url)
    .subscribe((data: IData) => {
      if (!!data.errorcode) {
        this.openStatusDialog(data.errorcode);
      } else {
        if (!!data.res) {
          this.types = <IType[]>data.res;
        }
      }
    });
  }

  /*開Dialog */
  openDialog(action: string, select?: IProduct) {
    switch (action) {
      case "insert":
        this.openDetailDialog();
        break;
      case "update":
        this.openDetailDialog(select);
        break;
      case "type":
        this.openTypeDialog();
        break;
    }
  }

  /*開啟新增/更新的dialog */
  openDetailDialog(select?: IProduct) {
    if (!!this.types && !!this.types.length) {
      let obj = <IDetailProduct>{
        product: null,
        types: this.types
      };
      if (!!select) {
        obj.product = select;
      }
      let dialogRef = this.dialog
                      .open(DialogProductDetailComponent, {
                        width: "600px",
                        data: obj
                      });
      dialogRef.afterClosed().subscribe((o: IProduct) => {
        if (!!o) {
          if (!select) {
            this.insertProduct(o);
          } else {
            this.updateProduct(o, select);
          }
        }
      });
    }
  }

  /*新增商品 */
  insertProduct(o: IProduct) {
    let url = this.dataService.setUrl("products");
    o = <IProduct>this.dataService.checkData(
                    o, 
                    this.userService.getUser().id
                  );
    this.dataService.insertOne(url, o)
    .subscribe((data: IData) => {
      this.openStatusDialog(data.errorcode);
    });
  }

  /*修改商品 */ 
  updateProduct(o: IProduct, select: IProduct) {
    let url = this.dataService.setUrl(
                                  "products", 
                                  null, 
                                  select.id
                                );
    o = <IProduct>this.dataService.checkData(
                      o, 
                      this.userService.getUser().id, 
                      false
                    );
    this.dataService.updateOne(url, o)
    .subscribe((data: IData) => {
      this.openStatusDialog(data.errorcode);
    });
  }

  ...

  /*打開 alert-dialog 提示視窗 */
  openStatusDialog(errorcode: number) {
    let dialogRef = this.dialog.open(DialogAlertComponent, {
      width: "250px",
      data: {
        errorcode: errorcode
      }
    });
    dialogRef.afterClosed().subscribe(() => {
      this.setDatas();
    });
  }
}

(三) 瀏覽圖片

如下圖中的縮圖
imageview1
imageview2

檔案結構:

-src
  |-app
    |-cms
    |-modules
        |-...
        |-preview
            |-preview.component.css
            |-preview.component.html
            |-preview.component.ts
            |-preview.module.ts

preview.component.ts

@Component({
  selector: "app-preview",
  templateUrl: "preview.component.html",
  styleUrls: ["preview.component.css"]
})
export class PreviewComponent {
  @Input() imageSrc: string = "";
  imageLoaded: boolean = false;

  constructor() {}

  handleImageLoad() {
    this.imageLoaded = true;
  }
}
  • imageLoaded是指圖片是否已加載完。

--

preview.component.html

<img *ngIf="imageSrc" [src]="imageSrc" (load)="handleImageLoad()" />
img {
  width: 25px;
  height: auto;
}

--

preview.module.ts

@NgModule({
  providers: [],
  imports: [CommonModule],
  declarations: [PreviewComponent],
  exports: [PreviewComponent]
})
export class PreviewModule {}

實際運用時:

<td [attr.data-title]="'product_img' | translate">
  <app-preview [imageSrc]="r.file"></app-preview>
</td>

範例碼

此為完整專案範例碼,連線方式為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


上一篇
day21 ProductModule(一)
下一篇
day23 OrderModule
系列文
用Angular打造完整後台30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言