除了文字多跟欄位多的問題,還有許多互動需要處理,使用者會有多重條件限制,符合A搜尋條件之後,還要做排序或是篩選。
資料流程中會先從後端取得資料,才到前端呈現使用者的操作跟調整。
排序方面可以由後端先排,在分配排序這項功能的時候需要考量資料大小,跟使用者端設備。如果對使用者端設備沒什麼信心,可以請後端多少做點排序,因為前端排序吃的是使用者端設備的瀏覽器的效能。
網頁展示:Day4
單欄位排序 vs 多欄位排序
排序指示器的視覺設計
舉例情境
提供示意圖
排序狀態示意:
┌──────────────┬──────────────┬──────────────┐
│ 商品名稱 ↕ │ 價格 ↓ │ 庫存 ↑ │
├──────────────┼──────────────┼──────────────┤
│ 商品A │ $1,200 │ 45 │
│ 商品B │ $800 │ 23 │
│ 商品C │ $2,000 │ 12 │
└──────────────┴──────────────┴──────────────┘
圖例:↕ 未排序 ↓ 降序 ↑ 升序
不適用情境&注意事項
全局搜尋 vs 欄位篩選
全局搜尋 vs 欄位篩選
全局搜尋是透過前端送API請求到後端,將搜尋關鍵字傳送給伺服器進行資料庫查詢,適合在大量資料中快速定位特定內容,但每次搜尋都需要網路請求。欄位篩選則是針對已經從API取得的前端資料進行即時篩選,就像Excel的篩選功能一樣,讓使用者能快速縮小當前資料集的範圍,無需額外的伺服器請求。
資料處理策略差異
全局搜尋因為涉及後端查詢,通常會有載入狀態和延遲,但能處理超大資料集。欄位篩選則是即時反應,使用者體驗更流暢,但僅限於當前已載入的資料範圍內操作。
舉例情境
提供示意圖
┌─ 搜尋與篩選 ─────────────────────────────────┐
│ 全域搜尋(發送API): │
│ [張小明_______________] [🔍] [載入中...] │
│ │
│ 欄位篩選(前端即時篩選): │
│ 已套用篩選: │
│ [部門:工程部 ✕] [年資:>3年 ✕] [清除全部] │
│ │
│ ┌─欄位篩選(類似Excel)─┐ │
│ │ 部門:[下拉選單▼] │ │
│ │ ☑工程部 ☑設計部 │ │
│ │ ☐行銷部 ☐財務部 │ │
│ │ │ │
│ │ 年資:[3___] 年以上 │ │
│ │ [套用] [清除] │ │
│ └──────────────────────┘ │
└───────────────────────────────────────────┘
不適用情境&注意事項
如果要操作到欄位的話,欄位通常要先整理成列表。
另外沒有真的串接API,所以搜尋的時候是模擬類似的行為。
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;
}
}
<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>
.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+ 筆資料)或有特殊業務需求時,可能需要考慮更進階的技術方案,但這些情況相對少見,我們會在後續章節中詳細探討。
除了資料處理策略外,一個優秀的表格還需要考慮使用者體驗、響應式設計、以及各種狀態處理。
接下來,我們將深入探討如何讓表格在不同裝置上都能提供良好體驗,以及一些進階的性能優化技巧。