dramaling-vocab-learning/docs/03_development/routing.md

569 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 路由架構指南
## 🗺️ 路由結構總覽
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 // ISR60 秒重新驗證
```
### 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',
},
})
}
```