哈囉,各位邦友們!
昨天我們把 Heroes 完成了即時搜尋,透過 Subject、debounceTime、switchMap 組成一條資料流。
目前累積使用了不少東西,因此今天將目前所學到的整合起來,除了新增分類篩選功能外,也進一步優化搜尋,並將常用到的程式片段共用元件化。
Subject 與 switchMap 的使用方式。HeroService 與 in-memory API 正常運作,可提供完整英雄清單。@for / track) 與 Day14 (錯誤處理) 的程式碼運作邏輯。一、讓服務層與細節頁同步
我們把快取邏輯搬到服務層,並讓細節頁改用新的 API,後續元件只要讀取 signal 就能取得資料。
// src/app/hero.service.ts
import { HttpClient, HttpParams } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
// ...existing code...
export class HeroService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = 'api/heroes';
  private readonly heroes = signal<Hero[]>([]);
  private readonly heroesById = computed(() => {
    const map = new Map<number, Hero>();
    for (const hero of this.heroes()) {
      map.set(hero.id, hero);
    }
    return map;
  });
  readonly heroesState = this.heroes.asReadonly();
  loadAll(): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.baseUrl).pipe(
      tap((list) => this.heroes.set(list))
    );
  }
  getById(id: number): Observable<Hero> {
    const cached = this.heroesById().get(id);
    if (cached) {
      return of(cached);
    }
    return this.http.get<Hero>(`${this.baseUrl}/${id}`).pipe(
      tap((hero) => {
        this.heroes.update((current) => {
          const exists = current.some((item) => item.id === hero.id);
          return exists ? current : [...current, hero];
        });
      })
    );
  }
  create(hero: Pick<Hero, 'name' | 'rank'>): Observable<Hero> {
    const payload = {
      name: hero.name.trim(),
      ...(hero.rank ? { rank: hero.rank } : {}),
    } as Partial<Hero>;
    return this.http.post<Hero>(this.baseUrl, payload).pipe(
      tap((created) => {
        this.heroes.update((current) => [...current, created]);
      })
    );
  }
  update(id: number, changes: Partial<Hero>): Observable<Hero> {
    const cached = this.heroesById().get(id);
    const payload = { ...(cached ?? { id }), ...changes, id } as Partial<Hero> & { id: number };
    if (payload.rank === '' || payload.rank == null) {
      delete payload.rank;
    }
    return this.http.put<Hero>(`${this.baseUrl}/${id}`, payload).pipe(
      tap((updated) => {
        this.heroes.update((current) =>
          current.map((hero) => (hero.id === updated.id ? updated : hero))
        );
      })
    );
  }
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
      tap(() => {
        this.heroes.update((current) => current.filter((hero) => hero.id !== id));
      })
    );
  }
  search$(term: string): Observable<Hero[]> {
    const keyword = term.trim();
    if (!keyword) {
      return of([]);
    }
    const params = new HttpParams().set('name', keyword);
    return this.http.get<Hero[]>(this.baseUrl, { params }).pipe(
      tap((heroes) => {
        if (!heroes.length) {
          return;
        }
        this.heroes.update((current) => {
          const map = new Map(current.map((hero) => [hero.id, hero] as const));
          for (const hero of heroes) {
            map.set(hero.id, hero);
          }
          return Array.from(map.values());
        });
      })
    );
  }
  // ...existing code...
}
// src/app/hero-detail/hero-detail.ts
// ...existing code...
export class HeroDetail {
  // ...existing code...
  private loadHero(id: number) {
    // ...existing code...
    this.heroService
      .getById(id)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: (h) => {
          this.hero.set(h ?? null);
          this.loading.set(false);
        },
        // ...existing code...
      });
  }
}
說明:
signal 與 computed 讓服務層可以同時提供陣列以及索引查找結果。heroesState 暴露唯讀 signal,元件可直接以 computed 或 effect 監聽。getById,避免重複請求並拿掉開發時才需要的 console.log。二、英雄分類篩選按鈕
接著在列表頁引入分類邏輯,同時讓搜尋結果也能套用相同條件。
// src/app/heroes/heroes.component.ts
import {
  Component,
  DestroyRef,
  computed,
  effect,
  inject,
  signal,
} from '@angular/core';
// ...existing code...
import { HeroListItem } from '../ui/hero-list-item/hero-list-item';
import { MessageBanner } from '../ui/message-banner/message-banner';
@Component({
  selector: 'app-heroes',
  imports: [
    HeroBadge,
    FormsModule,
    RouterModule,
    LoadingSpinner,
    MessageBanner,
    HeroListItem,
  ],
  templateUrl: './heroes.component.html',
  styleUrl: './heroes.component.scss',
})
export class HeroesComponent {
  private readonly heroService = inject(HeroService);
  private readonly destroyRef = inject(DestroyRef);
  protected readonly heroes = this.heroService.heroesState;
  protected readonly activeRank = signal<string>('ALL');
  protected readonly rankOptions = computed(() => {
    const ranks = new Set<string>();
    for (const hero of this.heroes()) {
      if (hero.rank) {
        ranks.add(hero.rank);
      }
    }
    return ['ALL', ...Array.from(ranks).sort()];
  });
  protected readonly filteredHeroes = computed(() => {
    const rank = this.activeRank();
    const list = this.heroes();
    if (rank === 'ALL') {
      return list;
    }
    return list.filter((hero) => hero.rank === rank);
  });
  protected readonly rawSearchResults = signal<Hero[]>([]);
  protected readonly filteredSearchResults = computed(() => {
    const rank = this.activeRank();
    const results = this.rawSearchResults();
    if (rank === 'ALL') {
      return results;
    }
    return results.filter((hero) => hero.rank === rank);
  });
  protected setRankFilter(option: string) {
    this.activeRank.set(option);
  }
  protected rankLabel(option: string) {
    return option === 'ALL' ? '全部' : option;
  }
  // ...existing code...
}
<!-- src/app/heroes/heroes.component.html -->
@if (heroesLoading()) {
  <!-- ...existing code... -->
} @else if (heroesError(); as e) {
  <app-message-banner type="error">Load failed: {{ e }}</app-message-banner>
} @else {
  <section class="filters" aria-label="Filter heroes by rank">
    <span class="filters__label">分類:</span>
    <div class="filters__group">
      @for (option of rankOptions(); track option) {
        <button
          type="button"
          (click)="setRankFilter(option)"
          [class.active]="activeRank() === option">
          {{ rankLabel(option) }}
        </button>
      }
    </div>
  </section>
  <!-- ...existing code... -->
}
說明:
heroesState 取得服務層快取後,再用 computed 做二次過濾即可。filteredSearchResults 與 filteredHeroes 共用相同的 Rank 條件,介面不會出現列表與搜尋結果不同步的狀況。rankLabel 保留 ALL 的易讀標籤,同時讓 HTML 中的按鈕維持簡潔。三、新增表單 Rank 下拉:讓新增與編輯可以設定Rank
讓新增與編輯流程共用同一組 Rank 選項,並在儲存時避免不必要的 API 呼叫。
// src/app/heroes/heroes.component.ts
// ...existing code...
type HeroRank = '' | 'S' | 'A' | 'B' | 'C';
export class HeroesComponent {
  // ...existing code...
  private readonly fallbackRanks: HeroRank[] = ['S', 'A', 'B', 'C'];
  protected readonly formRankOptions = computed<HeroRank[]>(() => {
    const derived = this.rankOptions().filter((option) => option !== 'ALL') as HeroRank[];
    return derived.length ? derived : this.fallbackRanks;
  });
  protected readonly newHeroRank = signal<HeroRank>('');
  protected readonly editRank = signal<HeroRank>('');
  constructor() {
    // ...existing code...
    effect(() => {
      const options = this.formRankOptions();
      const current = this.newHeroRank();
      if (current !== '' && !options.includes(current) && options.length) {
        this.newHeroRank.set(options[0]);
      }
    });
    effect(() => {
      const selected = this.selectedHero();
      this.editName.set(selected?.name ?? '');
      this.editRank.set((selected?.rank as HeroRank) ?? '');
      this.saveError.set(null);
    });
    // ...existing code...
  }
  protected resetCreateForm() {
    this.newHeroName.set('');
    this.newHeroRank.set('');
  }
  protected addHero() {
    const name = this.newHeroName().trim();
    if (!name) {
      return;
    }
    const payload: Pick<Hero, 'name' | 'rank'> = {
      name,
      rank: this.newHeroRank() || undefined,
    };
    this.creating.set(true);
    this.createError.set(null);
    this.feedback.set(null);
    this.heroService
      .create(payload)
      .pipe(
        finalize(() => this.creating.set(false)),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe({
        next: (created) => {
          this.feedback.set('新增英雄成功!');
          this.resetCreateForm();
          this.selectedId.set(created.id);
        },
        error: (err) => {
          this.createError.set(String(err ?? 'Unknown error'));
        },
      });
  }
  protected saveSelected() {
    const hero = this.selectedHero();
    if (!hero) {
      return;
    }
    const name = this.editName().trim();
    const rank = this.editRank();
    if (name === hero.name && rank === (hero.rank ?? '')) {
      return;
    }
    this.saving.set(true);
    this.saveError.set(null);
    this.feedback.set(null);
    this.heroService
      .update(hero.id, { name, rank: rank || undefined })
      .pipe(
        finalize(() => this.saving.set(false)),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe({
        next: (updated) => {
          this.feedback.set('更新英雄成功!');
          this.selectedId.set(updated.id);
        },
        error: (err) => {
          this.saveError.set(String(err ?? 'Unknown error'));
        },
      });
  }
  // ...existing code...
}
<!-- src/app/heroes/heroes.component.html -->
<!-- ...existing code... -->
<section class="create" id="create">
  <form (ngSubmit)="addHero()">
    <label for="new-hero">Name:</label>
    <!-- ...existing code... -->
    <label for="new-hero-rank">Rank:</label>
    <select
      id="new-hero-rank"
      name="new-hero-rank"
      [ngModel]="newHeroRank()"
      (ngModelChange)="newHeroRank.set($event)">
      <option [ngValue]="''">未指定</option>
      @for (rank of formRankOptions(); track rank) {
        <option [ngValue]="rank">{{ rankLabel(rank) }}</option>
      }
    </select>
    <button type="submit" [disabled]="creating() || newCtrl.invalid">
      @if (creating()) { Saving... } @else { Add }
    </button>
  </form>
  @if (createError(); as err) {
    <app-message-banner type="error">Create failed: {{ err }}</app-message-banner>
  }
</section>
<!-- ...existing code... -->
  @if (selectedHero(); as s) {
    <aside class="panel">
      <!-- ...existing code... -->
      <label for="hero-rank">Rank:</label>
      <select
        id="hero-rank"
        name="hero-rank"
        [ngModel]="editRank()"
        (ngModelChange)="editRank.set($event)">
        <option [ngValue]="''">未指定</option>
        @for (rank of formRankOptions(); track rank) {
          <option [ngValue]="rank">{{ rankLabel(rank) }}</option>
        }
      </select>
      <button
        type="button"
        (click)="saveSelected()"
        [disabled]="
          saving() ||
          nameCtrl.invalid ||
          (editName().trim() === s.name && editRank() === (s.rank ?? ''))
        ">
        @if (saving()) { Saving... } @else { Save }
      </button>
      @if (saveError(); as err) {
        <app-message-banner type="error">Save failed: {{ err }}</app-message-banner>
      }
    </aside>
  }
說明:
formRankOptions 會優先使用列表中已存在的 Rank;如果資料較少則回到預設清單。effect 負責同步選單與 signal,確保切換英雄時 Rank 不會殘留舊值。四、即時搜尋進階調整
延續昨天的即時搜尋,加入最短字數、訊息提示與錯誤處理。
// src/app/heroes/heroes.component.ts
// ...existing code...
export class HeroesComponent {
  // ...existing code...
  private readonly searchTerms = new Subject<string>();
  protected readonly searchKeyword = signal('');
  protected readonly searchMessage = signal<string | null>(null);
  protected readonly searchError = signal<string | null>(null);
  protected readonly searching = signal(false);
  constructor() {
    // ...existing code...
    this.searchTerms
      .pipe(
        map((term) => term.trim()),
        debounceTime(300),
        distinctUntilChanged(),
        filter((term) => term.length === 0 || term.length >= 2),
        tap((term) => {
          this.searchKeyword.set(term);
          this.searchError.set(null);
          this.searchMessage.set(term ? '搜尋中...' : null);
          this.feedback.set(null);
          if (!term) {
            this.rawSearchResults.set([]);
          }
          this.searching.set(term.length > 0);
        }),
        switchMap((term) => {
          if (!term) {
            return of<Hero[]>([]);
          }
          return this.heroService.search$(term).pipe(
            tap((heroes) => {
              if (heroes.length) {
                this.searchMessage.set(`命中 ${heroes.length} 位英雄`);
              } else {
                this.searchMessage.set('沒有符合條件的英雄,試著換個關鍵字。');
              }
            }),
            catchError((err) => {
              this.searchError.set(String(err ?? 'Unknown error'));
              this.searchMessage.set('查詢失敗,可稍後重試。');
              return of<Hero[]>([]);
            }),
            finalize(() => this.searching.set(false))
          );
        }),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe((heroes) => {
        this.rawSearchResults.set(heroes);
        this.searching.set(false);
      });
  }
  protected search(term: string) {
    this.searchTerms.next(term);
  }
  // ...existing code...
}
<!-- src/app/heroes/heroes.component.html -->
<!-- ...existing code... -->
<section class="search" role="search">
  <!-- ...existing code... -->
  @if (searching()) {
    <app-loading-spinner label="Searching..."></app-loading-spinner>
  }
  @if (searchError(); as err) {
    <app-message-banner type="error">Search failed: {{ err }}</app-message-banner>
  } @else if (searchMessage(); as msg) {
    <app-message-banner type="info">{{ msg }}</app-message-banner>
  }
  @if (searchKeyword() && !searching()) {
    <ul class="results">
      @for (hero of filteredSearchResults(); track hero.id) {
        <li>
          <app-hero-list-item
            [hero]="hero"
            [selectedId]="selectedId()"
            (pick)="onSelect($event)"></app-hero-list-item>
          <a [routerLink]="['/detail', hero.id]">View</a>
        </li>
      } @empty {
        <li class="muted">找不到符合「{{ searchKeyword() }}」的英雄。</li>
      }
    </ul>
  }
</section>
<!-- ...existing code... -->
說明:
filter 決定只有兩個字以上才打 API,避免短字造成大量請求。searchMessage 專門負責顯示狀態提示,不需要再額外寫 <p> 或 div。rawSearchResults 後,再交給 Rank 篩選套用相同邏輯。五、抽出共用的 UI Component:統一互動樣式
常見的 badge 與列表項目改成獨立元件,再加上一個 MessageBanner 管理訊息。
// src/app/hero-badge/hero-badge.ts
import { Component, input } from '@angular/core';
@Component({
  selector: 'app-hero-badge',
  standalone: true,
  imports: [],
  templateUrl: './hero-badge.html',
  styleUrl: './hero-badge.scss',
})
export class HeroBadge {
  // 顯示英雄等級,例如 'S' | 'A' | 'B' | 'C'
  readonly rank = input<string | undefined>();
}
<!-- src/app/hero-badge/hero-badge.html -->
<span class="badge">
  @if (rank()) { {{ rank() }} } @else { New Hero }
</span>
// src/app/ui/hero-list-item/hero-list-item.ts
import { Component, computed, input, output } from '@angular/core';
import { Hero } from '../../hero.service';
import { HeroBadge } from '../../hero-badge/hero-badge';
@Component({
  selector: 'app-hero-list-item',
  standalone: true,
  imports: [HeroBadge],
  templateUrl: './hero-list-item.html',
  styleUrl: './hero-list-item.scss',
})
export class HeroListItem {
  readonly hero = input.required<Hero>();
  readonly selectedId = input<number | null>(null);
  readonly selected = computed(() => this.hero().id === this.selectedId());
  readonly pick = output<number>();
  triggerSelect() {
    this.pick.emit(this.hero().id);
  }
}
<!-- src/app/ui/hero-list-item/hero-list-item.html -->
<button
  type="button"
  class="hero-list-item"
  (click)="triggerSelect()"
  [class.hero-list-item--active]="selected()">
  <span class="hero-list-item__name">{{ hero().name }}</span>
  @if (hero().rank) {
    <app-hero-badge [rank]="hero().rank"></app-hero-badge>
  }
</button>
// src/app/ui/hero-list-item/hero-list-item.scss
.hero-list-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-radius: 8px;
  border: 1px solid transparent;
  background: transparent;
  color: #1b365d;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s ease, border-color 0.2s ease;
}
.hero-list-item:hover,
.hero-list-item:focus-visible {
  border-color: #3b5ccc;
  background: #eef3ff;
  outline: none;
}
.hero-list-item--active {
  border-color: #2549b0;
  background: #dfe8ff;
}
.hero-list-item__name {
  flex: 1;
}
// src/app/ui/message-banner/message-banner.ts
import { Component, Input } from '@angular/core';
import { NgClass } from '@angular/common';
type BannerType = 'info' | 'error' | 'success';
@Component({
  selector: 'app-message-banner',
  standalone: true,
  imports: [NgClass],
  templateUrl: './message-banner.html',
  styleUrl: './message-banner.scss',
})
export class MessageBanner {
  @Input() type: BannerType = 'info';
  get roleAttr(): string | null {
    return this.type === 'error' ? 'alert' : null;
  }
  get ariaLiveAttr(): 'polite' | 'assertive' {
    return this.type === 'error' ? 'assertive' : 'polite';
  }
}
<!-- src/app/ui/message-banner/message-banner.html -->
<div
  class="banner"
  [ngClass]="type"
  [attr.role]="roleAttr"
  [attr.aria-live]="ariaLiveAttr">
  <ng-content />
</div>
// src/app/ui/message-banner/message-banner.scss
.banner {
  margin: 12px 0;
  padding: 10px 14px;
  border-radius: 8px;
  border: 1px solid transparent;
  font-size: 0.95rem;
  line-height: 1.4;
}
.banner.info {
  background: #eef4ff;
  border-color: #ccdcff;
  color: #1d3a78;
}
.banner.error {
  background: #fdecea;
  border-color: #f2b8b5;
  color: #8b1b1b;
}
.banner.success {
  background: #edf9f2;
  border-color: #bfe5cd;
  color: #185c35;
}
說明:
HeroBadge 接受可選的 Rank,缺值時顯示 New Hero,在列表與新增表單都能重用。HeroListItem 把名稱與 Badge 打包在一起,同時透過 output 回傳被選取的 ID。MessageBanner 統一 info/error/success 三種提示樣式,也處理對應的 ARIA 屬性。六、樣式整理
最後整理 heroes.component.scss,讓新元件與按鈕有一致的色調。
// src/app/heroes/heroes.component.scss
.filters {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-bottom: 20px;
}
.filters__group button {
  padding: 6px 12px;
  border-radius: 999px;
  border: 1px solid #d0d7ea;
  background: #fff;
  color: #3c4a69;
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.filters__group button.active {
  background: #e4ebff;
  border-color: #5568d5;
  color: #1d2b6f;
}
.create {
  margin-bottom: 24px;
  padding: 16px;
  border: 1px solid #e2e6f0;
  border-radius: 8px;
  background: #fafbff;
  display: grid;
  gap: 12px;
}
.create form {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
}
.create select,
.create input {
  padding: 8px 12px;
  border-radius: 6px;
  border: 1px solid #ccd5e4;
  font-size: 1rem;
}
.create button {
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid #3b5ccc;
  background: #3b5ccc;
  color: white;
  font-weight: 600;
  cursor: pointer;
}
.create button[disabled] {
  opacity: 0.6;
  cursor: not-allowed;
}
.list ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.list li {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 8px 12px;
  border: 1px solid #e1e6f3;
  border-radius: 10px;
  background: #fff;
}
.selected {
  box-shadow: 0 0 0 2px #3b5ccc;
}
.actions {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
button.danger {
  padding: 6px 12px;
  border-radius: 6px;
  border: 1px solid #f1c4c1;
  background: #f7eceb;
  color: #a32020;
  cursor: pointer;
}
button.danger[disabled] {
  opacity: 0.7;
  cursor: progress;
}
.panel {
  margin-top: 16px;
  padding: 16px;
  border: 1px solid #dde6f2;
  border-radius: 8px;
  background: #fafcff;
  display: grid;
  gap: 12px;
}
.panel button {
  justify-self: start;
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid #3b5ccc;
  background: #3b5ccc;
  color: white;
  font-weight: 600;
  cursor: pointer;
}
.panel button[disabled] {
  opacity: 0.6;
  cursor: not-allowed;
}
.muted {
  color: #888;
}

 

 

 

 
常見錯誤與排查:
rankOptions 是否包含 ALL,或資料中的 rank 欄位是否正確填寫。finalize 仍會被呼叫,以及 searching.set(false) 是否在訂閱回呼中被覆寫。HeroListItem 並傳入 selectedId 與 pick 事件。今日小結:
今天我們優化了快取、篩選、表單、搜尋和 UI,讓流程更順也更好維護。