iT邦幫忙

2025 iThome 鐵人賽

DAY 4
4
Modern Web

Angular 進階實務 30天系列 第 4

Day 4:常見UX實作 - 表格互動功能設計

  • 分享至 

  • xImage
  •  

前言

除了文字多跟欄位多的問題,還有許多互動需要處理,使用者會有多重條件限制,符合A搜尋條件之後,還要做排序或是篩選。

資料流程中會先從後端取得資料,才到前端呈現使用者的操作跟調整。

排序方面可以由後端先排,在分配排序這項功能的時候需要考量資料大小,跟使用者端設備。如果對使用者端設備沒什麼信心,可以請後端多少做點排序,因為前端排序吃的是使用者端設備的瀏覽器的效能。

網頁展示:Day4


互動功能設計

  • 排序功能
    • 單欄位排序 vs 多欄位排序

      • 單欄位排序是最常見的表格功能,使用者點擊欄位標題就能切換升序、降序或取消排序。多欄位排序則允許使用者同時對多個欄位進行優先順序排列,適合複雜的資料分析需求。
    • 排序指示器的視覺設計

      • 清楚的視覺回饋至關重要。通常使用箭頭符號(↑↓)或三角形來表示排序方向,未排序狀態可以顯示雙向箭頭或空白狀態。顏色變化也能強化使用者對當前排序狀態的認知。
    • 舉例情境

      • 電商後台的訂單管理系統,管理員需要快速找到特定條件的訂單。可能先按「訂單日期」降序排列查看最新訂單,再按「金額」升序找到小額訂單。多欄位排序讓管理員能建立「日期優先,金額次之」的複合排序邏輯。
    • 提供示意圖

      排序狀態示意:
      ┌──────────────┬──────────────┬──────────────┐
      │ 商品名稱 ↕   │ 價格 ↓       │ 庫存 ↑       │
      ├──────────────┼──────────────┼──────────────┤
      │ 商品A        │ $1,200      │ 45           │
      │ 商品B        │ $800        │ 23           │
      │ 商品C        │ $2,000      │ 12           │
      └──────────────┴──────────────┴──────────────┘
      
      圖例:↕ 未排序  ↓ 降序  ↑ 升序
      
    • 不適用情境&注意事項

      • 不適合在行動裝置上實作複雜的多欄位排序,螢幕空間有限會影響操作體驗。大量資料時建議後端排序避免前端效能問題。排序邏輯要考慮資料類型,數字、日期、文字需要不同的比較方式。
  • 篩選與搜尋
    • 全局搜尋 vs 欄位篩選

      • 全局搜尋 vs 欄位篩選

        全局搜尋是透過前端送API請求到後端,將搜尋關鍵字傳送給伺服器進行資料庫查詢,適合在大量資料中快速定位特定內容,但每次搜尋都需要網路請求。欄位篩選則是針對已經從API取得的前端資料進行即時篩選,就像Excel的篩選功能一樣,讓使用者能快速縮小當前資料集的範圍,無需額外的伺服器請求。

      • 資料處理策略差異

        全局搜尋因為涉及後端查詢,通常會有載入狀態和延遲,但能處理超大資料集。欄位篩選則是即時反應,使用者體驗更流暢,但僅限於當前已載入的資料範圍內操作。

    • 舉例情境

      • 人力資源系統的員工管理頁面,HR人員想要查詢特定員工資訊。首先使用全局搜尋輸入「張小明」,前端發送API請求到後端查詢包含此關鍵字的所有員工資料並返回結果。接著在這批搜尋結果中,使用欄位篩選功能進一步篩選「部門為工程部」的員工,這個篩選動作是在前端對已載入的資料進行即時過濾,就像在Excel表格中設定篩選條件一樣即時。
    • 提供示意圖

      • 篩選介面示意:
      ┌─ 搜尋與篩選 ─────────────────────────────────┐
      │ 全域搜尋(發送API):                          │
      │ [張小明_______________] [🔍] [載入中...]     │
      │                                             │
      │ 欄位篩選(前端即時篩選):                     │
      │ 已套用篩選:                                 │
      │ [部門:工程部 ✕] [年資:>3年 ✕] [清除全部]    │
      │                                             │
      │ ┌─欄位篩選(類似Excel)─┐                    │
      │ │ 部門:[下拉選單▼]     │                    │
      │ │ ☑工程部 ☑設計部      │                   │
      │ │ ☐行銷部 ☐財務部      │                   │
      │ │                      │                   │
      │ │ 年資:[3___] 年以上   │                   │
      │ │ [套用] [清除]         │                  │
      │ └──────────────────────┘                  │
      └───────────────────────────────────────────┘
      
      • 資料流程:
      • 全域搜尋 → API請求 → 後端查詢 → 返回結果
      • 欄位篩選 → 前端處理 → 即時顯示結果
    • 不適用情境&注意事項

      • 欄位篩選則要注意記憶體使用量,當前端載入的資料過多時可能影響瀏覽器效能。兩種搜尋方式的組合使用需要清楚區隔,避免使用者混淆操作邏輯。
  • 拖曳欄位
    • 使用者會需要根據情況調整需要看到的東西,所以需要改變欄位位置。拖曳功能讓使用者能自定義表格欄位的顯示順序,將最重要的資訊排在前面,提升工作效率。
    • 舉例情境
      • 使用者在分析資料的時候,有時候根據拿到的資料不同,或是有不同的分析重點,這時候就需要不同的欄位排列。
    • 不適用情境&注意事項
      • 在觸控設備上拖曳操作體驗較差,建議提供替代方案如「欄位設定選單」。
  • 選取功能
    • 單選、多選、全選的設計
      • 選取功能是表格中最基礎但也最重要的互動機制。單選適合需要執行特定操作的情境,如編輯單一資料或查看詳細資訊。多選跟全選則支援批量操作,讓使用者能同時處理多筆資料。
    • 批量操作的UX考量
      • 當使用者選取多筆資料時,應該清楚顯示已選取的數量和提供可執行的批量操作選項。批量操作前建議提供確認對話框,特別是涉及刪除或不可逆操作。選取狀態要在頁面間保持一致,避免使用者在分頁操作中遺失選取資訊。
    • 舉例情境
      • 電商後台的商品管理系統,商品經理需要批量調整商品狀態。首先使用篩選功能找到「庫存低於10件」的商品,接著透過全選功能快速選取所有符合條件的商品,然後執行批量「標記為缺貨」的操作。過程中系統會顯示「已選取 25 件商品」的狀態提示,並在執行操作前彈出確認對話框:「確定要將 25 件商品標記為缺貨嗎?」

