569 lines
12 KiB
Markdown
569 lines
12 KiB
Markdown
# 路由架構指南
|
||
|
||
## 🗺️ 路由結構總覽
|
||
|
||
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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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. 動態路由
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
// 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 認證檢查
|
||
|
||
```typescript
|
||
// 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. 客戶端路由守衛
|
||
|
||
```typescript
|
||
// 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. 程式化導航
|
||
|
||
```typescript
|
||
// 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 組件
|
||
|
||
```typescript
|
||
// 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. 動態麵包屑
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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 優化
|
||
|
||
```typescript
|
||
// 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. 路由預載入策略
|
||
|
||
```typescript
|
||
// 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. 查詢參數處理
|
||
|
||
```typescript
|
||
// 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. 部分預渲染
|
||
|
||
```typescript
|
||
// app/(dashboard)/flashcards/page.tsx
|
||
export const dynamic = 'force-dynamic' // 動態渲染
|
||
export const revalidate = 60 // ISR:60 秒重新驗證
|
||
```
|
||
|
||
### 2. 路由分割
|
||
|
||
```typescript
|
||
// 使用動態導入減少初始載入
|
||
const FlashcardEditor = dynamic(
|
||
() => import('@/components/FlashcardEditor'),
|
||
{
|
||
loading: () => <p>Loading editor...</p>,
|
||
ssr: false, // 客戶端渲染
|
||
}
|
||
)
|
||
```
|
||
|
||
### 3. 路由快取策略
|
||
|
||
```typescript
|
||
// 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',
|
||
},
|
||
})
|
||
}
|
||
``` |