FE Day 31 專案定義:預約系統的前端(設計與路由)
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第三十一天,也是「貫穿專案篇」的第一天。從今天起,連續 14 天(Day 31-44)我們要打造一個完整的「預約管理系統」前端,串接 Web 系列 Day 35-44 完成的 FastAPI 後端。今天不寫任何元件,只做最關鍵的一件事:把整個專案的「設計與路由」講清楚,把後續 13 天的所有決議都先攤出來。今天結束之前,你會拿到一張路由地圖、一份頁面盤點表、一組 API 端點對齊清單,以及一份 Day 32-44 都會沿用的命名與目錄慣例。
引言
很多 side project 失敗不是因為寫得不好,而是因為「寫到一半才發現當初沒想清楚」。今天的目的是把專案的「介面層」與「資料流」先畫清楚,後續每一天只負責實作其中一個區塊,不會再回頭做架構決策。這種「先設計、再實作」的節奏,是專業團隊與新手最大的差別:前者把爭議在白板階段解決,後者在寫程式時才發現「咦,這個頁面要叫 /admin 還是 /dashboard」。
貫穿專案的預約管理系統定位為「小型服務業的線上預約平台」,情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範。系統有兩種角色:服務提供者(admin)與客戶(customer)。前端要做的事包含「讓客戶可以看服務、看時段、建立預約、收到通知」,以及「讓 admin 可以管理服務品項、調整可預約時段、確認或取消預約、看今日行程」。為了讓沒有 FastAPI 後端的讀者也能跑,今天也會定義「內建 mock 資料」的離線模式,後續章節所有範例預設走 mock,只有在 Day 36 才把真實 API 串起來。
今天的目標有四:第一,定義產品範圍與兩種角色能做什麼;第二,畫出 Next.js App Router 的路由地圖;第三,對齊 Web 系列 Day 35-44 的 API 端點清單;第四,建立 Day 32-44 都會沿用的共用設定(命名、目錄、狀態管理)。讀完之後,你會對「這個專案最終長什麼樣」有一張具體的圖,未來 13 天每一天的實作都會呼應這張圖。
產品範圍與兩種角色
預約管理系統要解決的核心問題是「讓服務提供者不用接電話就能管理時段,讓客戶不用傳訊息就能預約」。為了讓這個系統在小型團隊規模下可完成,我們刻意把範圍縮到三個核心場景:服務展示、預約建立、後台管理。其他像金流、訊息推播、會員制度、評論評分都先不做。
兩種角色的具體權限分得很清楚:admin 可以管理服務品項(新增、編輯、停用)、設定可預約時段、看到所有客戶的預約、確認或取消預約;customer 可以看服務品項、看可預約時段、建立預約、看自己的歷史。一個帳號同一時間只能有一種角色,但 admin 可以把自己降級成 customer 來測試對方視角,這個切換會在 Day 38 登入流程實作。
為了避免「某天突然發現需要多一個角色」的災難,我們現在就把未來可能擴充的角色寫進 enum 預留位:UserRole = "admin" | "customer" | "staff",其中 staff 是 Day 44 之後可能新增的「只能看預約不能改服務」的客服角色。今天先把型別定義好,業務邏輯只實作 admin 與 customer 兩種。
路由地圖:URL 結構與檔案對應
Next.js App Router 的精神是「資料夾即 URL、檔名即特殊用途」。我們把所有路由畫成一張表,後續每一天會照這張表建立對應的 app/ 目錄結構。這是整個專案篇最重要的一張圖,建議複製到你的記事本:
| URL | 檔案 | 角色 | 說明 |
|---|---|---|---|
/ |
app/page.tsx |
公開 | 首頁,介紹系統並導引登入或開始預約 |
/services |
app/services/page.tsx |
公開 | 服務清單,所有人可瀏覽 |
/services/[id] |
app/services/[id]/page.tsx |
公開 | 單一服務的詳細頁,含可預約時段 |
/bookings/new?serviceId=... |
app/bookings/new/page.tsx |
customer | 建立預約的表單頁 |
/bookings |
app/bookings/page.tsx |
customer | 客戶的預約歷史與狀態 |
/login |
app/(auth)/login/page.tsx |
公開 | 登入頁 |
/register |
app/(auth)/register/page.tsx |
公開 | 註冊頁 |
/admin |
app/admin/page.tsx |
admin | 後台首頁,今日預約總覽 |
/admin/bookings |
app/admin/bookings/page.tsx |
admin | 所有預約的管理清單 |
/admin/services |
app/admin/services/page.tsx |
admin | 服務品項管理(新增、編輯、停用) |
/admin/services/[id]/slots |
app/admin/services/[id]/slots/page.tsx |
admin | 設定該服務的可預約時段 |
這張表有兩個關鍵設計。第一,把「公開」、「customer」、「admin」三種可見性直接寫在表格裡,後面寫權限檢查時只要對應欄位即可,不會漏掉。第二,用 route group (auth) 把登入與註冊包起來,未來這兩頁共用同一個 layout(不顯示頂部選單),而且 URL 仍然是 /login、/register 而不是 /auth/login,對 SEO 與使用者友善。
巢狀結構的部分,/admin 底下有自己的 layout.tsx,負責驗證 admin 身份、套上後台專用側欄。/admin/services/[id]/slots 是動態路由加巢狀目錄,動態參數 id 是服務 UUID。未來如果要加 /admin/services/[id]/blackouts(特殊休館日),照同樣的目錄慣例即可,不用改路由表。
頁面盤點與資訊密度
設計不能只看 URL,也要看每個頁面要呈現什麼。我們把每個頁面的「核心資訊」與「主要操作」列成盤點表,作為 Day 32 之後實作的依據:
| 頁面 | 主要資訊 | 主要操作 |
|---|---|---|
首頁 / |
服務介紹、登入與註冊入口 | 瀏覽服務、開始預約 |
服務清單 /services |
所有啟用中的服務卡片(名稱、時長、價格) | 點進單一服務 |
單一服務 /services/[id] |
服務描述、可預約時段(未來 7 天) | 選擇時段開始預約 |
建立預約 /bookings/new |
選定的服務、時段、備註欄位 | 確認建立預約 |
我的預約 /bookings |
預約清單(日期、狀態、服務) | 取消預約、查看詳情 |
後台首頁 /admin |
今日預約總數、未確認預約數、最近七天流量 | 進入各管理頁 |
後台預約管理 /admin/bookings |
所有客戶預約(篩選、搜尋) | 確認、取消、改期 |
後台服務管理 /admin/services |
所有服務品項(含停用的) | 新增、編輯、停用 |
後台時段設定 /admin/services/[id]/slots |
該服務的週期性時段 | 新增、刪除時段模板 |
這張表刻意寫得很簡略:主要資訊只列三項、主要操作只列三項。原因是「寫得越具體、未來越難改」。今天先決定大方向,未來實作時如果發現某個頁面需要更多欄位,就地加上即可;但如果今天把欄位寫得太死,明天就會陷入「為什麼這頁有這個欄位、那頁沒有」的細節辯論。把粒度控在「足夠指導實作、不至於綁死調整空間」,是介面設計的第一守則。
API 端點對齊:與 Web 系列的契約
Web 系列 Day 35-44 已經把後端 API 做完。我們要做的不是「自己決定端點格式」,而是「照著 Web 系列的契約呼叫」。下表列出前端會用到的所有端點,以及對應的 Day 來源(後續章節也會反覆引用這張表):
| 方法 | 端點 | 用途 | Web 系列出處 |
|---|---|---|---|
| POST | /auth/register |
註冊新帳號(customer) | Day 36 |
| POST | /auth/login |
登入並取得 token | Day 36 |
| POST | /auth/refresh |
用 refresh token 換新 token | Day 36 |
| GET | /services |
列出啟用中的服務 | Day 36 |
| POST | /services |
新增服務(admin) | Day 36 |
| PATCH | /services/{id} |
編輯服務(admin) | Day 36 |
| DELETE | /services/{id} |
停用服務(admin) | Day 36 |
| GET | /services/{id}/slots?from=...&to=... |
列出可預約時段 | Day 37 |
| GET | /bookings |
客戶自己的預約 | Day 37 |
| POST | /bookings |
建立預約 | Day 37 |
| POST | /bookings/{id}/cancel |
取消預約 | Day 37 |
這張表的關鍵是「前端不會自創端點」。如果某個畫面需要後端資料而端點表裡沒有,那就是要先回頭補 Web 系列的端點、不能前端自己掰一個。我們刻意把「資料來源於哪一天」標在表格最後一欄,這樣之後串接時可以照著 Web 系列的章節順序讀,邊讀邊對應今天寫的前端程式碼。
離線模式:沒有後端時的 mock 資料
為了讓沒有跑 FastAPI 的讀者也能跟著實作,今天定義的內建 mock 資料長這樣:
// lib/mock/types.ts
// 與 Web 系列 Day 35 的 SQLModel 對齊的純型別定義,方便前端、後端共用。
export type UserRole = "admin" | "customer";
export type Service = {
id: string;
name: string;
description: string;
durationMinutes: number;
bufferMinutes: number;
priceCents: number;
isActive: boolean;
};
export type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
export type Booking = {
id: string;
reference: string;
customerId: string;
customerName: string;
serviceId: string;
serviceName: string;
startAt: string;
endAt: string;
status: BookingStatus;
notes: string;
};
export type Session = {
userId: string;
email: string;
displayName: string;
role: UserRole;
accessToken: string;
refreshToken: string;
};
// lib/mock/data.ts
// 預設提供的虛構資料,全部都是假的,僅供示範。
import type { Booking, Service } from "./types";
export const mockServices: Service[] = [
{
id: "svc-001",
name: "個人攝影 60 分鐘",
description: "棚拍 + 修圖 5 張",
durationMinutes: 60,
bufferMinutes: 15,
priceCents: 450000,
isActive: true,
},
{
id: "svc-002",
name: "一對一健身 90 分鐘",
description: "器材指導與動作調整",
durationMinutes: 90,
bufferMinutes: 30,
priceCents: 800000,
isActive: true,
},
{
id: "svc-003",
name: "語言家教 30 分鐘",
description: "線上一對一",
durationMinutes: 30,
bufferMinutes: 5,
priceCents: 300000,
isActive: true,
},
];
export const mockBookings: Booking[] = [
{
id: "bk-0001",
reference: "BS-20260410-0001",
customerId: "u-001",
customerName: "王小明",
serviceId: "svc-001",
serviceName: "個人攝影 60 分鐘",
startAt: "2026-04-10T14:00:00+08:00",
endAt: "2026-04-10T15:00:00+08:00",
status: "confirmed",
notes: "請準備深色背景",
},
{
id: "bk-0002",
reference: "BS-20260411-0002",
customerId: "u-002",
customerName: "李小華",
serviceId: "svc-002",
serviceName: "一對一健身 90 分鐘",
startAt: "2026-04-11T16:00:00+08:00",
endAt: "2026-04-11T17:30:00+08:00",
status: "pending",
notes: "",
},
];
這份型別定義刻意與 Web 系列 Day 35 的 SQLModel 欄位一一對應,但用 camelCase(前端慣例)而非 snake_case(後端慣例)。Day 36 串接 FastAPI 時會寫一個轉換層把 snake_case 轉成 camelCase,讓 mock 與真實 API 的資料形狀一致,元件程式碼不會因為切換模式而壞掉。所有示範資料都是虛構的:服務名稱、價格、客戶姓名都不是真實的,可以放心放在前端範例裡。
mock 模式切換走環境變數與單一切換點:
// lib/api-mode.ts
// 依環境變數決定走 mock 還是 live;整個專案只允許從這裡讀取模式。
export type ApiMode = "mock" | "live";
export function getApiMode(): ApiMode {
const raw = process.env.NEXT_PUBLIC_API_MODE ?? "mock";
return raw === "live" ? "live" : "mock";
}
// 預設為 mock,開發期不需要後端也能跑;部署到正式環境時改 NEXT_PUBLIC_API_MODE=live。
export const API_MODE = getApiMode();
// lib/api/services.ts
// 同一個 useServices(),背後依 API_MODE 決定走 mock 還是 live API。
import { API_MODE } from "@/lib/api-mode";
import { mockServices } from "@/lib/mock/data";
import type { Service } from "@/lib/mock/types";
export async function fetchServices(): Promise<Service[]> {
if (API_MODE === "mock") {
// 模擬網路延遲,方便在 UI 上驗證載入狀態
await new Promise((resolve) => setTimeout(resolve, 150));
return mockServices.filter((s) => s.isActive);
}
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/services`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`fetchServices failed: ${res.status}`);
return res.json();
}
把模式切換集中在 API_MODE 常數,所有資料層函式(如 fetchServices)共用同一個分支判斷。日後新增 fetchBookings、createBooking 全部依循這個寫法,不會出現「某個函式忘了判斷模式」的 bug。這個抽象也是 Day 36 串接 FastAPI 時最重要的入口。
命名與目錄慣例
為了避免 Day 32 之後每一天都為「檔案放哪」爭論,我們現在把目錄與命名定下來。這份慣例從今天起每天都會沿用:
booking-frontend/
├── app/ # Next.js App Router 入口
│ ├── (auth)/ # 登入、註冊共用 layout
│ ├── admin/ # 後台區,套用 admin layout
│ ├── bookings/ # 客戶預約相關
│ ├── services/ # 服務瀏覽
│ └── layout.tsx # 根 layout
├── components/ # 共用元件
│ ├── ui/ # 按鈕、輸入框、卡片等基礎元件
│ ├── booking/ # 預約專用元件
│ └── admin/ # 後台專用元件
├── lib/ # 共用工具
│ ├── api-mode.ts # mock/live 切換
│ ├── api/ # FastAPI 呼叫層
│ ├── mock/ # 離線模式資料
│ ├── monitor.ts # Day 30 的監控抽象層
│ └── format.ts # 日期、貨幣格式化
├── public/ # 靜態檔案
├── styles/ # 全域 CSS
├── tests/ # 單元與元件測試
└── types/ # 全域型別定義
這份目錄有三個關鍵原則。第一,app/ 只放路由檔,業務邏輯放 components/ 與 lib/,避免「頁面檔越長越難讀」。第二,components/ 用子資料夾分類:ui/ 是通用元件、booking/ 與 admin/ 是功能專屬元件,這樣搜尋時一眼就知道去哪裡找。第三,lib/ 是「非元件、非路由」的所有東西,包括 API 切換層、API 呼叫、mock 資料、工具函式、Day 30 的監控層。這條規則是 Day 16 的延伸,把「邏輯跟畫面分離」這條原則貫穿到整個專案。
命名部分:元件檔名用 PascalCase(BookingCard.tsx)、工具函式檔名用 camelCase(formatDate.ts)、常數與設定檔用 kebab-case(api-mode.ts)。TypeScript 型別檔統一放 types/ 或就近放元件旁邊(小範圍)。
狀態管理策略
預約管理系統的狀態大致分四類:伺服器狀態(服務清單、預約清單)、UI 狀態(表單輸入、modal 開關)、使用者狀態(目前登入者、token)、全域設定(主題、語言)。每類用不同的工具,避免「什麼都丟 Redux」或「什麼都用 useState」:
// lib/state-strategy.ts
// 預約系統的狀態分類與對應工具(2026 年 3 月主流)
export type StateCategory =
| { kind: "server"; tool: "TanStack Query 5.x"; example: "useServices()" }
| { kind: "ui"; tool: "useState / useReducer"; example: "表單欄位、modal 開關" }
| { kind: "user"; tool: "React Context + httpOnly cookie"; example: "AuthProvider、currentUser" }
| { kind: "global-config"; tool: "Server Component props 或 env"; example: "API base URL、主題" };
// 對應的套件選擇:
// - 伺服器狀態:TanStack Query 5.x(Day 14 介紹)
// - UI 狀態:原生 useState / useReducer(Day 6 介紹)
// - 使用者狀態:Context + httpOnly cookie(Day 38 實作)
// - 全域設定:環境變數 + Server Component 傳遞
這份決策表有兩個理由。第一,伺服器狀態不該放 Redux 或 Context,因為它的生命週期跟 request 綁定,TQ(TanStack Query)有 cache、staleTime、refetch 等專屬機制,比 Context 強太多。第二,使用者狀態刻意放 httpOnly cookie 而不是 localStorage,因為 XSS 攻擊可以偷 localStorage 但偷不到 httpOnly cookie。Day 38 登入流程會完整實作。
把上面的策略寫成實際的常數檔,方便後續章節直接 import:
// lib/query-keys.ts
// TanStack Query 5.x 的 query key 集中管理,避免散落各處導致快取失效。
export const queryKeys = {
services: {
all: ["services"] as const,
list: () => [...queryKeys.services.all, "list"] as const,
detail: (id: string) => [...queryKeys.services.all, "detail", id] as const,
},
bookings: {
mine: (customerId: string) => ["bookings", "mine", customerId] as const,
detail: (id: string) => ["bookings", "detail", id] as const,
},
} as const;
// 預設的 staleTime:30 秒內不重複打後端
export const DEFAULT_STALE_TIME_MS = 30_000;
設計系統的視覺語彙(簡述)
今天不實作設計系統,但把視覺方向講清楚,Day 32 會正式建立。我們的設計選擇是「專業、克制、暖色」:背景用接近純白的 bg-slate-50、卡片用 bg-white 加 shadow-sm、主色用 indigo-600、強調色用 amber-500(用於「開始預約」這類主要 CTA)、錯誤用 rose-600。字型走 Tailwind 4.x 的 font-sans 預設 stack。
我們刻意不堆四張 KPI 卡、不用漸層、不用過度圓角。對 side project 來說,「看起來像 2026 年的中型 SaaS」比「看起來像設計師個人作品」更重要,這也呼應 Day 1 講過的「讓你能專注在程式邏輯而不是設計美感」。
常見錯誤與踩雷
第一個踩雷是「先寫程式再想路由」。很多新手會在 Day 31 直接開始寫元件,結果寫到一半才發現「預約詳情頁要叫 /bookings/[id] 還是 /my/bookings/[id]」,最後整個目錄要重構。今天就是把這類決策先做掉,未來寫程式時只剩「照著實作」。
第二個踩雷是「路由表跟 API 表分開放在不同檔案」。如果前端有兩份獨立的「端點清單」,半年後一定有一份過期而另一份沒過期,最後除錯時懷疑「到底是前端還是後端錯了」。我們把端點表放在前端專案根目錄的 API-CONTRACT.md,引用 Web 系列的具體 Day,兩邊對齊同一份來源。
第三個踩雷是「mock 資料與真實 API 形狀不同」。如果 mock 的欄位是 start_at(snake_case)而真實 API 回 startAt(camelCase),前端切換模式時就要改一堆存取。今天定義的型別統一用 camelCase,並在 Day 36 寫一層 snake_case 轉 camelCase 的轉換器,確保切換無痛。
第四個踩雷是「離線模式忘記標示」。如果某個畫面用的是 mock 資料、但 UI 上看起來跟真實資料一模一樣,使用者會誤以為「我按按鈕真的建立了預約」。Day 32 會在開發模式下加一個浮動的「Mock Mode」標籤,讓開發者一眼知道目前是離線狀態,這對 demo 與測試都特別重要。
效能與實務提醒
今天設計的路由有 11 個頁面,預估打包後的 First Load JS 約 80 到 120 KB。對一個中型 SaaS 來說這個體積合理,不需特別處理;對追求極致 LCP 的頁面(例如首頁),可以考慮用 Next.js 的 Partial Prerendering(PPR)把首頁靜態化。今天先不展開,Day 25 會深入效能主題。
另一個實務提醒是「動態路由的數量」。我們的 /services/[id] 與 /admin/services/[id]/slots 是動態路由,id 是 UUID。Next.js 對動態路由的處理是「伺服器端在 request 時才決定渲染結果」,所以靜態預先生成(SSG)不適用。如果未來想要預先生成熱門服務頁,可以用 generateStaticParams 把已知的 id 列出來預先渲染,但對小型系統來說 SSR 已經夠快,不必多此一舉。
對無障礙(a11y)的要求,今天雖然不深入實作,但所有頁面的設計原則都遵循:互動元素都要有 aria-label、表單欄位都要有關聯的 label 元素、色彩對比至少 4.5:1。Day 28 的無障礙基礎與 Day 42 的無障礙總檢會再次檢視這些原則,今天先把方向定下來。
另一個實務提醒是「mock 載入時間的處理」。UI 在 mock 模式下應該跟真實 API 有一樣的載入體驗(skeleton、spinner、optimistic update),不能因為是本地資料就立刻顯示出來。下面是一個簡單的 mock 載入 helper,未來所有 mock 函式都會用它:
// lib/mock/with-delay.ts
// 讓 mock 函式模擬網路延遲,方便在 UI 上驗證載入狀態。
export async function withDelay(value, ms = 150) {
await new Promise((resolve) => setTimeout(resolve, ms));
return value;
}
// 用法:
// return withDelay(mockServices.filter((s) => s.isActive));
// 之後切到 live 模式時,只要把 withDelay 拿掉、改回 return res.json(),介面不變。
小結
今天把貫穿專案「預約管理系統」的設計與路由定義完成。我們畫了 11 個頁面的路由地圖、寫了 11 個 API 端點的對齊表、定下目錄與命名慣例、確認狀態管理策略,並預留 mock 模式讓沒有後端的讀者也能跟著實作。明天 Day 32 會在這份藍圖上實際建立 Next.js 專案,把路由、目錄、共用元件先建好。今天的決議會被後續 13 天反覆引用,務必把這篇當作整個專案篇的「目錄頁」留存。
結語
貫穿專案從今天正式開工。設計在前、實作在後,這是軟體工程最基本也最容易被忽略的紀律。明天 Day 32 我們會把這張藍圖落實成 Next.js 專案:建立目錄結構、裝好 Tailwind 4.x、寫好設計系統的基礎元件、串好 TanStack Query 的 Provider,並把所有東西放到一個可以 pnpm dev 跑得起來的最小骨架。今天寫的路由表會在明天變成實際的 app/ 資料夾,型別定義會變成 lib/mock/types.ts,目錄慣例會變成實際的檔案結構。Day 33 開始寫第一個真正的畫面(服務清單)。
延伸資源
- Next.js 15 App Router 路由約定官方文件:
https://nextjs.org/docs/app/building-your-application/routing - TanStack Query 5.x 官方介紹(伺服器狀態管理):
https://tanstack.com/query/latest - Web 系列 Day 35 的預約系統資料模型:
https://blog.hao-code.com/2025/07/web-day-35.html - Web 系列 Day 36 的認證 API 契約:
https://blog.hao-code.com/2025/07/web-day-36.html - Web 系列 Day 37 的預約流程 API:
https://blog.hao-code.com/2025/07/web-day-37.html
留言
張貼留言