程式碼展示

如果要操作到欄位的話,欄位通常要先整理成列表。
另外沒有真的串接API,所以搜尋的時候是模擬類似的行為。

js

import { CommonModule } from '@angular/common';
import { Component, computed, signal } from '@angular/core';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzTableFilterFn, NzTableFilterList, NzTableModule, NzTableSortFn, NzTableSortOrder } from 'ng-zorro-antd/table';
import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal';
import { NzMessageModule, NzMessageService } from 'ng-zorro-antd/message';
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzSpaceModule } from 'ng-zorro-antd/space';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
  category: string;
  status: 'active' | 'inactive' | 'out-of-stock';
}
interface ColumnItem {
  name: string;
  sortOrder: NzTableSortOrder | null;
  sortFn: NzTableSortFn<Product> | null;
  listOfFilter: NzTableFilterList;
  filterFn: NzTableFilterFn<Product> | null;
}

@Component({
  selector: 'app-day4',
  standalone: true,
  imports: [
    CommonModule,
    FormsModule,
    DragDropModule,
    NzTableModule,
    NzInputModule,
    NzButtonModule,
    NzSelectModule,
    NzTagModule,
    NzSpaceModule,
    NzDropDownModule,
    NzIconModule,
    NzCheckboxModule,
    NzDividerModule,
    NzPopconfirmModule
  ],
  templateUrl: './day4.component.html',
  styleUrl: './day4.component.scss'
})
export class Day4Component {
  constructor(private message: NzMessageService) { }

  originProduct = <Product[]>([
    { id: 1, name: 'iPhone 15', price: 32900, stock: 25, category: 'electronics', status: 'active' },
    { id: 2, name: 'MacBook Pro', price: 59900, stock: 8, category: 'electronics', status: 'active' },
    { id: 3, name: '牛仔褲', price: 1500, stock: 45, category: 'clothing', status: 'active' },
    { id: 4, name: 'Angular 開發指南', price: 680, stock: 12, category: 'books', status: 'active' },
    { id: 5, name: '無線耳機', price: 2500, stock: 0, category: 'electronics', status: 'out-of-stock' },
    { id: 6, name: 'T恤', price: 450, stock: 30, category: 'clothing', status: 'inactive' },
  ]);

