哈囉,各位邦友們!
昨天我們完成了 HeroService 的新增、更新流程,也在畫面上實現了互動。
今天要補上 CRUD 最後一塊:刪除 (DELETE),同時透過 catchError
建立錯誤處理流程。
HeroService
新增 delete$()
。catchError
與 EMPTY
妥善處理錯誤,避免程式整個崩潰。ng serve
可正常啟動。一、HeroService:新增 delete$() 並清理 Map 快取
// src/app/hero.service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { tap } from 'rxjs';
export type Hero = { id: number; name: string; rank?: string };
@Injectable({
providedIn: 'root',
})
export class HeroService {
private readonly http = inject(HttpClient);
private readonly baseUrl = 'api/heroes';
private readonly cache = new Map<number, Hero>();
getAll$() {
return this.http.get<Hero[]>(this.baseUrl).pipe(
tap((heroes) => {
this.cache.clear();
for (const hero of heroes) {
this.cache.set(hero.id, hero);
}
})
);
}
getById$(id: number) {
return this.http.get<Hero>(`${this.baseUrl}/${id}`).pipe(
tap((hero) => {
if (!hero) {
return;
}
this.cache.set(hero.id, hero);
})
);
}
create$(name: string) {
const payload = { name: name.trim() };
return this.http.post<Hero>(this.baseUrl, payload).pipe(
tap((created) => {
this.cache.set(created.id, created);
})
);
}
update$(id: number, changes: Partial<Hero>) {
const cached = this.cache.get(id);
const payload = { ...(cached ?? { id }), ...changes, id };
return this.http.put<Hero>(`${this.baseUrl}/${id}`, payload).pipe(
tap((updated) => {
this.cache.set(updated.id, updated);
})
);
}
delete$(id: number) {
return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
tap(() => {
this.cache.delete(id);
})
);
}
}
二、HeroesComponent:整合刪除流程與 catchError
// src/app/heroes/heroes.component.ts
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { HeroService, Hero } from '../hero.service';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterModule } from '@angular/router';
import { HeroBadge } from '../hero-badge/hero-badge';
import { EMPTY, catchError, finalize } from 'rxjs';
@Component({
selector: 'app-heroes',
imports: [HeroBadge, FormsModule, RouterModule],
templateUrl: './heroes.component.html',
styleUrl: './heroes.component.scss',
})
export class HeroesComponent {
// 注入服務與 DestroyRef
private readonly heroService = inject(HeroService);
private readonly destroyRef = inject(DestroyRef);
// 狀態:英雄清單、目前選中的英雄、載入、錯誤
protected readonly heroes = signal<Hero[]>([]);
protected readonly selectedHero = signal<Hero | null>(null);
protected readonly heroesLoading = signal(true);
protected readonly heroesError = signal<string | null>(null);
// 新增:編輯與儲存狀態
protected readonly editName = signal('');
protected readonly saving = signal(false);
protected readonly saveError = signal<string | null>(null);
// 新增:建立英雄表單狀態
protected readonly newHeroName = signal('');
protected readonly creating = signal(false);
protected readonly createError = signal<string | null>(null);
protected readonly deletingId = signal<number | null>(null);
protected readonly deleteError = signal<string | null>(null);
protected readonly feedback = signal<string | null>(null);
constructor() {
// 從 Observable 取得資料
this.heroService
.getAll$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (list) => {
this.heroes.set(list);
this.heroesLoading.set(false);
},
error: (err) => {
this.heroesError.set(String(err ?? 'Unknown error'));
this.heroesLoading.set(false);
},
});
effect(() => {
const current = this.selectedHero();
this.editName.set(current?.name ?? '');
this.saveError.set(null);
});
}
// 點擊處理
onSelect(hero: Hero) {
this.selectedHero.set(hero);
}
saveSelected() {
const current = this.selectedHero();
if (!current) {
return;
}
const name = this.editName().trim();
if (name.length < 3 || name === current.name) {
return;
}
this.saving.set(true);
this.saveError.set(null);
this.feedback.set(null);
this.heroService
.update$(current.id, { name })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => {
this.heroes.update((list) =>
list.map((hero) => (hero.id === updated.id ? updated : hero))
);
this.selectedHero.set(updated);
this.editName.set(updated.name);
this.saving.set(false);
},
error: (err) => {
this.saveError.set(String(err ?? 'Unknown error'));
this.saving.set(false);
},
});
}
addHero() {
const name = this.newHeroName().trim();
if (name.length < 3) {
return;
}
this.creating.set(true);
this.createError.set(null);
this.feedback.set(null);
this.heroService
.create$(name)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (created) => {
this.heroes.update((list) => [...list, created]);
this.newHeroName.set('');
this.selectedHero.set(created);
this.editName.set(created.name);
this.creating.set(false);
},
error: (err) => {
this.createError.set(String(err ?? 'Unknown error'));
this.creating.set(false);
},
});
}
removeHero(hero: Hero) {
const confirmed = confirm(`確定要刪除英雄「${hero.name}」嗎?`);
if (!confirmed) {
return;
}
this.deletingId.set(hero.id);
this.deleteError.set(null);
this.feedback.set(null);
this.heroService
.delete$(hero.id)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((err) => {
this.deleteError.set(String(err ?? 'Unknown error'));
return EMPTY;
}),
finalize(() => {
this.deletingId.set(null);
})
)
.subscribe(() => {
this.heroes.update((list) => list.filter((h) => h.id !== hero.id));
if (this.selectedHero()?.id === hero.id) {
this.selectedHero.set(null);
this.editName.set('');
}
this.feedback.set(`已刪除英雄「${hero.name}」。`);
});
}
}
三、Heroes 模板:刪除按鈕與狀態提示
<!-- src/app/heroes/heroes.component.html -->
@if (heroesLoading()) {
<p class="muted">Loading heroes...</p>
} @else if (heroesError(); as e) {
<p class="error">Load failed: {{ e }}</p>
} @else {
<section class="create">
<app-hero-badge></app-hero-badge>
<form (ngSubmit)="addHero()">
<label for="new-hero">Name:</label>
<input
id="new-hero"
name="new-hero"
placeholder="enter new hero"
required
minlength="3"
#newCtrl="ngModel"
[ngModel]="newHeroName()"
(ngModelChange)="newHeroName.set($event)" />
<button type="submit" [disabled]="creating() || newCtrl.invalid">
@if (creating()) { Saving... } @else { Add }
</button>
</form>
@if (createError(); as err) {
<p class="error">Create failed: {{ err }}</p>
}
</section>
@if (feedback(); as msg) {
<p class="feedback" aria-live="polite">{{ msg }}</p>
}
@if (deleteError(); as err) {
<p class="error" aria-live="assertive">Delete failed: {{ err }}</p>
}
<section class="list">
<!-- 既有清單保留 Day10 寫法 -->
<ul>
@for (h of heroes(); track h.id; let i = $index; let c = $count) {
<li
(click)="onSelect(h)"
[class.is-a]="h.rank === 'A' || h.rank === 'S'"
[class.selected]="selectedHero()?.id === h.id"
[attr.data-id]="h.id"
[attr.aria-current]="selectedHero()?.id === h.id ? 'true' : null">
<span class="no">{{ i + 1 }}/{{ c }}</span>
<span class="name">{{ h.name }}</span>
@if (h.rank) { <span class="rank">[{{ h.rank }}]</span> }
<span class="actions">
<a [routerLink]="['/detail', h.id]" (click)="$event.stopPropagation()">View</a>
<button
type="button"
class="danger"
(click)="removeHero(h); $event.stopPropagation()"
[disabled]="deletingId() === h.id">
@if (deletingId() === h.id) { Deleting... } @else { Delete }
</button>
</span>
</li>
} @empty {
<li class="muted">No heroes.</li>
}
</ul>
@if (selectedHero(); as s) {
<aside class="panel">
<h3>Edit</h3>
<p>
#{{ s.id }} - {{ s.name }}
@if (s.rank) { <span class="rank">[{{ s.rank }}]</span> }
</p>
<label for="hero-name">Name:</label>
<input
id="hero-name"
name="hero-name"
type="text"
required
minlength="3"
#nameCtrl="ngModel"
[ngModel]="editName()"
(ngModelChange)="editName.set($event)"
[attr.aria-invalid]="nameCtrl.invalid && nameCtrl.touched" />
<button
type="button"
(click)="saveSelected()"
[disabled]="saving() || nameCtrl.invalid || editName().trim() === s.name">
@if (saving()) { Saving... } @else { Save }
</button>
@if (saveError(); as err) {
<p class="error">Save failed: {{ err }}</p>
}
</aside>
}
</section>
}
四、樣式:提示訊息與刪除按鈕
/* src/app/heroes/heroes.component.scss */
.error {
color: #c33;
}
.feedback {
margin: 12px 0;
padding: 8px 12px;
background: #e6f6ec;
color: #0a5e2a;
border-radius: 6px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
gap: 8px;
align-items: baseline;
padding: 4px 0;
}
.no {
width: 56px;
color: #789;
}
.name {
font-weight: 600;
}
.rank {
color: #445;
background: #eef;
padding: 2px 6px;
border-radius: 4px;
}
.is-a {
color: #225;
background: #eef;
padding: 4px 8px;
border-radius: 4px;
}
.muted {
color: #888;
}
.selected {
outline: 2px solid #58a;
background: #eef6ff;
}
.panel {
margin-top: 12px;
padding: 8px 12px;
border: 1px solid #dde6f2;
border-radius: 6px;
background: #fafcff;
}
.errors {
color: #c33;
}
.create {
margin-bottom: 24px;
padding: 16px;
border: 1px solid #e2e6f0;
border-radius: 8px;
background: #fafbff;
form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
}
.panel button {
margin-top: 12px;
}
.error {
color: #c33;
}
button.danger {
margin-left: 8px;
background: #f7eceb;
color: #a32020;
border-color: #f1c4c1;
}
button.danger[disabled] {
opacity: 0.7;
cursor: progress;
}
延續昨天的設定,InMemoryData 使用提供資料,刪除後的結果只影響本次執行中的假後端。
驗收清單:
已刪除英雄「xxx」
,刪除失敗時顯示 Delete failed: ...
。常見錯誤與排查:
heroes.update
是否回傳新的陣列,且別忘了 return list.filter(...)
。catchError
有回傳 EMPTY
,讓 Observable 停在錯誤分支。今日小結:
我們將 HeroService 補齊 delete$()
,完成了 CRUD 的最後一步!
參考資料: