跳到主要內容

FE Day 32 專案骨架與設計系統

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

留言

這個網誌中的熱門文章

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