  // 響應式信號
  products = signal<Product[]>([
    { id: 1, name: 'iPhone 15', price: 32900, stock: 25, category: 'electronics', status: 'active' },
    { id: 2, name: 'MacBook Pro', price: 59900, stock: 8, category: 'electronics', status: 'active' },
    { id: 3, name: '牛仔褲', price: 1500, stock: 45, category: 'clothing', status: 'active' },
    { id: 4, name: 'Angular 開發指南', price: 680, stock: 12, category: 'books', status: 'active' },
    { id: 5, name: '無線耳機', price: 2500, stock: 0, category: 'electronics', status: 'out-of-stock' },
    { id: 6, name: 'T恤', price: 450, stock: 30, category: 'clothing', status: 'inactive' },
  ]);

  selectedProductIds = signal<Set<number>>(new Set());
  globalSearchValue = '';

  // 定義表格欄位配置
  listOfColumns = signal<ColumnItem[]>([
    {
      name: '商品名稱',
      sortOrder: null,
      sortFn: (a: Product, b: Product) => a.name.localeCompare(b.name),
      listOfFilter: [
        { text: 'iPhone', value: 'iPhone' },
        { text: 'MacBook', value: 'MacBook' },
        { text: 'Angular', value: 'Angular' }
      ],
      filterFn: (list: string[], item: Product) => list.some(name => item.name.indexOf(name) !== -1)
    },
    {
      name: '價格',
      sortOrder: null,
      sortFn: (a: Product, b: Product) => a.price - b.price,
      listOfFilter: [
        { text: '低於 1000', value: 'low' },
        { text: '1000-10000', value: 'medium' },
        { text: '高於 10000', value: 'high' }
      ],
      filterFn: (list: string[], item: Product) => {
        return list.some(range => {
          if (range === 'low') return item.price < 1000;
          if (range === 'medium') return item.price >= 1000 && item.price <= 10000;
          if (range === 'high') return item.price > 10000;
          return false;
        });
      }
    },
    {
      name: '庫存',
      sortOrder: null,
      sortFn: (a: Product, b: Product) => a.stock - b.stock,
      listOfFilter: [
        { text: '庫存不足 (<10)', value: 'low' },
        { text: '庫存正常 (>=10)', value: 'normal' }
      ],
      filterFn: (list: string[], item: Product) => {
        return list.some(level => {
          if (level === 'low') return item.stock < 10;
          if (level === 'normal') return item.stock >= 10;
          return false;
        });
      }
    },
    {
      name: '分類',
      sortFn: null,
      sortOrder: null,
      listOfFilter: [
        { text: '電子產品', value: 'electronics' },
        { text: '服裝', value: 'clothing' },
        { text: '書籍', value: 'books' }
      ],
      filterFn: (list: string[], item: Product) => list.some(category => item.category === category)
    },
    {
      name: '狀態',
      sortFn: null,
      sortOrder: null,
      listOfFilter: [
        { text: '啟用', value: 'active' },
        { text: '停用', value: 'inactive' },
        { text: '缺貨', value: 'out-of-stock' }
      ],
      filterFn: (list: string[], item: Product) => list.some(status => item.status === status)
    }
  ]);

  // 計算屬性
  selectedProducts = computed(() =>
    this.products().filter(p => this.selectedProductIds().has(p.id))
  );

  filteredProducts = computed(() => {
    let filtered = this.products();

    // 全域搜尋篩選
    if (this.globalSearchValue.trim()) {
      const searchTerm = this.globalSearchValue.toLowerCase();
      filtered = filtered.filter(product =>
        product.name.toLowerCase().includes(searchTerm) ||
        product.category.toLowerCase().includes(searchTerm)
      );
    }

    return filtered;
  });

  // 全域搜尋
  onGlobalSearch(): void {

    // 此處沒有串接API資料,所以用過濾跟帶入原資料的方式模擬
    if (!this.globalSearchValue) {
      this.products.set(this.originProduct)
    } else {
      const filter = this.products().filter(p => p.name === this.globalSearchValue);
      this.products.set(filter);
    }
    this.message.info(`搜尋關鍵字: ${this.globalSearchValue}`);
  }

