dramaling-vocab-learning/web-technical-architecture.md

24 KiB
Raw Blame History

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 和現代化的技術棧,確保開發效率和產品品質。