Frontend 路由系統詳解#

Next.js 16 使用 App Router,以檔案系統結構定義路由。這與 Angular 的 app-routing.module.ts 概念完全不同。

檔案位置#

frontend/src/app/
├── page.tsx                    # / 首頁
├── layout.tsx                  # 根 Layout
├── not-found.tsx               # 404 頁面
├── sign-in/
│   └── page.tsx               # /sign-in 登入頁
├── courses/
│   ├── page.tsx               # /courses 課程列表
│   └── [courseId]/
│       ├── missions/
│       │   └── [missionId]/
│       │       └── page.tsx   # /courses/123/missions/456
│       └── orders/
│           └── page.tsx       # /courses/123/orders
├── journeys/
│   └── [courseId]/
│       ├── page.tsx           # /journeys/123
│       └── gyms/
│           └── page.tsx       # /journeys/123/gyms
├── gyms/
│   └── [gymId]/
│       ├── page.tsx           # /gyms/123
│       └── challenges/
│           └── [challengeId]/
│               └── page.tsx   # /gyms/123/challenges/456
├── leaderboard/
│   └── page.tsx               # /leaderboard
├── profile/
│   └── page.tsx               # /profile
└── admin/
    └── submissions/
        ├── page.tsx           # /admin/submissions
        └── [id]/
            └── grade/
                └── page.tsx   # /admin/submissions/123/grade

核心概念#

1. 檔案即路由#

Angular 做法:

// app-routing.module.ts
const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'sign-in', component: SignInComponent },
  { path: 'courses', component: CoursesComponent },
  { path: 'courses/:courseId', component: CourseDetailComponent },
];

Next.js App Router 做法:

app/
├── page.tsx              # / → HomeComponent
├── sign-in/page.tsx      # /sign-in → SignInComponent
├── courses/page.tsx      # /courses → CoursesComponent
└── courses/[courseId]/page.tsx  # /courses/:courseId → CourseDetailComponent

2. 動態路由參數#

[參數名] 資料夾表示動態路由:

app/courses/[courseId]/page.tsx
     └── /courses/123 → courseId = "123"
         /courses/456 → courseId = "456"

在元件中取得參數:

'use client';

import { useParams } from 'next/navigation';

export default function CourseDetailPage() {
  const params = useParams();
  const courseId = params.courseId as string;  // "123"

  // 使用 courseId 取得資料...
}

Angular 對照:

import { ActivatedRoute } from '@angular/router';

constructor(private route: ActivatedRoute) {}

ngOnInit() {
  const courseId = this.route.snapshot.paramMap.get('courseId');
}

3. 巢狀路由#

資料夾結構直接對應 URL 路徑:

app/courses/[courseId]/missions/[missionId]/page.tsx
     └── /courses/123/missions/456
export default function MissionPage() {
  const params = useParams();
  const courseId = params.courseId as string;    // "123"
  const missionId = params.missionId as string;  // "456"
}

頁面結構#

page.tsx - 頁面元件#

每個 page.tsx 就是一個頁面:

// app/sign-in/page.tsx
'use client';  // 客戶端元件(可使用 hooks)

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useUserStore } from '@/store/useUserStore';

export default function SignInPage() {
  const router = useRouter();
  const setUser = useUserStore((state) => state.setUser);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();

    // 呼叫登入 API
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });

    if (response.ok) {
      const data = await response.json();
      localStorage.setItem('token', data.token);
      setUser(data.user);
      router.push('/');  // 導向首頁
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">登入</button>
    </form>
  );
}

layout.tsx - 共用版面#

// app/layout.tsx
import type { Metadata } from 'next';
import { Providers } from '@/components/layout/Providers';
import './globals.css';

export const metadata: Metadata = {
  title: '水球軟體學院',
  description: '線上課程平台',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-TW">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

導航方式#

靜態連結:

import Link from 'next/link';

// 基本用法
<Link href="/courses">課程列表</Link>

// 動態路由
<Link href={`/courses/${course.id}`}>
  {course.name}
</Link>

Angular 對照:

<a routerLink="/courses">課程列表</a>
<a [routerLink]="['/courses', course.id]">{{ course.name }}</a>

2. useRouter Hook#

程式化導航:

'use client';

import { useRouter } from 'next/navigation';

export default function MyComponent() {
  const router = useRouter();

  const handleClick = () => {
    router.push('/courses');          // 導航
    router.replace('/courses');       // 導航(不留歷史記錄)
    router.back();                    // 返回上一頁
    router.refresh();                 // 重新整理頁面
  };
}

Angular 對照:

import { Router } from '@angular/router';

constructor(private router: Router) {}

navigateToCourses() {
  this.router.navigate(['/courses']);
}

‘use client’ 指令#

Next.js 區分 Server ComponentsClient Components

// Server Component(預設)
// 可以直接 async/await,不能使用 hooks
export default async function ServerPage() {
  const data = await fetch('...').then(r => r.json());
  return <div>{data.title}</div>;
}

// Client Component(加上 'use client')
// 可以使用 hooks,但不能直接 async
'use client';

import { useState, useEffect } from 'react';

export default function ClientPage() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('...').then(r => r.json()).then(setData);
  }, []);

  return <div>{data?.title}</div>;
}

