919 lines
24 KiB
Markdown
919 lines
24 KiB
Markdown
# LinguaForge 網頁版技術架構文件
|
||
|
||
## 1. 架構總覽
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
│ 瀏覽器端 │
|
||
│ ┌──────────────────────────────────────┐ │
|
||
│ │ Next.js App Router │ │
|
||
│ │ ┌────────────────────────────────┐ │ │
|
||
│ │ │ React Server Components │ │ │
|
||
│ │ └────────────────────────────────┘ │ │
|
||
│ │ ┌────────────────────────────────┐ │ │
|
||
│ │ │ Client Components (交互) │ │ │
|
||
│ │ └────────────────────────────────┘ │ │
|
||
│ └──────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────┘
|
||
↓
|
||
┌──────────────────────────────────────────────┐
|
||
│ Vercel Edge Network │
|
||
│ ┌──────────────────────────────────────┐ │
|
||
│ │ Edge Functions (API Routes) │ │
|
||
│ └──────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────┘
|
||
↓
|
||
┌──────────────────────────────────────────────┐
|
||
│ External Services │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ Supabase │ │ Gemini │ │ Vercel │ │
|
||
│ │ DB │ │ API │ │Analytics │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||
└──────────────────────────────────────────────┘
|
||
```
|
||
|
||
## 2. 技術棧詳細說明
|
||
|
||
### 2.1 前端框架選擇理由
|
||
|
||
| 技術 | 選擇 | 理由 |
|
||
|------|------|------|
|
||
| **框架** | Next.js 14 | App Router、RSC、內建優化 |
|
||
| **語言** | TypeScript | 類型安全、減少 Bug |
|
||
| **樣式** | Tailwind CSS | 快速開發、一致性 |
|
||
| **元件** | shadcn/ui | 免費、可客製、美觀 |
|
||
| **狀態** | Zustand | 簡單、輕量、TypeScript 友好 |
|
||
| **請求** | TanStack Query | 強大的快取、同步機制 |
|
||
|
||
### 2.2 後端服務選擇
|
||
|
||
| 服務 | 選擇 | 理由 |
|
||
|------|------|------|
|
||
| **資料庫** | Supabase | PostgreSQL、即時訂閱、免費額度充足 |
|
||
| **認證** | Supabase Auth | 整合度高、支援社群登入 |
|
||
| **API** | Next.js API Routes | 無需額外後端、型別共享 |
|
||
| **AI** | Gemini API | 免費額度、中文支援佳 |
|
||
| **儲存** | Supabase Storage | 整合方便、1GB 免費 |
|
||
| **部署** | Vercel | Next.js 原生支援、免費額度充足 |
|
||
|
||
## 3. 專案結構設計
|
||
|
||
```
|
||
linguaforge-web/
|
||
├── app/ # Next.js App Router
|
||
│ ├── (auth)/ # 認證群組
|
||
│ │ ├── login/
|
||
│ │ │ └── page.tsx
|
||
│ │ ├── register/
|
||
│ │ │ └── page.tsx
|
||
│ │ └── layout.tsx
|
||
│ ├── (dashboard)/ # 主應用群組
|
||
│ │ ├── cards/
|
||
│ │ │ ├── page.tsx # 詞卡列表
|
||
│ │ │ └── [id]/page.tsx # 詞卡詳情
|
||
│ │ ├── generate/
|
||
│ │ │ └── page.tsx # AI 生成
|
||
│ │ ├── review/
|
||
│ │ │ └── page.tsx # 複習頁面
|
||
│ │ ├── stats/
|
||
│ │ │ └── page.tsx # 統計頁面
|
||
│ │ ├── layout.tsx # Dashboard Layout
|
||
│ │ └── page.tsx # Dashboard 首頁
|
||
│ ├── api/ # API Routes
|
||
│ │ ├── gemini/
|
||
│ │ │ └── route.ts # Gemini API
|
||
│ │ ├── cards/
|
||
│ │ │ ├── route.ts # Cards CRUD
|
||
│ │ │ └── [id]/route.ts
|
||
│ │ └── review/
|
||
│ │ └── route.ts # Review API
|
||
│ ├── layout.tsx # Root Layout
|
||
│ ├── page.tsx # 首頁
|
||
│ └── globals.css # 全域樣式
|
||
├── components/
|
||
│ ├── ui/ # shadcn/ui 元件
|
||
│ │ ├── button.tsx
|
||
│ │ ├── card.tsx
|
||
│ │ ├── dialog.tsx
|
||
│ │ └── ...
|
||
│ ├── cards/ # 詞卡相關元件
|
||
│ │ ├── card-item.tsx
|
||
│ │ ├── card-list.tsx
|
||
│ │ └── card-generator.tsx
|
||
│ ├── layout/ # 版面元件
|
||
│ │ ├── header.tsx
|
||
│ │ ├── sidebar.tsx
|
||
│ │ └── mobile-nav.tsx
|
||
│ └── providers/ # Context Providers
|
||
│ ├── auth-provider.tsx
|
||
│ └── theme-provider.tsx
|
||
├── lib/ # 工具函式
|
||
│ ├── supabase/
|
||
│ │ ├── client.ts # Supabase Client
|
||
│ │ ├── server.ts # Supabase Server
|
||
│ │ └── middleware.ts # Supabase Middleware
|
||
│ ├── gemini/
|
||
│ │ └── client.ts # Gemini Client
|
||
│ ├── algorithms/
|
||
│ │ └── sm2.ts # SM-2 演算法
|
||
│ └── utils.ts # 工具函式
|
||
├── hooks/ # Custom Hooks
|
||
│ ├── use-auth.ts
|
||
│ ├── use-cards.ts
|
||
│ └── use-review.ts
|
||
├── types/ # TypeScript 型別
|
||
│ ├── database.ts
|
||
│ ├── api.ts
|
||
│ └── ui.ts
|
||
├── public/ # 靜態資源
|
||
│ ├── icons/
|
||
│ ├── images/
|
||
│ └── manifest.json # PWA Manifest
|
||
└── middleware.ts # Next.js Middleware
|
||
```
|
||
|
||
## 4. 核心功能實作
|
||
|
||
### 4.1 認證系統
|
||
|
||
```typescript
|
||
// lib/supabase/client.ts
|
||
import { createBrowserClient } from '@supabase/ssr'
|
||
import { Database } from '@/types/database'
|
||
|
||
export function createClient() {
|
||
return createBrowserClient<Database>(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||
)
|
||
}
|
||
|
||
// hooks/use-auth.ts
|
||
import { useEffect, useState } from 'react'
|
||
import { User } from '@supabase/supabase-js'
|
||
import { createClient } from '@/lib/supabase/client'
|
||
|
||
export function useAuth() {
|
||
const [user, setUser] = useState<User | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const supabase = createClient()
|
||
|
||
useEffect(() => {
|
||
// 取得當前用戶
|
||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||
setUser(user)
|
||
setLoading(false)
|
||
})
|
||
|
||
// 監聽認證狀態變化
|
||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||
(_event, session) => {
|
||
setUser(session?.user ?? null)
|
||
}
|
||
)
|
||
|
||
return () => subscription.unsubscribe()
|
||
}, [])
|
||
|
||
const signIn = async (email: string, password: string) => {
|
||
const { error } = await supabase.auth.signInWithPassword({
|
||
email,
|
||
password,
|
||
})
|
||
if (error) throw error
|
||
}
|
||
|
||
const signUp = async (email: string, password: string) => {
|
||
const { error } = await supabase.auth.signUp({
|
||
email,
|
||
password,
|
||
})
|
||
if (error) throw error
|
||
}
|
||
|
||
const signOut = async () => {
|
||
const { error } = await supabase.auth.signOut()
|
||
if (error) throw error
|
||
}
|
||
|
||
return { user, loading, signIn, signUp, signOut }
|
||
}
|
||
```
|
||
|
||
### 4.2 AI 詞卡生成
|
||
|
||
```typescript
|
||
// app/api/gemini/route.ts
|
||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { z } from 'zod'
|
||
|
||
// 請求驗證
|
||
const requestSchema = z.object({
|
||
sentence: z.string().min(1).max(200),
|
||
targetWord: z.string().min(1).max(50),
|
||
})
|
||
|
||
// 回應型別
|
||
interface CardGeneration {
|
||
word: string
|
||
pronunciation: string
|
||
definition: string
|
||
partOfSpeech: string
|
||
examples: Array<{
|
||
english: string
|
||
chinese: string
|
||
}>
|
||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
// 驗證請求
|
||
const body = await request.json()
|
||
const { sentence, targetWord } = requestSchema.parse(body)
|
||
|
||
// 初始化 Gemini
|
||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!)
|
||
const model = genAI.getGenerativeModel({ model: 'gemini-pro' })
|
||
|
||
// 建構 Prompt
|
||
const prompt = `
|
||
你是一個專業的英語教學助手。請根據以下資訊生成詞彙學習卡片。
|
||
|
||
原始句子:${sentence}
|
||
目標單字:${targetWord}
|
||
|
||
請以純 JSON 格式回應,包含以下欄位:
|
||
{
|
||
"word": "目標單字",
|
||
"pronunciation": "IPA音標",
|
||
"definition": "繁體中文定義(簡潔明瞭)",
|
||
"partOfSpeech": "詞性(noun/verb/adjective等)",
|
||
"examples": [
|
||
{
|
||
"english": "英文例句1",
|
||
"chinese": "中文翻譯1"
|
||
},
|
||
{
|
||
"english": "英文例句2",
|
||
"chinese": "中文翻譯2"
|
||
}
|
||
],
|
||
"difficulty": "難度等級(beginner/intermediate/advanced)"
|
||
}
|
||
`
|
||
|
||
// 生成內容
|
||
const result = await model.generateContent(prompt)
|
||
const response = await result.response
|
||
const text = response.text()
|
||
|
||
// 解析 JSON
|
||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||
if (!jsonMatch) {
|
||
throw new Error('Invalid response format')
|
||
}
|
||
|
||
const cardData: CardGeneration = JSON.parse(jsonMatch[0])
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: cardData,
|
||
})
|
||
} catch (error) {
|
||
console.error('Gemini API error:', error)
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: 'Failed to generate card',
|
||
},
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// Rate Limiting Middleware
|
||
export async function middleware(request: NextRequest) {
|
||
// 實作 rate limiting
|
||
const ip = request.ip ?? '127.0.0.1'
|
||
|
||
// 使用 Vercel KV 或其他方式實作
|
||
// 這裡是簡化版本
|
||
|
||
return NextResponse.next()
|
||
}
|
||
```
|
||
|
||
### 4.3 間隔重複演算法
|
||
|
||
```typescript
|
||
// lib/algorithms/sm2.ts
|
||
export interface ReviewResult {
|
||
interval: number
|
||
easinessFactor: number
|
||
repetitions: number
|
||
nextReviewDate: Date
|
||
}
|
||
|
||
export function calculateSM2(
|
||
quality: number, // 0-5 評分
|
||
previousInterval: number,
|
||
previousEF: number,
|
||
previousRepetitions: number
|
||
): ReviewResult {
|
||
let interval: number
|
||
let easinessFactor: number
|
||
let repetitions: number
|
||
|
||
// 評分小於 3 表示忘記,重置
|
||
if (quality < 3) {
|
||
interval = 1
|
||
repetitions = 0
|
||
easinessFactor = previousEF
|
||
} else {
|
||
// 計算新的 easiness factor
|
||
easinessFactor = Math.max(
|
||
1.3,
|
||
previousEF + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)
|
||
)
|
||
|
||
repetitions = previousRepetitions + 1
|
||
|
||
// 計算間隔
|
||
if (previousRepetitions === 0) {
|
||
interval = 1
|
||
} else if (previousRepetitions === 1) {
|
||
interval = 6
|
||
} else {
|
||
interval = Math.round(previousInterval * easinessFactor)
|
||
}
|
||
}
|
||
|
||
const nextReviewDate = new Date()
|
||
nextReviewDate.setDate(nextReviewDate.getDate() + interval)
|
||
|
||
return {
|
||
interval,
|
||
easinessFactor,
|
||
repetitions,
|
||
nextReviewDate,
|
||
}
|
||
}
|
||
|
||
// 使用範例
|
||
export function submitReview(
|
||
cardId: string,
|
||
quality: number,
|
||
currentCard: {
|
||
interval: number
|
||
easinessFactor: number
|
||
repetitions: number
|
||
}
|
||
) {
|
||
const result = calculateSM2(
|
||
quality,
|
||
currentCard.interval,
|
||
currentCard.easinessFactor,
|
||
currentCard.repetitions
|
||
)
|
||
|
||
// 更新資料庫
|
||
return updateCard(cardId, result)
|
||
}
|
||
```
|
||
|
||
### 4.4 資料庫操作
|
||
|
||
```typescript
|
||
// lib/supabase/database.ts
|
||
import { createClient } from './client'
|
||
|
||
export interface Card {
|
||
id: string
|
||
user_id: string
|
||
word: string
|
||
pronunciation: string
|
||
definition: string
|
||
examples: any[]
|
||
next_review_date: string
|
||
easiness_factor: number
|
||
interval_days: number
|
||
repetition_count: number
|
||
created_at: string
|
||
}
|
||
|
||
export class CardService {
|
||
private supabase = createClient()
|
||
|
||
async createCard(card: Omit<Card, 'id' | 'created_at'>) {
|
||
const { data, error } = await this.supabase
|
||
.from('cards')
|
||
.insert(card)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) throw error
|
||
return data
|
||
}
|
||
|
||
async getCards(userId: string) {
|
||
const { data, error } = await this.supabase
|
||
.from('cards')
|
||
.select('*')
|
||
.eq('user_id', userId)
|
||
.order('created_at', { ascending: false })
|
||
|
||
if (error) throw error
|
||
return data
|
||
}
|
||
|
||
async getTodayReviews(userId: string) {
|
||
const today = new Date().toISOString()
|
||
|
||
const { data, error } = await this.supabase
|
||
.from('cards')
|
||
.select('*')
|
||
.eq('user_id', userId)
|
||
.lte('next_review_date', today)
|
||
.order('next_review_date', { ascending: true })
|
||
|
||
if (error) throw error
|
||
return data
|
||
}
|
||
|
||
async updateCard(cardId: string, updates: Partial<Card>) {
|
||
const { data, error } = await this.supabase
|
||
.from('cards')
|
||
.update(updates)
|
||
.eq('id', cardId)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) throw error
|
||
return data
|
||
}
|
||
|
||
async deleteCard(cardId: string) {
|
||
const { error } = await this.supabase
|
||
.from('cards')
|
||
.delete()
|
||
.eq('id', cardId)
|
||
|
||
if (error) throw error
|
||
}
|
||
}
|
||
```
|
||
|
||
## 5. 狀態管理
|
||
|
||
### 5.1 Zustand Store
|
||
|
||
```typescript
|
||
// stores/use-card-store.ts
|
||
import { create } from 'zustand'
|
||
import { Card } from '@/lib/supabase/database'
|
||
|
||
interface CardStore {
|
||
cards: Card[]
|
||
todayReviews: Card[]
|
||
isLoading: boolean
|
||
error: string | null
|
||
|
||
// Actions
|
||
setCards: (cards: Card[]) => void
|
||
addCard: (card: Card) => void
|
||
updateCard: (id: string, updates: Partial<Card>) => void
|
||
deleteCard: (id: string) => void
|
||
setTodayReviews: (cards: Card[]) => void
|
||
setLoading: (loading: boolean) => void
|
||
setError: (error: string | null) => void
|
||
}
|
||
|
||
export const useCardStore = create<CardStore>((set) => ({
|
||
cards: [],
|
||
todayReviews: [],
|
||
isLoading: false,
|
||
error: null,
|
||
|
||
setCards: (cards) => set({ cards }),
|
||
|
||
addCard: (card) =>
|
||
set((state) => ({ cards: [card, ...state.cards] })),
|
||
|
||
updateCard: (id, updates) =>
|
||
set((state) => ({
|
||
cards: state.cards.map((card) =>
|
||
card.id === id ? { ...card, ...updates } : card
|
||
),
|
||
})),
|
||
|
||
deleteCard: (id) =>
|
||
set((state) => ({
|
||
cards: state.cards.filter((card) => card.id !== id),
|
||
})),
|
||
|
||
setTodayReviews: (cards) => set({ todayReviews: cards }),
|
||
setLoading: (isLoading) => set({ isLoading }),
|
||
setError: (error) => set({ error }),
|
||
}))
|
||
```
|
||
|
||
### 5.2 TanStack Query
|
||
|
||
```typescript
|
||
// hooks/use-cards.ts
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { CardService } from '@/lib/supabase/database'
|
||
import { useAuth } from './use-auth'
|
||
|
||
const cardService = new CardService()
|
||
|
||
export function useCards() {
|
||
const { user } = useAuth()
|
||
|
||
return useQuery({
|
||
queryKey: ['cards', user?.id],
|
||
queryFn: () => cardService.getCards(user!.id),
|
||
enabled: !!user,
|
||
})
|
||
}
|
||
|
||
export function useTodayReviews() {
|
||
const { user } = useAuth()
|
||
|
||
return useQuery({
|
||
queryKey: ['reviews', 'today', user?.id],
|
||
queryFn: () => cardService.getTodayReviews(user!.id),
|
||
enabled: !!user,
|
||
refetchInterval: 1000 * 60 * 5, // 每 5 分鐘更新
|
||
})
|
||
}
|
||
|
||
export function useCreateCard() {
|
||
const queryClient = useQueryClient()
|
||
const { user } = useAuth()
|
||
|
||
return useMutation({
|
||
mutationFn: (card: any) =>
|
||
cardService.createCard({ ...card, user_id: user!.id }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export function useUpdateCard() {
|
||
const queryClient = useQueryClient()
|
||
|
||
return useMutation({
|
||
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
|
||
cardService.updateCard(id, updates),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||
queryClient.invalidateQueries({ queryKey: ['reviews'] })
|
||
},
|
||
})
|
||
}
|
||
```
|
||
|
||
## 6. UI 元件設計
|
||
|
||
### 6.1 詞卡元件
|
||
|
||
```tsx
|
||
// components/cards/card-item.tsx
|
||
import { Card } from '@/lib/supabase/database'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card as UICard, CardContent, CardFooter } from '@/components/ui/card'
|
||
|
||
interface CardItemProps {
|
||
card: Card
|
||
onReview?: () => void
|
||
onEdit?: () => void
|
||
onDelete?: () => void
|
||
}
|
||
|
||
export function CardItem({ card, onReview, onEdit, onDelete }: CardItemProps) {
|
||
return (
|
||
<UICard className="hover:shadow-lg transition-shadow">
|
||
<CardContent className="pt-6">
|
||
<div className="space-y-2">
|
||
<h3 className="text-2xl font-bold">{card.word}</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{card.pronunciation}
|
||
</p>
|
||
<p className="text-base">{card.definition}</p>
|
||
</div>
|
||
|
||
{card.examples && card.examples.length > 0 && (
|
||
<div className="mt-4 space-y-2">
|
||
<p className="text-sm font-semibold">例句:</p>
|
||
{card.examples.map((example, index) => (
|
||
<div key={index} className="pl-4 text-sm">
|
||
<p className="text-muted-foreground">{example.english}</p>
|
||
<p>{example.chinese}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
|
||
<CardFooter className="flex justify-between">
|
||
<div className="text-xs text-muted-foreground">
|
||
下次複習:{new Date(card.next_review_date).toLocaleDateString()}
|
||
</div>
|
||
<div className="space-x-2">
|
||
{onReview && (
|
||
<Button size="sm" onClick={onReview}>
|
||
複習
|
||
</Button>
|
||
)}
|
||
{onEdit && (
|
||
<Button size="sm" variant="outline" onClick={onEdit}>
|
||
編輯
|
||
</Button>
|
||
)}
|
||
{onDelete && (
|
||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||
刪除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardFooter>
|
||
</UICard>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 6.2 生成器元件
|
||
|
||
```tsx
|
||
// components/cards/card-generator.tsx
|
||
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { useCreateCard } from '@/hooks/use-cards'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
import { Loader2 } from 'lucide-react'
|
||
|
||
export function CardGenerator() {
|
||
const [sentence, setSentence] = useState('')
|
||
const [selectedWord, setSelectedWord] = useState('')
|
||
const [isGenerating, setIsGenerating] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const createCard = useCreateCard()
|
||
|
||
const words = sentence.split(' ').filter(word => word.length > 2)
|
||
|
||
const handleGenerate = async () => {
|
||
if (!selectedWord) {
|
||
setError('請選擇一個單字')
|
||
return
|
||
}
|
||
|
||
setIsGenerating(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// 呼叫 Gemini API
|
||
const response = await fetch('/api/gemini', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sentence, targetWord: selectedWord }),
|
||
})
|
||
|
||
if (!response.ok) throw new Error('生成失敗')
|
||
|
||
const { data } = await response.json()
|
||
|
||
// 儲存到資料庫
|
||
await createCard.mutateAsync(data)
|
||
|
||
// 重置表單
|
||
setSentence('')
|
||
setSelectedWord('')
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '發生錯誤')
|
||
} finally {
|
||
setIsGenerating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-sm font-medium">輸入句子</label>
|
||
<Textarea
|
||
value={sentence}
|
||
onChange={(e) => setSentence(e.target.value)}
|
||
placeholder="輸入包含生詞的英文句子..."
|
||
className="mt-1"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
|
||
{words.length > 0 && (
|
||
<div>
|
||
<label className="text-sm font-medium">選擇單字</label>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{words.map((word) => (
|
||
<Button
|
||
key={word}
|
||
variant={selectedWord === word ? 'default' : 'outline'}
|
||
size="sm"
|
||
onClick={() => setSelectedWord(word)}
|
||
>
|
||
{word}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<Button
|
||
onClick={handleGenerate}
|
||
disabled={!selectedWord || isGenerating}
|
||
className="w-full"
|
||
>
|
||
{isGenerating ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
生成中...
|
||
</>
|
||
) : (
|
||
'生成詞卡'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 7. 效能優化
|
||
|
||
### 7.1 圖片優化
|
||
|
||
```tsx
|
||
// next.config.js
|
||
const nextConfig = {
|
||
images: {
|
||
domains: ['your-supabase-url.supabase.co'],
|
||
formats: ['image/avif', 'image/webp'],
|
||
},
|
||
// 啟用 SWC minification
|
||
swcMinify: true,
|
||
}
|
||
```
|
||
|
||
### 7.2 動態載入
|
||
|
||
```tsx
|
||
// 動態載入重量級元件
|
||
import dynamic from 'next/dynamic'
|
||
|
||
const CardGenerator = dynamic(
|
||
() => import('@/components/cards/card-generator'),
|
||
{
|
||
loading: () => <p>載入中...</p>,
|
||
ssr: false, // 客戶端渲染
|
||
}
|
||
)
|
||
```
|
||
|
||
### 7.3 快取策略
|
||
|
||
```typescript
|
||
// app/api/cards/route.ts
|
||
export async function GET(request: NextRequest) {
|
||
// 設置快取標頭
|
||
const response = NextResponse.json(data)
|
||
|
||
response.headers.set(
|
||
'Cache-Control',
|
||
'public, s-maxage=10, stale-while-revalidate=59'
|
||
)
|
||
|
||
return response
|
||
}
|
||
```
|
||
|
||
## 8. PWA 配置
|
||
|
||
### 8.1 next-pwa 設置
|
||
|
||
```javascript
|
||
// next.config.js
|
||
const withPWA = require('next-pwa')({
|
||
dest: 'public',
|
||
register: true,
|
||
skipWaiting: true,
|
||
runtimeCaching,
|
||
buildExcludes: [/middleware-manifest.json$/],
|
||
disable: process.env.NODE_ENV === 'development',
|
||
})
|
||
|
||
module.exports = withPWA(nextConfig)
|
||
```
|
||
|
||
### 8.2 Manifest
|
||
|
||
```json
|
||
// public/manifest.json
|
||
{
|
||
"name": "LinguaForge",
|
||
"short_name": "LinguaForge",
|
||
"description": "AI 驅動的英語詞彙學習平台",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"background_color": "#ffffff",
|
||
"theme_color": "#4F46E5",
|
||
"icons": [
|
||
{
|
||
"src": "/icon-192x192.png",
|
||
"sizes": "192x192",
|
||
"type": "image/png"
|
||
},
|
||
{
|
||
"src": "/icon-512x512.png",
|
||
"sizes": "512x512",
|
||
"type": "image/png"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
## 9. 部署配置
|
||
|
||
### 9.1 環境變數
|
||
|
||
```bash
|
||
# .env.local
|
||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||
GEMINI_API_KEY=your_gemini_api_key
|
||
```
|
||
|
||
### 9.2 Vercel 配置
|
||
|
||
```json
|
||
// vercel.json
|
||
{
|
||
"buildCommand": "npm run build",
|
||
"devCommand": "npm run dev",
|
||
"installCommand": "npm install",
|
||
"framework": "nextjs",
|
||
"regions": ["sin1"], // 新加坡,接近台灣
|
||
"functions": {
|
||
"app/api/gemini/route.ts": {
|
||
"maxDuration": 10
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 10. 監控與分析
|
||
|
||
### 10.1 Vercel Analytics
|
||
|
||
```tsx
|
||
// app/layout.tsx
|
||
import { Analytics } from '@vercel/analytics/react'
|
||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||
|
||
export default function RootLayout({ children }) {
|
||
return (
|
||
<html>
|
||
<body>
|
||
{children}
|
||
<Analytics />
|
||
<SpeedInsights />
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 10.2 錯誤追蹤
|
||
|
||
```typescript
|
||
// lib/sentry.ts
|
||
import * as Sentry from '@sentry/nextjs'
|
||
|
||
Sentry.init({
|
||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||
tracesSampleRate: 0.1,
|
||
environment: process.env.NODE_ENV,
|
||
})
|
||
```
|
||
|
||
這個技術架構提供了完整的網頁版實作指南,使用最新的 Next.js 14 和現代化的技術棧,確保開發效率和產品品質。 |