跳到主要內容

FE Day 35 後台管理介面

FE Day 35 後台管理介面

執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」後台篇。Day 33 與 Day 34 把客戶端「瀏覽服務」、「建立預約」兩個畫面做完,今天我們把鏡頭轉到管理者:服務提供者每天打開後台看「今日有誰預約」、「哪幾筆還沒確認」、「客戶想改期怎麼辦」。後台不是 SPA 版的客戶端,而是「資訊密度高、操作直覺、可以在三秒內找到要做的事」的桌面工具。今天要做四件事:第一,建立 admin layout(側欄 + 頂列 + role guard);第二,把 /admin 首頁填成「今日預約總覽」;第三,把 /admin/bookings 填成「所有預約的管理清單」,含搜尋、狀態篩選、確認與取消動作;第四,把 /admin/services 填成「服務品項管理」,含新增 / 編輯 / 停用。今天所有資料都是虛構示範,沿用 Day 31 的 mock 模式;無後端時走內建 fixture,後續 Day 36 串 FastAPI 時只換底層呼叫,元件程式碼不動。

引言

客戶端介面與後台介面是兩種完全不同的東西。客戶端要「看完服務就想預約」,資訊密度低、引導性強;後台則相反,要「在一頁裡看完所有要處理的事」,資訊密度高、操作直接。學後端工程師常把兩者用同一套元件硬刻,結果後台介面變成「客戶端頁面多塞幾張表格」,操作三步才能完成一個動作。今天會刻意把後台當作另一種介面來對待:版面用桌面寬度、表格用 compact rows、動作按鈕緊貼在每列右側、頂列提供「今天、明天、本週」快速切換。我們也會把客戶端設計裡那種「把使用者當新手」的引導拿掉,管理者對系統熟、需要的是效率,不是溫馨提示,更不是設計師個人風格的展示場。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範)。Web 系列的 FastAPI 後端在 Day 36、Day 37 完成了認證與預約 API,後台在 Day 39 用 HTMX 寫過一版。今天寫的版本是「Next.js 前端的後台」,跟 Web 系列後台是兩條獨立路徑:Web 系列用 FastAPI + Jinja2 + HTMX、本系列用 Next.js + React + TanStack Query。兩者並存,讀者可依團隊偏好選擇。今天的範例預設走 mock,Day 36 串 FastAPI 時把資料層換掉即可。

今天的內容分五段:第一段把 Day 31–34 的共用設定再次統整;第二段講 admin layout 的結構與角色檢查;第三段實作 admin 首頁(今日預約總覽);第四段實作預約管理清單(篩選、搜尋、確認、取消);第五段實作服務品項管理(CRUD + 停用)。讀完之後你會拿到一整套後台介面的 React 程式碼、約 8 個基礎元件、4 個頁面,所有程式碼都可以 pnpm dev 直接跑、無後端時用 mock 模式。

貫穿專案共用設定(Day 31–44 沿用)

