跳到主要內容

FE Day 33 預約列表與篩選介面

FE Day 33 預約列表與篩選介面

執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」第三天。Day 31 定好路由與 API 契約、Day 32 建好設計系統,今天開始把規劃中的頁面一個一個填實,第一個上場的是公開的 /services「服務清單與篩選介面」。這頁是客戶進入系統後第一個真正有互動的畫面:要能一眼看懂有哪些服務、用搜尋與篩選把範圍縮小、點進單一服務看可預約時段。今天全程沿用 Day 32 的 Button、Input、Card、Badge 與 mock 模式,沒有啟動 FastAPI 也能完整跑起來。

引言

清單頁是前端專案最容易寫壞的畫面。常見的四種錯誤是:把所有資料一次攤開(沒有篩選、沒有載入狀態)、篩選條件寫死(後端新增條件時前端要跟著改)、搜尋與篩選互相打架(兩者算出來的結果不一致)、以及空狀態沒有設計(使用者分不清「還在載入」與「真的沒資料」)。這四個問題在日常流量下也許不明顯,但當服務品項從 3 個長到 30 個,客戶就會開始抱怨「找不到我要的服務」。

今天的設計目標有四個。第一,客戶進站 5 秒內能找到想要的服務。第二,篩選條件直覺,主條件不超過三個(類型、時長、價格),避免「篩選面板比清單還長」。第三,當前篩選狀態反映在網址上,可以分享、可以加書籤、按上一頁會還原。第四,載入、空白、失敗三種狀態都有明確的視覺回饋。我們會用 Next.js 15 App Router 的 URL search params 當作篩選狀態的來源,讓「篩選條件、網址、畫面」三者保持一致。

實作範圍固定如下:app/services/page.tsx 是 Server Component,負責初始資料載入;components/booking/ServicesList.tsx 是 Client Component,負責把篩選面板與清單串起來;components/booking/ServiceFilters.tsx 是純展示的篩選元件;ServiceCard 負責單一服務卡片的視覺。資料層沿用 Day 31 的 fetchServices(),並在今天把它擴充成支援篩選參數。

原理:URL 驅動的篩選狀態

篩選狀態有兩種常見的管理方式:本地 state(useState)與網址 state(useSearchParams)。本地 state 寫起來最簡單,但有兩個致命缺點:篩選結果無法分享(「我找到三個便宜的服務」只能截圖),而且使用者按上一頁會直接失去剛剛的篩選。網址 state 把條件寫進 query string(例如 /services?q=攝影&maxPriceCents=200000),既可分享也可加書籤,上一頁與重新整理都會還原。

Next.js 15 對網址 state 的支援很完整:Server Component 可以直接讀 searchParams prop,Client Component 可以用 useSearchParams 這個鉤子。我們把篩選條件寫進網址,並用 router.replace() 更新(不污染瀏覽紀錄),再用 useTransition 標記過渡狀態,讓畫面在更新網址時不會卡頓。

第二個決策是「前端篩選還是伺服器端篩選」。資料量小於 100 筆時,在 client 端篩選最快,因為不必多一趟網路往返;資料量大於上千筆時,就必須交給伺服器端,否則每次載入都要傳送整包資料。預約系統的服務清單通常小於 50 筆,所以我們走 client 端篩選。但資料層介面統一設計成「接受 filter 參數」,未來服務數量長大,只要把 fetchServices 的 live 分支改成帶 query string,元件完全不用改。

比較面向 本地 state 網址 state(本篇採用)
可否分享 不可 可以,複製網址即可
上一頁行為 失去篩選 還原篩選
重新整理 回到預設 保留條件
實作成本 低 中,需要同步網址

資料層:擴充型別與 fetchServices

Day 31 定義的 Service 只有名稱、描述、時長、緩衝、價格與啟用狀態。要做分類篩選,我們在今天替型別加上 category 欄位。這個欄位是列舉型別,讓 TypeScript 在編譯期就幫我們檢查「有沒有填錯分類」:

