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 → CourseDetailComponent2. 動態路由參數#
用 [參數名] 資料夾表示動態路由:
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/456export 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>
);
}導航方式#
1. Link 元件#
靜態連結:
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 Components 和 Client 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-in | app/sign-in/page.tsx | 登入頁 |
/courses | app/courses/page.tsx | 課程列表 |
/courses/123 | app/courses/[courseId]/page.tsx | 課程詳情 |
/courses/123/missions/456 | app/courses/[courseId]/missions/[missionId]/page.tsx | 任務觀看 |
/journeys/123 | app/journeys/[courseId]/page.tsx | 學習旅程 |
/leaderboard | app/leaderboard/page.tsx | 排行榜 |
/profile | app/profile/page.tsx | 個人資料 |
/admin/submissions | app/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=xxxQ: 怎麼做路由守衛(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 元件。