FE Day 32 專案骨架與設計系統
執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」第二天。我們把昨天 Day 31 畫好的藍圖落實成可執行的 Next.js 15 專案:建立實際的目錄結構、裝好 Tailwind CSS 4.x、寫好設計系統的基礎元件(按鈕、輸入框、卡片、徽章)、設定 TanStack Query 的 Provider、把昨天定義的 mock 模式串起來,最後產出一個 pnpm dev 就能跑的最小骨架。今天結束之前你會拿到一個「所有後續章節都會沿用」的視覺基底,以及一個能切換 mock / live 模式的資料層入口。
引言
很多團隊在寫 side project 時會跳過「設計系統」這一步,直接在頁面檔裡塞一堆 Tailwind class。短期看起來很快,但三個月後回頭改樣式時會發現「按鈕的圓角三個地方不一致」、「錯誤訊息的顏色有四種版本」、「間距用 3、4、5、6、8 混雜」,整個 UI 變成視覺大雜燴。今天刻意花一整篇把設計系統的基礎元件寫好,後續 13 天的每個畫面都會直接 import,不會再出現「這個頁面的按鈕跟另一個頁面的按鈕長得不太一樣」的窘境;同時也讓視覺決策集中在一處,未來想做品牌升級或改色,只改設計 token 就能套用到整個專案。
今天的目標有五:第一,用 create-next-app 建立 Next.js 15 專案並調整預設設定;第二,設定 Tailwind 4.x 的設計語彙(顏色、字型、間距 token);第三,建立 components/ui/ 基礎元件(Button、Input、Card、Badge);第四,把 Day 31 的 mock 模式與 TanStack Query Provider 串進根 layout;第五,建立「Mock Mode」浮動標籤,提示開發者目前是離線狀態。今天全程用本機 CPU,沒有任何雲端服務依賴。
原理解念:設計系統、視覺一致性與元件邊界
設計系統的核心是「把視覺決策從散落各處的 className 集中成可重用的元件」。這條原則跟 Day 13 的「元件設計」一脈相承,只是把抽象層從「邏輯」延伸到「視覺」。當我們寫 <Button variant="primary">確認預約</Button> 而不是 <button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md">確認預約</button>,每個頁面拿到的按鈕都長一樣、hover 行為一致、focus ring 一致,未來要改品牌色只改一個檔案。
設計系統不是「做一個 component library 並 publish 到 npm」。對 side project 來說,「放在自己專案 components/ui/ 裡、依需求演化」就夠了。真正的元件庫像 Radix UI、shadcn/ui 是給「要反覆用在很多專案」的情境設計的;我們的預約系統只需要在內部 14 天裡重用,先求「介面統一、可維護」,不追求「可獨立發布」。
視覺一致性的具體落實是「設計 token」。顏色、字型大小、間距、圓角、陰影這些值都應該集中成常數,所有元件共用同一份來源。Tailwind 4.x 的 @theme 指令剛好做這件事:在 CSS 檔裡定義 --color-brand: ...、--spacing-card: ...,Tailwind 自動產生對應的 utility class,整個專案共用同一份語彙。今天會把品牌色、灰階、字型大小、間距 token 都建好。
完整實作:建立 Next.js 15 專案
第一步,用 create-next-app 建立 Next.js 15 App Router 專案。我們選 TypeScript、Tailwind、App Router、ESLint、不要 src/ 目錄(直接用根目錄的 app/)、啟用 import alias:
# 用 create-next-app 建立 Next.js 15 專案(2026 年 3 月主流版本)
pnpm create next-app@^15 booking-frontend \
--typescript \
--tailwind \
--eslint \
--app \
--no-src-dir \
--import-alias "@/*"
cd booking-frontend
# 裝其他會用到的套件
pnpm add @tanstack/react-query@^5.62 zod@^3.23 clsx@^2.1 tailwind-merge@^2.5
裝完之後,先把 package.json 的 name 改成 booking-frontend、private 設 true,並加上 type: "module" 確保 ESM 語法可用。Next.js 15 預設就支援 ESM,這步只是保險。
第二步,調整全域 CSS。Tailwind 4.x 改用 CSS 檔內的 @theme 設定,不再依賴 tailwind.config.ts 的 theme.extend。我們建立 styles/theme.css:
/* styles/theme.css
Tailwind 4.x 設計 token(2026 年 3 月主流用法) */
@import "tailwindcss";
@theme {
/* 品牌色:indigo 主色 + amber 強調 */
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
/* 強調色:用於 CTA 與強調狀態 */
--color-accent-500: #f59e0b;
--color-accent-600: #d97706;
/* 語意色:成功、警告、錯誤、資訊 */
--color-success-500: #10b981;
--color-warning-500: #f59e0b;
--color-danger-500: #f43f5e;
--color-danger-600: #e11d48;
--color-info-500: #3b82f6;
/* 灰階:對應 Tailwind 預設的 slate */
--color-surface: #ffffff;
--color-surface-muted: #f8fafc;
--color-surface-border: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
/* 間距 token */
--spacing-card: 1rem;
--spacing-section: 2rem;
/* 圓角 token */
--radius-card: 0.5rem;
--radius-button: 0.375rem;
}
把 styles/theme.css import 到 app/layout.tsx,所有 bg-brand-600、text-text-secondary、rounded-card 等 utility class 就會自動可用。Tailwind 4.x 的設計 token 用 CSS 變數,換主題(例如暗色模式)時只要覆寫這些變數即可,不用改元件程式碼。
基礎元件:Button、Input、Card、Badge
把設計 token 與基礎元件分開。元件只負責結構與 props,視覺走 Tailwind class,集中在一個地方。我們先做 clsx 與 tailwind-merge 的組合 helper,避免 className 合併時的優先序問題:
// lib/cn.ts
// 合併 className 的標準寫法,Tailwind 後寫的優先。
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
接著是 Button 元件。我們定義四種 variant(primary、secondary、ghost、danger)與三種 size(sm、md、lg),全部用 props 組合而不是寫死 className:
// components/ui/Button.tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
size?: Size;
loading?: boolean;
children: ReactNode;
};
const variantClasses: Record<Variant, string> = {
primary: "bg-brand-600 text-white hover:bg-brand-700 focus-visible:ring-brand-500",
secondary: "bg-white text-text-primary border border-surface-border hover:bg-surface-muted",
ghost: "bg-transparent text-text-primary hover:bg-surface-muted",
danger: "bg-danger-500 text-white hover:bg-danger-600 focus-visible:ring-danger-500",
};
const sizeClasses: Record<Size, string> = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
};
export function Button({
variant = "primary",
size = "md",
loading = false,
disabled,
className,
children,
...rest
}: ButtonProps) {
return (
<button
type="button"
disabled={disabled || loading}
aria-busy={loading}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-button font-medium",
"transition-colors disabled:cursor-not-allowed disabled:opacity-60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...rest}
>
{loading ? <span aria-hidden>載入中…</span> : children}
</button>
);
}
這個 Button 有四個關鍵設計。第一,用 Record<Variant, string> 把變體映射成 className 字串,未來新增 variant 只要加一行;不要用 if/else 串接,避免「忘了某個分支」。第二,loading 狀態自動 disable 按鈕並設 aria-busy,對螢幕閱讀器友善。第三,focus-visible:ring-2 給鍵盤使用者明確的焦點指示,但 focus-visible 不會影響滑鼠點選的外觀。第四,type="button" 預設給 button 而不是繼承 form 的 submit,避免「按鈕在表單外按一下意外送出」的 bug。
Input 與 Card 的設計理念類似:把 props 對應到 className、加上 label 與錯誤訊息的關聯。先看 Input:
// components/ui/Input.tsx
import { forwardRef, useId, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/cn";
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
hint?: string;
error?: string;
};
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, id, className, ...rest },
ref,
) {
const autoId = useId();
const inputId = id ?? autoId;
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
return (
<div className="flex flex-col gap-1">
{label ? (
<label htmlFor={inputId} className="text-sm font-medium text-text-primary">
{label}
</label>
) : null}
<input
ref={ref}
id={inputId}
aria-invalid={Boolean(error)}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
className={cn(
"h-10 rounded-button border bg-white px-3 text-base",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
error ? "border-danger-500" : "border-surface-border",
className,
)}
{...rest}
/>
{hint ? (
<p id={hintId} className="text-xs text-text-muted">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} role="alert" className="text-xs text-danger-600">
{error}
</p>
) : null}
</div>
);
});
Input 的關鍵設計是「label 與 input 用 htmlFor 與 id 明確綁定」。點 label 會 focus 到 input,螢幕閱讀器會唸出 label,這是無障礙的基本要求。錯誤訊息用 role="alert" 標記,讓輔助科技立刻朗讀出來。useId 是 React 19 內建的 SSR 安全 id 產生器,避免 server 與 client 渲染出來的 HTML id 不一致而導致 hydration mismatch。
Card 與 Badge 是另外兩個常用的容器元件:
// components/ui/Card.tsx
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/cn";
type CardProps = HTMLAttributes<HTMLDivElement> & { children: ReactNode };
export function Card({ className, children, ...rest }: CardProps) {
return (
<div
className={cn(
"rounded-card border border-surface-border bg-surface p-card shadow-sm",
className,
)}
{...rest}
>
{children}
</div>
);
}
export function CardHeader({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("mb-4 flex items-center justify-between", className)}>{children}</div>;
}
export function CardTitle({ children, className }: { children: ReactNode; className?: string }) {
return <h3 className={cn("text-lg font-semibold text-text-primary", className)}>{children}</h3>;
}
export function CardBody({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("text-text-secondary", className)}>{children}</div>;
}
// components/ui/Badge.tsx
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
type Tone = "neutral" | "success" | "warning" | "danger" | "info";
const toneClasses: Record<Tone, string> = {
neutral: "bg-surface-muted text-text-secondary",
success: "bg-success-500/10 text-success-500",
warning: "bg-warning-500/10 text-warning-500",
danger: "bg-danger-500/10 text-danger-600",
info: "bg-info-500/10 text-info-500",
};
export function Badge({ tone = "neutral", children }: { tone?: Tone; children: ReactNode }) {
return (
<span className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium", toneClasses[tone])}>
{children}
</span>
);
}
Card 把外觀(邊框、陰影、圓角、間距)抽到這層,使用端只關心內容;CardHeader 與 CardTitle 是子元件,提供一致的標題排版。Badge 的 tone 用語意色(success / warning / danger / info),對應預約狀態(pending、confirmed、cancelled)剛好可以套用,Day 33 之後會大量使用。
根 layout 與 TanStack Query Provider
接下來把所有東西串到根 app/layout.tsx:
// app/providers.tsx
"use client";
// 把需要 client 端的 providers 集中在這裡,方便 layout 保持 server component。
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { DEFAULT_STALE_TIME_MS } from "@/lib/query-keys";
export function Providers({ children }: { children: ReactNode }) {
// useState 確保 QueryClient 只建立一次
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: DEFAULT_STALE_TIME_MS,
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
// app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "@/styles/theme.css";
import { Providers } from "./providers";
import { MockModeBanner } from "@/components/dev/MockModeBanner";
import { ErrorBoundary } from "@/components/ErrorBoundary";
export const metadata: Metadata = {
title: "預約管理系統",
description: "小型服務業的線上預約平台",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-Hant">
<body className="bg-surface-muted text-text-primary antialiased">
<Providers>
<ErrorBoundary>
{children}
<MockModeBanner />
</ErrorBoundary>
</Providers>
</body>
</html>
);
}
這個 layout 有三個關鍵設計。Providers 是 client component,因為 TanStack Query 需要 React Context;layout 本身保持 server component,最大化靜態渲染的部分。QueryClient 用 useState 包起來,確保 React strict mode 雙重呼叫時也只建立一次。MockModeBanner 是 dev 模式才會出現的浮動標籤,提醒開發者目前是離線狀態,Day 31 提到的「離線模式忘記標示」這條踩雷就被這層補上。
Mock Mode Banner:離線模式提示
實作這個浮動標籤元件。它只在開發模式(NEXT_PUBLIC_API_MODE === "mock")且沒有被手動關閉時出現:
// components/dev/MockModeBanner.tsx
"use client";
import { useState } from "react";
import { API_MODE } from "@/lib/api-mode";
export function MockModeBanner() {
const [dismissed, setDismissed] = useState(false);
if (API_MODE !== "mock" || dismissed) return null;
return (
<div
role="status"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-card border border-warning-500/40 bg-warning-500/10 px-4 py-2 text-sm text-warning-500 shadow-md"
>
<span aria-hidden>※</span>
<span>Mock 模式:資料來自前端 mock,不會打到後端</span>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-warning-500 underline hover:no-underline"
>
關閉
</button>
</div>
);
}
這支元件有三個設計要點。第一,只在 API_MODE === "mock" 時渲染,部署到正式環境(live)就自動消失。第二,用 role="status" 讓輔助科技知道這是「狀態訊息」而非「主要內容」。第三,提供「關閉」按鈕(僅本次 session 有效),避免擋住頁面。實務上會把 dismissed 狀態存進 localStorage 讓重整後也保持關閉,今天先用 useState 簡化,Day 40 測試篇章會補上 localStorage 版本。
另一個常見需求是「在正式環境顯示 build 版本」。我們再加一個 BuildInfoBadge:
// components/dev/BuildInfoBadge.tsx
"use client";
import { useState } from "react";
export function BuildInfoBadge() {
const [open, setOpen] = useState(false);
const info = {
buildAt: process.env.NEXT_PUBLIC_BUILD_AT ?? "未知",
gitSha: process.env.NEXT_PUBLIC_GIT_SHA ?? "未知",
apiMode: process.env.NEXT_PUBLIC_API_MODE ?? "mock",
};
return (
<div className="fixed bottom-4 left-4 z-40 text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="rounded-full bg-surface px-2 py-1 text-text-muted shadow-sm hover:text-text-primary"
>
環境資訊
</button>
{open ? (
<div className="mt-2 w-64 rounded-card border border-surface-border bg-surface p-3 text-xs text-text-secondary shadow-md">
<div>建置時間:{info.buildAt}</div>
<div>版控識別:{info.gitSha}</div>
<div>API 模式:{info.apiMode}</div>
</div>
) : null}
</div>
);
}
這支元件對客服與工程師都很實用:當使用者回報「按按鈕沒反應」時,工程師可以請對方打開右下角的「環境資訊」看 build 版本與 API 模式,立刻判斷是 build 太舊還是 mock 環境。NEXT_PUBLIC_BUILD_AT 與 NEXT_PUBLIC_GIT_SHA 在 CI 階段注入(Vercel 會自動帶 deployment id,Day 41 部署篇章會示範)。
路由骨架:先建空的頁面佔位
最後把 Day 31 規劃的 11 個路由先建好佔位頁面。Day 33 起每天會把一個路由填實:
# 建立 Day 31 規劃的所有路由目錄與佔位頁面
mkdir -p "app/(auth)/login" "app/(auth)/register" \
"app/services/[id]" "app/bookings/new" "app/bookings" \
"app/admin/bookings" "app/admin/services/[id]/slots"
# 每個目錄放一個最小的 page.tsx,之後每天會填實
for path in \
"app/page.tsx" \
"app/services/page.tsx" \
"app/services/[id]/page.tsx" \
"app/bookings/page.tsx" \
"app/bookings/new/page.tsx" \
"app/(auth)/login/page.tsx" \
"app/(auth)/register/page.tsx" \
"app/admin/page.tsx" \
"app/admin/bookings/page.tsx" \
"app/admin/services/page.tsx" \
"app/admin/services/[id]/slots/page.tsx"; do
cat > "$path" <<'EOF'
export default function Page() {
return (
<main className="p-section">
<h1 className="text-2xl font-semibold">預約管理系統</h1>
<p className="mt-2 text-text-secondary">此頁面將於後續章節填實。</p>
</main>
);
}
EOF
done
這些佔位頁面只是讓 pnpm dev 跑得起來、URL 都有效。每個檔案只有幾行 JSX,後續每一天會把它們變成完整的畫面。今天最後一步是確認 pnpm dev 能啟動、首頁能看到「Mock Mode」標籤、所有 URL 都能打開。
佔位頁面有一個值得討論的細節:應該放什麼內容?我們刻意只放「頁面標題」而不是「完整骨架」,原因是佔位階段不該預測未來的內容結構。Day 33 開始填實 /services 時,會把整個版面重寫成正確的 layout;如果現在先放一堆佔位結構,未來 Day 33 的實作會受限於「這頁有沒有為 X 預留空間」。這條原則在 Day 13 的元件設計中也有呼應:「先求骨架清晰,再填內容」。
首頁雛形:把 Mock 模式展示出來
首頁是訪客進入系統的第一個畫面,今天雖然不寫完整的產品頁,但我們把 mock 模式的入口與登入按鈕放進去:
// app/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main className="mx-auto max-w-4xl p-section">
<section className="rounded-card bg-surface p-card shadow-sm">
<h1 className="text-3xl font-semibold">預約管理系統</h1>
<p className="mt-3 text-text-secondary">
小型服務業的線上預約平台。今天是 Day 32 雛形,所有資料來自前端 mock,不會打到後端。
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/services"
className="inline-flex h-10 items-center rounded-button bg-brand-600 px-4 text-white hover:bg-brand-700"
>
瀏覽服務
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center rounded-button border border-surface-border px-4 text-text-primary hover:bg-surface-muted"
>
登入
</Link>
<Link
href="/admin"
className="inline-flex h-10 items-center rounded-button border border-surface-border px-4 text-text-secondary hover:bg-surface-muted"
>
後台(管理員)
</Link>
</div>
</section>
</main>
);
}
這個首頁刻意把「瀏覽服務」、「登入」、「後台」三個入口放在一起。客戶走「瀏覽服務 → 建立預約」的路徑、admin 走「後台 → 管理」的路徑。兩條路徑從首頁就分流,未來不會出現「客戶誤闖後台」或「admin 找不到入口」的困擾。Day 34 實作「建立預約」流程時,會在「瀏覽服務」這條路徑上加 CTA。
首頁是 Server Component(沒有 "use client"),由 Next.js 在 request 時渲染。對 side project 來說 SSR 已經夠快;未來要做 SSG 預先生成(行銷頁常見),可以在這個檔案加 export const dynamic = "force-static"。今天不深入展開。
有一個實務上的小細節值得提一下:佔位頁面(route skeleton)雖然可以在 pnpm dev 跑起來,但開發期間很容易出現「點進 /admin/bookings 看到一片空白,誤以為程式沒寫好」的挫折感。為了降低這個摩擦,我們額外在佔位頁面加上一行「將於後續章節填實」的提示文字,讓開發者一眼知道「這個 URL 是規劃中的、不是 bug」。到了填實那一天,提示文字自然會被實際內容取代,不會留下多餘的痕跡。這是 side project 開發節奏裡很常見、但教科書很少提到的細節。
常見錯誤與踩雷
貫穿專案「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範)。從 Day 31 到今天 Day 32 我們把整個專案的「視覺地基」打下來,但每個團隊第一次把設計系統建起來時都會踩幾個相同的雷:QueryClient 漏包、Tailwind token 寫錯位置、id 採 random 造成 hydration mismatch、樣式 hardcode 在元件內。下面把這四條逐一拆開來看,並附上對應的修法,後續章節遇到相同症狀可以直接對照。
第一個踩雷是「把 QueryClient 直接宣告在模組層」。如果這樣寫:
// 不要這樣寫
const client = new QueryClient();
export function Providers({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
在 Next.js SSR 模式下,每個 request 都會共享同一個 QueryClient,造成「使用者 A 的 cache 被使用者 B 看到」的隱私漏洞。務必用 useState 包起來,確保每個 React tree 都有獨立 instance。
第二個踩雷是「Tailwind 4.x 的 @theme 寫在 tailwind.config.ts」。Tailwind 4 改用 CSS 內 @theme 設定,tailwind.config.ts 只剩 plugin 註冊用途。如果照 Tailwind 3 的習慣把 token 寫在 config 裡,會發現 utility class 完全沒被產生。
第三個踩雷是「forwardRef + Math.random() 做 id」。如果元件需要 SSR 安全的 id,應該用 React 19 的 useId 而不是 Math.random()。後者在 server 與 client 各產生一次 id,hydration 時對不上就會跳警告。今天的 Input 直接用 useId,確保 server 與 client 渲染出來的 HTML 完全一致。
第四個踩雷是「設計 token 散落在元件裡」。如果某個元件寫死 bg-[#4f46e5](Tailwind 的 arbitrary value),未來要改品牌色就要全文搜尋。把顏色寫進 @theme 後用 bg-brand-600 是唯一可維護的寫法。
效能與實務提醒
今天裝的 @tanstack/react-query bundle size 約 12 KB gzipped,加上其他套件(clsx、tailwind-merge、zod)總共約 18 KB。對 80–120 KB 的 First Load JS 預算來說,這些是合理成本。如果未來要做非常在意 bundle 的頁面,可以用 next/dynamic 把 MockModeBanner 改成 client-only dynamic import,讓它在第一次互動之後才載入;對一個只在開發模式出現的元件來說,這個延遲完全感受不到,但首頁的 First Load JS 可以再省 1–2 KB。
另一個實務提醒是「設計系統要先求夠用、再求完美」。今天做的 Button、Input、Card、Badge 四個元件已經能覆蓋 Day 33–37 將近 90% 的需求。剩下的 Modal、Toast、Skeleton 等元件等用到再開,不要先做「可能會用到」的元件——這是軟體工程最常見的過度設計。我們的判斷原則很簡單:寫到第三個畫面時還沒用到,就先不做;寫到第三個畫面已經重複自己 copy-paste,就立刻抽出來。
對沒有實際運行 FastAPI 的讀者,今天的 MockModeBanner 與 API_MODE="mock" 預設值會讓你直接看到完整的 mock 體驗。對已經部署 FastAPI 的讀者,把 .env.local 設成 NEXT_PUBLIC_API_MODE=live 與 NEXT_PUBLIC_API_BASE=http://localhost:8000 就能切到真實 API(前提是 Day 36 的資料層設計已完成)。無論哪一種情境,今天的視覺基底都不需要再改,這也是設計系統最大的價值:把「改介面」這件事的成本壓到最低。
最後一個細節:路徑別名(path alias)要記得在 tsconfig.json 與 Next.js 的 config 兩邊都設。我們用 @/* 對應到根目錄,這樣 import { Button } from "@/components/ui/Button" 就能運作。如果忘記設,TypeScript 編譯會爆、IDE 也無法跳轉。create-next-app 預設會建好 paths 設定,但團隊成員接手時常會漏看這個檔案,記得在 README 標註。另一個常見的小地雷是:VS Code 預設用「VS Code 內建 TypeScript」,與專案內裝的版本可能不同。解法是在 .vscode/settings.json 設 "typescript.tsdk": "node_modules/typescript/lib",讓編輯器讀專案版本,與 pnpm tsc 行為一致。
小結
今天把 Day 31 的藍圖落實成可執行的 Next.js 15 專案。我們裝好 Tailwind 4.x 與 TanStack Query 5.x、寫好 Button、Input、Card、Badge 四個基礎元件、建立 Providers 與 MockModeBanner、把 11 個路由的佔位頁面建好,整個專案可以 pnpm dev 啟動。今天收尾的時候,整個專案的程式碼量已經超過 400 行(含設計 token、四個基礎元件、三個 dev 元件、Providers、layout、首頁),對一個 side project 來說是不小的投資,但這些程式碼會被後續 12 天反覆引用,是整個專案篇的「視覺地基」。
結語
骨架與設計系統是 side project 最容易被跳過、但回頭看最值得投資的部分。今天花一整篇把基礎打穩,明天開始的每個畫面都能用 <Button>、<Card>、<Badge> 直接寫,視覺一致、邏輯清晰、重構成本低。明天 Day 33 開始實作「服務總覽頁」:串 TanStack Query、寫 loading 與 error state、設計篩選 UI,把客戶第一次進入系統時看到的畫面做出來。Day 34 接著做「建立預約」流程,Day 35 進入後台。
延伸資源
- Next.js 15 App Router 起步官方文件:
https://nextjs.org/docs/app/getting-started/installation - Tailwind CSS 4.x
@theme設定:https://tailwindcss.com/docs/theme - TanStack Query 5.x SSR 與
QueryClient初始化:https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults - clsx + tailwind-merge 的標準組合(className 合併):
https://github.com/dcastil/tailwind-merge - React 19
useId與 SSR 安全 id:https://react.dev/reference/react/useId
留言
張貼留言