  // 清除篩選和排序
  resetFilters(): void {
    const currentColumns = this.listOfColumns();
    currentColumns.forEach(column => {
      // 重置篩選條件到初始狀態
      if (column.name === '商品名稱') {
        column.listOfFilter = [
          { text: 'iPhone', value: 'iPhone' },
          { text: 'MacBook', value: 'MacBook' },
          { text: 'Angular', value: 'Angular' }
        ];
      } else if (column.name === '價格') {
        column.listOfFilter = [
          { text: '低於 1000', value: 'low' },
          { text: '1000-10000', value: 'medium' },
          { text: '高於 10000', value: 'high' }
        ];
      } else if (column.name === '庫存') {
        column.listOfFilter = [
          { text: '庫存不足 (<10)', value: 'low' },
          { text: '庫存正常 (>=10)', value: 'normal' }
        ];
      } else if (column.name === '分類') {
        column.listOfFilter = [
          { text: '電子產品', value: 'electronics' },
          { text: '服裝', value: 'clothing' },
          { text: '書籍', value: 'books' }
        ];
      } else if (column.name === '狀態') {
        column.listOfFilter = [
          { text: '啟用', value: 'active' },
          { text: '停用', value: 'inactive' },
          { text: '缺貨', value: 'out-of-stock' }
        ];
      }
    });
    this.listOfColumns.set([...currentColumns]);
    this.message.info('已清除所有篩選條件');
  }

  // 清除排序和篩選
  resetSortAndFilters(): void {
    const currentColumns = this.listOfColumns();
    currentColumns.forEach(column => {
      column.sortOrder = null;
    });
    this.listOfColumns.set([...currentColumns]);
    this.resetFilters();
    this.message.info('已清除所有排序和篩選條件');
  }

  // 快速排序按鈕範例
  sortByPrice(): void {
    const currentColumns = this.listOfColumns();
    currentColumns.forEach(column => {
      if (column.name === '價格') {
        column.sortOrder = 'descend';
      } else {
        column.sortOrder = null;
      }
    });
    this.listOfColumns.set([...currentColumns]);
  }

  sortByStock(): void {
    const currentColumns = this.listOfColumns();
    currentColumns.forEach(column => {
      if (column.name === '庫存') {
        column.sortOrder = 'ascend';
      } else {
        column.sortOrder = null;
      }
    });
    this.listOfColumns.set([...currentColumns]);
  }

  // 拖曳欄位功能
  dropColumn(event: CdkDragDrop<ColumnItem[]>): void {
    const currentColumns = [...this.listOfColumns()];
    moveItemInArray(currentColumns, event.previousIndex, event.currentIndex);
    this.listOfColumns.set(currentColumns);
    this.message.success(`欄位 "${currentColumns[event.currentIndex].name}" 已移動到第 ${event.currentIndex + 1} 位`);
  }

  trackByName(_: number, item: ColumnItem): string {
    return item.name;
  }

  // 選取相關方法
  isProductSelected(productId: number): boolean {
    return this.selectedProductIds().has(productId);
  }

  onProductChecked(productId: number, checked: boolean): void {
    const currentSelected = new Set(this.selectedProductIds());
    if (checked) {
      currentSelected.add(productId);
    } else {
      currentSelected.delete(productId);
    }
    this.selectedProductIds.set(currentSelected);
  }

  isAllChecked(): boolean {
    const filtered = this.filteredProducts();
    return filtered.length > 0 && filtered.every(product => this.selectedProductIds().has(product.id));
  }

  isIndeterminate(): boolean {
    const filtered = this.filteredProducts();
    const selectedCount = filtered.filter(product => this.selectedProductIds().has(product.id)).length;
    return selectedCount > 0 && selectedCount < filtered.length;
  }

  onAllChecked(checked: boolean): void {
    const currentSelected = new Set(this.selectedProductIds());

    if (checked) {
      // 全選當前頁面的項目
      this.filteredProducts().forEach(product => {
        currentSelected.add(product.id);
      });
    } else {
      // 取消選取當前頁面的項目
      this.filteredProducts().forEach(product => {
        currentSelected.delete(product.id);
      });
    }

    this.selectedProductIds.set(currentSelected);
  }