今天的後台建立在 Day 31–34 已建好的 stack 上,繼續沿用:

  • 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
  • 樣式:Tailwind CSS 4.x、CSS variables 主題;沿用 Day 32 的 components/ui/
  • 狀態管理:TanStack Query 5.x(伺服器狀態)、React 19 useState / useReducer(UI 狀態)
  • 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API),今天走 mock
  • Mock:NEXT_PUBLIC_API_MODE=mock 時走 lib/mock/*.ts fixture,不打真實 API
  • 角色:admin(管理者)、customer(客戶);本篇聚焦 admin 視角,Day 38 再談登入分流

這份設定從 Day 31 到 Day 44 不變。今天的後台只在既有設定上「加頁面與元件」,不引入新的架構概念。

原理解念:admin layout 的結構與角色檢查

後台的 layout 跟客戶端 layout 是完全不同的兩個檔案。客戶端的 root layout 只有頂部選單與 footer;後台要的是「側欄(導覽)+ 頂列(麵包屑與使用者選單)+ 主內容區」三段式結構。我們用 Next.js 的巢狀 layout 把後台目錄獨立出來:

// app/admin/layout.tsx
import type { ReactNode } from "react";
import { redirect } from "next/navigation";
import Link from "next/link";
import { SidebarNav } from "@/components/admin/SidebarNav";
import { TopBar } from "@/components/admin/TopBar";
import { getCurrentRole } from "@/lib/auth-server";

export default async function AdminLayout({ children }: { children: ReactNode }) {
  const role = await getCurrentRole();
  if (role !== "admin") {
    // 未登入或非 admin:直接重導向到登入頁
    redirect("/login?next=/admin");
  }
  return (
    <div className="grid min-h-screen grid-cols-[16rem_1fr] bg-surface-muted">
      <aside className="border-r border-surface-border bg-surface p-card">
        <Link href="/admin" className="mb-6 block text-lg font-semibold">
          預約後台
        </Link>
        <SidebarNav />
      </aside>
      <div className="flex flex-col">
        <TopBar />
        <main className="flex-1 p-section">{children}</main>
      </div>
    </div>
  );
}

這個 layout 有四個關鍵設計。第一,用 grid 把畫面切成「16rem 側欄 + 主內容」,這是後台常用的黃金比例。第二,getCurrentRole() 在 server 端讀 cookie 與 session,回傳角色字串;非 admin 直接 redirect,比在 client 端檢查更早一步擋下未授權請求。第三,layout 是 async function,可以 await 資料;這是 Server Component 的優勢,client 端首屏不需要多一次 API 呼叫,畫面到達時就已經帶資料。第四,把 SidebarNav 與 TopBar 拆成獨立元件,方便之後重複使用與測試。

角色檢查為什麼要在 server 端做?原因是「前端可以被繞過」:駭客只要打開 DevTools、把 role 改成 admin 就能看到後台畫面,但實際資料還是要靠 server 端檢查才安全。我們的 getCurrentRole() 在 server 端讀 httpOnly cookie(Day 38 會實作),JavaScript 完全碰不到,這是業界標準的權限分層:UI 隱藏 + API 拒絕 兩層防護。對後台來說,「UI 隱藏」是體驗(讓沒權限的人看不到畫面)、「API 拒絕」是安全(即使 UI 被繞過也拿不到資料);兩者缺一都會出事。

側欄與頂列:後台的視覺骨架

側欄是後台最高頻被使用的元件。每天管理者打開後台第一件事就是點側欄切換頁面。我們把導覽選項集中在 SidebarNav,用陣列定義避免散落:

// components/admin/SidebarNav.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/cn";

type NavItem = { href: string; label: string; description: string };

const items: NavItem[] = [
  { href: "/admin", label: "總覽", description: "今日預約與待辦" },
  { href: "/admin/bookings", label: "預約管理", description: "所有客戶預約" },
  { href: "/admin/services", label: "服務品項", description: "新增、編輯、停用" },
];

export function SidebarNav() {
  const pathname = usePathname();
  return (
    <nav aria-label="後台導覽">
      <ul className="flex flex-col gap-1">
        {items.map((it) => {
          const active = pathname === it.href || pathname.startsWith(it.href + "/");
          return (
            <li key={it.href}>
              <Link
                href={it.href}
                aria-current={active ? "page" : undefined}
                className={cn(
                  "block rounded-button px-3 py-2 text-sm",
                  active
                    ? "bg-brand-50 text-brand-700"
                    : "text-text-secondary hover:bg-surface-muted",
                )}
              >
                <div className="font-medium">{it.label}</div>
                <div className="text-xs text-text-muted">{it.description}</div>
              </Link>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

三個重點設計。第一,usePathname() 取當前路徑,與導覽選項的 href 比對決定 active 樣式;用 startsWith 處理巢狀路由(例如 /admin/bookings/123 仍標記 /admin/bookings 為 active)。第二,aria-current="page" 標記當前頁面,螢幕閱讀器會唸出「目前所在頁面」。第三,選項定義用陣列集中,未來新增「報表」、「通知」頁面只要加一行;不要把 Link 寫死在 JSX 裡。

頂列則放「使用者選單」與「目前頁面標題」。這部分今天先放 placeholder,明天 Day 38 登入流程完成後再串真實的 currentUser:

// components/admin/TopBar.tsx
"use client";
import { usePathname } from "next/navigation";
import { Badge } from "@/components/ui/Badge";

const TITLES: Record<string, string> = {
  "/admin": "總覽",
  "/admin/bookings": "預約管理",
  "/admin/services": "服務品項",
};

export function TopBar() {
  const pathname = usePathname();
  const title = TITLES[pathname] ?? TITLES[pathname.split("/").slice(0, 3).join("/")] ?? "後台";
  return (
    <header className="flex items-center justify-between border-b border-surface-border bg-surface px-card py-3">
      <h1 className="text-lg font-semibold">{title}</h1>
      <div className="flex items-center gap-3">
        <Badge tone="info">Admin</Badge>
        <span className="text-sm text-text-secondary">admin@demo.local</span>
      </div>
    </header>
  );
}

頂列用 Record<string, string> 把 pathname 對應到頁面標題,找不到時用前三段當 fallback。這種「以資料驅動 UI」的寫法在後台非常常見:路由變了只改 map,不用動 JSX。實際部署時 admin@demo.local 會從 Day 38 的 AuthProvider 讀,今天先寫死。

Admin 首頁:今日預約總覽

後台首頁的目標是「打開就看見今天最重要的三件事」:今日有幾筆預約、有幾筆待確認、最近一筆新預約是什麼。我們用三張統計卡 + 一個「今日預約清單」組成:

// app/admin/page.tsx
import { Card, CardHeader, CardTitle, CardBody } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { fetchAllBookings } from "@/lib/api/bookings";
import { API_MODE } from "@/lib/api-mode";
import { formatTime } from "@/lib/format";

export default async function AdminOverviewPage() {
  // 後台讀「所有預約」,不是「我的預約」,所以這裡走 admin 版本的 fetch
  const bookings = await fetchAllBookings();
  const today = new Date().toISOString().slice(0, 10);
  const todayBookings = bookings.filter((b) => b.startAt.startsWith(today));
  const pending = todayBookings.filter((b) => b.status === "pending").length;
  const confirmed = todayBookings.filter((b) => b.status === "confirmed").length;
  const recent = [...bookings].sort((a, b) => b.startAt.localeCompare(a.startAt)).slice(0, 5);

  return (
    <div className="flex flex-col gap-section">
      <section className="grid grid-cols-3 gap-card">
        <StatCard label="今日預約總數" value={todayBookings.length} />
        <StatCard label="待確認" value={pending} tone="warning" />
        <StatCard label="已確認" value={confirmed} tone="success" />
      </section>

      <Card>
        <CardHeader>
          <CardTitle>最近預約</CardTitle>
          <Badge tone={API_MODE === "mock" ? "warning" : "success"}>
            {API_MODE === "mock" ? "Mock 資料" : "Live"}
          </Badge>
        </CardHeader>
        <CardBody>
          <table className="w-full text-sm">
            <thead>
              <tr className="text-left text-text-muted">
                <th className="py-2">時間</th>
                <th>客戶</th>
                <th>服務</th>
                <th>狀態</th>
              </tr>
            </thead>
            <tbody>
              {recent.map((b) => (
                <tr key={b.id} className="border-t border-surface-border">
                  <td className="py-2">{formatTime(b.startAt)}</td>
                  <td>{b.customerName}</td>
                  <td>{b.serviceName}</td>
                  <td>{<StatusBadge status={b.status} />}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </CardBody>
      </Card>
    </div>
  );
}

function StatCard({ label, value, tone }: { label: string; value: number; tone?: "success" | "warning" }) {
  return (
    <Card>
      <div className="text-sm text-text-secondary">{label}</div>
      <div className={`mt-2 text-3xl font-semibold ${tone === "warning" ? "text-warning-500" : tone === "success" ? "text-success-500" : "text-text-primary"}`}>
        {value}
      </div>
    </Card>
  );
}

function StatusBadge({ status }: { status: "pending" | "confirmed" | "cancelled" | "completed" }) {
  const map = {
    pending: { tone: "warning", label: "待確認" },
    confirmed: { tone: "success", label: "已確認" },
    cancelled: { tone: "danger", label: "已取消" },
    completed: { tone: "info", label: "已完成" },
  } as const;
  return <Badge tone={map[status].tone}>{map[status].label}</Badge>;
}

這個首頁有四個值得討論的設計。第一,fetchAllBookings 是 admin 版本的函式,跟客戶端的 fetchMyBookings 是兩條 API(GET /bookings?scope=admin 與 GET /bookings/me),區分清楚避免洩漏別人的資料。第二,三張統計卡刻意不做成「四張 KPI」模板,這呼應 Day 31 講的「不堆 KPI 卡」原則。三張就夠:總數、待辦、已完成。第三,「最近預約」只取 5 筆,桌面端一頁剛好;行動端會在 Day 42 響應式檢查時改成 single column。第四,StatusBadge 把「狀態字串 → 顯示文字 → Badge tone」三個映射寫在同一處,後台其他頁面可以重用。

預約管理清單:篩選、搜尋、確認、取消

後台最常做的事是「處理預約」:有人打電話來改期、有人臨時取消、有人 walk-in 要塞進時段。這些動作都集中在 /admin/bookings 一頁。我們把篩選(狀態 + 日期區間)、搜尋(客戶姓名 / Email)、確認 / 取消動作都放在同一個 client component 裡,搭配 TanStack Query 的 useMutation 處理寫入:

// app/admin/bookings/page.tsx
import { BookingsAdminTable } from "@/components/admin/BookingsAdminTable";

export default function AdminBookingsPage() {
  return (
    <div className="flex flex-col gap-card">
      <h2 className="text-xl font-semibold">預約管理</h2>
      <BookingsAdminTable />
    </div>
  );
}
// components/admin/BookingsAdminTable.tsx
"use client";
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardBody } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Badge } from "@/components/ui/Badge";
import { queryKeys } from "@/lib/query-keys";
import { fetchAllBookings, confirmBooking, cancelBooking } from "@/lib/api/bookings";
import { formatDateTime } from "@/lib/format";
import type { Booking, BookingStatus } from "@/lib/mock/types";

export function BookingsAdminTable() {
  const qc = useQueryClient();
  const [q, setQ] = useState("");
  const [status, setStatus] = useState<BookingStatus | "all">("all");

  const { data: bookings = [], isLoading, isError } = useQuery({
    queryKey: queryKeys.bookings.adminAll(),
    queryFn: fetchAllBookings,
  });

  const filtered = useMemo(() => {
    return bookings.filter((b) => {
      if (status !== "all" && b.status !== status) return false;
      if (q && !`${b.customerName} ${b.reference}`.toLowerCase().includes(q.toLowerCase())) return false;
      return true;
    });
  }, [bookings, q, status]);

  const confirmMut = useMutation({
    mutationFn: (id: string) => confirmBooking(id),
    onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.bookings.adminAll() }),
  });
  const cancelMut = useMutation({
    mutationFn: (id: string) => cancelBooking(id),
    onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.bookings.adminAll() }),
  });

  return (
    <Card>
      <div className="mb-4 flex flex-wrap items-end gap-3">
        <Input label="搜尋" placeholder="客戶姓名 / 預約編號" value={q} onChange={(e) => setQ(e.target.value)} className="w-64" />
        <label className="flex flex-col gap-1 text-sm font-medium">
          狀態
          <select className="h-10 rounded-button border border-surface-border bg-white px-3" value={status} onChange={(e) => setStatus(e.target.value as BookingStatus | "all")}>
            <option value="all">全部</option>
            <option value="pending">待確認</option>
            <option value="confirmed">已確認</option>
            <option value="cancelled">已取消</option>
            <option value="completed">已完成</option>
          </select>
        </label>
      </div>

      {isLoading ? <div className="py-8 text-center text-text-muted">載入中…</div> : null}
      {isError ? <div className="py-8 text-center text-danger-600">載入失敗,請稍後重試</div> : null}

      {!isLoading && !isError ? (
        <table className="w-full text-sm">
          <thead className="text-left text-text-muted">
            <tr>
              <th className="py-2">預約編號</th>
              <th>時間</th>
              <th>客戶</th>
              <th>服務</th>
              <th>狀態</th>
              <th className="text-right">操作</th>
            </tr>
          </thead>
          <tbody>
            {filtered.map((b) => (
              <BookingRow
                key={b.id}
                booking={b}
                onConfirm={() => confirmMut.mutate(b.id)}
                onCancel={() => cancelMut.mutate(b.id)}
                pending={confirmMut.isPending || cancelMut.isPending}
              />
            ))}
            {filtered.length === 0 ? (
              <tr><td colSpan={6} className="py-6 text-center text-text-muted">沒有符合條件的預約</td></tr>
            ) : null}
          </tbody>
        </table>
      ) : null}
    </Card>
  );
}

function BookingRow({ booking, onConfirm, onCancel, pending }: {
  booking: Booking;
  onConfirm: () => void;
  onCancel: () => void;
  pending: boolean;
}) {
  return (
    <tr className="border-t border-surface-border">
      <td className="py-2 font-mono text-xs text-text-secondary">{booking.reference}</td>
      <td>{formatDateTime(booking.startAt)}</td>
      <td>{booking.customerName}</td>
      <td>{booking.serviceName}</td>
      <td>{<StatusBadge status={booking.status} />}</td>
      <td className="text-right">
        {booking.status === "pending" ? (
          <Button size="sm" onClick={onConfirm} loading={pending}>確認</Button>
        ) : null}
        {booking.status !== "cancelled" && booking.status !== "completed" ? (
          <Button size="sm" variant="ghost" onClick={onCancel} loading={pending} className="ml-2">取消</Button>
        ) : null}
      </td>
    </tr>
  );
}

這個元件是後台最重要的操作介面,幾個設計值得說明。第一,篩選與搜尋都用本地 useState、不重打 API,適合「資料量小、要即時反應」的場景;如果資料量達到上千筆,再改為 server-side 篩選(加 query param 給 fetchAllBookings)。第二,useMutation + onSuccess 的 invalidateQueries 模式是 TanStack Query 5.x 的標準寫法:mutation 成功後讓該 query refetch,UI 自動同步最新資料。第三,「確認」按鈕只在 pending 狀態出現、「取消」按鈕只在 pending 或 confirmed 狀態出現,避免「已經取消的預約又點取消」的冪等 bug。第四,BookingRow 拆成子元件後表格本體的可讀性大幅提升,每列的樣式邏輯集中在一處。

服務品項管理:CRUD 與停用

後台第二高頻操作是「管理服務品項」:新增服務、改價、調整時長、暫時停用某個服務。我們用一個「清單 + 編輯 Modal」的組合,把建立與編輯放在同一個 dialog:

// components/admin/ServicesAdminList.tsx
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardBody } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { ServiceEditDialog } from "@/components/admin/ServiceEditDialog";
import { queryKeys } from "@/lib/query-keys";
import { fetchServicesAdmin, toggleServiceActive } from "@/lib/api/services";
import { formatPrice } from "@/lib/format";

export function ServicesAdminList() {
  const qc = useQueryClient();
  const { data: services = [] } = useQuery({
    queryKey: queryKeys.services.adminList(),
    queryFn: fetchServicesAdmin,
  });
  const [editing, setEditing] = useState<{ id?: string } | null>(null);

  const toggle = useMutation({
    mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
      toggleServiceActive(id, isActive),
    onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.services.adminList() }),
  });

  return (
    <Card>
      <div className="mb-4 flex justify-end">
        <Button onClick={() => setEditing({})}>新增服務</Button>
      </div>
      <table className="w-full text-sm">
        <thead className="text-left text-text-muted">
          <tr>
            <th className="py-2">名稱</th>
            <th>時長</th>
            <th>緩衝</th>
            <th>價格</th>
            <th>狀態</th>
            <th className="text-right">操作</th>
          </tr>
        </thead>
        <tbody>
          {services.map((s) => (
            <tr key={s.id} className="border-t border-surface-border">
              <td className="py-2">
                <div className="font-medium">{s.name}</div>
                <div className="text-xs text-text-muted">{s.description}</div>
              </td>
              <td>{s.durationMinutes} 分</td>
              <td>{s.bufferMinutes} 分</td>
              <td>{formatPrice(s.priceCents)}</td>
              <td>
                <Badge tone={s.isActive ? "success" : "neutral"}>
                  {s.isActive ? "啟用" : "停用"}
                </Badge>
              </td>
              <td className="text-right">
                <Button size="sm" variant="secondary" onClick={() => setEditing({ id: s.id })}>編輯</Button>
                <Button
                  size="sm"
                  variant="ghost"
                  className="ml-2"
                  onClick={() => toggle.mutate({ id: s.id, isActive: !s.isActive })}
                >
                  {s.isActive ? "停用" : "啟用"}
                </Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {editing ? (
        <ServiceEditDialog
          serviceId={editing.id}
          onClose={() => {
            setEditing(null);
            qc.invalidateQueries({ queryKey: queryKeys.services.adminList() });
          }}
        />
      ) : null}
    </Card>
  );
}

「編輯對話框」是另一個常用元件。我們刻意把 Dialog 寫成「受控的 modal」:父層用 state 決定開關、子元件只負責內容與關閉 callback:

// components/admin/ServiceEditDialog.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { fetchServiceById, createService, updateService } from "@/lib/api/services";

export function ServiceEditDialog({
  serviceId,
  onClose,
}: {
  serviceId?: string;
  onClose: () => void;
}) {
  const titleId = useId();
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [duration, setDuration] = useState(60);
  const [priceCents, setPriceCents] = useState(0);

  useEffect(() => {
    if (!serviceId) return;
    fetchServiceById(serviceId).then((s) => {
      setName(s.name);
      setDescription(s.description);
      setDuration(s.durationMinutes);
      setPriceCents(s.priceCents);
    });
  }, [serviceId]);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const payload = { name, description, durationMinutes: duration, bufferMinutes: 15, priceCents };
    if (serviceId) await updateService(serviceId, payload);
    else await createService(payload);
    onClose();
  }

  return (
    <div role="dialog" aria-modal="true" aria-labelledby={titleId} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
      <form
        onSubmit={onSubmit}
        className="w-full max-w-md rounded-card bg-surface p-card shadow-md"
      >
        <h2 id={titleId} className="mb-4 text-lg font-semibold">
          {serviceId ? "編輯服務" : "新增服務"}
        </h2>
        <div className="flex flex-col gap-card">
          <Input label="名稱" value={name} onChange={(e) => setName(e.target.value)} required />
          <Input label="說明" value={description} onChange={(e) => setDescription(e.target.value)} />
          <Input label="時長(分)" type="number" min={15} step={15} value={duration} onChange={(e) => setDuration(Number(e.target.value))} required />
          <Input label="價格(分位/新台幣)" type="number" min={0} step={100} value={priceCents} onChange={(e) => setPriceCents(Number(e.target.value))} required />
        </div>
        <div className="mt-6 flex justify-end gap-2">
          <Button variant="ghost" type="button" onClick={onClose}>取消</Button>
          <Button type="submit">{serviceId ? "儲存" : "建立"}</Button>
        </div>
      </form>
    </div>
  );
}

這個 Dialog 有三個關鍵設計。第一,role="dialog" + aria-modal="true" + aria-labelledby 標記無障礙語意,螢幕閱讀器會唸出「對話框,標題為編輯服務」。第二,useId 產生 SSR 安全的 id 給標題,避免 server / client mismatch。第三,背景的半透明黑底(bg-black/40)加上 fixed inset-0 讓 Dialog 永遠覆蓋全螢幕;點背景或按 Esc 都可以關閉,這部分今天先做「按取消鍵」,Esc 與背景按一下關閉會在 Day 37 統一實作。

常見錯誤與踩雷

第一個踩雷是「後台也用客戶端的 layout」。客戶端的 layout 有頂部選單與 footer,後台不需要這兩者;如果硬把客戶端 layout 套到後台,會出現「後台頂部還出現『登出』按鈕,管理者按了之後誤以為操作失敗」的窘境。我們在 app/admin/ 底下獨立寫一個 layout.tsx,刻意不繼承客戶端的頂部選單。

第二個踩雷是「客戶端的 fetchMyBookings 被後台誤用」。客戶端的函式只回傳「自己」的預約,後台需要看到「所有客戶」的預約;如果後台直接呼叫客戶端函式,畫面上會顯示空清單,管理者會以為系統壞掉。我們刻意分開 fetchMyBookings(client scope)與 fetchAllBookings(admin scope),後端 API 也用 /bookings/me 與 /bookings 區隔,權限檢查更明確。

第三個踩雷是「狀態篩選寫死成 enum 陣列」。如果未來加一個新的 rescheduled 狀態但忘記更新前端,篩選器會看不到那個狀態的資料。我們的狀態定義放在 lib/mock/types.ts,並用 BookingStatus union type 編譯期強制提醒;新增狀態時 TypeScript 會把所有需要更新的地方列出來。

第四個踩雷是「編輯 Dialog 寫成 uncontrolled」。如果 Dialog 內部的表單欄位是 uncontrolled(直接讀 DOM),close 後再開新資料會殘留舊值。我們刻意把每個欄位用 useState 管理,並在 useEffect 內根據 serviceId 重新初始化。Day 37 還會加「未儲存提示」與「Esc 關閉」等細節。

效能與實務提醒

今天寫的後台首頁是 Server Component,直接在 server 端 fetch 資料;其他三個頁面是 Client Component,用 TanStack Query 管理 cache。混用是 Next.js 15 的標準寫法:能 SSR 就 SSR、需要互動就 client。對後台來說,「首頁 SSR + 操作頁 client」這個分工很合理,因為首頁更新頻率低、操作頁需要即時反應。SSR 的另一個好處是首屏 HTML 直接帶資料,使用者不會看到「先空白再載入」的閃爍;client 端則負責處理 mutation 之後的 invalidate 與 optimistic update。

另一個實務提醒是「表格的虛擬化」。今天資料量小(mock 只有 2 筆),整張表直接渲染沒問題;當資料量達到 100 筆以上時,請用 @tanstack/react-virtual 把表格虛擬化(Day 39 效能篇章會展開)。同樣的,今天的搜尋與篩選都在 client 端做,未來資料量達到上千筆時改為 server-side(把 q 與 status 加到 query params)。這個「小資料走 client、大資料走 server」的判斷原則,是後台效能優化的第一條守則。

最後是「後台資訊密度」。我們刻意把每張卡的 padding 縮小、把字級降到 text-sm、把表格行距縮短。原因是管理者每天看 8 小時的後台,太寬鬆的版面會拖慢瀏覽節奏;緊湊一點的設計反而更友善。但這條原則只適用於「專業工作者每天看」的後台,行銷頁或客戶端介面就應該用舒服的 padding 與較大的字級。順道提醒:後台的 hover、focus、active 三態都應該有明確視覺回饋(例如背景色變深 5%),這對「每天用 8 小時」的後台尤其重要,使用者需要隨時知道「我按了什麼、現在該按什麼」。

小結

今天把貫穿專案的後台介面一次做完。我們建立了 admin layout(側欄 + 頂列 + role guard)、實作了「今日預約總覽」首頁、寫出可篩選可搜尋的「預約管理清單」、加上 CRUD + 停用的「服務品項管理」。後台共用 5 個 admin 元件、3 個頁面、2 個對話框,整體約 700 行 React 程式碼。今天所有資料都是 mock,Day 36 串 FastAPI 時只改 lib/api/* 的實作,元件程式碼不需要動。明天 Day 36 會把這套後台介面從 mock 切到真實 API:寫一個 snake_case → camelCase 的轉換層、把 fetch 包成統一的 API client、處理 FastAPI 的錯誤格式。預期明天結束時,整個系統就可以打到 Web 系列的 FastAPI 後端。

結語

後台是 side project 與企業系統差別最大的地方:客戶端是「把訪客變成客戶」、後台是「讓工作者完成工作」。今天的 admin layout、預約清單、服務管理三個頁面,已經能覆蓋一個小型服務業後台的八成日常操作;剩下兩成(報表、通知、權限管理)會在 Day 45 之後的延伸路線再談。明天 Day 36 我們會把今天所有 fetch* 函式從「讀 mock 檔」換成「打 FastAPI」,並寫一個 snake_case 轉 camelCase 的轉換層、處理錯誤格式。預期會用到 Day 14 的資料取得與快取、Day 37 的錯誤處理會在串 API 過程中被提前用到一點。

延伸資源

  • Next.js 15 巢狀 layout 與 route group:https://nextjs.org/docs/app/building-your-application/routing/layouts-and-templates
  • TanStack Query 5.x useMutation + invalidateQueries 標準寫法:https://tanstack.com/query/latest/docs/framework/react/guides/mutations
  • Web 系列 Day 35 的預約系統資料模型:https://blog.hao-code.com/2025/07/web-day-35.html
  • Web 系列 Day 39 的 HTMX 後台(另一條路線):https://blog.hao-code.com/2025/08/web-day-39.html
  • React 19 useId 與無障礙 Dialog 模式:https://react.dev/reference/react/useId

留言

這個網誌中的熱門文章

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 建構深度學習模型。 開發者與研究人員 :想更深入了...