// lib/mock/types.ts(Day 33 擴充)
// Day 31 已定義 Service 的核心欄位;今天加上 category 以支援分類篩選。

export type ServiceCategory = "photography" | "fitness" | "language" | "consulting";

export type Service = {
  id: string;
  name: string;
  description: string;
  category: ServiceCategory;
  durationMinutes: number;
  bufferMinutes: number;
  priceCents: number;
  isActive: boolean;
};

型別補上之後,也別忘了在 lib/mock/data.ts 為每個示範服務填上對應的分類,例如攝影服務填 photography、健身服務填 fitness。如果只改了型別卻沒補資料,mock 模式下依分類篩選會一個都找不到,這種「程式沒報錯、畫面卻空掉」的情況就是資料與型別脫鉤的典型症狀。

接著把顯示用的格式化函式集中到 lib/format.ts。這件事看似瑣碎,但它是「金額與時間只在一處定義」的關鍵:如果不集中,某一天就會出現「卡片顯示 4,500 元、明細顯示 4500.0 元」的不一致。金額統一以「分」為內部單位(整數運算不會有浮點誤差),顯示時才除以 100 並套上台灣的貨幣格式。

// lib/format.ts
// 全站共用的格式化工具,不要把 Intl 散落在元件裡。

const TAIPEI = "Asia/Taipei";

export function formatPrice(cents: number): string {
  return new Intl.NumberFormat("zh-TW", {
    style: "currency",
    currency: "TWD",
    maximumFractionDigits: 0,
  }).format(cents / 100);
}

export function formatDuration(minutes: number): string {
  if (minutes < 60) return `${minutes} 分鐘`;
  const hours = Math.floor(minutes / 60);
  const rest = minutes % 60;
  return rest === 0 ? `${hours} 小時` : `${hours} 小時 ${rest} 分`;
}

export function formatSlotRange(startAt: string, endAt: string): string {
  const fmt = new Intl.DateTimeFormat("zh-TW", {
    timeZone: TAIPEI,
    month: "numeric",
    day: "numeric",
    weekday: "short",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  });
  return `${fmt.format(new Date(startAt))} - ${fmt.format(new Date(endAt))}`;
}

格式化完成後,擴充 fetchServices 讓它接受篩選參數。這裡刻意維持 Day 31 的形狀:mock 分支回傳前端濾過的資料,live 分支把條件組進 query string。篩選邏輯抽成純函式 applyFilter,不依賴任何外部狀態,因此可以直接單元測試:

// lib/api/services.ts(擴充篩選支援)
import { API_MODE } from "@/lib/api-mode";
import { mockServices } from "@/lib/mock/data";
import { withDelay } from "@/lib/mock/with-delay";
import type { Service, ServiceCategory } from "@/lib/mock/types";

export type ServiceFilter = {
  q?: string;
  category?: ServiceCategory;
  maxDuration?: number;
  maxPriceCents?: number;
};

