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

919 lines
24 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.

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