  // 批量操作
  batchOperation(operation: string): void {
    const selectedCount = this.selectedProducts().length;

    switch (operation) {
      case 'update-status':
        const updatedProductsStatus = this.products().map((p) => { p.status = 'out-of-stock'; p.stock = 0; return p });
        this.products.set(updatedProductsStatus);
        this.selectedProductIds.set(new Set());
        this.message.success(`已更新 ${selectedCount} 項商品狀態為缺貨`);
        break;
      case 'delete':
        this.message.success(`已刪除 ${selectedCount} 項商品`);
        // 實際刪除邏輯
        const updatedProducts = this.products().filter(p => !this.selectedProductIds().has(p.id));
        this.products.set(updatedProducts);
        this.selectedProductIds.set(new Set());
        break;
    }
  }

  cancel(): void {
    this.message.info('取消本次操作');
  }

  // 單項操作
  editProduct(product: Product): void {
    this.message.info(`編輯商品: ${product.name}`);
  }

  deleteProduct(productId: number): void {
    const updatedProducts = this.products().filter(p => p.id !== productId);
    this.products.set(updatedProducts);
    this.message.success('商品已刪除');
  }

  // 工具方法
  getCategoryText(category: string): string {
    const categoryMap: { [key: string]: string } = {
      'electronics': '電子產品',
      'clothing': '服裝',
      'books': '書籍'
    };
    return categoryMap[category] || category;
  }

  getStatusColor(status: string): string {
    const colors = {
      'active': 'green',
      'inactive': 'orange',
      'out-of-stock': 'red'
    };
    return colors[status as keyof typeof colors] || 'default';
  }

  getStatusText(status: string): string {
    const texts = {
      'active': '啟用',
      'inactive': '停用',
      'out-of-stock': '缺貨'
    };
    return texts[status as keyof typeof texts] || status;
  }
}


html

<div class="table-container">
  <!-- 搜尋與篩選區域 -->
  <div class="search-filter-section">
    <!-- 全域搜尋 -->
    <div class="global-search">
      <nz-input-group nzSearch [nzAddOnAfter]="suffixButton">
        <input type="text" nz-input placeholder="請搜尋商品名稱" [(ngModel)]="globalSearchValue" />
      </nz-input-group>
      <ng-template #suffixButton>
        <button nz-button nzType="primary" nzSearch (click)="onGlobalSearch()">
          搜尋
        </button>
      </ng-template>
    </div>

    <!-- 快速操作按鈕 -->
    <div class="quick-operations">
      <button nz-button (click)="sortByPrice()">按價格排序</button>
      <button nz-button (click)="sortByStock()">按庫存排序</button>
      <button nz-button (click)="resetFilters()">清除篩選</button>
      <button nz-button (click)="resetSortAndFilters()">清除排序與篩選</button>
      <nz-divider nzType="vertical"></nz-divider>
      <span class="drag-tip">
        <i nz-icon nzType="drag" nzTheme="outline"></i>
        拖曳欄位標題可調整欄位順序
      </span>
    </div>

    <!-- 已選取項目提示 -->
    <div class="selection-info" *ngIf="selectedProducts().length > 0">
      <span>已選取 {{ selectedProducts().length }} 項商品</span>
      <button nz-button nzType="primary" nz-popconfirm nzPopconfirmTitle="你確定要更新嗎?" (nzOnCancel)="cancel()"
        (nzOnConfirm)="batchOperation('update-status')">
        批次更新缺貨
      </button>
      <button nz-button nzDanger nz-popconfirm nzPopconfirmTitle="你確定要刪除嗎?" (nzOnCancel)="cancel()"
        (nzOnConfirm)="batchOperation('delete')">
        批次刪除
      </button>
    </div>
  </div>

  <!-- 表格 -->
  <nz-table #basicTable [nzData]="filteredProducts()" [nzPageSize]="10" [nzShowSizeChanger]="true"
    [nzShowQuickJumper]="true" nzShowPagination nzTableLayout="fixed">
    <thead>
      <!-- 可拖曳的欄位標題行 -->
      <tr cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="dropColumn($event)">
        <!-- 全選checkbox(固定不可拖曳) -->
        <th [nzChecked]="isAllChecked()" [nzIndeterminate]="isIndeterminate()" (nzCheckedChange)="onAllChecked($event)"
          nzWidth="60px" class="fixed-column"></th>

        <!-- 可拖曳的欄位標題 -->
        <th *ngFor="let column of listOfColumns(); trackBy: trackByName" [(nzSortOrder)]="column.sortOrder"
          [nzSortFn]="column.sortFn" [nzFilters]="column.listOfFilter" [nzFilterFn]="column.filterFn" cdkDrag
          class="draggable-column">
          <div class="column-header">
            <i nz-icon nzType="drag" nzTheme="outline" class="drag-handle"></i>
            {{ column.name }}
          </div>
        </th>

        <!-- 操作欄(固定不可拖曳) -->
        <th nzWidth="150px" class="fixed-column">操作</th>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let product of basicTable.data">
        <!-- 選取checkbox -->
        <td [nzChecked]="isProductSelected(product.id)" (nzCheckedChange)="onProductChecked(product.id, $event)"></td>

        <!-- 動態渲染欄位內容 -->
        <td *ngFor="let column of listOfColumns(); trackBy: trackByName">
          <ng-container [ngSwitch]="column.name">
            <span *ngSwitchCase="'商品名稱'">{{ product.name }}</span>
            <span *ngSwitchCase="'價格'">{{ product.price | currency:'TWD':'symbol':'1.0-0' }}</span>
            <span *ngSwitchCase="'庫存'" [class.low-stock]="product.stock < 10">{{ product.stock }}</span>
            <span *ngSwitchCase="'分類'">{{ getCategoryText(product.category) }}</span>
            <nz-tag *ngSwitchCase="'狀態'" [nzColor]="getStatusColor(product.status)">
              {{ getStatusText(product.status) }}
            </nz-tag>
          </ng-container>
        </td>

        <!-- 操作欄 -->
        <td>
          <nz-space nzSize="small">
            <button *nzSpaceItem nz-button nzSize="small" (click)="editProduct(product)">編輯</button>
            <button *nzSpaceItem nz-button nzDanger nz-popconfirm nzPopconfirmTitle="你確定要刪除嗎?" (nzOnCancel)="cancel()"
              nzSize="small" (nzOnConfirm)="deleteProduct(product.id)">
              刪除
            </button>
          </nz-space>
        </td>
      </tr>
    </tbody>
  </nz-table>