什麼時候用 ‘use client’?

  • 使用 useState, useEffect 等 hooks
  • 使用瀏覽器 API(localStorage, window 等)
  • 需要互動(onClick, onChange 等)
  • 使用第三方客戶端套件

完整頁面範例#

任務觀看頁#

// app/courses/[courseId]/missions/[missionId]/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';

interface MissionDetail {
  id: number;
  title: string;
  type: string;
  videoUrl: string;
  expReward: number;
  userProgress: {
    progress: number;
    completed: boolean;
    rewardClaimed: boolean;
  } | null;
}

export default function MissionPage() {
  // 1. 取得路由參數
  const params = useParams();
  const router = useRouter();
  const courseId = params.courseId as string;
  const missionId = params.missionId as string;

  // 2. 狀態管理
  const [mission, setMission] = useState<MissionDetail | null>(null);
  const [loading, setLoading] = useState(true);

  // 3. 載入資料
  useEffect(() => {
    async function fetchMission() {
      try {
        const token = localStorage.getItem('token');
        const headers: HeadersInit = token
          ? { 'Authorization': `Bearer ${token}` }
          : {};

        const response = await fetch(`${API_URL}/api/missions/${missionId}`, {
          headers,
        });

        if (!response.ok) throw new Error('Failed to fetch');

        const data = await response.json();
        setMission(data);
      } catch (error) {
        console.error('Failed to fetch mission:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchMission();
  }, [missionId]);

  // 4. 領取獎勵
  async function handleClaimReward() {
    const token = localStorage.getItem('token');
    if (!token) {
      router.push('/sign-in');
      return;
    }

    const response = await fetch(`${API_URL}/api/missions/${missionId}/claim`, {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${token}` },
    });

    if (response.ok) {
      const result = await response.json();
      alert(`獲得 ${result.expGained} XP!`);
      // 重新載入資料
      setMission(prev => prev ? {
        ...prev,
        userProgress: { ...prev.userProgress!, rewardClaimed: true }
      } : null);
    }
  }

  // 5. 渲染
  if (loading) return <div>Loading...</div>;
  if (!mission) return <div>Mission not found</div>;

  return (
    <div className="flex min-h-screen bg-[#0d1117]">
      {/* 影片播放器 */}
      <main className="flex-1">
        <iframe
          src={`https://www.youtube.com/embed/${extractVideoId(mission.videoUrl)}`}
          className="w-full aspect-video"
        />

        {/* 任務資訊 */}
        <div className="p-6">
          <h1 className="text-2xl font-bold text-white">{mission.title}</h1>
          <p className="text-gray-400">經驗值: {mission.expReward} XP</p>

          {/* 進度條 */}
          <div className="mt-4">
            <div className="w-full bg-gray-700 rounded h-2">
              <div
                className="bg-yellow-500 h-2 rounded"
                style={{ width: `${mission.userProgress?.progress || 0}%` }}
              />
            </div>
          </div>

          {/* 領取獎勵按鈕 */}
          {mission.userProgress?.completed && !mission.userProgress?.rewardClaimed && (
            <button
              onClick={handleClaimReward}
              className="mt-4 bg-yellow-500 text-black px-6 py-2 rounded font-bold"
            >
              領取 {mission.expReward} XP
            </button>
          )}
        </div>
      </main>
    </div>
  );
}

function extractVideoId(url: string): string | null {
  const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/);
  return match ? match[1] : null;
}

路由對照表#

URL檔案位置說明
/app/page.tsx首頁
/sign-inapp/sign-in/page.tsx登入頁
/coursesapp/courses/page.tsx課程列表
/courses/123app/courses/[courseId]/page.tsx課程詳情
/courses/123/missions/456app/courses/[courseId]/missions/[missionId]/page.tsx任務觀看
/journeys/123app/journeys/[courseId]/page.tsx學習旅程
/leaderboardapp/leaderboard/page.tsx排行榜
/profileapp/profile/page.tsx個人資料
/admin/submissionsapp/admin/submissions/page.tsx管理後台

常見問題#

Q: 為什麼有些頁面要加 ‘use client’?#

因為這些頁面使用了:

  • React hooks (useState, useEffect, useRouter 等)
  • 瀏覽器 API (localStorage, window 等)
  • 事件處理 (onClick, onChange 等)

Q: 環境變數怎麼設定?#

// 前端可見的環境變數必須以 NEXT_PUBLIC_ 開頭
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';

設定在 .env.local

NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx

Q: 怎麼做路由守衛(Route Guard)?#

Next.js 沒有內建路由守衛,需要在元件中手動檢查:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';

export default function ProtectedPage() {
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) {
      router.push('/sign-in');
    }
  }, [router]);

  // 渲染頁面內容...
}

下一步#

理解路由後,接下來看 元件結構 了解如何組織 React 元件。