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/*.tsfixture,不打真實 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
留言
張貼留言