</div>

scss

.table-container {
  padding: 20px;
}

.search-filter-section {
  margin-bottom: 16px;
  padding: 16px;
  background: #fafafa;
  border-radius: 6px;
}

.global-search {
  margin-bottom: 12px;
}

.quick-operations {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 12px;
}

.drag-tip {
  color: #8c8c8c;
  font-size: 12px;
  display: flex;
  align-items: center;
  gap: 4px;
}

.selection-info {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 12px;
  background: #e6f7ff;
  border: 1px solid #91d5ff;
  border-radius: 4px;
}

.low-stock {
  color: #ff4d4f;
  font-weight: bold;
}

nz-table {
  background: white;
}

.table-operations > button {
  margin-right: 8px;
}

/* 拖曳相關樣式 */
.draggable-column {
  cursor: move;
  position: relative;
}

.draggable-column:hover {
  background-color: #f5f5f5;
}

.column-header {
  display: flex;
  align-items: center;
  gap: 6px;
}

.drag-handle {
  color: #bfbfbf;
  cursor: grab;
}

.drag-handle:hover {
  color: #1890ff;
}

.fixed-column {
  background-color: #fafafa;
  position: relative;
}

/* CDK 拖曳樣式 */
::ng-deep .cdk-drag-preview {
  display: table-cell !important;
  background: white;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  opacity: 0.9;
}

::ng-deep .cdk-drag-placeholder {
  background: #f0f0f0;
  opacity: 0.5;
}

::ng-deep .cdk-drag-animating {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

::ng-deep .cdk-drop-list-dragging .draggable-column:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

小結

在大多數業務場景中,本文介紹的三種處理方式(分頁顯示、客戶端篩選、伺服器端搜尋)已經能夠有效解決表格資料的顯示和效能問題。這些基礎的資料處理策略是構建高效表格的核心基礎。
當資料量特別龐大(如單頁需顯示 1000+ 筆資料)或有特殊業務需求時,可能需要考慮更進階的技術方案,但這些情況相對少見,我們會在後續章節中詳細探討。

除了資料處理策略外,一個優秀的表格還需要考慮使用者體驗、響應式設計、以及各種狀態處理。
接下來,我們將深入探討如何讓表格在不同裝置上都能提供良好體驗,以及一些進階的性能優化技巧。


上一篇
Day 3:常見UX實作 - 表格佈局設計策略
下一篇
Day 5:表格進階實作 - 響應式設計與性能優化
系列文
Angular 進階實務 30天18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言