export async function fetchServices(filter: ServiceFilter = {}): Promise<Service[]> {
  if (API_MODE === "mock") {
    return withDelay(applyFilter(mockServices, filter), 150);
  }
  const params = new URLSearchParams();
  if (filter.q) params.set("q", filter.q);
  if (filter.category) params.set("category", filter.category);
  if (filter.maxDuration) params.set("maxDuration", String(filter.maxDuration));
  if (filter.maxPriceCents) params.set("maxPriceCents", String(filter.maxPriceCents));

  const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/services?${params}`, {
    cache: "no-store",
  });
  if (!res.ok) throw new Error(`fetchServices failed: ${res.status}`);
  return res.json();
}

export function applyFilter(services: Service[], filter: ServiceFilter): Service[] {
  const q = filter.q?.trim().toLowerCase();
  return services
    .filter((s) => s.isActive)
    .filter((s) => !q || s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
    .filter((s) => !filter.category || s.category === filter.category)
    .filter((s) => !filter.maxDuration || s.durationMinutes <= filter.maxDuration)
    .filter((s) => !filter.maxPriceCents || s.priceCents <= filter.maxPriceCents);
}

這段程式有三個關鍵設計。第一,filter 是 optional,沒給就回傳全部;給了就在 mock 端(前端)或 live 端(後端)套用,呼叫端永遠只看到一種介面。第二,applyFilter 是純函式,輸入陣列與條件、回傳新陣列,不改動原始資料,這讓「篩選結果錯誤」這類 bug 能被單元測試一次鎖住。第三,live 分支刻意用原生 fetch,因為統一的 apiClient 要等 Day 36 才建立;今天的重點是篩選邏輯,先不引入新的抽象層。等到 Day 36,這段會換成 apiFetch("/services", { query: filter }),元件端一行都不用改。

完整實作:Server Component 與清單元件

頁面本身寫成 Server Component,負責在伺服器端先取回資料,讓首屏 HTML 直接帶內容。因為 ServicesList 會用到 useSearchParams,在 Next.js 15 的靜態渲染階段需要一層 Suspense 邊界,我們用 Suspense 包住它並提供骨架作為 fallback:

// app/services/page.tsx
import type { Metadata } from "next";
import { Suspense } from "react";
import { fetchServices } from "@/lib/api/services";
import { ServicesList } from "@/components/booking/ServicesList";

export const metadata: Metadata = {
  title: "預約服務 | 預約管理系統",
  description: "瀏覽所有可預約的服務,選擇適合您的時段",
};

export default async function ServicesPage() {
  // 一次取回全部服務;服務數量小時,交由 client 端篩選最快。
  const services = await fetchServices();

  return (
    <main className="mx-auto max-w-6xl px-4 py-section">
      <header className="mb-section">
        <h1 className="text-3xl font-bold text-text-primary">預約服務</h1>
        <p className="mt-2 text-text-secondary">選擇您想預約的服務,查看可預約時段。</p>
      </header>
      <Suspense fallback={<ServicesSkeleton />}>
        <ServicesList services={services} />
      </Suspense>
    </main>
  );
}

function ServicesSkeleton() {
  return <div className="h-10 w-full animate-pulse rounded-card bg-surface" />;
}

清單元件是今天的主角。它同時負責三件事:把篩選條件同步到網址、依條件算出要顯示的服務、以及處理三種狀態。我們用 useDeferredValue 讓搜尋輸入保持流暢,用 useMemo 確保只有在條件或資料變動時才重算清單,並用 useTransition 把網址更新標記成過渡更新:

// components/booking/ServicesList.tsx
"use client";
import { useDeferredValue, useMemo, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/Input";
import { applyFilter, type ServiceFilter } from "@/lib/api/services";
import type { Service, ServiceCategory } from "@/lib/mock/types";
import { ServiceFilters } from "./ServiceFilters";
import { ServiceCard } from "./ServiceCard";

function toNumber(value: string | null): number | undefined {
  return value ? Number(value) : undefined;
}

export function ServicesList({ services }: { services: Service[] }) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();
  const [text, setText] = useState(searchParams.get("q") ?? "");

  // 輸入框立刻更新,但套用到清單的值延後一拍,避免每個字都重算。
  const deferredText = useDeferredValue(text);

  const filter = useMemo<ServiceFilter>(
    () => ({
      q: deferredText || undefined,
      category: (searchParams.get("category") as ServiceCategory) ?? undefined,
      maxDuration: toNumber(searchParams.get("maxDuration")),
      maxPriceCents: toNumber(searchParams.get("maxPriceCents")),
    }),
    [deferredText, searchParams],
  );

  const visible = useMemo(() => applyFilter(services, filter), [services, filter]);

  function updateUrl(patch: Partial<ServiceFilter>) {
    const next = new URLSearchParams(searchParams.toString());
    const merged = { ...filter, ...patch };
    const write = (key: string, value: string | number | undefined) => {
      if (value === undefined || value === "") next.delete(key);
      else next.set(key, String(value));
    };
    write("q", merged.q);
    write("category", merged.category);
    write("maxDuration", merged.maxDuration);
    write("maxPriceCents", merged.maxPriceCents);

    startTransition(() => {
      router.replace(`/services?${next.toString()}`, { scroll: false });
    });
  }

  return (
    <div className="grid gap-section lg:grid-cols-[280px_1fr]">
      <aside className="flex flex-col gap-card">
        <Input
          label="搜尋"
          type="search"
          placeholder="輸入服務名稱或描述"
          value={text}
          onChange={(e) => {
            setText(e.target.value);
            updateUrl({ q: e.target.value });
          }}
        />
        <ServiceFilters value={filter} onChange={updateUrl} />
      </aside>

      <section aria-busy={isPending} aria-live="polite">
        <p className="mb-3 text-sm text-text-muted">共 {visible.length} 項服務</p>
        {visible.length === 0 ? (
          <EmptyState query={text} />
        ) : (
          <ul className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
            {visible.map((service) => (
              <li key={service.id}>
                <ServiceCard service={service} />
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}

function EmptyState({ query }: { query: string }) {
  return (
    <div className="rounded-card border border-dashed border-surface-border p-12 text-center">
      <p className="text-text-secondary">
        {query ? `找不到符合「${query}」的服務` : "目前沒有可預約的服務"}
      </p>
      <p className="mt-2 text-sm text-text-muted">試試調整篩選條件,或清除搜尋。</p>
    </div>
  );
}

這個元件有五個設計重點。第一,useDeferredValue 讓輸入框的值立即更新,但套用到清單的值延後一拍,打長字串時不會每個字都重算。第二,useMemo 有明確的依賴陣列,只在條件變動時重算,避免每次 render 都重新跑 applyFilter。第三,router.replace() 而非 push(),不會讓每次打字都塞一筆瀏覽紀錄;{ scroll: false } 避免畫面跳回頂端。第四,aria-busy 與 aria-live 讓螢幕閱讀器知道「清單正在更新」(這是 Day 28 的無障礙細節)。第五,空狀態會根據「有沒有輸入關鍵字」給不同訊息,並提示下一步,而不是只丟一句「沒有資料」。

篩選面板與服務卡片

篩選面板是純展示元件:接收當前的 filter 與一個 onChange,把使用者的選擇往上拋。所有下拉選項都由常數陣列產生,未來新增分類或價格級距只要改陣列,不用動 JSX:

// components/booking/ServiceFilters.tsx
"use client";
import { useId } from "react";
import type { ServiceFilter } from "@/lib/api/services";
import type { ServiceCategory } from "@/lib/mock/types";

type Props = {
  value: ServiceFilter;
  onChange: (next: Partial<ServiceFilter>) => void;
};

const CATEGORIES: { value: ServiceCategory; label: string }[] = [
  { value: "photography", label: "攝影" },
  { value: "fitness", label: "健身" },
  { value: "language", label: "語言" },
  { value: "consulting", label: "諮詢" },
];

const MAX_DURATIONS = [30, 60, 90, 120];
const MAX_PRICES = [500, 1000, 2000, 5000];

const selectClass =
  "h-10 w-full rounded-button border border-surface-border bg-white px-3 text-base " +
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500";

export function ServiceFilters({ value, onChange }: Props) {
  const catId = useId();
  const durId = useId();
  const priceId = useId();

  return (
    <div className="space-y-4 rounded-card border border-surface-border bg-surface p-card">
      <div>
        <label htmlFor={catId} className="mb-1 block text-sm font-medium text-text-primary">服務類型</label>
        <select
          id={catId}
          className={selectClass}
          value={value.category ?? ""}
          onChange={(e) => onChange({ category: (e.target.value || undefined) as ServiceCategory | undefined })}
        >
          <option value="">全部類型</option>
          {CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
        </select>
      </div>

      <div>
        <label htmlFor={durId} className="mb-1 block text-sm font-medium text-text-primary">最長時長</label>
        <select
          id={durId}
          className={selectClass}
          value={value.maxDuration ?? ""}
          onChange={(e) => onChange({ maxDuration: e.target.value ? Number(e.target.value) : undefined })}
        >
          <option value="">不限</option>
          {MAX_DURATIONS.map((m) => <option key={m} value={m}>{m} 分鐘以內</option>)}
        </select>
      </div>

      <div>
        <label htmlFor={priceId} className="mb-1 block text-sm font-medium text-text-primary">最高價格</label>
        <select
          id={priceId}
          className={selectClass}
          value={value.maxPriceCents ? value.maxPriceCents / 100 : ""}
          onChange={(e) => onChange({ maxPriceCents: e.target.value ? Number(e.target.value) * 100 : undefined })}
        >
          <option value="">不限</option>
          {MAX_PRICES.map((p) => <option key={p} value={p}>{p.toLocaleString("zh-TW")} 元以下</option>)}
        </select>
      </div>

      <button
        type="button"
        onClick={() => onChange({ q: undefined, category: undefined, maxDuration: undefined, maxPriceCents: undefined })}
        className="w-full rounded-button border border-surface-border bg-white px-4 py-2 text-sm font-medium text-text-primary hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
      >
        清除全部篩選
      </button>
    </div>
  );
}

篩選面板有三個設計重點。第一,每個下拉都有 label 與 htmlFor 明確綁定,點標籤可以聚焦到欄位、螢幕閱讀器會唸出標籤。第二,useId 產生 SSR 安全的識別碼,避免伺服器與瀏覽器端渲染出不同 id 造成 hydration 警告。第三,價格用「元」當顯示單位、內部轉成「分」,使用者不必理解「分」是什麼,程式端又保有整數運算的好處。這條「顯示單位與儲存單位分離」的規則在之後的金流情境同樣重要。

最後是服務卡片。它是整個頁面的視覺重心,每張卡顯示名稱、兩行描述、時長、緩衝時間、價格與主要行動按鈕。用 Card 與 Badge 這兩個 Day 32 的基礎元件組合,不另外寫死樣式:

// components/booking/ServiceCard.tsx
import Link from "next/link";
import type { Service } from "@/lib/mock/types";
import { Card, CardBody, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { formatDuration, formatPrice } from "@/lib/format";

export function ServiceCard({ service }: { service: Service }) {
  return (
    <Card className="flex h-full flex-col">
      <CardTitle>{service.name}</CardTitle>
      <CardBody>
        <p className="line-clamp-2 text-sm">{service.description}</p>
      </CardBody>
      <div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-text-secondary">
        <Badge tone="info">{formatDuration(service.durationMinutes)}</Badge>
        {service.bufferMinutes > 0 ? (
          <Badge tone="neutral">含 {service.bufferMinutes} 分鐘緩衝</Badge>
        ) : null}
      </div>
      <div className="mt-card flex items-center justify-between">
        <span className="text-xl font-semibold text-text-primary">{formatPrice(service.priceCents)}</span>
        <Link
          href={`/services/${service.id}`}
          className="rounded-button bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2"
        >
          查看時段
        </Link>
      </div>
    </Card>
  );
}

卡片有三個設計重點。第一,line-clamp-2 限制描述最多兩行,避免某個服務的描述特別長、把整排卡片撐高。第二,用 Badge 顯示時長與緩衝,讓客戶一眼看出這個服務需要投入多少時間。第三,「查看時段」是主要行動,用品牌色 bg-brand-600;價格雖然視覺重量高,但行動按鈕才是要引導點選的目標,所以放在卡片右下角,與價格形成視覺動線。

載入狀態與單元測試

載入狀態有兩層。第一層是路由層:Next.js 15 會自動使用同目錄下的 loading.tsx 作為該路由的 Suspense fallback。第二層是元件層:清單更新時用 aria-busy 標記。我們補上 app/services/loading.tsx,用骨架畫面取代傳統的置中轉圈,避免版面在資料到達時大幅跳動:

// app/services/loading.tsx
// Next.js 15 會在 Suspense 邊界顯示這個檔案,直到 Server Component 完成。
export default function ServicesLoading() {
  return (
    <main className="mx-auto max-w-6xl px-4 py-section">
      <div className="mb-section h-9 w-48 animate-pulse rounded bg-surface" />
      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
        {Array.from({ length: 6 }).map((_, index) => (
          <div key={index} className="h-48 animate-pulse rounded-card bg-surface" />
        ))}
      </div>
    </main>
  );
}

骨架的關鍵是「形狀要接近最終畫面」。如果骨架是一條置中的細線,資料到達時整個版面會重排,使用者會感覺畫面「跳了一下」。今天的骨架先放標題與六張卡片的占位塊,與真實版面同高,切換時幾乎無感。

最後把篩選邏輯用單元測試鎖住。因為 applyFilter 是純函式,測試不需要渲染任何元件,跑得又快又穩:

// tests/services-filter.test.ts
import { describe, expect, it } from "vitest";
import { applyFilter } from "@/lib/api/services";
import { mockServices } from "@/lib/mock/data";

describe("applyFilter", () => {
  it("只回傳啟用中的服務", () => {
    const result = applyFilter(mockServices, {});
    expect(result.every((s) => s.isActive)).toBe(true);
  });

  it("依關鍵字比對名稱與描述", () => {
    const result = applyFilter(mockServices, { q: "攝影" });
    expect(result.map((s) => s.id)).toContain("svc-001");
  });

  it("依價格上限過濾", () => {
    const result = applyFilter(mockServices, { maxPriceCents: 500000 });
    expect(result.every((s) => s.priceCents <= 500000)).toBe(true);
  });

  it("空條件回傳全部啟用服務", () => {
    expect(applyFilter(mockServices, {}).length).toBeGreaterThan(0);
  });
});

常見錯誤與踩雷

第一個踩雷是「篩選條件只放 useState、不寫網址」。這樣使用者篩完想分享給朋友只能截圖,按上一頁又會回到全部服務。修法:所有篩選條件都寫進 URL search params,並用 router.replace 同步;這樣分享、書籤、上一頁、重新整理四個情境都會正確。

第二個踩雷是「搜尋框每打一個字就打 API」。這在資料量大時會打爆伺服器,畫面也會因為反覆重渲染而卡頓。修法有兩條:資料小時用 useDeferredValue 把套用時機延後(今天的做法),或資料大時加 debounce(例如 300 毫秒內的輸入合併成一次查詢)。兩者不衝突,可以一起用。

第三個踩雷是「在元件裡直接對 prop 陣列做 filter 又改動它」。像 services.sort(...) 會就地改動原始陣列,導致資料層的快取被汙染,別的頁面看到的順序也跟著變。修法:applyFilter 一律回傳新陣列,需要排序時先用展開運算子複製再排。這條在共用快取(例如 TanStack Query)的情境尤其重要。

第四個踩雷是「篩選後畫面跳回頂端」。使用者好不容易捲到第三排,改一個條件就回到最上面,體驗很差。修法:router.replace 的第二個參數帶 { scroll: false },Next.js 15 會保留捲動位置。

第五個踩雷是「空狀態與錯誤狀態長得一樣」。兩者都是「沒有任何卡片」,但一個是「系統壞了」、一個是「條件太嚴」。修法:空狀態用虛線邊框與低彩度呈現,錯誤狀態用紅色與重試按鈕,並在文字上明確區分「目前沒有符合條件的服務」與「載入失敗,請稍後重試」。

第六個踩雷是「忘記處理 useSearchParams 的 Suspense 邊界」。在 Next.js 15 的靜態渲染階段,Client Component 使用 useSearchParams 會被要求包在 <Suspense> 內,否則 build 會警告、部分頁面退回全動態渲染。修法:把清單元件包進 Suspense 並給 fallback,今天的頁面就是這樣寫的。

效能與實務提醒

這一頁的初始資料由 Server Component 直接帶進 HTML,client 端篩選不會再多打任何 API,對 50 筆以內的清單是最快的做法。若未來服務數量成長到數百筆,建議把 ServicesList 改成用 TanStack Query 搭配 keepPreviousData 取得分頁資料,並把篩選條件一起送給後端;元件結構不必大改,只換資料來源。

另一個實務提醒是「篩選條件數量」。我們刻意只留三個主條件,因為每多一個條件,使用者就要多花一秒判斷「這個欄位要不要動」。真的需要更多維度時,正確的做法是收進「更多篩選」的摺疊面板,而不是一路往下堆。

手機版的篩選也要重新安排,而不是把桌機的側欄縮小。今天的 lg:grid-cols-[280px_1fr] 在桌機是左側面板、手機自動變成單欄(篩選疊在清單上方)。對小螢幕更友善的做法是把手機版的篩選收進可摺疊的 details 元素,點開才展開,這一步留到 Day 42 的響應式總檢處理。

最後提醒 mock 模式的可見性。只要 NEXT_PUBLIC_API_MODE=mock,Day 32 的「Mock 模式」浮動標籤就會出現,開發者一眼就知道目前資料來自前端 fixture、不會打到後端。這對示範與測試都很重要,也避免了「誤以為按鈕真的建立了預約」的誤會。

小結

今天把 /services 從佔位頁變成可用的服務清單與篩選介面。重點回顧:網址驅動的篩選狀態讓結果可分享、可書籤;Server Component 負責首屏資料,Client Component 負責互動;useDeferredValue 與 useTransition 讓篩選保持流暢;applyFilter 抽成純函式以便測試;載入、空白、錯誤三種狀態都有明確處理。資料層也從「不帶參數」擴充成「接受 filter」,為 Day 36 的正式 API 串接預留位置。

結語

清單與篩選是客戶進入預約系統的第一個畫面,它做得好不好,直接決定客戶願不願意繼續往下走。今天我們用 Day 32 的設計系統元件把這頁組起來,沒有新增任何視覺規則,只增加資料層的篩選能力與狀態處理。明天 Day 34 我們會接著做預約流程與表單:把 /bookings/new 從佔位頁變成「選擇時段、填寫資料、確認預約」的完整流程,並沿用今天 ServiceCard 上「查看時段」的連結。讀完這篇你應該能回答:為什麼篩選要用網址 state?Server Component 與 Client Component 的責任怎麼分?useDeferredValue 適合用在哪裡?空狀態要怎麼設計才友善?

延伸資源

  • Next.js 15 useSearchParams:https://nextjs.org/docs/app/api-reference/functions/use-search-params,網址狀態管理的官方說明。
  • React useTransition:https://react.dev/reference/react/useTransition,非同步過渡狀態的標準用法。
  • React useDeferredValue:https://react.dev/reference/react/useDeferredValue,延遲更新值的官方說明。
  • TanStack Query 5.x:https://tanstack.com/query/latest,伺服器狀態管理(Day 14 介紹)。
  • Tailwind CSS 4.x line-clamp:https://tailwindcss.com/docs/line-clamp,文字截斷的 utility。
  • Web 系列 Day 35 的預約系統資料模型:https://blog.hao-code.com/2025/07/web-day-35.html,後端欄位對照來源。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...