feat: 實現完整測驗模式與 AI 生成功能更新

主要更新:
1. 測驗模式重構
   - 選擇題:改為英文定義選中文翻譯
   - 新增填空題模式(含例句圖片顯示)
   - 新增聽力測試模式
   - 新增口說測試模式

2. AI 生成功能更新
   - 改為詞彙萃取和智能萃取兩種模式
   - 實現用戶權限差異(免費/訂閱)
   - 例句圖改為模態視窗顯示

3. 其他改進
   - 整合真實例句圖片資源
   - 難度等級改用 CEFR 標準 (A1-C2)
   - 優化圖片顯示比例與體驗
   - 移除測驗模式正確率顯示

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-16 01:12:34 +08:00
parent 1d0acf5111
commit 96e205fd7f
12 changed files with 2845 additions and 177 deletions

View File

@ -11,7 +11,8 @@
"Bash(xargs:*)",
"Bash(npm init:*)",
"Bash(npm run dev:*)",
"Bash(npm uninstall:*)"
"Bash(npm uninstall:*)",
"Bash(git push:*)"
],
"deny": [],
"ask": []

View File

@ -4,57 +4,83 @@ import { useState } from 'react'
import Link from 'next/link'
export default function GeneratePage() {
const [mode, setMode] = useState<'text' | 'theme'>('text')
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
const [textInput, setTextInput] = useState('')
const [selectedTheme, setSelectedTheme] = useState('')
const [difficulty, setDifficulty] = useState('intermediate')
const [extractionType, setExtractionType] = useState<'vocabulary' | 'smart'>('vocabulary')
const [cardCount, setCardCount] = useState(10)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedCards, setGeneratedCards] = useState<any[]>([])
const [showPreview, setShowPreview] = useState(false)
const themes = [
{ id: 'daily', name: '日常對話', icon: '🗣️' },
{ id: 'business', name: '商務英語', icon: '💼' },
{ id: 'tv', name: '美劇經典', icon: '📺' },
{ id: 'movie', name: '電影台詞', icon: '🎬' },
{ id: 'academic', name: '學術英語', icon: '🎓' },
{ id: 'travel', name: '旅遊英語', icon: '✈️' },
]
const [isPremium] = useState(false) // Mock premium status
const [showImageForCard, setShowImageForCard] = useState<{ [key: number]: boolean }>({}) // Track which cards show images
const [modalImage, setModalImage] = useState<string | null>(null) // Track modal image
const mockGeneratedCards = [
{
id: 1,
word: 'negotiate',
word: 'brought',
partOfSpeech: 'verb',
pronunciation: '/nɪˈɡoʊʃieɪt/',
translation: '協商、談判',
definition: 'To discuss something with someone in order to reach an agreement',
example: 'We need to negotiate a better deal with our suppliers.',
exampleTranslation: '我們需要與供應商協商更好的交易。',
difficulty: 'intermediate'
pronunciation: {
us: '/brɔːt/',
uk: '/brɔːt/'
},
translation: '提出、帶來',
definition: 'Past tense of bring; to mention or introduce a topic in conversation',
synonyms: ['mentioned', 'raised', 'introduced'],
antonyms: ['concealed', 'withheld'],
originalExample: 'He brought this thing up during our meeting and no one agreed.',
originalExampleTranslation: '他在我們的會議中提出了這件事,但沒有人同意。',
generatedExample: {
sentence: 'She brought up an interesting point about the budget.',
translation: '她提出了一個關於預算的有趣觀點。',
imageUrl: '/images/examples/bring_up.png',
audioUrl: '#'
},
difficulty: 'B1'
},
{
id: 2,
word: 'perspective',
word: 'instincts',
partOfSpeech: 'noun',
pronunciation: '/pərˈspektɪv/',
translation: '觀點、看法',
definition: 'A particular way of considering something',
example: 'From my perspective, this is the best solution.',
exampleTranslation: '從我的角度來看,這是最好的解決方案。',
difficulty: 'intermediate'
pronunciation: {
us: '/ˈɪnstɪŋkts/',
uk: '/ˈɪnstɪŋkts/'
},
translation: '本能、直覺',
definition: 'Natural abilities that help living things survive without learning',
synonyms: ['intuition', 'impulse', 'tendency'],
antonyms: ['logic', 'reasoning'],
originalExample: 'Animals use their instincts to find food and stay safe.',
originalExampleTranslation: '動物利用本能來尋找食物並保持安全。',
generatedExample: {
sentence: 'Trust your instincts when making important decisions.',
translation: '在做重要決定時要相信你的直覺。',
imageUrl: '/images/examples/instinct.png',
audioUrl: '#'
},
difficulty: 'B2'
},
{
id: 3,
word: 'accomplish',
partOfSpeech: 'verb',
pronunciation: '/əˈkɒmplɪʃ/',
translation: '完成、達成',
definition: 'To finish something successfully or to achieve something',
example: 'She accomplished her goal of running a marathon.',
exampleTranslation: '她完成了跑馬拉松的目標。',
difficulty: 'intermediate'
word: 'warrants',
partOfSpeech: 'noun',
pronunciation: {
us: '/ˈːrənts/',
uk: '/ˈwɒrənts/'
},
translation: '搜查令、授權令',
definition: 'Official documents that give police permission to do something',
synonyms: ['authorization', 'permit', 'license'],
antonyms: ['prohibition', 'ban'],
originalExample: 'The police obtained warrants to search the building.',
originalExampleTranslation: '警方取得了搜查令來搜查這棟建築物。',
generatedExample: {
sentence: 'The judge issued arrest warrants for three suspects.',
translation: '法官對三名嫌疑人發出了逮捕令。',
imageUrl: '/images/examples/warrant.png',
audioUrl: '#'
},
difficulty: 'C1'
}
]
@ -73,6 +99,13 @@ export default function GeneratePage() {
alert('詞卡已保存到您的卡組!')
}
const toggleImageForCard = (cardId: number) => {
setShowImageForCard(prev => ({
...prev,
[cardId]: !prev[cardId]
}))
}
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@ -97,42 +130,81 @@ export default function GeneratePage() {
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">AI </h1>
{/* Mode Selection */}
{/* Input Mode Selection */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setMode('text')}
onClick={() => setMode('manual')}
className={`p-4 rounded-lg border-2 transition-all ${
mode === 'text'
? 'border-primary bg-primary-light'
mode === 'manual'
? 'border-primary bg-primary-light'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-2">📄</div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1"></div>
<div className="text-2xl mb-2"></div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1"></div>
</button>
<button
onClick={() => setMode('theme')}
onClick={() => setMode('screenshot')}
disabled={!isPremium}
className={`p-4 rounded-lg border-2 transition-all relative ${
mode === 'screenshot'
? 'border-primary bg-primary-light'
: isPremium
? 'border-gray-200 hover:border-gray-300'
: 'border-gray-200 bg-gray-100 cursor-not-allowed opacity-60'
}`}
>
<div className="text-2xl mb-2">📷</div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1"> (Phase 2)</div>
{!isPremium && (
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded-full">
</div>
)}
</button>
</div>
</div>
{/* Extraction Type Selection */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setExtractionType('vocabulary')}
className={`p-4 rounded-lg border-2 transition-all ${
mode === 'theme'
? 'border-primary bg-primary-light'
extractionType === 'vocabulary'
? 'border-primary bg-primary-light'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-2">🎨</div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1"></div>
<div className="text-2xl mb-2">📖</div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1"> API CEFR</div>
</button>
<button
onClick={() => setExtractionType('smart')}
className={`p-4 rounded-lg border-2 transition-all ${
extractionType === 'smart'
? 'border-primary bg-primary-light'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-2">🤖</div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-600 mt-1">AI </div>
</button>
</div>
</div>
{/* Content Input */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
{mode === 'text' ? (
{mode === 'manual' ? (
<div>
<h2 className="text-lg font-semibold mb-4"></h2>
<h2 className="text-lg font-semibold mb-4"></h2>
<textarea
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
@ -142,25 +214,44 @@ export default function GeneratePage() {
<div className="mt-2 text-sm text-gray-600">
5000 {textInput.length}
</div>
{/* Extraction Type Info */}
{extractionType === 'vocabulary' ? (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-800">
<strong>📖 </strong>
<ul className="mt-2 space-y-1 text-xs">
<li> API</li>
<li> CEFR </li>
<li> </li>
</ul>
</div>
</div>
) : (
<div className="mt-4 p-3 bg-purple-50 rounded-lg">
<div className="text-sm text-purple-800">
<strong>🤖 </strong>
<ul className="mt-2 space-y-1 text-xs">
<li> AI </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
)}
</div>
) : (
<div>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{themes.map((theme) => (
<button
key={theme.id}
onClick={() => setSelectedTheme(theme.id)}
className={`p-4 rounded-lg border-2 transition-all ${
selectedTheme === theme.id
? 'border-primary bg-primary-light'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-1">{theme.icon}</div>
<div className="font-medium text-sm">{theme.name}</div>
</button>
))}
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<div className="text-4xl mb-3">📷</div>
<div className="text-gray-600 mb-2"></div>
<div className="text-sm text-gray-500"> JPG, PNG 10MB</div>
<div className="mt-4">
<span className="inline-block px-4 py-2 bg-gray-100 text-gray-400 rounded-lg">
Phase 2
</span>
</div>
</div>
</div>
)}
@ -172,21 +263,7 @@ export default function GeneratePage() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent outline-none"
>
<option value="beginner"></option>
<option value="intermediate"></option>
<option value="advanced"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{cardCount}
{cardCount}
</label>
<input
type="range"
@ -198,16 +275,44 @@ export default function GeneratePage() {
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>5</span>
<span> 10</span>
<span>20</span>
</div>
</div>
{/* User Limits */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm">
{isPremium ? (
<div className="text-green-700">
<strong>🌟 </strong>
<div className="text-xs mt-1">
50
<br />
使0/50
</div>
</div>
) : (
<div className="text-gray-700">
<strong>🆓 </strong>
<div className="text-xs mt-1">
<br />
使
<br />
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={isGenerating || (mode === 'text' && !textInput) || (mode === 'theme' && !selectedTheme)}
disabled={isGenerating || (mode === 'manual' && !textInput) || (mode === 'screenshot')}
className="w-full bg-primary text-white py-4 rounded-lg font-semibold hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
@ -216,10 +321,12 @@ export default function GeneratePage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
AI ...
...
</span>
) : extractionType === 'vocabulary' ? (
'📖 開始詞彙萃取'
) : (
'🤖 開始生成詞卡'
'🤖 開始智能萃取'
)}
</button>
</div>
@ -252,37 +359,163 @@ export default function GeneratePage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{generatedCards.map((card) => (
<div key={card.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold">{card.word}</h3>
<div className="text-sm text-gray-600">
{card.partOfSpeech} {card.pronunciation}
<div key={card.id} className="border rounded-lg p-5 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-xl font-bold">{card.word}</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-gray-600">{card.partOfSpeech}</span>
<div className="flex items-center gap-2">
<button className="text-xs bg-gray-100 px-2 py-1 rounded hover:bg-gray-200">
🇺🇸 {card.pronunciation.us}
</button>
<button className="text-xs bg-gray-100 px-2 py-1 rounded hover:bg-gray-200">
🇬🇧 {card.pronunciation.uk}
</button>
<button className="text-primary hover:text-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
</div>
</div>
<button className="text-red-500 hover:text-red-700">
×
</button>
<button className="text-red-500 hover:text-red-700 text-2xl">×</button>
</div>
<div className="space-y-2">
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm">{card.translation}</div>
<div className="text-sm font-semibold text-gray-700"></div>
<div className="text-base font-medium">{card.translation}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm font-semibold text-gray-700"></div>
<div className="text-sm text-gray-600">{card.definition}</div>
</div>
{card.synonyms.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700"></div>
<div className="flex flex-wrap gap-2 mt-1">
{card.synonyms.map((syn, idx) => (
<span key={idx} className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs">
{syn}
</span>
))}
</div>
</div>
)}
{card.antonyms.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700"></div>
<div className="flex flex-wrap gap-2 mt-1">
{card.antonyms.map((ant, idx) => (
<span key={idx} className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs">
{ant}
</span>
))}
</div>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm text-gray-600">{card.example}</div>
<div className="text-sm text-gray-500 mt-1">{card.exampleTranslation}</div>
<div className="text-sm font-semibold text-gray-700 mb-2"></div>
<div className="space-y-3">
{/* 原始例句 */}
<div className="border-l-2 border-gray-400 pl-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm text-gray-800">
{card.originalExample.split(card.word).map((part, i) => (
<span key={i}>
{part}
{i < card.originalExample.split(card.word).length - 1 && (
<span className="font-bold text-primary">{card.word}</span>
)}
</span>
))}
</div>
<div className="text-sm text-gray-500 mt-1">{card.originalExampleTranslation}</div>
</div>
<div className="flex items-center gap-1 ml-2">
<button className="text-gray-400 hover:text-primary" title="播放例句">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
</div>
{/* 生成例句 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="text-xs text-primary mb-1">AI </div>
<div className="text-sm text-gray-800">
{card.generatedExample.sentence.split(card.word).map((part, i) => (
<span key={i}>
{part}
{i < card.generatedExample.sentence.split(card.word).length - 1 && (
<span className="font-bold text-primary">{card.word}</span>
)}
</span>
))}
</div>
<div className="text-sm text-gray-500 mt-1">{card.generatedExample.translation}</div>
</div>
<div className="flex items-center gap-1 ml-2">
<button className="text-gray-400 hover:text-primary" title="播放例句">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
onClick={() => toggleImageForCard(card.id)}
className={`${showImageForCard[card.id] ? 'text-primary' : 'text-gray-400'} hover:text-primary`}
title={showImageForCard[card.id] ? "隱藏例句圖" : "查看例句圖"}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
{/* Example Image Display */}
{showImageForCard[card.id] && card.generatedExample.imageUrl && (
<div className="mt-3 p-2 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-600 mb-2 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<img
src={card.generatedExample.imageUrl}
alt="Example context"
className="w-full rounded-md shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: '250px', objectFit: 'contain', backgroundColor: 'white' }}
onClick={() => setModalImage(card.generatedExample.imageUrl)}
/>
<div className="text-xs text-gray-500 mt-2 text-center"></div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t">
<button className="text-primary text-sm hover:text-primary-hover">
<div className="mt-4 pt-3 border-t flex justify-between items-center">
<span className="text-xs text-gray-500">CEFR {card.difficulty}</span>
<button className="text-primary text-sm hover:text-primary-hover font-medium">
</button>
</div>
@ -293,6 +526,39 @@ export default function GeneratePage() {
</div>
)}
</div>
{/* Image Modal */}
{modalImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"
onClick={() => setModalImage(null)}
>
<div
className="relative max-w-4xl max-h-[90vh] bg-white rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={() => setModalImage(null)}
className="absolute top-2 right-2 z-10 p-2 bg-white bg-opacity-90 rounded-full hover:bg-opacity-100 transition-all shadow-lg"
>
<svg className="w-6 h-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Image */}
<div className="p-4">
<img
src={modalImage}
alt="Example context enlarged"
className="w-full h-full object-contain"
style={{ maxHeight: 'calc(90vh - 2rem)' }}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -6,60 +6,70 @@ import Link from 'next/link'
export default function LearnPage() {
const [currentCardIndex, setCurrentCardIndex] = useState(0)
const [isFlipped, setIsFlipped] = useState(false)
const [mode, setMode] = useState<'flip' | 'quiz'>('flip')
const [mode, setMode] = useState<'flip' | 'quiz' | 'fill' | 'listening' | 'speaking'>('flip')
const [score, setScore] = useState({ correct: 0, total: 0 })
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const [fillAnswer, setFillAnswer] = useState('')
const [showHint, setShowHint] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [audioPlaying, setAudioPlaying] = useState(false)
const [modalImage, setModalImage] = useState<string | null>(null)
// Mock data
// Mock data with real example images
const cards = [
{
id: 1,
word: 'negotiate',
word: 'brought',
partOfSpeech: 'verb',
pronunciation: '/nɪˈɡoʊʃieɪt/',
translation: '協商、談判',
definition: 'To discuss something with someone in order to reach an agreement',
example: 'We need to negotiate a better deal with our suppliers.',
exampleTranslation: '我們需要與供應商協商更好的交易。',
synonyms: ['bargain', 'discuss', 'mediate'],
difficulty: 'intermediate'
pronunciation: '/brɔːt/',
translation: '提出、帶來',
definition: 'Past tense of bring; to mention or introduce a topic in conversation',
example: 'He brought this thing up during our meeting and no one agreed.',
exampleTranslation: '他在我們的會議中提出了這件事,但沒有人同意。',
exampleImage: '/images/examples/bring_up.png',
synonyms: ['mentioned', 'raised', 'introduced'],
difficulty: 'B1'
},
{
id: 2,
word: 'perspective',
word: 'instincts',
partOfSpeech: 'noun',
pronunciation: '/pərˈspektɪv/',
translation: '觀點、看法',
definition: 'A particular way of considering something',
example: 'From my perspective, this is the best solution.',
exampleTranslation: '從我的角度來看,這是最好的解決方案。',
synonyms: ['viewpoint', 'outlook', 'stance'],
difficulty: 'intermediate'
pronunciation: '/ˈɪnstɪŋkts/',
translation: '本能、直覺',
definition: 'Natural abilities that help living things survive without learning',
example: 'Animals use their instincts to find food and stay safe.',
exampleTranslation: '動物利用本能來尋找食物並保持安全。',
exampleImage: '/images/examples/instinct.png',
synonyms: ['intuition', 'impulse', 'tendency'],
difficulty: 'B2'
},
{
id: 3,
word: 'accomplish',
partOfSpeech: 'verb',
pronunciation: '/əˈkɒmplɪʃ/',
translation: '完成、達成',
definition: 'To finish something successfully or to achieve something',
example: 'She accomplished her goal of running a marathon.',
exampleTranslation: '她完成了跑馬拉松的目標。',
synonyms: ['achieve', 'complete', 'fulfill'],
difficulty: 'intermediate'
word: 'warrants',
partOfSpeech: 'noun',
pronunciation: '/ˈːrənts/',
translation: '搜查令、授權令',
definition: 'Official documents that give police permission to do something',
example: 'The police obtained warrants to search the building.',
exampleTranslation: '警方取得了搜查令來搜查這棟建築物。',
exampleImage: '/images/examples/warrant.png',
synonyms: ['authorization', 'permit', 'license'],
difficulty: 'C1'
}
]
const currentCard = cards[currentCardIndex]
// Quiz mode options
// Quiz mode options - dynamically generate from current cards
const quizOptions = [
'協商、談判',
'觀點、看法',
'完成、達成',
'建議、提議'
]
cards[currentCardIndex].translation,
...cards
.filter((_, idx) => idx !== currentCardIndex)
.map(card => card.translation)
.slice(0, 2),
'建議、提議' // additional wrong option
].sort(() => Math.random() - 0.5) // shuffle options
const handleFlip = () => {
setIsFlipped(!isFlipped)
@ -71,6 +81,8 @@ export default function LearnPage() {
setIsFlipped(false)
setSelectedAnswer(null)
setShowResult(false)
setFillAnswer('')
setShowHint(false)
}
}
@ -80,6 +92,8 @@ export default function LearnPage() {
setIsFlipped(false)
setSelectedAnswer(null)
setShowResult(false)
setFillAnswer('')
setShowHint(false)
}
}
@ -143,10 +157,10 @@ export default function LearnPage() {
{/* Mode Toggle */}
<div className="flex justify-center mb-6">
<div className="bg-white rounded-lg shadow-sm p-1 inline-flex">
<div className="bg-white rounded-lg shadow-sm p-1 inline-flex flex-wrap">
<button
onClick={() => setMode('flip')}
className={`px-4 py-2 rounded-md transition-colors ${
className={`px-3 py-2 rounded-md transition-colors ${
mode === 'flip'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-900'
@ -156,13 +170,43 @@ export default function LearnPage() {
</button>
<button
onClick={() => setMode('quiz')}
className={`px-4 py-2 rounded-md transition-colors ${
className={`px-3 py-2 rounded-md transition-colors ${
mode === 'quiz'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
<button
onClick={() => setMode('fill')}
className={`px-3 py-2 rounded-md transition-colors ${
mode === 'fill'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
<button
onClick={() => setMode('listening')}
className={`px-3 py-2 rounded-md transition-colors ${
mode === 'listening'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
<button
onClick={() => setMode('speaking')}
className={`px-3 py-2 rounded-md transition-colors ${
mode === 'speaking'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
</div>
</div>
@ -269,13 +313,17 @@ export default function LearnPage() {
</div>
)}
</div>
) : (
/* Quiz Mode */
) : mode === 'quiz' ? (
/* Quiz Mode - 選擇題:英文定義選中文翻譯 */
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="mb-6">
<div className="text-sm text-gray-600 mb-2"></div>
<div className="text-3xl font-bold text-gray-900">{currentCard.word}</div>
<div className="text-lg text-gray-500 mt-1">{currentCard.pronunciation}</div>
<div className="text-sm text-gray-600 mb-2"></div>
<div className="text-xl text-gray-800 leading-relaxed">
{currentCard.definition}
</div>
<div className="text-sm text-gray-500 mt-2">
({currentCard.partOfSpeech})
</div>
</div>
<div className="space-y-3">
@ -318,15 +366,263 @@ export default function LearnPage() {
<div className="text-gray-500 text-sm mt-1">{currentCard.exampleTranslation}</div>
</div>
)}
</div>
) : mode === 'fill' ? (
/* Fill in the Blank Mode - 填空題 */
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="mb-6">
<div className="text-sm text-gray-600 mb-4"></div>
<div className="mt-6 text-center">
<div className="text-sm text-gray-600">
{score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0}%
({score.correct}/{score.total})
{/* Example Image */}
{currentCard.exampleImage && (
<div className="mb-4">
<img
src={currentCard.exampleImage}
alt="Example context"
className="w-full rounded-lg shadow-md cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: '400px', objectFit: 'contain' }}
onClick={() => setModalImage(currentCard.exampleImage)}
/>
<div className="text-xs text-gray-500 mt-2 text-center"></div>
</div>
)}
{/* Example Sentence with Blank */}
<div className="text-lg text-gray-800 mb-4">
{currentCard.example.split(currentCard.word).map((part, i) => (
<span key={i}>
{part}
{i < currentCard.example.split(currentCard.word).length - 1 && (
<span className="inline-block w-32 border-b-2 border-gray-400 mx-1"></span>
)}
</span>
))}
</div>
{/* Hint Button */}
{!showHint && (
<button
onClick={() => setShowHint(true)}
className="text-sm text-primary hover:text-primary-hover flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
{/* Definition Hint */}
{showHint && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-800">
<strong></strong> {currentCard.definition}
</div>
</div>
)}
</div>
{/* Answer Input */}
<div className="mb-6">
<input
type="text"
value={fillAnswer}
onChange={(e) => setFillAnswer(e.target.value)}
placeholder="輸入答案..."
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-primary focus:outline-none text-lg"
onKeyPress={(e) => {
if (e.key === 'Enter' && fillAnswer) {
setShowResult(true)
}
}}
/>
</div>
{/* Submit Button */}
{!showResult && (
<button
onClick={() => fillAnswer && setShowResult(true)}
disabled={!fillAnswer}
className="w-full py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
)}
{/* Result Display */}
{showResult && (
<div className={`p-4 rounded-lg ${
fillAnswer.toLowerCase() === currentCard.word.toLowerCase()
? 'bg-green-50 border-2 border-green-500'
: 'bg-red-50 border-2 border-red-500'
}`}>
<div className="flex items-center justify-between mb-2">
<span className={`font-semibold ${
fillAnswer.toLowerCase() === currentCard.word.toLowerCase()
? 'text-green-700'
: 'text-red-700'
}`}>
{fillAnswer.toLowerCase() === currentCard.word.toLowerCase() ? '✓ 正確!' : '✗ 錯誤'}
</span>
</div>
{fillAnswer.toLowerCase() !== currentCard.word.toLowerCase() && (
<div className="text-sm text-gray-700">
<span className="font-bold">{currentCard.word}</span>
</div>
)}
<div className="mt-3 text-sm text-gray-600">
<div className="font-semibold mb-1"></div>
<div>{currentCard.example}</div>
<div className="text-gray-500 mt-1">{currentCard.exampleTranslation}</div>
</div>
</div>
)}
</div>
) : mode === 'listening' ? (
/* Listening Test Mode - 聽力測試 */
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="mb-6 text-center">
<div className="text-sm text-gray-600 mb-4"></div>
{/* Audio Play Button */}
<button
onClick={() => {
setAudioPlaying(true)
// Simulate audio playing
setTimeout(() => setAudioPlaying(false), 2000)
}}
className="mx-auto mb-6 p-8 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors"
>
{audioPlaying ? (
<svg className="w-16 h-16 text-primary animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
) : (
<svg className="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
<div className="text-sm text-gray-500"></div>
</div>
{/* Word Options */}
<div className="grid grid-cols-2 gap-3">
{[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => (
<button
key={word}
onClick={() => !showResult && handleQuizAnswer(word)}
disabled={showResult}
className={`p-4 text-lg font-medium rounded-lg border-2 transition-all ${
showResult && word === currentCard.word
? 'border-green-500 bg-green-50'
: showResult && word === selectedAnswer && word !== currentCard.word
? 'border-red-500 bg-red-50'
: selectedAnswer === word
? 'border-primary bg-primary-light'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{word}
</button>
))}
</div>
{/* Result Display */}
{showResult && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-2"></div>
<div className="space-y-2">
<div>
<span className="font-semibold">{currentCard.word}</span> - {currentCard.translation}
</div>
<div className="text-sm text-gray-600">{currentCard.definition}</div>
<div className="text-sm text-gray-500 italic">"{currentCard.example}"</div>
</div>
</div>
)}
</div>
) : mode === 'speaking' ? (
/* Speaking Test Mode - 口說測試 */
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="mb-6">
<div className="text-sm text-gray-600 mb-4"></div>
{/* Target Sentence */}
<div className="p-6 bg-gray-50 rounded-lg mb-6">
<div className="text-xl text-gray-800 leading-relaxed mb-3">
{currentCard.example}
</div>
<div className="text-gray-600">
{currentCard.exampleTranslation}
</div>
</div>
{/* Pronunciation Guide */}
<div className="mb-6">
<div className="text-sm text-gray-600 mb-2"></div>
<div className="flex items-center gap-4">
<span className="font-semibold text-lg">{currentCard.word}</span>
<span className="text-gray-500">{currentCard.pronunciation}</span>
<button className="text-primary hover:text-primary-hover">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
</div>
{/* Recording Button */}
<div className="text-center">
<button
onClick={() => {
setIsRecording(!isRecording)
if (!isRecording) {
// Start recording
setTimeout(() => {
setIsRecording(false)
setShowResult(true)
}, 3000)
}
}}
className={`p-6 rounded-full transition-all ${
isRecording
? 'bg-red-500 hover:bg-red-600 animate-pulse'
: 'bg-primary hover:bg-primary-hover'
}`}
>
{isRecording ? (
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
) : (
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
)}
</button>
<div className="mt-3 text-sm text-gray-600">
{isRecording ? '錄音中... 點擊停止' : '點擊開始錄音'}
</div>
</div>
{/* Result Display */}
{showResult && (
<div className="mt-6 p-4 bg-green-50 border-2 border-green-500 rounded-lg">
<div className="text-green-700 font-semibold mb-2">
</div>
<div className="text-sm text-gray-600">
</div>
</div>
)}
</div>
</div>
)}
) : null}
{/* Navigation Buttons */}
<div className="flex justify-between mt-8">
@ -353,6 +649,39 @@ export default function LearnPage() {
</button>
</div>
</div>
{/* Image Modal */}
{modalImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"
onClick={() => setModalImage(null)}
>
<div
className="relative max-w-4xl max-h-[90vh] bg-white rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={() => setModalImage(null)}
className="absolute top-2 right-2 z-10 p-2 bg-white bg-opacity-90 rounded-full hover:bg-opacity-100 transition-all shadow-lg"
>
<svg className="w-6 h-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Image */}
<div className="p-4">
<img
src={modalImage}
alt="Example context enlarged"
className="w-full h-full object-contain"
style={{ maxHeight: 'calc(90vh - 2rem)' }}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -60,26 +60,34 @@
- 電影台詞Movie Quotes
- 學術英語Academic English
- 自定義主題輸入
- 難度選擇:初級/中級/高級
- 難度選擇:A1, A2, B1, B2, C1, C2
#### 1.2.2 AI 生成規格
- **生成方式**
1. 原始例句類型
- 影劇截圖(訂閱功能, phase2)
- 手動輸入
2. 詞彙萃取把每個單字拿去查詢字典API並標記CEFR
3. 智能萃取(訂閱功能)將原始例句拿去問AI有無常用片語或俚語並直接生成相關詞彙內容
- **生成數量**
- 預設10個詞卡
- 範圍5-20個用戶可調
- 免費用戶每日50個限制
- 付費用戶:無限制
- 免費用戶:
- 無法自行生成例句圖,但若系統中匹配到現成例句圖,可直接使用
- 每日學習數量無限制
- 訂閱用戶每天最多生成50張例句圖
- **生成內容詳情**
- **單字/片語**
- 原形展示
- 詞性標註n./v./adj./adv./phrase
- 同義詞最多3個
- 詞性標註n./v./adj./adv./phrase/slang
- 英文定義 (程度應維持在A1-A2)
- 同義詞最多3個且程度應維持在A1-A2
- 反義詞(如適用)
- **翻譯**
- 繁體中文翻譯
- 多義詞說明
- 慣用語解釋
- **發音**
- IPA 國際音標
@ -88,19 +96,11 @@
- **例句**
- 原始例句(來自輸入文本)
- 生成例句(2-3個)
- 生成例句(1個)
- 例句中文翻譯
- 重點標示highlight目標詞
- **使用情境**
- 正式/非正式場合
- 使用頻率(常用/進階/罕見)
- 文化背景說明(如有)
- **記憶提示**
- 詞根詞綴分析
- 聯想記憶法
- 圖像記憶(未來功能)
- 例句圖
- 例句發音
- **生成後處理**
- 預覽所有生成詞卡
@ -170,12 +170,12 @@
#### 1.4.1 間隔重複算法SM-2
- **算法參數**
- 初始間隔:1天、3天、7天、14天、30天
- 初始間隔:2^0天、2^1天...依此類推
- 難度係數0.8-2.5
- 最小間隔1天
- 最大間隔365天
- **評分系統**
- **評分及間隔時間**
- 1分完全不記得重置進度
- 2分有印象但錯誤間隔×0.6
- 3分困難但正確間隔×0.8
@ -189,17 +189,20 @@
#### 1.4.2 學習模式
- **翻卡模式**
- 正面:英文單字
- 背面:翻譯、例句、發音
- 正面:英文詞彙
- 背面:英文定義、例句、發音、例句圖
- 手勢操作:左滑(不記得)、右滑(記得)、上滑(收藏)
- 鍵盤快捷鍵支援
- **測驗模式**
- 選擇題4選1
- 填空題(例句挖空)
- 拼寫測試
- 選擇題題目是英文定義答案中文翻譯4選1
- 填空題
- 題目是顯示例句圖和挖空的例句
- 點擊提示,會出現詞彙的英文定義
- 答案就是詞彙,但是不是原型,而是例句挖空的部分
- 拼寫測試 (phase 2)
- 聽力測試(聽音選詞)
- 即時反饋和解釋
- 口說測試 (念例句)
- **沉浸模式**
- 全螢幕學習

View File

@ -0,0 +1,742 @@
# DramaLing API 端點詳細規格
## 🌐 API 基礎設定
### Base URL
```
Development: http://localhost:3000/api
Production: https://api.dramaling.com
```
### 認證方式
```http
Authorization: Bearer <JWT_TOKEN>
```
### 響應格式
```typescript
interface ApiResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: any
}
meta?: {
pagination?: {
page: number
limit: number
total: number
totalPages: number
}
}
}
```
## 🔐 認證 API
### POST /api/auth/register
**註冊新用戶**
```typescript
// Request
interface RegisterRequest {
email: string
username: string
password: string
confirmPassword: string
}
// Response
interface RegisterResponse {
user: {
id: string
email: string
username: string
}
message: string
}
// Validation
- email: valid email format
- username: 3-20 characters, alphanumeric and underscore
- password: min 8 chars, must contain uppercase, lowercase, number
- confirmPassword: must match password
```
### POST /api/auth/login
**用戶登入**
```typescript
// Request
interface LoginRequest {
email: string
password: string
rememberMe?: boolean
}
// Response
interface LoginResponse {
user: User
accessToken: string
refreshToken: string
expiresIn: number
}
// Error codes
- INVALID_CREDENTIALS: 帳號或密碼錯誤
- ACCOUNT_LOCKED: 帳號被鎖定
- EMAIL_NOT_VERIFIED: Email 未驗證
```
### POST /api/auth/logout
**登出**
```typescript
// Request
interface LogoutRequest {
refreshToken?: string
allDevices?: boolean
}
// Response
interface LogoutResponse {
message: string
}
```
### POST /api/auth/refresh
**更新 Token**
```typescript
// Request
interface RefreshRequest {
refreshToken: string
}
// Response
interface RefreshResponse {
accessToken: string
refreshToken: string
expiresIn: number
}
```
### POST /api/auth/verify-email
**驗證 Email**
```typescript
// Request
interface VerifyEmailRequest {
token: string
}
// Response
interface VerifyEmailResponse {
message: string
user: User
}
```
### POST /api/auth/forgot-password
**忘記密碼**
```typescript
// Request
interface ForgotPasswordRequest {
email: string
}
// Response
interface ForgotPasswordResponse {
message: string
}
```
### POST /api/auth/reset-password
**重設密碼**
```typescript
// Request
interface ResetPasswordRequest {
token: string
newPassword: string
confirmPassword: string
}
// Response
interface ResetPasswordResponse {
message: string
}
```
### POST /api/auth/google
**Google OAuth**
```typescript
// Request
interface GoogleAuthRequest {
idToken: string
}
// Response
interface GoogleAuthResponse {
user: User
accessToken: string
refreshToken: string
isNewUser: boolean
}
```
## 🎃 用戶 API
### GET /api/users/profile
**獲取用戶資料**
```typescript
// Response
interface ProfileResponse {
user: User
profile: UserProfile
stats: UserStats
}
```
### PUT /api/users/profile
**更新用戶資料**
```typescript
// Request
interface UpdateProfileRequest {
username?: string
displayName?: string
bio?: string
avatar?: File
preferredLanguage?: string
timezone?: string
}
// Response
interface UpdateProfileResponse {
user: User
message: string
}
```
### PUT /api/users/settings
**更新用戶設定**
```typescript
// Request
interface UpdateSettingsRequest {
dailyGoal?: number
reminderEnabled?: boolean
reminderTime?: string
emailNotifications?: boolean
pushNotifications?: boolean
theme?: 'light' | 'dark' | 'auto'
studyMode?: 'flip' | 'quiz' | 'typing'
}
```
### DELETE /api/users/account
**刪除帳號**
```typescript
// Request
interface DeleteAccountRequest {
password: string
reason?: string
}
// Response
interface DeleteAccountResponse {
message: string
}
```
## 🎓 詞卡 API
### GET /api/flashcard-sets
**獲取卡組列表**
```typescript
// Query parameters
interface GetSetsQuery {
page?: number
limit?: number
search?: string
tags?: string[]
orderBy?: 'created' | 'updated' | 'name' | 'progress'
order?: 'asc' | 'desc'
}
// Response
interface GetSetsResponse {
sets: FlashcardSet[]
pagination: Pagination
}
```
### POST /api/flashcard-sets
**創建卡組**
```typescript
// Request
interface CreateSetRequest {
name: string
description?: string
color?: string
tags?: string[]
isPublic?: boolean
}
// Response
interface CreateSetResponse {
set: FlashcardSet
}
```
### GET /api/flashcard-sets/:setId
**獲取卡組詳情**
```typescript
// Response
interface GetSetDetailResponse {
set: FlashcardSet
cards: Flashcard[]
stats: SetStats
}
```
### PUT /api/flashcard-sets/:setId
**更新卡組**
```typescript
// Request
interface UpdateSetRequest {
name?: string
description?: string
color?: string
tags?: string[]
isPublic?: boolean
}
```
### DELETE /api/flashcard-sets/:setId
**刪除卡組**
```typescript
// Response
interface DeleteSetResponse {
message: string
deletedCount: number
}
```
### GET /api/flashcards
**獲取詞卡列表**
```typescript
// Query parameters
interface GetCardsQuery {
setId?: string
search?: string
tags?: string[]
difficulty?: 'beginner' | 'intermediate' | 'advanced'
mastered?: boolean
favorite?: boolean
dueForReview?: boolean
page?: number
limit?: number
}
// Response
interface GetCardsResponse {
cards: Flashcard[]
pagination: Pagination
}
```
### POST /api/flashcards
**創建詞卡**
```typescript
// Request
interface CreateCardRequest {
setId: string
word: string
translation: string
partOfSpeech?: string
pronunciation?: string
definition?: string
exampleSentence?: string
exampleTranslation?: string
synonyms?: string[]
tags?: string[]
difficulty?: 'beginner' | 'intermediate' | 'advanced'
}
// Response
interface CreateCardResponse {
card: Flashcard
}
```
### PUT /api/flashcards/:cardId
**更新詞卡**
```typescript
// Request - same as CreateCardRequest but all fields optional
```
### DELETE /api/flashcards/:cardId
**刪除詞卡**
### POST /api/flashcards/batch
**批量操作**
```typescript
// Request
interface BatchOperationRequest {
operation: 'create' | 'update' | 'delete' | 'move'
cardIds?: string[]
data?: any
targetSetId?: string
}
// Response
interface BatchOperationResponse {
success: number
failed: number
errors?: any[]
}
```
## 🤖 AI 生成 API
### POST /api/ai/generate
**AI 生成詞卡**
```typescript
// Request
interface GenerateRequest {
mode: 'text' | 'theme'
input: string // text content or theme name
difficulty: 'beginner' | 'intermediate' | 'advanced'
count: number // 5-20
includeExamples?: boolean
includeSynonyms?: boolean
}
// Response
interface GenerateResponse {
cards: GeneratedCard[]
usage: {
promptTokens: number
completionTokens: number
totalTokens: number
}
}
interface GeneratedCard {
word: string
translation: string
partOfSpeech: string
pronunciation: string
definition: string
example: string
exampleTranslation: string
synonyms: string[]
difficulty: string
confidence: number // 0-1
}
```
### POST /api/ai/analyze-text
**分析文本難度**
```typescript
// Request
interface AnalyzeTextRequest {
text: string
}
// Response
interface AnalyzeTextResponse {
difficulty: 'beginner' | 'intermediate' | 'advanced'
wordCount: number
uniqueWords: number
averageSentenceLength: number
readabilityScore: number
suggestedWords: string[]
}
```
### GET /api/ai/quota
**查詢 AI 使用額度**
```typescript
// Response
interface QuotaResponse {
used: number
limit: number
resetAt: string
isPremium: boolean
}
```
## 📚 學習 API
### GET /api/learning/due
**獲取待複習詞卡**
```typescript
// Query parameters
interface GetDueCardsQuery {
setId?: string
limit?: number
}
// Response
interface GetDueCardsResponse {
cards: StudyCard[]
totalDue: number
}
interface StudyCard extends Flashcard {
learningRecord: LearningRecord
}
```
### POST /api/learning/review
**提交評分**
```typescript
// Request
interface ReviewRequest {
cardId: string
rating: 1 | 2 | 3 | 4 | 5
responseTime: number // milliseconds
mode: 'flip' | 'quiz' | 'typing'
}
// Response
interface ReviewResponse {
nextReviewDate: string
interval: number
easeFactor: number
masteryLevel: number
}
```
### GET /api/learning/stats
**獲取學習統計**
```typescript
// Query parameters
interface GetStatsQuery {
period: 'today' | 'week' | 'month' | 'year' | 'all'
setId?: string
}
// Response
interface StatsResponse {
cardsReviewed: number
cardsLearned: number
cardsMastered: number
studyTime: number // minutes
accuracy: number // percentage
streak: number
dailyGoalProgress: number
chart: {
dates: string[]
values: number[]
}
}
```
### POST /api/learning/session/start
**開始學習會話**
```typescript
// Request
interface StartSessionRequest {
mode: 'review' | 'learn' | 'cram'
setId?: string
}
// Response
interface StartSessionResponse {
sessionId: string
cards: StudyCard[]
}
```
### POST /api/learning/session/end
**結束學習會話**
```typescript
// Request
interface EndSessionRequest {
sessionId: string
}
// Response
interface EndSessionResponse {
duration: number
cardsStudied: number
accuracy: number
xpEarned: number
achievements: Achievement[]
}
```
## 🏆 成就 API
### GET /api/achievements
**獲取成就列表**
```typescript
// Response
interface GetAchievementsResponse {
unlocked: Achievement[]
locked: Achievement[]
recent: Achievement[]
}
```
### POST /api/achievements/:achievementId/claim
**領取成就獎勵**
```typescript
// Response
interface ClaimAchievementResponse {
xpEarned: number
totalXp: number
newLevel?: number
}
```
## 🔔 通知 API
### GET /api/notifications
**獲取通知**
```typescript
// Query parameters
interface GetNotificationsQuery {
unreadOnly?: boolean
type?: string
page?: number
limit?: number
}
// Response
interface GetNotificationsResponse {
notifications: Notification[]
unreadCount: number
pagination: Pagination
}
```
### PUT /api/notifications/:notificationId/read
**標記已讀**
### PUT /api/notifications/read-all
**全部標記已讀**
### DELETE /api/notifications/:notificationId
**刪除通知**
## 📁 匯出/匯入 API
### GET /api/export/cards
**匯出詞卡**
```typescript
// Query parameters
interface ExportQuery {
format: 'csv' | 'json' | 'anki'
setId?: string
cardIds?: string[]
}
// Response: File download
```
### POST /api/import/cards
**匯入詞卡**
```typescript
// Request: FormData with file
interface ImportRequest {
file: File
setId: string
format: 'csv' | 'json' | 'anki'
duplicateStrategy: 'skip' | 'replace' | 'create'
}
// Response
interface ImportResponse {
imported: number
skipped: number
errors: any[]
}
```
## 🔒 錯誤碼規範
```typescript
enum ErrorCode {
// Authentication
AUTH_INVALID_TOKEN = 'AUTH001',
AUTH_TOKEN_EXPIRED = 'AUTH002',
AUTH_UNAUTHORIZED = 'AUTH003',
// Validation
VALIDATION_ERROR = 'VAL001',
INVALID_INPUT = 'VAL002',
// Resource
NOT_FOUND = 'RES001',
ALREADY_EXISTS = 'RES002',
// Rate Limit
RATE_LIMIT_EXCEEDED = 'RATE001',
QUOTA_EXCEEDED = 'RATE002',
// Server
INTERNAL_ERROR = 'SRV001',
SERVICE_UNAVAILABLE = 'SRV002',
}
```
## 📊 Rate Limiting
| Endpoint | Free User | Premium User |
|----------|-----------|-------------|
| AI Generate | 50/day | Unlimited |
| API Calls | 1000/hour | 10000/hour |
| File Upload | 10MB | 50MB |
| Export | 5/day | Unlimited |
## 🔧 Webhooks
### 可用 Webhooks
1. `user.created` - 新用戶註冊
2. `user.deleted` - 用戶刪除
3. `subscription.created` - 訂閱創建
4. `subscription.updated` - 訂閱更新
5. `achievement.unlocked` - 成就解鎖
### Webhook Payload
```typescript
interface WebhookPayload {
event: string
timestamp: string
data: any
signature: string
}
```

View File

@ -0,0 +1,381 @@
# DramaLing 後端開發計劃
## 📋 現況評估
### ✅ 已完成項目
1. **前端 Prototype** - 完整的 UI/UX 流程
2. **功能需求文檔** - 詳細的功能規格
3. **資料模型設計** - 基本的數據結構
4. **API 規格** - 初步的 API 端點設計
5. **用戶流程** - 完整的業務邏輯流程
### ❌ 缺失項目
1. **詳細的資料庫 Schema**
2. **API 認證與授權機制細節**
3. **第三方服務整合規格**
4. **錯誤處理與日誌規範**
5. **效能與擴展性要求**
6. **部署與 DevOps 流程**
## 🎯 後端開發計劃
### Phase 1: 基礎架構設置 (第1週)
#### 1.1 專案初始化
```bash
# 技術棧
- Framework: Next.js 14 API Routes
- Database: Supabase (PostgreSQL)
- Authentication: NextAuth.js + Supabase Auth
- ORM: Prisma
- Validation: Zod
- API Documentation: Swagger/OpenAPI
```
#### 1.2 需要完成的規格文檔
- [ ] **資料庫 Schema 詳細設計**
- 所有表格結構
- 索引設計
- 關聯關係
- 資料遷移策略
- [ ] **環境配置規格**
- 開發/測試/生產環境變數
- 資料庫連接配置
- 第三方服務 API Keys
#### 1.3 實作任務
- [ ] 設置 Supabase 專案
- [ ] 配置 Prisma ORM
- [ ] 建立資料庫 Schema
- [ ] 設置環境變數管理
- [ ] 配置 TypeScript 類型定義
### Phase 2: 認證系統 (第1-2週)
#### 2.1 需要的規格
- [ ] **認證流程詳細設計**
```typescript
interface AuthSpec {
// Email/Password 註冊流程
emailRegistration: {
validation: ValidationRules
emailVerification: VerificationProcess
welcomeEmail: EmailTemplate
}
// OAuth 整合
googleOAuth: {
scopes: string[]
dataMapping: UserDataMapping
errorHandling: ErrorScenarios
}
// Session 管理
sessionManagement: {
tokenExpiry: TokenExpiryRules
refreshStrategy: RefreshTokenStrategy
deviceManagement: MultiDevicePolicy
}
}
```
- [ ] **安全性規格**
- 密碼加密策略
- Rate limiting 規則
- CORS 設定
- CSRF 保護
#### 2.2 實作任務
- [ ] 實作註冊 API
- [ ] 實作登入/登出 API
- [ ] 整合 Google OAuth
- [ ] 實作 Email 驗證
- [ ] 實作忘記密碼功能
- [ ] Session/Token 管理
### Phase 3: 核心業務邏輯 (第2-3週)
#### 3.1 AI 詞卡生成系統
**需要的規格:**
- [ ] **Gemini API 整合規格**
```typescript
interface GeminiIntegration {
apiKey: string
model: 'gemini-pro' | 'gemini-pro-vision'
prompts: {
textAnalysis: PromptTemplate
vocabularyExtraction: PromptTemplate
exampleGeneration: PromptTemplate
}
rateLimit: {
requestsPerMinute: number
tokensPerRequest: number
retryStrategy: RetryPolicy
}
errorHandling: {
apiErrors: ErrorMapping
fallbackStrategy: FallbackOptions
}
}
```
- [ ] **詞卡生成邏輯**
- 文本分析算法
- 難度評估規則
- 重複詞彙處理
- 批量生成優化
#### 3.2 學習系統
**需要的規格:**
- [ ] **SM-2 算法實現細節**
```typescript
interface SM2Algorithm {
// 初始參數
initialInterval: number
easinessFactor: number
// 計算規則
calculateNextReview: (rating: 1-5, currentInterval: number) => Date
updateEasinessFactor: (rating: 1-5, currentEF: number) => number
// 特殊情況處理
handleOverdue: (daysOverdue: number) => Adjustment
handleStreaks: (consecutiveCorrect: number) => Bonus
}
```
- [ ] **學習進度追蹤**
- 學習記錄存儲結構
- 統計數據計算方法
- 成就系統規則
#### 3.3 實作任務
- [ ] Gemini API 整合
- [ ] 詞卡生成 API
- [ ] 卡組管理 CRUD API
- [ ] SM-2 算法實現
- [ ] 學習記錄 API
- [ ] 統計數據 API
### Phase 4: 資料管理 (第3-4週)
#### 4.1 需要的規格
- [ ] **資料驗證規則**
```typescript
// 每個 API 端點的輸入驗證
const schemas = {
createFlashcard: z.object({
word: z.string().min(1).max(100),
translation: z.string().min(1).max(200),
example: z.string().max(500),
// ...
}),
// 其他 schemas...
}
```
- [ ] **資料存取層設計**
- Repository Pattern 實現
- 查詢優化策略
- 快取策略
- [ ] **批量操作規格**
- 批量導入限制
- 批量更新策略
- 事務處理規則
#### 4.2 實作任務
- [ ] 實作資料驗證中間件
- [ ] 建立 Repository 層
- [ ] 實作快取機制
- [ ] 批量操作 API
- [ ] 資料導出功能
### Phase 5: 第三方整合 (第4週)
#### 5.1 需要的規格
- [ ] **Email 服務整合**
```typescript
interface EmailService {
provider: 'Resend' | 'SendGrid'
templates: {
welcome: EmailTemplate
verification: EmailTemplate
passwordReset: EmailTemplate
learningReminder: EmailTemplate
}
sendingRules: {
retryPolicy: RetryPolicy
bounceHandling: BounceStrategy
}
}
```
- [ ] **支付系統整合** (未來功能)
- Stripe/綠界設置
- 訂閱方案邏輯
- 支付 Webhook 處理
- [ ] **分析服務整合**
- Google Analytics 事件
- 自定義指標追蹤
- 用戶行為分析
#### 5.2 實作任務
- [ ] Email 服務設置
- [ ] Email 模板實作
- [ ] 分析事件追蹤
- [ ] 錯誤追蹤 (Sentry)
### Phase 6: 效能與安全 (第5週)
#### 6.1 需要的規格
- [ ] **API Rate Limiting**
```typescript
interface RateLimiting {
rules: {
global: { requests: number, window: string }
perUser: { requests: number, window: string }
perEndpoint: Map<string, LimitRule>
}
storage: 'memory' | 'redis'
response: {
headers: RateLimitHeaders
errorMessage: string
}
}
```
- [ ] **安全性措施**
- SQL Injection 防護
- XSS 防護
- Input Sanitization
- API Key 管理
- [ ] **效能優化**
- 資料庫查詢優化
- N+1 問題解決
- API Response 壓縮
- CDN 設置
#### 6.2 實作任務
- [ ] 實作 Rate Limiting
- [ ] 安全中間件設置
- [ ] 查詢優化
- [ ] 快取策略實作
- [ ] 監控設置
### Phase 7: 測試與部署 (第5-6週)
#### 7.1 需要的規格
- [ ] **測試策略**
```typescript
interface TestStrategy {
unitTests: {
coverage: number // 目標: 80%
framework: 'Jest'
}
integrationTests: {
apiEndpoints: string[]
scenarios: TestScenario[]
}
e2eTests: {
userFlows: string[]
framework: 'Cypress' | 'Playwright'
}
}
```
- [ ] **部署流程**
- CI/CD Pipeline
- 環境變數管理
- 資料庫遷移策略
- 零停機部署
- [ ] **監控與日誌**
- 錯誤監控
- 效能監控
- 日誌聚合
- 告警設置
#### 7.2 實作任務
- [ ] 撰寫單元測試
- [ ] 撰寫整合測試
- [ ] 設置 CI/CD
- [ ] 部署到 Vercel
- [ ] 監控系統設置
## 📊 資源需求
### 技術資源
- **Supabase**: Free tier → Pro ($25/月)
- **Gemini API**: Free tier (60 requests/minute)
- **Vercel**: Free tier → Pro ($20/月)
- **Email Service**: Resend free tier (100/day)
- **Monitoring**: Sentry free tier
### 時間預估
- **總開發時間**: 5-6 週
- **每週工時**: 40 小時
- **總工時**: 200-240 小時
### 風險評估
1. **Gemini API 限制**: 需要實作 queue 系統
2. **資料庫效能**: 需要適當的索引設計
3. **成本控制**: 監控 API 使用量
4. **安全風險**: 定期安全審計
## 🚀 下一步行動
### 立即需要完成的規格文檔
1. **資料庫 Schema 完整設計** (2天)
2. **API 端點詳細規格** (2天)
3. **Gemini API 整合規格** (1天)
4. **認證流程詳細設計** (1天)
5. **錯誤處理規範** (1天)
### 開發優先順序
1. 🔴 **必要**: 認證系統、基本 CRUD、AI 生成
2. 🟡 **重要**: 學習系統、統計功能
3. 🟢 **可選**: 進階功能、優化
## 📝 檢查清單
### Week 1
- [ ] 完成所有規格文檔
- [ ] 設置開發環境
- [ ] 建立資料庫
- [ ] 實作認證 API
### Week 2-3
- [ ] 完成核心 API
- [ ] 整合 Gemini
- [ ] 實作學習邏輯
### Week 4-5
- [ ] 第三方服務整合
- [ ] 效能優化
- [ ] 安全加固
### Week 6
- [ ] 完整測試
- [ ] 部署上線
- [ ] 監控設置
## 🎯 成功指標
1. **功能完整性**: 100% 核心功能實作
2. **效能指標**: API 響應 < 200ms
3. **可靠性**: 99.9% uptime
4. **安全性**: 通過 OWASP 基本檢查
5. **測試覆蓋**: > 80% 代碼覆蓋率

View File

@ -0,0 +1,312 @@
# Claude Code 開發 SOP 流程
## 🎯 目標
確保使用 Claude Code 進行開發時,能夠穩定、有序地修正和增強現有功能。
## 📋 標準作業流程
### 1⃣ 需求分析階段
#### 1.1 明確定義需求
```markdown
## 修改需求
- **功能名稱**: [例如: 登入流程]
- **現況問題**: [描述現有問題]
- **期望結果**: [描述預期行為]
- **影響範圍**: [列出可能受影響的檔案/功能]
- **優先級**: [高/中/低]
```
#### 1.2 需求模板
```markdown
# 需求:[功能名稱]
## 現況
- 現有行為:
- 問題描述:
- 相關檔案:
## 期望
- 新行為:
- 成功標準:
- 測試案例:
## 限制
- 必須保留:
- 不可更動:
- 效能要求:
```
### 2⃣ 開發前準備
#### 2.1 環境檢查
```bash
# 1. 確認開發伺服器狀態
npm run dev
# 2. 確認 Git 狀態
git status
# 3. 創建新分支
git checkout -b feature/[功能名稱]
```
#### 2.2 備份關鍵檔案
```bash
# 備份將要修改的檔案
cp app/[檔案名].tsx app/[檔案名].tsx.backup
```
### 3⃣ 與 Claude Code 協作流程
#### 3.1 初始對話模板
```markdown
我需要修改 [功能名稱],請幫我:
1. 先讀取相關檔案:
- [檔案路徑1]
- [檔案路徑2]
2. 分析現有實作
3. 確認修改方案
4. 實施修改
要求:
- 保持現有功能不受影響
- 遵循現有程式碼風格
- 加入適當的錯誤處理
```
#### 3.2 階段性確認
```markdown
## 檢查點
- [ ] 需求理解正確
- [ ] 影響範圍評估完成
- [ ] 修改方案確認
- [ ] 程式碼修改完成
- [ ] 功能測試通過
- [ ] 無破壞現有功能
```
### 4⃣ 實作階段
#### 4.1 修改優先順序
1. **資料模型** (types, interfaces)
2. **API 邏輯** (API routes, services)
3. **狀態管理** (stores, contexts)
4. **UI 組件** (components)
5. **樣式調整** (CSS, Tailwind)
#### 4.2 程式碼修改原則
```typescript
// ❌ 避免:直接覆蓋整個檔案
// ✅ 建議:精準修改特定函數或區塊
// ❌ 避免:刪除未使用的程式碼
// ✅ 建議:先註解,確認無誤後再刪除
// ❌ 避免:一次修改多個功能
// ✅ 建議:單一功能逐步修改
```
#### 4.3 安全修改策略
```markdown
## 修改策略
1. **增量修改**:先新增,後替換,最後刪除舊代碼
2. **功能開關**:使用 feature flag 控制新舊功能
3. **漸進式重構**:保持功能可用的前提下逐步改進
```
### 5⃣ 測試驗證
#### 5.1 功能測試清單
```markdown
## 測試項目
- [ ] 正常流程測試
- [ ] 邊界條件測試
- [ ] 錯誤處理測試
- [ ] 響應式設計測試
- [ ] 效能測試
```
#### 5.2 測試指令
```bash
# 1. 本地測試
npm run dev
# 手動測試各項功能
# 2. 建置測試
npm run build
# 確認無編譯錯誤
# 3. TypeScript 檢查
npx tsc --noEmit
# 4. Lint 檢查
npm run lint
```
### 6⃣ 版本控制
#### 6.1 提交規範
```bash
# 提交格式
git add [修改的檔案]
git commit -m "[type]: [description]"
# Type 類型:
# feat: 新功能
# fix: 修復錯誤
# refactor: 重構
# style: 樣式修改
# docs: 文檔更新
# test: 測試相關
# chore: 其他修改
```
#### 6.2 提交前檢查
```markdown
## 提交前確認
- [ ] 功能正常運作
- [ ] 無 console.log 遺留
- [ ] 無敏感資訊外洩
- [ ] 程式碼已格式化
- [ ] 註解已更新
```
### 7⃣ 問題處理
#### 7.1 常見問題與解決
```markdown
## 編譯錯誤
1. 清除快取rm -rf .next
2. 重新安裝rm -rf node_modules && npm install
3. 重啟開發伺服器
## 樣式不生效
1. 檢查 Tailwind 類名
2. 清除瀏覽器快取
3. 檢查 CSS 載入順序
## 狀態不同步
1. 檢查 useState/useEffect
2. 確認資料流向
3. 使用 React DevTools 調試
```
#### 7.2 回滾策略
```bash
# 方法 1使用備份
cp app/[檔案名].tsx.backup app/[檔案名].tsx
# 方法 2Git 回滾
git checkout -- [檔案路徑]
# 方法 3回到上一個提交
git reset --hard HEAD^
```
### 8⃣ 最佳實踐
#### 8.1 與 Claude Code 溝通技巧
```markdown
## 有效溝通
1. **具體明確**:提供檔案路徑、行號、函數名
2. **分步進行**:複雜修改分解為多個小步驟
3. **即時反饋**:發現問題立即告知
4. **保存對話**:重要決策和方案記錄下來
```
#### 8.2 開發習慣
```markdown
## 良好習慣
- ✅ 每完成一個小功能就測試
- ✅ 定期提交到 Git
- ✅ 保持開發伺服器運行
- ✅ 使用瀏覽器開發工具監控
- ✅ 記錄修改日誌
```
### 9⃣ 文檔維護
#### 9.1 修改記錄模板
```markdown
# 修改記錄
## [日期] - [功能名稱]
### 修改內容
- 檔案:[路徑]
- 改動:[描述]
- 原因:[為什麼修改]
### 測試結果
- [x] 功能測試通過
- [x] 無副作用
- [x] 效能正常
### 備註
[任何特殊說明]
```
#### 9.2 知識累積
```markdown
## 經驗總結
- 問題:[遇到的問題]
- 解決:[解決方案]
- 學習:[獲得的經驗]
- 建議:[未來改進建議]
```
## 🔄 持續改進
### 定期檢視
- 每週回顧開發流程
- 記錄痛點和改進點
- 更新 SOP 文檔
### 工具優化
- 建立程式碼片段庫
- 自動化重複任務
- 優化開發環境配置
## 📊 檢查清單總覽
```markdown
# 快速檢查清單
## 開始前
- [ ] 需求明確
- [ ] 環境就緒
- [ ] 分支創建
## 開發中
- [ ] 讀取相關檔案
- [ ] 分析影響範圍
- [ ] 逐步實施修改
- [ ] 即時測試驗證
## 完成後
- [ ] 全面功能測試
- [ ] 程式碼檢查
- [ ] Git 提交
- [ ] 文檔更新
```
## 🚀 快速開始指令
```bash
# 1. 開始新功能
git checkout -b feature/new-feature
npm run dev
# 2. 測試修改
npm run build
npx tsc --noEmit
# 3. 提交更改
git add .
git commit -m "feat: add new feature"
git push origin feature/new-feature
```

View File

@ -0,0 +1,465 @@
# DramaLing 資料庫 Schema 詳細設計
## 🗄️ 資料庫架構
### 技術棧
- **Database**: PostgreSQL 15+ (via Supabase)
- **ORM**: Prisma
- **Migration Tool**: Prisma Migrate
- **Backup**: Daily automated backups via Supabase
## 📦 資料表設計
### 1. 用戶相關表
#### users
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255), -- NULL for OAuth users
provider VARCHAR(20) DEFAULT 'email', -- email, google, facebook
provider_id VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
avatar_url TEXT,
display_name VARCHAR(100),
bio TEXT,
preferred_language VARCHAR(10) DEFAULT 'zh-TW',
timezone VARCHAR(50) DEFAULT 'Asia/Taipei',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
is_premium BOOLEAN DEFAULT FALSE,
premium_expires_at TIMESTAMP WITH TIME ZONE,
-- Indexes
INDEX idx_users_email (email),
INDEX idx_users_username (username),
INDEX idx_users_provider (provider, provider_id)
);
```
#### user_profiles
```sql
CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
daily_goal INTEGER DEFAULT 10,
reminder_enabled BOOLEAN DEFAULT TRUE,
reminder_time TIME DEFAULT '20:00:00',
study_streak INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
total_study_time INTEGER DEFAULT 0, -- in minutes
total_words_learned INTEGER DEFAULT 0,
experience_points INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
achievements JSONB DEFAULT '[]',
preferences JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
#### sessions
```sql
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
refresh_token VARCHAR(255) UNIQUE,
device_info JSONB,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sessions_token (token),
INDEX idx_sessions_user (user_id),
INDEX idx_sessions_expires (expires_at)
);
```
### 2. 詞卡相關表
#### flashcard_sets
```sql
CREATE TABLE flashcard_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
cover_image_url TEXT,
color VARCHAR(20) DEFAULT '#3B82F6',
is_public BOOLEAN DEFAULT FALSE,
is_system BOOLEAN DEFAULT FALSE,
tags TEXT[] DEFAULT '{}',
source_type VARCHAR(50), -- 'manual', 'ai_generated', 'imported'
source_content TEXT, -- Original text for AI generated sets
card_count INTEGER DEFAULT 0,
completed_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_studied_at TIMESTAMP WITH TIME ZONE,
INDEX idx_sets_user (user_id),
INDEX idx_sets_public (is_public),
INDEX idx_sets_tags (tags) USING GIN
);
```
#### flashcards
```sql
CREATE TABLE flashcards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
set_id UUID NOT NULL REFERENCES flashcard_sets(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Core content
word VARCHAR(200) NOT NULL,
part_of_speech VARCHAR(50),
pronunciation VARCHAR(200),
audio_url TEXT,
-- Translations
translation VARCHAR(500) NOT NULL,
definition TEXT,
definition_cn TEXT,
-- Examples
example_sentence TEXT,
example_translation TEXT,
additional_examples JSONB DEFAULT '[]',
-- Learning aids
synonyms TEXT[] DEFAULT '{}',
antonyms TEXT[] DEFAULT '{}',
related_words TEXT[] DEFAULT '{}',
memory_tips TEXT,
notes TEXT,
image_url TEXT,
-- Metadata
difficulty_level VARCHAR(20) DEFAULT 'intermediate',
frequency_rank INTEGER,
tags TEXT[] DEFAULT '{}',
context_tags TEXT[] DEFAULT '{}', -- 'business', 'casual', 'academic', etc.
-- Learning data
is_favorite BOOLEAN DEFAULT FALSE,
is_mastered BOOLEAN DEFAULT FALSE,
mastery_level INTEGER DEFAULT 0, -- 0-100
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cards_set (set_id),
INDEX idx_cards_user (user_id),
INDEX idx_cards_word (word),
INDEX idx_cards_mastery (mastery_level),
INDEX idx_cards_tags (tags) USING GIN,
UNIQUE(set_id, word)
);
```
### 3. 學習系統表
#### learning_records
```sql
CREATE TABLE learning_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
flashcard_id UUID NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
-- SM-2 Algorithm fields
ease_factor DECIMAL(3,2) DEFAULT 2.5,
interval INTEGER DEFAULT 1, -- days
repetitions INTEGER DEFAULT 0,
-- Review data
last_reviewed_at TIMESTAMP WITH TIME ZONE,
next_review_at TIMESTAMP WITH TIME ZONE,
-- Performance tracking
total_reviews INTEGER DEFAULT 0,
correct_reviews INTEGER DEFAULT 0,
incorrect_reviews INTEGER DEFAULT 0,
average_rating DECIMAL(2,1),
-- Time tracking
total_study_time INTEGER DEFAULT 0, -- seconds
last_study_duration INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_learning_user (user_id),
INDEX idx_learning_card (flashcard_id),
INDEX idx_learning_next_review (next_review_at),
UNIQUE(user_id, flashcard_id)
);
```
#### review_logs
```sql
CREATE TABLE review_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
flashcard_id UUID NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
learning_record_id UUID REFERENCES learning_records(id) ON DELETE CASCADE,
-- Review details
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
response_time INTEGER, -- milliseconds
review_type VARCHAR(20), -- 'scheduled', 'practice', 'cram'
mode VARCHAR(20), -- 'flip', 'quiz', 'typing'
-- Context
session_id UUID,
device_type VARCHAR(20),
reviewed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_review_user (user_id),
INDEX idx_review_card (flashcard_id),
INDEX idx_review_date (reviewed_at)
);
```
#### study_sessions
```sql
CREATE TABLE study_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Session info
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP WITH TIME ZONE,
duration INTEGER, -- seconds
-- Performance
cards_studied INTEGER DEFAULT 0,
cards_mastered INTEGER DEFAULT 0,
correct_count INTEGER DEFAULT 0,
incorrect_count INTEGER DEFAULT 0,
accuracy_rate DECIMAL(5,2),
-- Context
study_mode VARCHAR(20), -- 'review', 'learn', 'cram'
set_id UUID REFERENCES flashcard_sets(id) ON DELETE SET NULL,
device_type VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sessions_user_date (user_id, start_time)
);
```
### 4. AI 生成相關表
#### ai_generation_logs
```sql
CREATE TABLE ai_generation_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Request info
input_text TEXT,
input_type VARCHAR(50), -- 'text', 'theme'
theme VARCHAR(100),
difficulty_level VARCHAR(20),
card_count INTEGER,
-- Generation details
model_used VARCHAR(50),
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
generation_time INTEGER, -- milliseconds
-- Results
generated_cards JSONB,
cards_saved INTEGER,
set_id UUID REFERENCES flashcard_sets(id) ON DELETE SET NULL,
-- Status
status VARCHAR(20), -- 'pending', 'completed', 'failed'
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ai_gen_user (user_id),
INDEX idx_ai_gen_date (created_at)
);
```
### 5. 統計與分析表
#### daily_stats
```sql
CREATE TABLE daily_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
-- Learning metrics
cards_reviewed INTEGER DEFAULT 0,
cards_learned INTEGER DEFAULT 0,
cards_mastered INTEGER DEFAULT 0,
study_time INTEGER DEFAULT 0, -- minutes
sessions_count INTEGER DEFAULT 0,
-- Performance
average_accuracy DECIMAL(5,2),
average_ease_factor DECIMAL(3,2),
-- Streaks
streak_maintained BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date),
INDEX idx_daily_stats_user_date (user_id, date)
);
```
#### achievements
```sql
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
icon_url TEXT,
category VARCHAR(50),
points INTEGER DEFAULT 0,
requirement_type VARCHAR(50), -- 'streak', 'total_words', 'accuracy', etc.
requirement_value INTEGER,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
#### user_achievements
```sql
CREATE TABLE user_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
achievement_id UUID NOT NULL REFERENCES achievements(id) ON DELETE CASCADE,
unlocked_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
progress INTEGER DEFAULT 0,
UNIQUE(user_id, achievement_id),
INDEX idx_user_achievements (user_id)
);
```
### 6. 通知與設定表
#### notifications
```sql
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'reminder', 'achievement', 'streak', etc.
title VARCHAR(200) NOT NULL,
message TEXT,
data JSONB,
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
INDEX idx_notifications_user (user_id),
INDEX idx_notifications_unread (user_id, is_read)
);
```
#### user_settings
```sql
CREATE TABLE user_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Notification settings
email_notifications BOOLEAN DEFAULT TRUE,
push_notifications BOOLEAN DEFAULT TRUE,
reminder_notifications BOOLEAN DEFAULT TRUE,
achievement_notifications BOOLEAN DEFAULT TRUE,
-- Learning preferences
default_study_mode VARCHAR(20) DEFAULT 'flip',
auto_play_audio BOOLEAN DEFAULT TRUE,
show_pronunciation BOOLEAN DEFAULT TRUE,
review_order VARCHAR(20) DEFAULT 'due_date', -- 'due_date', 'random', 'difficulty'
-- Display preferences
theme VARCHAR(20) DEFAULT 'light',
font_size VARCHAR(20) DEFAULT 'medium',
card_layout VARCHAR(20) DEFAULT 'standard',
-- Privacy
profile_visibility VARCHAR(20) DEFAULT 'private',
share_progress BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
## 🔗 關聯關係
### 主要關係
1. **users → flashcard_sets**: 一對多 (用戶擁有多個卡組)
2. **flashcard_sets → flashcards**: 一對多 (卡組包含多個詞卡)
3. **users → learning_records**: 一對多 (用戶的學習記錄)
4. **flashcards → learning_records**: 一對多 (每個詞卡的學習記錄)
5. **learning_records → review_logs**: 一對多 (學習記錄的詳細日誌)
## 🔑 索引策略
### 主要索引
1. **用戶查詢**: email, username
2. **詞卡查詢**: word, set_id, user_id
3. **學習查詢**: next_review_at, user_id
4. **統計查詢**: user_id + date
5. **全文搜索**: 使用 PostgreSQL 的 GIN 索引於 tags 和 JSONB 欄位
## 🔄 資料遷移策略
### 版本控制
```bash
# Prisma migration commands
npx prisma migrate dev --name init
npx prisma migrate deploy
npx prisma migrate status
```
### 備份策略
1. **自動備份**: 每日 02:00 UTC
2. **保留期限**: 30 天
3. **地理位置**: 跨區域複製
4. **恢復測試**: 每季度一次
## 📊 效能優化
### 查詢優化
1. **分頁**: 使用 cursor-based pagination
2. **快取**: Redis 快取熱門查詢
3. **預載**: Eager loading 避免 N+1 問題
4. **分區**: 考慮將 review_logs 按月份分區
### 容量規劃
- **預期用戶數**: 10,000
- **平均詞卡/用戶**: 1,000
- **平均評論/詞卡**: 50
- **總資料量**: ~10GB
## 🔒 安全考慮
1. **Row Level Security (RLS)**: Supabase 提供
2. **加密**: 敏感資料加密存儲
3. **稽核**: 所有資料變更記錄
4. **資料遮罩**: 個人資訊不外洩

View File

@ -0,0 +1,169 @@
# 🚀 簡化開發流程 (開發階段專用)
## 核心原則:快速迭代、立即看結果
---
## 📝 超簡單三步驟流程
### 1⃣ 告訴我要改什麼
```markdown
「我要修改 [功能名稱]」
「現在是 [A],我要改成 [B]」
```
### 2⃣ 我幫你改
- 我會先看檔案
- 直接修改
- 告訴你改了什麼
### 3⃣ 你測試確認
- 瀏覽器看結果
- OK → 繼續下一個
- 不OK → 告訴我,馬上修
---
## 💬 對話範例
### 範例 1改文字
```
「Dashboard 的標題要改成『學習中心』」
我:改好了
你:看瀏覽器確認
```
### 範例 2改功能
```
你:「登入成功後要跳到 /dashboard 不是 /home」
我:改好了,在 login/page.tsx 第 XX 行
你:測試 OK
```
### 範例 3改樣式
```
你:「按鈕太小,改大一點,顏色改成綠色」
我:改好了
你:「再大一點」
我:調整了
```
---
## 🔧 常用需求快速指令
### UI 調整
- 「把 X 改成 Y」
- 「這個太小/太大」
- 「顏色改成 #XXXXXX
- 「加個按鈕做 XXX」
### 流程修改
- 「點擊後要跳到 XXX 頁」
- 「這個欄位要必填」
- 「加個 loading 狀態」
- 「錯誤訊息改成 XXX」
### 資料調整
- 「這個假資料改成 XXX」
- 「多加幾筆測試資料」
- 「預設值改成 XXX」
---
## ⚡ 快速修復
### 頁面壞了
```
你:「/flashcards 打不開」
我:馬上修復
```
### 樣式跑版
```
你:「手機版跑版了」
我:調整 responsive
```
### 功能失效
```
你:「按鈕點了沒反應」
我:檢查並修復
```
---
## 📁 檔案位置速查
```
頁面 → /app/[頁面名]/page.tsx
樣式 → /app/globals.css 或 Tailwind classes
設定 → /next.config.mjs, /tailwind.config.ts
```
---
## 🎯 一句話原則
**「先做出來,能動就好,之後再優化」**
---
## 💡 小技巧
1. **不用管 Git**:開發階段改了再說,最後再整理 commit
2. **不用寫文檔**:先把功能做出來
3. **不用完美**70% 完成度就可以繼續下一個
4. **隨時問**:卡住就問,不要糾結
---
## 🔄 每日收尾(可選)
結束前跟我說:
```
「今天改完了,幫我 git add + commit」
```
我會幫你:
- 整理今天的修改
- 寫 commit message
- 推上 Git
---
## ⚠️ 唯一要注意的
**開發伺服器要開著**
```bash
npm run dev
```
如果壞了:
1. Ctrl+C 停止
2. `npm run dev` 重開
3. 還是壞了就叫我
---
## 🚫 不用管的事
- ❌ 測試
- ❌ 文檔
- ❌ Code Review
- ❌ 效能優化
- ❌ TypeScript 錯誤(如果能動)
- ❌ Console 警告(如果能動)
**這些等要上線前再處理**
---
## 📢 記住
> 現在是開發階段,目標是:
> 1. 快速看到結果
> 2. 快速調整
> 3. 快速迭代
**完美是上線前的事!**

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB