路由架構指南
🗺️ 路由結構總覽
DramaLing 使用 Next.js 14 App Router,採用檔案系統路由。
app/
├── (auth)/ # 認證相關頁面群組
│ ├── login/
│ ├── signup/
│ └── forgot-password/
├── (dashboard)/ # 需要登入的頁面群組
│ ├── layout.tsx # 共用 Dashboard Layout
│ ├── page.tsx # Dashboard 首頁
│ ├── flashcards/
│ ├── decks/
│ ├── progress/
│ └── settings/
├── api/ # API 路由
├── layout.tsx # 根 Layout
├── page.tsx # 首頁
└── not-found.tsx # 404 頁面
📁 詳細路由規劃
公開路由(無需登入)
| 路徑 |
頁面 |
說明 |
/ |
首頁 |
Landing page,產品介紹 |
/features |
功能介紹 |
詳細功能說明 |
/pricing |
價格方案 |
訂閱方案(未來) |
/about |
關於我們 |
團隊介紹 |
/privacy |
隱私政策 |
法律文件 |
/terms |
使用條款 |
法律文件 |
認證路由
| 路徑 |
頁面 |
說明 |
/login |
登入 |
Email/密碼或 Google 登入 |
/signup |
註冊 |
新用戶註冊 |
/forgot-password |
忘記密碼 |
密碼重設請求 |
/reset-password |
重設密碼 |
實際重設密碼 |
/verify-email |
驗證信箱 |
Email 驗證頁面 |
受保護路由(需登入)
| 路徑 |
頁面 |
說明 |
/dashboard |
儀表板 |
用戶主頁,學習統計 |
/flashcards |
詞卡列表 |
所有詞卡總覽 |
/flashcards/new |
新增詞卡 |
AI 生成詞卡 |
/flashcards/[id] |
詞卡詳情 |
單一詞卡檢視/編輯 |
/decks |
卡組列表 |
詞卡分類管理 |
/decks/[id] |
卡組詳情 |
特定卡組的詞卡 |
/learn/[deckId] |
學習模式 |
間隔重複學習 |
/progress |
學習進度 |
統計與成就 |
/settings |
設定 |
個人資料與偏好 |
🔧 路由實作
1. 根 Layout
// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'
import { Toaster } from '@/components/ui/toaster'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'DramaLing - Learn English with Drama',
description: 'Master English vocabulary through your favorite TV shows',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
)
}
2. 路由群組 Layout
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}
3. 動態路由
// app/(dashboard)/flashcards/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getFlashcard } from '@/lib/api/flashcards'
interface PageProps {
params: {
id: string
}
}
export default async function FlashcardPage({ params }: PageProps) {
const flashcard = await getFlashcard(params.id)
if (!flashcard) {
notFound()
}
return (
<div>
<h1>{flashcard.word}</h1>
<p>{flashcard.translation}</p>
{/* 詞卡詳情 */}
</div>
)
}
// 生成靜態參數(可選)
export async function generateStaticParams() {
const flashcards = await getPopularFlashcards()
return flashcards.map((card) => ({
id: card.id,
}))
}
4. 平行路由(Modal)
// app/(dashboard)/@modal/(.)flashcards/[id]/page.tsx
import { Modal } from '@/components/ui/modal'
import FlashcardDetail from '@/components/FlashcardDetail'
export default function FlashcardModal({ params }: { params: { id: string } }) {
return (
<Modal>
<FlashcardDetail id={params.id} />
</Modal>
)
}
// app/(dashboard)/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
🛡️ 路由保護
1. Middleware 認證檢查
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
// 需要認證的路由
const protectedRoutes = [
'/dashboard',
'/flashcards',
'/decks',
'/learn',
'/progress',
'/settings',
]
// 已登入用戶不應訪問的路由
const authRoutes = ['/login', '/signup', '/forgot-password']
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request })
const { pathname } = request.nextUrl
// 檢查受保護路由
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
)
if (isProtectedRoute && !token) {
const url = new URL('/login', request.url)
url.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(url)
}
// 已登入用戶重定向
const isAuthRoute = authRoutes.some(route =>
pathname.startsWith(route)
)
if (isAuthRoute && token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
2. 客戶端路由守衛
// components/auth/AuthGuard.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useStore } from '@/store'
interface AuthGuardProps {
children: React.ReactNode
fallback?: React.ReactNode
}
export function AuthGuard({ children, fallback }: AuthGuardProps) {
const router = useRouter()
const { isAuthenticated, isLoading, checkAuth } = useStore()
useEffect(() => {
checkAuth()
}, [])
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login')
}
}, [isAuthenticated, isLoading, router])
if (isLoading) {
return fallback || <div>Loading...</div>
}
if (!isAuthenticated) {
return null
}
return <>{children}</>
}
🔀 路由導航
1. 程式化導航
// components/FlashcardList.tsx
'use client'
import { useRouter } from 'next/navigation'
export function FlashcardList() {
const router = useRouter()
const handleCardClick = (id: string) => {
router.push(`/flashcards/${id}`)
}
const handleCreateNew = () => {
router.push('/flashcards/new')
}
return (
// ...
)
}
2. Link 組件
// components/Navigation.tsx
import Link from 'next/link'
export function Navigation() {
return (
<nav>
<Link href="/dashboard" className="nav-link">
Dashboard
</Link>
<Link
href="/flashcards"
prefetch={true} // 預載入
className="nav-link"
>
Flashcards
</Link>
<Link
href={{
pathname: '/decks',
query: { sort: 'recent' },
}}
className="nav-link"
>
Decks
</Link>
</nav>
)
}
3. 動態麵包屑
// components/Breadcrumbs.tsx
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
export function Breadcrumbs() {
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
return (
<nav aria-label="Breadcrumb">
<ol className="flex space-x-2">
<li>
<Link href="/">Home</Link>
</li>
{segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join('/')}`
const isLast = index === segments.length - 1
return (
<li key={segment}>
<span>/</span>
{isLast ? (
<span className="font-semibold">
{segment.charAt(0).toUpperCase() + segment.slice(1)}
</span>
) : (
<Link href={href}>
{segment.charAt(0).toUpperCase() + segment.slice(1)}
</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}
📱 路由載入狀態
1. Loading UI
// app/(dashboard)/flashcards/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-48 bg-gray-200 rounded animate-pulse" />
))}
</div>
</div>
)
}
2. Error Boundary
// app/(dashboard)/flashcards/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Try again
</button>
</div>
)
}
🎯 路由最佳實踐
1. SEO 優化
// app/(dashboard)/flashcards/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Flashcards | DramaLing',
description: 'Manage your vocabulary flashcards',
openGraph: {
title: 'Flashcards | DramaLing',
description: 'Learn English vocabulary with AI-powered flashcards',
},
}
// 動態 metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const flashcard = await getFlashcard(params.id)
return {
title: `${flashcard.word} | DramaLing`,
description: flashcard.translation,
}
}
2. 路由預載入策略
// components/FlashcardGrid.tsx
import Link from 'next/link'
export function FlashcardGrid({ flashcards }: { flashcards: Flashcard[] }) {
return (
<div className="grid grid-cols-3 gap-4">
{flashcards.map((card, index) => (
<Link
key={card.id}
href={`/flashcards/${card.id}`}
// 只預載入前 6 個
prefetch={index < 6}
>
<FlashcardCard card={card} />
</Link>
))}
</div>
)
}
3. 查詢參數處理
// app/(dashboard)/flashcards/page.tsx
import { Suspense } from 'react'
interface PageProps {
searchParams: {
page?: string
sort?: 'recent' | 'alphabetical' | 'difficulty'
filter?: string
}
}
export default function FlashcardsPage({ searchParams }: PageProps) {
const page = Number(searchParams.page) || 1
const sort = searchParams.sort || 'recent'
const filter = searchParams.filter
return (
<Suspense fallback={<Loading />}>
<FlashcardList
page={page}
sort={sort}
filter={filter}
/>
</Suspense>
)
}
🚀 路由性能優化
1. 部分預渲染
// app/(dashboard)/flashcards/page.tsx
export const dynamic = 'force-dynamic' // 動態渲染
export const revalidate = 60 // ISR:60 秒重新驗證
2. 路由分割
// 使用動態導入減少初始載入
const FlashcardEditor = dynamic(
() => import('@/components/FlashcardEditor'),
{
loading: () => <p>Loading editor...</p>,
ssr: false, // 客戶端渲染
}
)
3. 路由快取策略
// app/api/flashcards/route.ts
export async function GET(request: Request) {
// 設定快取標頭
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=10, stale-while-revalidate=59',
},
})
}