24 KiB
24 KiB
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 認證系統
// 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 詞卡生成
// 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 間隔重複演算法
// 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 資料庫操作
// 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
// 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
// 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 詞卡元件
// 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 生成器元件
// 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 圖片優化
// next.config.js
const nextConfig = {
images: {
domains: ['your-supabase-url.supabase.co'],
formats: ['image/avif', 'image/webp'],
},
// 啟用 SWC minification
swcMinify: true,
}
7.2 動態載入
// 動態載入重量級元件
import dynamic from 'next/dynamic'
const CardGenerator = dynamic(
() => import('@/components/cards/card-generator'),
{
loading: () => <p>載入中...</p>,
ssr: false, // 客戶端渲染
}
)
7.3 快取策略
// 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 設置
// 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
// 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 環境變數
# .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 配置
// 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
// 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 錯誤追蹤
// 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 和現代化的技術棧,確保開發效率和產品品質。