feat: 實現完整的詞彙儲存功能與UI設計優化

- 新增後端批量詞卡保存API (POST /api/flashcards/batch)
- 實現前端詞卡選擇對話框組件 (CardSelectionDialog)
- 優化句子分析頁面設計,以句子為主體
- 重新設計ClickableTextV2詞彙popup,採用現代玻璃morphism風格
- 改進詞卡清單頁面,採用簡潔的清單設計
- 添加CEFR等級標註與六級顏色設計
- 新增收藏功能與收藏詞卡tab頁面
- 創建詞彙版型展示頁面 (vocab-designs)
- 建立完整的UI/UX設計規範文件
- 撰寫詞彙生成與儲存系統技術規格文件
- 使用假數據實現快速測試功能
- 優化例句圖片展示與播放按鈕設計

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-20 17:52:22 +08:00
parent b8aa0214f0
commit 4e69030bc2
23 changed files with 8511 additions and 450 deletions

File diff suppressed because it is too large Load Diff

View File

@ -322,6 +322,112 @@ public class FlashcardsController : ControllerBase
});
}
}
[HttpPost("batch")]
public async Task<ActionResult> BatchCreateFlashcards([FromBody] BatchCreateFlashcardsRequest request)
{
try
{
var userId = GetUserId();
if (request.Cards == null || !request.Cards.Any())
return BadRequest(new { Success = false, Error = "No cards provided" });
if (request.Cards.Count > 50)
return BadRequest(new { Success = false, Error = "Maximum 50 cards per batch" });
// 確定要使用的卡組ID
Guid cardSetId;
if (request.CardSetId.HasValue)
{
var cardSet = await _context.CardSets
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
if (cardSet == null)
return NotFound(new { Success = false, Error = "Card set not found" });
cardSetId = request.CardSetId.Value;
}
else
{
cardSetId = await GetOrCreateDefaultCardSetAsync(userId);
}
var savedCards = new List<object>();
var errors = new List<string>();
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
foreach (var cardRequest in request.Cards)
{
try
{
var flashcard = new Flashcard
{
Id = Guid.NewGuid(),
UserId = userId,
CardSetId = cardSetId,
Word = cardRequest.Word.Trim(),
Translation = cardRequest.Translation.Trim(),
Definition = cardRequest.Definition.Trim(),
PartOfSpeech = cardRequest.PartOfSpeech?.Trim(),
Pronunciation = cardRequest.Pronunciation?.Trim(),
Example = cardRequest.Example?.Trim(),
ExampleTranslation = cardRequest.ExampleTranslation?.Trim()
};
_context.Flashcards.Add(flashcard);
savedCards.Add(new
{
Id = flashcard.Id,
Word = flashcard.Word,
Translation = flashcard.Translation
});
}
catch (Exception ex)
{
errors.Add($"Failed to save card '{cardRequest.Word}': {ex.Message}");
}
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return Ok(new
{
Success = true,
Data = new
{
SavedCards = savedCards,
SavedCount = savedCards.Count,
ErrorCount = errors.Count,
Errors = errors
},
Message = $"Successfully saved {savedCards.Count} flashcards"
});
}
catch (Exception ex)
{
await transaction.RollbackAsync();
throw;
}
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new { Success = false, Error = "Unauthorized" });
}
catch (Exception ex)
{
return StatusCode(500, new
{
Success = false,
Error = "Failed to create flashcards",
Timestamp = DateTime.UtcNow
});
}
}
}
// DTOs
@ -347,4 +453,10 @@ public class UpdateFlashcardRequest
public string? Example { get; set; }
public string? ExampleTranslation { get; set; }
public bool? IsFavorite { get; set; }
}
public class BatchCreateFlashcardsRequest
{
public Guid? CardSetId { get; set; }
public List<CreateFlashcardRequest> Cards { get; set; } = new();
}

View File

@ -18,10 +18,36 @@ function FlashcardsContent() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 臨時使用學習功能的例句圖片作為測試
const getExampleImage = (word: string): string => {
const imageMap: {[key: string]: string} = {
'brought': '/images/examples/bring_up.png',
'instincts': '/images/examples/instinct.png',
'warrants': '/images/examples/warrant.png',
'elaborate': '/images/examples/bring_up.png', // 作為預設
'hello': '/images/examples/instinct.png',
'good': '/images/examples/warrant.png'
}
// 根據詞彙返回對應圖片,如果沒有則返回隨機圖片
return imageMap[word?.toLowerCase()] ||
imageMap[Object.keys(imageMap)[Math.floor(Math.random() * Object.keys(imageMap).length)]]
}
// Form states
const [showForm, setShowForm] = useState(false)
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
// 添加假資料用於展示CEFR效果
const mockFlashcards = [
{ id: 'mock1', word: 'hello', translation: '你好', partOfSpeech: 'interjection', pronunciation: '/həˈloʊ/', masteryLevel: 95, timesReviewed: 15, isFavorite: true, nextReviewDate: '2025-09-21', cardSet: { name: '基礎詞彙', color: 'bg-blue-500' }, difficultyLevel: 'A1' },
{ id: 'mock2', word: 'beautiful', translation: '美麗的', partOfSpeech: 'adjective', pronunciation: '/ˈbjuːtɪfəl/', masteryLevel: 78, timesReviewed: 8, isFavorite: false, nextReviewDate: '2025-09-22', cardSet: { name: '描述詞彙', color: 'bg-green-500' }, difficultyLevel: 'A2' },
{ id: 'mock3', word: 'understand', translation: '理解', partOfSpeech: 'verb', pronunciation: '/ˌʌndərˈstænd/', masteryLevel: 65, timesReviewed: 12, isFavorite: true, nextReviewDate: '2025-09-20', cardSet: { name: '常用動詞', color: 'bg-yellow-500' }, difficultyLevel: 'B1' },
{ id: 'mock4', word: 'elaborate', translation: '詳細說明', partOfSpeech: 'verb', pronunciation: '/ɪˈlæbərət/', masteryLevel: 45, timesReviewed: 5, isFavorite: false, nextReviewDate: '2025-09-19', cardSet: { name: '高級詞彙', color: 'bg-purple-500' }, difficultyLevel: 'B2' },
{ id: 'mock5', word: 'sophisticated', translation: '精密的', partOfSpeech: 'adjective', pronunciation: '/səˈfɪstɪkeɪtɪd/', masteryLevel: 30, timesReviewed: 3, isFavorite: true, nextReviewDate: '2025-09-18', cardSet: { name: '進階詞彙', color: 'bg-indigo-500' }, difficultyLevel: 'C1' },
{ id: 'mock6', word: 'ubiquitous', translation: '無處不在的', partOfSpeech: 'adjective', pronunciation: '/juːˈbɪkwɪtəs/', masteryLevel: 15, timesReviewed: 1, isFavorite: false, nextReviewDate: '2025-09-17', cardSet: { name: '學術詞彙', color: 'bg-red-500' }, difficultyLevel: 'C2' }
]
// Load data from API
useEffect(() => {
loadCardSets()
@ -109,13 +135,54 @@ function FlashcardsContent() {
}
}
// Filter data
const handleToggleFavorite = async (card: any) => {
try {
// 如果是假資料,只更新本地狀態
if (card.id.startsWith('mock')) {
const updatedMockCards = mockFlashcards.map(mockCard =>
mockCard.id === card.id
? { ...mockCard, isFavorite: !mockCard.isFavorite }
: mockCard
)
// 這裡需要更新state但由於是const我們直接重新載入頁面來模擬效果
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
return
}
// 真實API調用
const result = await flashcardsService.toggleFavorite(card.id)
if (result.success) {
loadFlashcards()
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
} else {
alert(result.error || '操作失敗')
}
} catch (err) {
alert('操作失敗,請重試')
}
}
// 獲取CEFR等級顏色
const getCEFRColor = (level: string) => {
switch (level) {
case 'A1': return 'bg-green-100 text-green-700 border-green-200' // 淺綠 - 最基礎
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200' // 淺藍 - 基礎
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200' // 淺黃 - 中級
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200' // 淺橙 - 中高級
case 'C1': return 'bg-red-100 text-red-700 border-red-200' // 淺紅 - 高級
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200' // 淺紫 - 精通
default: return 'bg-gray-100 text-gray-700 border-gray-200' // 預設灰色
}
}
// Filter data - 合併真實資料和假資料
const filteredSets = cardSets.filter(set =>
set.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
set.description.toLowerCase().includes(searchTerm.toLowerCase())
)
const filteredCards = flashcards.filter(card => {
const allCards = [...flashcards, ...mockFlashcards] // 合併真實和假資料
const filteredCards = allCards.filter(card => {
if (searchTerm) {
return card.word?.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.translation?.toLowerCase().includes(searchTerm.toLowerCase())
@ -191,6 +258,17 @@ function FlashcardsContent() {
>
({filteredCards.length})
</button>
<button
onClick={() => setActiveTab('favorites')}
className={`pb-4 px-1 border-b-2 font-medium text-sm flex items-center gap-1 ${
activeTab === 'favorites'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="text-yellow-500"></span>
({allCards.filter(card => card.isFavorite).length})
</button>
<button
onClick={() => {
const defaultSet = cardSets.find(set => set.isDefault)
@ -279,6 +357,142 @@ function FlashcardsContent() {
</div>
)}
{/* Favorites Tab */}
{activeTab === 'favorites' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="text-yellow-500"></span>
({allCards.filter(card => card.isFavorite).length} )
</h3>
</div>
{allCards.filter(card => card.isFavorite).length === 0 ? (
<div className="text-center py-12">
<div className="text-yellow-500 text-6xl mb-4"></div>
<p className="text-gray-500 mb-4"></p>
<p className="text-sm text-gray-400"></p>
</div>
) : (
<div className="space-y-2">
{allCards.filter(card => card.isFavorite).map(card => (
<div key={card.id} className="bg-white border border-yellow-200 rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer relative ring-1 ring-yellow-100">
<div
className="p-4"
onClick={() => {
alert(`即將進入「${card.word}」的詳細頁面 (開發中)`)
}}
>
{/* 收藏詞卡內容 - 與普通詞卡相同的佈局 */}
<div className="flex items-center justify-between">
<div className="absolute top-3 right-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor(card.difficultyLevel || 'A1')}`}>
{card.difficultyLevel || 'A1'}
</span>
</div>
<div className="flex items-center gap-4">
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
<img
src={getExampleImage(card.word)}
alt={`${card.word} example`}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement!.innerHTML = `
<div class="text-gray-400 text-xs text-center">
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
`
}}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">{card.word || '未設定'}</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">{card.translation || '未設定'}</span>
{card.pronunciation && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{card.pronunciation}</span>
<button
onClick={(e) => {
e.stopPropagation()
console.log(`播放 ${card.word} 的發音`)
}}
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>: {card.cardSet.name}</span>
<span>: {card.masteryLevel}%</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleToggleFavorite(card)}
className="p-2 text-yellow-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg transition-colors"
title="取消收藏"
>
<svg className="w-4 h-4" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
onClick={() => handleEdit(card)}
className="p-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors"
title="編輯詞卡"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(card)}
className="p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
title="刪除詞卡"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* All Cards Tab */}
{activeTab === 'all-cards' && (
<div>
@ -325,46 +539,130 @@ function FlashcardsContent() {
</Link>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2">
{filteredCards.map(card => (
<div key={card.id} className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-4">
<div key={card.id} className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer relative">
<div
className="p-4"
onClick={() => {
// TODO: 導航到詞卡詳細頁面
alert(`即將進入「${card.word}」的詳細頁面 (開發中)`)
}}
>
<div className="flex items-center justify-between">
{/* 詞卡右上角CEFR標註 */}
<div className="absolute top-3 right-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor(card.difficultyLevel || 'A1')}`}>
{card.difficultyLevel || 'A1'}
</span>
</div>
{/* 左側:詞彙基本信息 */}
<div className="flex items-center gap-4">
{/* 例句圖片 - 超大尺寸 */}
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
<img
src={getExampleImage(card.word)}
alt={`${card.word} example`}
className="w-full h-full object-cover"
onError={(e) => {
// 圖片載入失敗時顯示佔位符
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement!.innerHTML = `
<div class="text-gray-400 text-xs text-center">
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
`
}}
/>
</div>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-semibold text-lg">{card.word || '未設定'}</h4>
<span className="text-sm text-gray-500">{card.partOfSpeech}</span>
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">{card.word || '未設定'}</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">{card.translation || '未設定'}</span>
{card.pronunciation && (
<span className="text-sm text-blue-600">{card.pronunciation}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{card.pronunciation}</span>
<button
onClick={(e) => {
e.stopPropagation()
// TODO: 播放發音
console.log(`播放 ${card.word} 的發音`)
}}
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
)}
</div>
<p className="text-gray-700 mt-1">{card.translation || '未設定'}</p>
{card.example && (
<p className="text-sm text-gray-600 mt-2 italic">: {card.example}</p>
)}
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
{/* 簡要統計 */}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>: {card.cardSet.name}</span>
<span>: {card.masteryLevel}/5</span>
<span>: {card.timesReviewed} </span>
<span>: {new Date(card.nextReviewDate).toLocaleDateString()}</span>
<span>: {card.masteryLevel}%</span>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleEdit(card)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
</button>
<button
onClick={() => handleDelete(card)}
className="text-red-600 hover:text-red-800 text-sm"
>
</button>
{/* 右側:操作按鈕 */}
<div className="flex items-center gap-3">
{/* 快速操作按鈕 */}
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleToggleFavorite(card)}
className={`p-2 rounded-lg transition-colors ${
card.isFavorite
? 'text-yellow-500 hover:text-yellow-600 hover:bg-yellow-50'
: 'text-gray-400 hover:text-yellow-500 hover:bg-yellow-50'
}`}
title={card.isFavorite ? "取消收藏" : "加入收藏"}
>
<svg className="w-4 h-4" fill={card.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
onClick={() => handleEdit(card)}
className="p-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors"
title="編輯詞卡"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(card)}
className="p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
title="刪除詞卡"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* 進入詳細頁面箭頭 */}
<div className="text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,21 +1,16 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation'
import { ClickableTextV2 } from '@/components/ClickableTextV2'
import { GrammarCorrectionPanel } from '@/components/GrammarCorrectionPanel'
import { flashcardsService } from '@/lib/services/flashcards'
import Link from 'next/link'
function GenerateContent() {
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
const [textInput, setTextInput] = useState('')
const [extractionType, setExtractionType] = useState<'vocabulary' | 'smart'>('vocabulary')
const [cardCount, setCardCount] = useState(10)
const [isGenerating, setIsGenerating] = useState(false)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [generatedCards, setGeneratedCards] = useState<any[]>([])
const [showPreview, setShowPreview] = useState(false)
const [showAnalysisView, setShowAnalysisView] = useState(false)
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)
const [sentenceMeaning, setSentenceMeaning] = useState('')
@ -23,11 +18,11 @@ function GenerateContent() {
const [finalText, setFinalText] = useState('')
const [usageCount, setUsageCount] = useState(0)
const [isPremium] = useState(true)
// 移除快取狀態,每次都是新查詢
// 處理句子分析 - 使用真實AI API
// 處理句子分析 - 使用假數據進行快速測試
const handleAnalyzeSentence = async () => {
console.log('🚀 handleAnalyzeSentence 被調用')
console.log('🚀 handleAnalyzeSentence 被調用 (假數據模式)')
console.log('📝 輸入文本:', textInput)
if (!textInput.trim()) {
@ -35,70 +30,185 @@ function GenerateContent() {
return
}
// 取得用戶設定的程度
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2';
console.log('🎯 使用用戶程度:', userLevel);
if (!isPremium && usageCount >= 5) {
console.log('❌ 使用次數超限')
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
return
}
console.log('✅ 開始分析,設定 loading 狀態')
setIsAnalyzing(true)
try {
// 調用真實的後端AI API
console.log('🌐 發送API請求到:', 'http://localhost:5000/api/ai/analyze-sentence')
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
inputText: textInput,
userLevel: userLevel, // 傳遞用戶程度
analysisMode: 'full'
})
})
// 模擬API延遲
await new Promise(resolve => setTimeout(resolve, 1500))
console.log('📡 API響應狀態:', response.status, response.statusText)
// 生成假的分析數據
const mockAnalysis = generateMockAnalysis(textInput)
if (!response.ok) {
throw new Error(`API 錯誤: ${response.status}`)
}
setSentenceAnalysis(mockAnalysis.wordAnalysis)
setSentenceMeaning(mockAnalysis.sentenceTranslation)
setGrammarCorrection(mockAnalysis.grammarCorrection)
setFinalText(mockAnalysis.finalText)
setShowAnalysisView(true)
setUsageCount(prev => prev + 1)
const result = await response.json()
console.log('📦 完整API響應:', result)
if (result.success) {
// 移除快取狀態,每次都是新的 AI 分析
// 使用真實AI的回應資料 - 支援兩種key格式 (小寫/大寫)
setSentenceAnalysis(result.data.wordAnalysis || result.data.WordAnalysis || {})
// 安全處理 sentenceMeaning - 支援兩種key格式 (小寫/大寫)
const sentenceMeaning = result.data.sentenceMeaning || result.data.SentenceMeaning || {}
const translation = sentenceMeaning.Translation || sentenceMeaning.translation || '翻譯處理中...'
setSentenceMeaning(translation)
setGrammarCorrection(result.data.grammarCorrection || result.data.GrammarCorrection || { hasErrors: false })
setFinalText(result.data.finalAnalysisText || result.data.FinalAnalysisText || textInput)
setShowAnalysisView(true)
setUsageCount(prev => prev + 1)
} else {
throw new Error(result.error || '分析失敗')
}
console.log('✅ 假數據分析完成:', mockAnalysis)
} catch (error) {
console.error('Error analyzing sentence:', error)
console.error('Error in mock analysis:', error)
alert(`分析句子時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsAnalyzing(false)
}
}
// 生成假的分析數據
const generateMockAnalysis = (inputText: string) => {
const words = inputText.toLowerCase().split(/\s+/).filter(word =>
word.length > 2 && /^[a-z]+$/.test(word.replace(/[.,!?;:]/g, ''))
)
const wordAnalysis: any = {}
words.forEach((word, index) => {
const cleanWord = word.replace(/[.,!?;:]/g, '')
const isHighValue = index % 3 === 0 // 每3個詞中有1個高價值
const isPhrase = cleanWord.length > 6 // 長詞視為片語
wordAnalysis[cleanWord] = {
word: cleanWord,
translation: getRandomTranslation(cleanWord),
definition: `Definition of ${cleanWord} - a common English word`,
partOfSpeech: getRandomPartOfSpeech(),
pronunciation: `/${cleanWord}/`,
isHighValue: isHighValue,
isPhrase: isPhrase,
difficultyLevel: getRandomDifficulty(),
synonyms: [getRandomSynonym(cleanWord), getRandomSynonym(cleanWord)],
learningPriority: isHighValue ? 'high' : 'medium'
}
})
return {
wordAnalysis,
sentenceTranslation: `這是「${inputText}」的中文翻譯。`,
grammarCorrection: {
hasErrors: Math.random() > 0.7, // 30%機率有語法錯誤
correctedText: inputText,
originalText: inputText
},
finalText: inputText
}
}
// 輔助函數 - 改進翻譯生成
const getRandomTranslation = (word: string) => {
// 常見英文單字的實際翻譯
const commonTranslations: {[key: string]: string} = {
'hello': '你好',
'how': '如何',
'are': '是',
'you': '你',
'today': '今天',
'good': '好的',
'morning': '早晨',
'evening': '晚上',
'thank': '謝謝',
'please': '請',
'sorry': '抱歉',
'love': '愛',
'like': '喜歡',
'want': '想要',
'need': '需要',
'think': '思考',
'know': '知道',
'see': '看見',
'go': '去',
'come': '來',
'get': '得到',
'make': '製作',
'take': '拿取',
'give': '給予',
'find': '找到',
'work': '工作',
'feel': '感覺',
'become': '成為',
'leave': '離開',
'put': '放置',
'mean': '意思',
'keep': '保持',
'let': '讓',
'begin': '開始',
'seem': '似乎',
'help': '幫助',
'talk': '談話',
'turn': '轉向',
'start': '開始',
'show': '顯示',
'hear': '聽見',
'play': '玩耍',
'run': '跑步',
'move': '移動',
'live': '生活',
'believe': '相信',
'bring': '帶來',
'happen': '發生',
'write': '寫作',
'provide': '提供',
'sit': '坐下',
'stand': '站立',
'lose': '失去',
'pay': '付費',
'meet': '遇見',
'include': '包含',
'continue': '繼續',
'set': '設置',
'learn': '學習',
'change': '改變',
'lead': '領導',
'understand': '理解',
'watch': '觀看',
'follow': '跟隨',
'stop': '停止',
'create': '創造',
'speak': '說話',
'read': '閱讀',
'allow': '允許',
'add': '添加',
'spend': '花費',
'grow': '成長',
'open': '打開',
'walk': '走路',
'win': '獲勝',
'offer': '提供',
'remember': '記住',
'consider': '考慮',
'appear': '出現',
'buy': '購買',
'wait': '等待',
'serve': '服務',
'die': '死亡',
'send': '發送',
'expect': '期待',
'build': '建造',
'stay': '停留',
'fall': '跌倒',
'cut': '切割',
'reach': '到達',
'kill': '殺死',
'remain': '保持'
}
return commonTranslations[word.toLowerCase()] || `${word}的翻譯`
}
const getRandomPartOfSpeech = () => {
const parts = ['noun', 'verb', 'adjective', 'adverb', 'preposition']
return parts[Math.floor(Math.random() * parts.length)]
}
const getRandomDifficulty = () => {
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
return levels[Math.floor(Math.random() * levels.length)]
}
const getRandomSynonym = (word: string) => {
return `synonym_${word}_${Math.floor(Math.random() * 10)}`
}
const handleAcceptCorrection = () => {
if (grammarCorrection?.correctedText) {
setFinalText(grammarCorrection.correctedText)
@ -111,42 +221,27 @@ function GenerateContent() {
alert('📝 已保持原始版本,將基於您的原始輸入進行學習。')
}
const handleGenerate = async () => {
if (!textInput.trim()) return
setIsGenerating(true)
// 保存單個詞彙
const handleSaveWord = async (word: string, analysis: any) => {
try {
const response = await fetch('http://localhost:5000/api/ai/test/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
inputText: textInput,
extractionType: extractionType,
cardCount: cardCount
})
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
const cardData = {
english: word, // 修正API欄位名稱
chinese: analysis.translation || analysis.Translation || '',
pronunciation: analysis.pronunciation || analysis.Pronunciation || `/${word}/`,
partOfSpeech: analysis.partOfSpeech || analysis.PartOfSpeech || 'unknown',
example: `Example sentence with ${word}.` // 提供預設例句
}
const result = await response.json()
const response = await flashcardsService.createFlashcard(cardData)
if (result.success) {
setGeneratedCards(result.data)
setShowPreview(true)
setShowAnalysisView(false)
if (response.success) {
alert(`✅ 已將「${word}」保存到詞卡!`)
} else {
throw new Error(result.error || '生成詞卡失敗')
throw new Error(response.error || '保存失敗')
}
} catch (error) {
console.error('Error generating cards:', error)
alert(`生成詞卡時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsGenerating(false)
console.error('Save word error:', error)
throw error // 重新拋出錯誤讓組件處理
}
}
@ -155,7 +250,7 @@ function GenerateContent() {
<Navigation />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{!showAnalysisView && !showPreview ? (
{!showAnalysisView ? (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">AI </h1>
@ -327,217 +422,114 @@ function GenerateContent() {
</div>
</div>
</div>
) : showAnalysisView ? (
/* 句子分析視圖 */
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold"></h1>
<button
onClick={() => setShowAnalysisView(false)}
className="text-gray-600 hover:text-gray-900"
>
</button>
</div>
{/* 語法修正面板 */}
{grammarCorrection && (
<GrammarCorrectionPanel
correction={grammarCorrection}
onAcceptCorrection={handleAcceptCorrection}
onRejectCorrection={handleRejectCorrection}
/>
)}
{/* 原始句子顯示 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
<span className="mr-1">🤖</span>
<span>AI </span>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">📝 </h3>
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="text-base">{textInput}</div>
</div>
</div>
{finalText !== textInput && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">🎯 </h3>
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="text-base font-medium">{finalText}</div>
<div className="text-xs text-blue-600 mt-1"> </div>
</div>
</div>
)}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">📖 </h3>
<div className="text-gray-700 leading-relaxed p-3 bg-gray-50 rounded-lg">
{sentenceMeaning}
</div>
</div>
</div>
</div>
{/* 互動式文字 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-400 mb-4">
<p className="text-sm text-blue-800">
💡 <strong>使</strong><br/>
{/* 🟡 <strong> + </strong> = <br/>
🟢 <strong> + </strong> = <br/>
🔵 <strong></strong> = 1 */}
</p>
</div>
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<ClickableTextV2
text={finalText}
analysis={sentenceAnalysis}
remainingUsage={5 - usageCount}
onWordClick={(word, analysis) => {
console.log('Clicked word:', word, analysis)
}}
onWordCostConfirm={async () => {
return true // 移除付費限制,直接允許
}}
onNewWordAnalysis={(word, newAnalysis) => {
// 將新的詞彙分析資料加入到現有分析中
setSentenceAnalysis((prev: any) => ({
...prev,
[word]: newAnalysis
}))
console.log(`✅ 新增詞彙分析: ${word}`, newAnalysis)
}}
/>
</div>
</div>
{/* 操作按鈕 */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex gap-4">
<button
onClick={() => setShowAnalysisView(false)}
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
🔄
</button>
<button
onClick={() => {
setShowAnalysisView(false)
setShowPreview(true)
// 這裡可以整合從分析結果生成詞卡的功能
}}
className="flex-1 bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors"
>
📖
</button>
</div>
</div>
</div>
) : (
/* 現有的詞卡預覽功能保持不變 */
/* 重新設計的句子分析視圖 - 簡潔流暢 */
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold"></h1>
<div className="flex gap-2">
<button
onClick={() => {
setShowPreview(false)
setShowAnalysisView(true)
}}
className="text-gray-600 hover:text-gray-900"
>
</button>
<span className="text-gray-300">|</span>
<button
onClick={() => {
setShowPreview(false)
setShowAnalysisView(false)
}}
className="text-gray-600 hover:text-gray-900"
>
</button>
</div>
</div>
{/* 移除冗餘標題,直接進入內容 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{generatedCards.map((card, index) => (
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* 詞卡內容 */}
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">{card.word}</h3>
<span className="text-sm bg-gray-100 text-gray-600 px-2 py-1 rounded">
{card.partOfSpeech}
</span>
</div>
{/* 語法修正面板 - 如果需要的話 */}
{grammarCorrection && grammarCorrection.hasErrors && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-6">
<div className="flex items-start gap-3">
<div className="text-yellow-600 text-2xl"></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-800 mb-2"></h3>
<p className="text-yellow-700 mb-4">AI建議修正以下內容</p>
<div className="space-y-3">
<div className="space-y-3 mb-4">
<div>
<span className="text-sm font-medium text-gray-500"></span>
<p className="text-gray-700">{card.pronunciation}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-500"></span>
<p className="text-gray-900 font-medium">{card.translation}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-500"></span>
<p className="text-gray-700">{card.definition}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-500"></span>
<p className="text-gray-700 italic">"{card.example}"</p>
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-500"></span>
<div className="flex flex-wrap gap-1 mt-1">
{card.synonyms.map((synonym: string, idx: number) => (
<span key={idx} className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{synonym}
</span>
))}
<span className="text-sm font-medium text-yellow-700"></span>
<div className="bg-white p-3 rounded border border-yellow-300 mt-1">
{textInput}
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t">
<span className="text-xs text-gray-500">: {card.difficultyLevel}</span>
<div>
<span className="text-sm font-medium text-yellow-700"></span>
<div className="bg-yellow-100 p-3 rounded border border-yellow-300 mt-1 font-medium">
{grammarCorrection.correctedText || finalText}
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleAcceptCorrection}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
</button>
<button
onClick={handleRejectCorrection}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
📝
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 主句子展示 - 最重要的內容 */}
<div className="bg-white rounded-xl shadow-lg p-8 mb-6">
{/* 句子主體展示 */}
<div className="text-center mb-8">
<div className="text-3xl leading-relaxed font-medium text-gray-900 mb-6">
<ClickableTextV2
text={finalText}
analysis={sentenceAnalysis}
remainingUsage={5 - usageCount}
onWordClick={(word, analysis) => {
console.log('Clicked word:', word, analysis)
}}
onWordCostConfirm={async () => {
return true
}}
onNewWordAnalysis={(word, newAnalysis) => {
setSentenceAnalysis((prev: any) => ({
...prev,
[word]: newAnalysis
}))
console.log(`✅ 新增詞彙分析: ${word}`, newAnalysis)
}}
onSaveWord={handleSaveWord}
/>
</div>
{/* 翻譯 - 次要但重要 */}
<div className="text-xl text-gray-600 leading-relaxed bg-gray-50 p-4 rounded-lg">
{sentenceMeaning}
</div>
</div>
{/* 學習提示 - 精簡版 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-400 border border-green-500 rounded"></div>
<span className="text-green-700"> </span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-400 border border-yellow-500 rounded"></div>
<span className="text-yellow-700"> </span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-400 border border-blue-500 rounded"></div>
<span className="text-blue-700"></span>
</div>
<span className="text-gray-600"> </span>
</div>
</div>
</div>
{/* 操作按鈕 */}
<div className="mt-8 flex justify-center gap-4">
{/* 下方操作區 - 簡化 */}
<div className="flex justify-center">
<button
onClick={() => setShowPreview(false)}
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
onClick={() => setShowAnalysisView(false)}
className="px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center gap-2"
>
🔄
</button>
<button
onClick={() => alert('保存功能開發中...')}
className="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
💾
<span>🔄</span>
<span></span>
</button>
</div>
</div>

View File

@ -835,7 +835,7 @@ export default function LearnPage() {
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
()
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}

View File

@ -0,0 +1,634 @@
'use client'
import { useState } from 'react'
import { Navigation } from '@/components/Navigation'
import { ProtectedRoute } from '@/components/ProtectedRoute'
export default function VocabDesignsPage() {
return (
<ProtectedRoute>
<VocabDesignsContent />
</ProtectedRoute>
)
}
function VocabDesignsContent() {
const [selectedDesign, setSelectedDesign] = useState('modern')
const [showPopup, setShowPopup] = useState(false)
// 假詞彙數據
const mockWord = {
word: 'elaborate',
pronunciation: '/ɪˈlæbərət/',
partOfSpeech: 'verb',
translation: '詳細說明',
definition: 'To explain something in more detail; to develop or present a theory, policy, or system in further detail',
synonyms: ['explain', 'detail', 'expand', 'clarify'],
difficultyLevel: 'B2',
isHighValue: true,
example: 'Could you elaborate on your proposal?',
exampleTranslation: '你能詳細說明一下你的提案嗎?'
}
const designs = [
{ id: 'modern', name: '現代玻璃', description: '毛玻璃效果,現代感設計' },
{ id: 'classic', name: '經典卡片', description: '傳統卡片風格,清晰分區' },
{ id: 'minimal', name: '極簡風格', description: '簡潔乾淨,突出重點' },
{ id: 'magazine', name: '雜誌排版', description: '類似雜誌的排版風格' },
{ id: 'mobile', name: '移動應用', description: 'iOS/Android應用風格' },
{ id: 'learning', name: '學習卡片', description: '與學習功能一致的風格' }
]
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 頁面標題 */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4"></h1>
<p className="text-xl text-gray-600 mb-6">6</p>
{/* 測試詞彙 */}
<div className="inline-block bg-white rounded-lg shadow-sm p-4 border border-gray-200">
<span className="text-gray-600 text-sm"></span>
<button
onClick={() => setShowPopup(true)}
className="ml-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
"elaborate"
</button>
</div>
</div>
{/* 版型選擇器 */}
<div className="flex justify-center mb-8">
<div className="bg-white rounded-xl shadow-sm p-2 inline-flex flex-wrap gap-2">
{designs.map((design) => (
<button
key={design.id}
onClick={() => setSelectedDesign(design.id)}
className={`px-4 py-2 rounded-lg transition-all ${
selectedDesign === design.id
? 'bg-primary text-white shadow-md'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
{design.name}
</button>
))}
</div>
</div>
{/* 版型預覽 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左側:版型說明 */}
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
{designs.find(d => d.id === selectedDesign)?.name}
</h2>
<p className="text-gray-600 mb-6">
{designs.find(d => d.id === selectedDesign)?.description}
</p>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-gray-900 mb-2"></h3>
<ul className="text-sm text-gray-700 space-y-1">
{getDesignFeatures(selectedDesign).map((feature, idx) => (
<li key={idx} className="flex items-center gap-2">
<span className="text-blue-500"></span>
{feature}
</li>
))}
</ul>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-700">
{getDesignScenario(selectedDesign)}
</p>
</div>
</div>
</div>
{/* 右側:版型預覽 */}
<div className="bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl p-8 flex items-center justify-center min-h-[600px] relative">
<div className="text-center">
<p className="text-gray-600 mb-4"></p>
<button
onClick={() => setShowPopup(true)}
className="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium shadow-lg"
>
"{mockWord.word}"
</button>
</div>
{/* 模擬背景文字 */}
<div className="absolute inset-4 opacity-10 text-gray-500 text-lg leading-relaxed pointer-events-none">
This is a sample sentence where you can click on any word to see the elaborate definition and detailed explanation.
</div>
</div>
</div>
</div>
{/* 詞彙彈窗 - 根據選擇的設計風格渲染 */}
{showPopup && (
<>
{/* 背景遮罩 */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setShowPopup(false)}
/>
{/* 彈窗內容 */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
{renderVocabPopup(selectedDesign, mockWord, () => setShowPopup(false))}
</div>
</>
)}
</div>
)
}
// 渲染不同風格的詞彙彈窗
function renderVocabPopup(design: string, word: any, onClose: () => void) {
const handleSave = () => {
alert(`✅ 已將「${word.word}」保存到詞卡!`)
onClose()
}
switch (design) {
case 'modern':
return <ModernGlassDesign word={word} onClose={onClose} onSave={handleSave} />
case 'classic':
return <ClassicCardDesign word={word} onClose={onClose} onSave={handleSave} />
case 'minimal':
return <MinimalDesign word={word} onClose={onClose} onSave={handleSave} />
case 'magazine':
return <MagazineDesign word={word} onClose={onClose} onSave={handleSave} />
case 'mobile':
return <MobileAppDesign word={word} onClose={onClose} onSave={handleSave} />
case 'learning':
return <LearningCardDesign word={word} onClose={onClose} onSave={handleSave} />
default:
return <ModernGlassDesign word={word} onClose={onClose} onSave={handleSave} />
}
}
// 1. 現代玻璃風格
function ModernGlassDesign({ word, onClose, onSave }: any) {
return (
<div
className="bg-white rounded-2xl shadow-2xl border-0 w-80 backdrop-blur-sm"
style={{
background: 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(12px)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.8)'
}}
>
<div className="relative p-5 pb-0">
<button
onClick={onClose}
className="absolute top-3 right-3 w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors flex items-center justify-center text-gray-500"
>
</button>
<div className="pr-8">
<div className="flex items-baseline gap-3 mb-1">
<h3 className="text-2xl font-bold text-gray-900">{word.word}</h3>
{word.isHighValue && <span className="text-yellow-500 text-lg"></span>}
</div>
<div className="flex items-center gap-3 text-gray-600">
<span className="text-sm font-medium">{word.pronunciation}</span>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-blue-100 text-blue-700">
{word.difficultyLevel}
</span>
</div>
</div>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="text-lg font-semibold text-gray-900">{word.translation}</div>
</div>
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="text-sm text-gray-700 leading-relaxed">{word.definition}</div>
</div>
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="flex flex-wrap gap-1">
{word.synonyms.map((synonym: string, idx: number) => (
<span key={idx} className="bg-blue-50 text-blue-700 px-2 py-1 rounded text-xs font-medium border border-blue-200">
{synonym}
</span>
))}
</div>
</div>
</div>
<div className="p-5 pt-2 border-t border-gray-100">
<button
onClick={onSave}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-700 hover:to-blue-800 transition-all duration-200 transform hover:scale-105 active:scale-95 flex items-center justify-center gap-2 shadow-lg"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span className="text-sm font-medium"></span>
</button>
</div>
</div>
)
}
// 2. 經典卡片風格
function ClassicCardDesign({ word, onClose, onSave }: any) {
return (
<div className="bg-white rounded-lg shadow-lg border border-gray-200 w-96 max-w-md">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold">{word.word}</h3>
<p className="text-blue-100 text-sm">{word.pronunciation}</p>
</div>
<button
onClick={onClose}
className="text-blue-100 hover:text-white w-8 h-8 rounded-full hover:bg-blue-800 transition-colors flex items-center justify-center"
>
</button>
</div>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center gap-3">
<span className="bg-gray-100 text-gray-700 px-3 py-1 rounded-full text-sm font-medium">
{word.partOfSpeech}
</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
word.difficultyLevel === 'A1' || word.difficultyLevel === 'A2' ? 'bg-green-100 text-green-700' :
word.difficultyLevel === 'B1' || word.difficultyLevel === 'B2' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{word.difficultyLevel}
</span>
{word.isHighValue && (
<span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs font-medium">
</span>
)}
</div>
<div className="bg-blue-50 rounded-lg p-3 border-l-4 border-blue-400">
<h4 className="font-semibold text-blue-900 mb-1"></h4>
<p className="text-blue-800 font-medium">{word.translation}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="font-semibold text-gray-900 mb-2"></h4>
<p className="text-gray-700 text-sm leading-relaxed">{word.definition}</p>
</div>
<div>
<h4 className="font-semibold text-gray-900 mb-2"></h4>
<div className="flex flex-wrap gap-2">
{word.synonyms.map((synonym: string, idx: number) => (
<span key={idx} className="bg-gray-100 text-gray-700 px-3 py-1 rounded-full text-sm">
{synonym}
</span>
))}
</div>
</div>
<button
onClick={onSave}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
>
<span>💾</span>
<span></span>
</button>
</div>
</div>
)
}
// 3. 極簡風格
function MinimalDesign({ word, onClose, onSave }: any) {
return (
<div className="bg-white rounded-lg shadow-lg w-72 border border-gray-100">
<div className="p-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold text-gray-900">{word.word}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 w-5 h-5 flex items-center justify-center"
>
</button>
</div>
<p className="text-sm text-gray-500 mt-1">{word.pronunciation}</p>
</div>
<div className="p-4 space-y-3">
<div>
<span className="text-lg font-medium text-gray-900">{word.translation}</span>
<span className="ml-2 text-xs text-gray-500">({word.partOfSpeech})</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">{word.definition}</p>
<div className="flex flex-wrap gap-1 pt-2">
{word.synonyms.slice(0, 3).map((synonym: string, idx: number) => (
<span key={idx} className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
{synonym}
</span>
))}
</div>
<button
onClick={onSave}
className="w-full mt-4 bg-gray-900 text-white py-2 rounded text-sm font-medium hover:bg-gray-800 transition-colors"
>
</button>
</div>
</div>
)
}
// 4. 雜誌排版風格
function MagazineDesign({ word, onClose, onSave }: any) {
return (
<div className="bg-white shadow-xl w-96 max-w-md" style={{ fontFamily: 'Georgia, serif' }}>
<div className="p-6 border-b-2 border-gray-900">
<div className="flex items-start justify-between">
<div>
<h3 className="text-3xl font-bold text-gray-900 mb-1" style={{ fontFamily: 'Georgia, serif' }}>
{word.word}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span className="italic">{word.pronunciation}</span>
<span className="font-semibold">{word.partOfSpeech}</span>
<span className="bg-gray-900 text-white px-2 py-1 text-xs">{word.difficultyLevel}</span>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl"
>
×
</button>
</div>
</div>
<div className="p-6">
<div className="mb-4">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-2 border-b border-gray-200 pb-1">
Translation
</h4>
<p className="text-xl font-semibold text-gray-900" style={{ fontFamily: 'Georgia, serif' }}>
{word.translation}
</p>
</div>
<div className="mb-4">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Definition</h4>
<p className="text-sm text-gray-700 leading-relaxed" style={{ textAlign: 'justify' }}>
{word.definition}
</p>
</div>
<div className="mb-6">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Synonyms</h4>
<p className="text-sm text-gray-700 italic">
{word.synonyms.join(', ')}
</p>
</div>
<button
onClick={onSave}
className="w-full bg-gray-900 text-white py-3 font-semibold uppercase tracking-wider text-sm hover:bg-gray-800 transition-colors"
>
Add to Collection
</button>
</div>
</div>
)
}
// 5. 移動應用風格
function MobileAppDesign({ word, onClose, onSave }: any) {
return (
<div className="bg-white rounded-3xl shadow-xl w-80 overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
{word.word[0].toUpperCase()}
</div>
<span className="font-medium text-gray-900"></span>
</div>
<button
onClick={onClose}
className="text-blue-500 font-medium text-sm"
>
</button>
</div>
</div>
<div className="p-4">
<div className="text-center mb-4">
<h3 className="text-2xl font-bold text-gray-900 mb-1">{word.word}</h3>
<p className="text-gray-500 text-sm">{word.pronunciation}</p>
<div className="flex items-center justify-center gap-2 mt-2">
<span className="bg-gray-100 text-gray-700 px-2 py-1 rounded-full text-xs">
{word.partOfSpeech}
</span>
<span className={`px-2 py-1 rounded-full text-xs ${
word.difficultyLevel === 'A1' || word.difficultyLevel === 'A2' ? 'bg-green-100 text-green-700' :
word.difficultyLevel === 'B1' || word.difficultyLevel === 'B2' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{word.difficultyLevel}
</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 py-2">
<div className="w-8 text-center">🌏</div>
<div className="flex-1">
<div className="font-medium text-gray-900">{word.translation}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
<div className="flex items-start gap-3 py-2 border-t border-gray-100">
<div className="w-8 text-center">📝</div>
<div className="flex-1">
<div className="text-sm text-gray-700 leading-relaxed">{word.definition}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
</div>
<div className="flex items-start gap-3 py-2 border-t border-gray-100">
<div className="w-8 text-center">🔗</div>
<div className="flex-1">
<div className="flex flex-wrap gap-1">
{word.synonyms.slice(0, 3).map((synonym: string, idx: number) => (
<span key={idx} className="bg-gray-100 text-gray-700 px-2 py-1 rounded-full text-xs">
{synonym}
</span>
))}
</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
</div>
</div>
<button
onClick={onSave}
className="w-full mt-6 bg-blue-500 text-white py-3 rounded-xl font-medium text-base hover:bg-blue-600 transition-colors active:bg-blue-700"
>
</button>
</div>
</div>
)
}
// 6. 學習卡片風格
function LearningCardDesign({ word, onClose, onSave }: any) {
return (
<div className="bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden">
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
{word.word[0].toUpperCase()}
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{word.word}</h3>
<p className="text-sm text-gray-600">{word.partOfSpeech}</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700">{word.pronunciation}</span>
<button className="text-blue-600 hover:text-blue-700 p-1 rounded-full hover:bg-blue-50 transition-colors">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
</div>
<div className="p-5 space-y-4">
<div className="bg-green-50 rounded-lg p-4">
<h4 className="font-semibold text-green-900 mb-2 text-left"></h4>
<p className="text-green-800 font-medium text-left">{word.translation}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-semibold text-gray-900 mb-2 text-left"></h4>
<p className="text-gray-700 text-sm text-left leading-relaxed">{word.definition}</p>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2 text-left"></h4>
<div className="flex flex-wrap gap-2">
{word.synonyms.map((synonym: string, idx: number) => (
<span key={idx} className="bg-white text-blue-700 px-3 py-1 rounded-full text-sm border border-blue-200">
{synonym}
</span>
))}
</div>
</div>
<button
onClick={onSave}
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
>
<span>💾</span>
<span></span>
</button>
</div>
</div>
)
}
// 輔助函數
function getDesignFeatures(design: string): string[] {
const features = {
modern: [
'毛玻璃背景效果',
'大尺寸陰影和圓角',
'漸層按鈕設計',
'微互動動畫',
'現代配色方案'
],
classic: [
'藍色漸層標題欄',
'清晰的區塊分隔',
'傳統卡片佈局',
'顏色編碼標籤',
'穩重的設計風格'
],
minimal: [
'極簡配色方案',
'去除多餘裝飾',
'重點信息突出',
'輕量化設計',
'快速瀏覽體驗'
],
magazine: [
'雜誌式字體',
'專業排版設計',
'大標題小說明',
'黑白主色調',
'閱讀導向佈局'
],
mobile: [
'iOS/Android風格',
'圓角和圖標設計',
'列表式信息展示',
'觸控友好設計',
'移動端優化'
],
learning: [
'學習功能一致性',
'彩色區塊設計',
'教育導向佈局',
'清晰的信息分類',
'學習體驗優化'
]
}
return features[design as keyof typeof features] || []
}
function getDesignScenario(design: string): string {
const scenarios = {
modern: '適合追求現代感和科技感的用戶,特別是年輕用戶群體。設計前衛,視覺效果佳。',
classic: '適合喜歡傳統界面的用戶,信息展示清晰,功能分區明確,適合學術或商務場景。',
minimal: '適合追求效率的用戶,減少視覺干擾,快速獲取核心信息,適合頻繁使用的場景。',
magazine: '適合喜歡閱讀體驗的用戶,類似字典或雜誌的專業排版,適合深度學習。',
mobile: '適合手機用戶,觸控友好,符合移動端應用的使用習慣,適合隨時隨地學習。',
learning: '與現有學習功能保持一致,用戶體驗連貫,適合在學習流程中使用。'
}
return scenarios[design as keyof typeof scenarios] || ''
}

View File

@ -0,0 +1,267 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Modal } from './ui/Modal'
import { Check, Loader2 } from 'lucide-react'
interface GeneratedCard {
word: string
translation: string
definition: string
partOfSpeech?: string
pronunciation?: string
example?: string
exampleTranslation?: string
synonyms?: string[]
difficultyLevel?: string
}
interface CardSet {
id: string
name: string
color: string
}
interface CardSelectionDialogProps {
isOpen: boolean
generatedCards: GeneratedCard[]
cardSets: CardSet[]
onClose: () => void
onSave: (selectedCards: GeneratedCard[], cardSetId?: string) => Promise<void>
}
export const CardSelectionDialog: React.FC<CardSelectionDialogProps> = ({
isOpen,
generatedCards,
cardSets,
onClose,
onSave
}) => {
const [selectedCardIndices, setSelectedCardIndices] = useState<Set<number>>(
new Set(generatedCards.map((_, index) => index)) // 預設全選
)
const [selectedCardSetId, setSelectedCardSetId] = useState<string>('')
const [isSaving, setIsSaving] = useState(false)
const selectedCount = selectedCardIndices.size
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedCardIndices(new Set(generatedCards.map((_, index) => index)))
} else {
setSelectedCardIndices(new Set())
}
}
const handleCardToggle = (index: number, checked: boolean) => {
const newSelected = new Set(selectedCardIndices)
if (checked) {
newSelected.add(index)
} else {
newSelected.delete(index)
}
setSelectedCardIndices(newSelected)
}
const handleSave = async () => {
if (selectedCount === 0) {
alert('請至少選擇一張詞卡')
return
}
setIsSaving(true)
try {
const selectedCards = Array.from(selectedCardIndices).map(index => generatedCards[index])
await onSave(selectedCards, selectedCardSetId || undefined)
} catch (error) {
console.error('Save error:', error)
alert(`保存失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsSaving(false)
}
}
const isAllSelected = selectedCount === generatedCards.length
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="選擇要保存的詞卡"
size="xl"
>
<div className="p-6 space-y-6">
{/* 操作工具列 */}
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium">
({selectedCount}/{generatedCards.length})
</span>
</label>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600"></span>
<select
value={selectedCardSetId}
onChange={(e) => setSelectedCardSetId(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value=""></option>
{cardSets.map(set => (
<option key={set.id} value={set.id}>
{set.name}
</option>
))}
</select>
</div>
</div>
{/* 詞卡列表 */}
<div className="space-y-3 max-h-96 overflow-y-auto">
{generatedCards.map((card, index) => (
<CardPreviewItem
key={index}
card={card}
index={index}
isSelected={selectedCardIndices.has(index)}
onToggle={(checked) => handleCardToggle(index, checked)}
/>
))}
</div>
{/* 底部操作按鈕 */}
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
onClick={onClose}
disabled={isSaving}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleSave}
disabled={selectedCount === 0 || isSaving}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</>
) : (
<>
<Check className="w-4 h-4" />
<span> {selectedCount} </span>
</>
)}
</button>
</div>
</div>
</Modal>
)
}
interface CardPreviewItemProps {
card: GeneratedCard
index: number
isSelected: boolean
onToggle: (checked: boolean) => void
}
const CardPreviewItem: React.FC<CardPreviewItemProps> = ({
card,
index,
isSelected,
onToggle
}) => {
return (
<div className={`
border rounded-lg p-4 transition-all duration-200
${isSelected
? 'border-blue-500 bg-blue-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}
`}>
<div className="flex items-start space-x-3">
<label className="flex items-center cursor-pointer mt-1">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggle(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
</label>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{card.word}
</h3>
{card.difficultyLevel && (
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
{card.difficultyLevel}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span className="font-medium text-gray-700"></span>
<span className="text-gray-900">{card.translation}</span>
</div>
{card.partOfSpeech && (
<div>
<span className="font-medium text-gray-700"></span>
<span className="text-gray-900">{card.partOfSpeech}</span>
</div>
)}
{card.pronunciation && (
<div>
<span className="font-medium text-gray-700"></span>
<span className="text-gray-900">{card.pronunciation}</span>
</div>
)}
</div>
<div>
<span className="font-medium text-gray-700"></span>
<p className="text-gray-900 leading-relaxed">{card.definition}</p>
</div>
{card.example && (
<div>
<span className="font-medium text-gray-700"></span>
<p className="text-gray-900 italic">"{card.example}"</p>
{card.exampleTranslation && (
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
)}
</div>
)}
{card.synonyms && card.synonyms.length > 0 && (
<div>
<span className="font-medium text-gray-700"></span>
<div className="flex flex-wrap gap-1 mt-1">
{card.synonyms.map((synonym, idx) => (
<span key={idx} className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{synonym}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -36,6 +36,7 @@ interface ClickableTextProps {
onWordClick?: (word: string, analysis: WordAnalysis) => void
onWordCostConfirm?: (word: string, cost: number) => Promise<boolean> // 收費確認
onNewWordAnalysis?: (word: string, analysis: WordAnalysis) => void // 新詞彙分析資料回調
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<void> // 保存詞彙回調
remainingUsage?: number // 剩餘使用次數
}
@ -47,15 +48,17 @@ export function ClickableTextV2({
onWordClick,
onWordCostConfirm,
onNewWordAnalysis,
onSaveWord,
remainingUsage = 5
}: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
const [showCostConfirm, setShowCostConfirm] = useState<{
word: string
cost: number
position: { x: number, y: number }
} | null>(null)
const [isSavingWord, setIsSavingWord] = useState(false)
// 輔助函數:兼容大小寫屬性名稱
const getWordProperty = (wordData: any, propName: string) => {
@ -72,9 +75,17 @@ export function ClickableTextV2({
const wordAnalysis = analysis?.[cleanWord]
const rect = event.currentTarget.getBoundingClientRect()
const viewportHeight = window.innerHeight
const popupHeight = 400 // 估計popup高度
// 智能定位:如果上方空間不足,就顯示在下方
const spaceAbove = rect.top
const spaceBelow = viewportHeight - rect.bottom
const position = {
x: rect.left + rect.width / 2,
y: rect.top - 10
y: spaceAbove >= popupHeight ? rect.top - 10 : rect.bottom + 10,
showBelow: spaceAbove < popupHeight
}
if (wordAnalysis) {
@ -150,6 +161,21 @@ export function ClickableTextV2({
setSelectedWord(null)
}
const handleSaveWord = async () => {
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
setIsSavingWord(true)
try {
await onSaveWord(selectedWord, analysis[selectedWord])
setSelectedWord(null) // 保存成功後關閉popup
} catch (error) {
console.error('Save word error:', error)
alert(`保存詞彙失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsSavingWord(false)
}
}
const queryWordWithAI = async (word: string, position: { x: number, y: number }) => {
try {
console.log(`🤖 查詢單字: ${word}`)
@ -258,135 +284,53 @@ export function ClickableTextV2({
})}
</div>
{/* 單字資訊彈窗 */}
{/* 現代風格詞彙彈窗 */}
{selectedWord && analysis?.[selectedWord] && (
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
className="fixed z-50 bg-white rounded-2xl shadow-2xl border-0 w-80 backdrop-blur-sm"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: 'translate(-50%, -100%)',
transform: popupPosition.showBelow
? 'translate(-50%, 8px)'
: 'translate(-50%, calc(-100% - 8px))',
maxHeight: '85vh',
overflowY: 'auto',
background: 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(12px)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.8)'
}}
>
<div className="space-y-3">
{/* 標題 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{getWordProperty(analysis[selectedWord], 'word')}
</h3>
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* 詞彙標題 - 簡約設計 */}
<div className="relative p-5 pb-0">
<button
onClick={closePopup}
className="absolute top-3 right-3 w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors flex items-center justify-center text-gray-500 hover:text-gray-700"
>
</button>
{/* 重點學習標記 */}
{getWordProperty(analysis[selectedWord], 'isHighValue') && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<div className="text-green-600 text-lg">🎯</div>
<div className="text-sm font-medium text-green-800">
</div>
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
{getWordProperty(analysis[selectedWord], 'learningPriority') === 'high' ? '⭐⭐⭐⭐⭐' :
getWordProperty(analysis[selectedWord], 'learningPriority') === 'medium' ? '⭐⭐⭐' : '⭐'}
</div>
</div>
<div className="pr-8">
<div className="flex items-baseline gap-3 mb-1">
<h3 className="text-2xl font-bold text-gray-900">
{getWordProperty(analysis[selectedWord], 'word')}
</h3>
{getWordProperty(analysis[selectedWord], 'isHighValue') && (
<span className="text-yellow-500 text-lg"></span>
)}
</div>
)}
{/* 片語警告 */}
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-yellow-600 text-lg"></div>
<div>
<div className="text-sm font-medium text-yellow-800">
</div>
<div className="text-sm text-yellow-700 mt-1">
<strong></strong>{analysis[selectedWord].phraseInfo.phrase}
</div>
<div className="text-sm text-yellow-700">
<strong></strong>{analysis[selectedWord].phraseInfo.meaning}
</div>
<div className="text-xs text-yellow-600 mt-2 italic">
{analysis[selectedWord].phraseInfo.warning}
</div>
</div>
</div>
</div>
)}
{/* 詞性和發音 */}
<div className="flex items-center gap-4">
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
<span className="text-sm text-gray-600">
{getWordProperty(analysis[selectedWord], 'pronunciation')}
</span>
<button className="text-blue-600 hover:text-blue-800">
<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 className="text-sm font-medium text-gray-700"></div>
<div className="text-base text-gray-900">{getWordProperty(analysis[selectedWord], 'translation')}</div>
</div>
{/* 定義 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm text-gray-600">{getWordProperty(analysis[selectedWord], 'definition')}</div>
</div>
{/* 同義詞 */}
{getWordProperty(analysis[selectedWord], 'synonyms')?.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{getWordProperty(analysis[selectedWord], 'synonyms')?.map((synonym, idx) => (
<span
key={idx}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
>
{synonym}
</span>
))}
</div>
</div>
)}
{/* 反義詞 */}
{getWordProperty(analysis[selectedWord], 'antonyms')?.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{getWordProperty(analysis[selectedWord], 'antonyms')?.map((antonym, idx) => (
<span
key={idx}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full"
>
{antonym}
</span>
))}
</div>
</div>
)}
{/* 難度等級 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="inline-flex items-center gap-1 mt-1">
<span className={`text-xs px-2 py-1 rounded-full ${
<div className="flex items-center gap-3 text-gray-600">
<span className="text-sm font-medium">
{getWordProperty(analysis[selectedWord], 'pronunciation')}
</span>
<button className="text-blue-500 hover:text-blue-600 transition-colors">
<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.728" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9l6-6m-6 6v6m6-6H3" />
</svg>
</button>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
(() => {
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
return difficulty === 'A1' || difficulty === 'A2' ? 'bg-green-100 text-green-700' :
@ -394,18 +338,82 @@ export function ClickableTextV2({
'bg-red-100 text-red-700'
})()
}`}>
CEFR {getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
<span className="text-xs text-gray-500">
({(() => {
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
return difficulty === 'A1' || difficulty === 'A2' ? '基礎' :
difficulty === 'B1' || difficulty === 'B2' ? '中級' : '高級'
})()})
{getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
</div>
</div>
</div>
{/* 內容區 - 現代卡片設計 */}
<div className="px-5 py-4 space-y-4">
{/* 翻譯 - 最重要的信息 */}
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="text-lg font-semibold text-gray-900">
{getWordProperty(analysis[selectedWord], 'translation')}
</div>
</div>
{/* 定義 */}
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="text-sm text-gray-700 leading-relaxed">
{getWordProperty(analysis[selectedWord], 'definition')}
</div>
</div>
{/* 詞性和難度 */}
<div className="flex items-center gap-3">
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1"></div>
<span className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-medium">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
</div>
</div>
{/* 同義詞 */}
{getWordProperty(analysis[selectedWord], 'synonyms')?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"></div>
<div className="flex flex-wrap gap-1">
{getWordProperty(analysis[selectedWord], 'synonyms')?.slice(0, 4).map((synonym, idx) => (
<span
key={idx}
className="bg-blue-50 text-blue-700 px-2 py-1 rounded text-xs font-medium border border-blue-200"
>
{synonym}
</span>
))}
</div>
</div>
)}
</div>
{/* 保存按鈕 - 現代設計 */}
{onSaveWord && (
<div className="p-5 pt-2 border-t border-gray-100">
<button
onClick={handleSaveWord}
disabled={isSavingWord}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-700 hover:to-blue-800 transition-all duration-200 transform hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none flex items-center justify-center gap-2 shadow-lg"
>
{isSavingWord ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span className="text-sm">...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span className="text-sm font-medium"></span>
</>
)}
</button>
</div>
)}
</div>
)}

View File

@ -0,0 +1,91 @@
'use client'
import React, { useEffect } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'lg'
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen, onClose])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl'
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal Content */}
<div className={`
relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden
${sizeClasses[size]} w-full mx-4
`}>
{/* Header */}
{title && (
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
)}
{/* Body */}
<div className="overflow-y-auto max-h-[calc(90vh-8rem)]">
{children}
</div>
</div>
</div>
)
}

View File

@ -206,6 +206,39 @@ class FlashcardsService {
};
}
}
async batchCreateFlashcards(request: BatchCreateFlashcardsRequest): Promise<ApiResponse<BatchCreateFlashcardsResponse>> {
try {
return await this.makeRequest<ApiResponse<BatchCreateFlashcardsResponse>>('/flashcards/batch', {
method: 'POST',
body: JSON.stringify(request),
});
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save flashcards',
};
}
}
}
// 新增批量創建相關介面
export interface BatchCreateFlashcardsRequest {
cardSetId?: string;
cards: CreateFlashcardRequest[];
}
export interface BatchCreateFlashcardsResponse {
savedCards: SavedCard[];
savedCount: number;
errorCount: number;
errors: string[];
}
export interface SavedCard {
id: string;
word: string;
translation: string;
}
export const flashcardsService = new FlashcardsService();

View File

@ -0,0 +1,509 @@
# 個人化詞彙庫功能規格
## 🎯 功能概述
個人化詞彙庫是一個用戶專屬的詞彙管理系統,允許用戶收集、組織和追蹤自己的學習詞彙,並根據學習表現提供個人化的學習建議。
## 📋 核心功能需求
### 1. 詞彙收集功能
#### 1.1 手動添加詞彙
- **功能描述**:用戶可以手動輸入新詞彙到個人詞彙庫
- **輸入欄位**
- 英文詞彙(必填)
- 詞性(可選,下拉選單)
- 發音(可選,自動生成或手動輸入)
- 定義(可選,自動生成或手動輸入)
- 中文翻譯(可選,自動生成或手動輸入)
- 個人筆記(可選,用戶自訂)
- **自動補全**:系統自動查詢並填入詞彙資訊
- **重複檢查**:避免添加重複詞彙
#### 1.2 學習中收集
- **學習頁面收藏**:在任何測驗模式中點擊「收藏」按鈕
- **困難詞彙標記**:答錯的詞彙自動標記為需要加強
- **快速收集**:一鍵添加當前學習的詞彙到個人庫
#### 1.3 批量導入
- **文字檔導入**:支援 .txt 格式的詞彙列表
- **CSV 導入**:支援結構化的詞彙資料
- **從學習記錄導入**:將過往答錯的詞彙批量加入
### 2. 詞彙組織功能
#### 2.1 分類管理
- **預設分類**
- 新學詞彙New
- 學習中Learning
- 熟悉Familiar
- 精通Mastered
- 困難詞彙Difficult
- **自訂分類**:用戶可創建自己的分類標籤
- **多重分類**:單一詞彙可屬於多個分類
#### 2.2 標籤系統
- **難度標籤**A1, A2, B1, B2, C1, C2
- **主題標籤**:商業、旅遊、學術、日常等
- **來源標籤**:書籍、電影、新聞、會話等
- **自訂標籤**:用戶可創建個人標籤
#### 2.3 優先級管理
- **高優先級**:急需掌握的詞彙
- **中優先級**:重要但不緊急的詞彙
- **低優先級**:選擇性學習的詞彙
### 3. 學習追蹤功能
#### 3.1 熟悉度評分
- **評分機制**0-100 分的熟悉度評分
- **多維度評估**
- 認識度Recognition看到詞彙能理解
- 回想度Recall能主動想起詞彙
- 應用度Application能在語境中正確使用
- **動態調整**:根據測驗表現自動調整評分
#### 3.2 學習歷史
- **學習次數**:詞彙被學習的總次數
- **正確率**:各種測驗模式的正確率統計
- **最後學習時間**:記錄最近一次學習時間
- **學習軌跡**:詳細的學習歷程記錄
#### 3.3 遺忘曲線追蹤
- **複習提醒**:基於遺忘曲線的智能提醒
- **複習間隔**:動態調整複習時間間隔
- **記憶強度**:評估詞彙在記憶中的鞏固程度
### 4. 個人化學習功能
#### 4.1 智能推薦
- **弱點分析**:識別用戶的學習弱點
- **相似詞彙**:推薦語義相關的詞彙
- **同根詞擴展**:推薦同詞根的相關詞彙
- **搭配詞推薦**:推薦常見的詞彙搭配
#### 4.2 個人化測驗
- **客製化題組**:根據個人詞彙庫生成測驗
- **弱點加強**:針對困難詞彙的專門訓練
- **複習模式**:基於遺忘曲線的複習測驗
- **混合練習**:結合不同來源詞彙的綜合測驗
#### 4.3 學習計劃
- **每日目標**:設定每日學習詞彙數量
- **週期計劃**:制定短期和長期學習目標
- **進度追蹤**:視覺化顯示學習進度
- **成就系統**:學習里程碑和獎勵機制
## 🗃️ 資料結構設計
### 個人詞彙資料模型
```typescript
interface PersonalVocabulary {
id: string;
userId: string;
word: string;
partOfSpeech?: string;
pronunciation?: string;
definition?: string;
translation?: string;
personalNotes?: string;
// 分類和標籤
categories: string[];
tags: string[];
priority: 'high' | 'medium' | 'low';
// 學習追蹤
familiarityScore: number; // 0-100
recognitionScore: number; // 0-100
recallScore: number; // 0-100
applicationScore: number; // 0-100
// 學習統計
totalPractices: number;
correctAnswers: number;
incorrectAnswers: number;
lastPracticed: Date;
nextReview: Date;
// 測驗模式統計
flipMemoryStats: TestModeStats;
vocabChoiceStats: TestModeStats;
sentenceFillStats: TestModeStats;
// ... 其他測驗模式
// 元資料
createdAt: Date;
updatedAt: Date;
source?: string; // 詞彙來源
}
interface TestModeStats {
attempts: number;
correct: number;
averageTime: number; // 平均回答時間(秒)
lastAttempt: Date;
}
```
### 學習會話記錄
```typescript
interface LearningSession {
id: string;
userId: string;
startTime: Date;
endTime: Date;
mode: string;
vocabulariesPracticed: string[]; // 詞彙 IDs
totalQuestions: number;
correctAnswers: number;
timeSpent: number; // 秒
performance: SessionPerformance;
}
interface SessionPerformance {
accuracy: number; // 正確率
speed: number; // 平均回答速度
improvement: number; // 相對上次的進步
weakWords: string[]; // 表現較差的詞彙
strongWords: string[]; // 表現較好的詞彙
}
```
## 🔧 技術實現方案
### 前端實現
#### 1. 狀態管理
```typescript
// 使用 Context API 或 Zustand
interface PersonalVocabStore {
vocabularies: PersonalVocabulary[];
currentSession: LearningSession | null;
filters: VocabFilters;
// Actions
addVocabulary: (vocab: Partial<PersonalVocabulary>) => void;
updateVocabulary: (id: string, updates: Partial<PersonalVocabulary>) => void;
deleteVocabulary: (id: string) => void;
updateFamiliarity: (id: string, testResult: TestResult) => void;
// ...
}
```
#### 2. 本地儲存策略
- **IndexedDB**:大量詞彙資料的本地儲存
- **localStorage**:用戶偏好和設定
- **同步機制**:與伺服器的雙向同步
#### 3. UI 組件結構
```
/components/PersonalVocab/
├── VocabLibrary.tsx # 詞彙庫主頁面
├── VocabCard.tsx # 單一詞彙卡片
├── VocabForm.tsx # 新增/編輯詞彙表單
├── VocabFilters.tsx # 篩選和搜尋
├── VocabStats.tsx # 學習統計
├── CategoryManager.tsx # 分類管理
├── TagManager.tsx # 標籤管理
└── ReviewScheduler.tsx # 複習排程
```
### 後端實現
#### 1. API 端點設計
```
GET /api/personal-vocab # 獲取用戶詞彙庫
POST /api/personal-vocab # 新增詞彙
PUT /api/personal-vocab/:id # 更新詞彙
DELETE /api/personal-vocab/:id # 刪除詞彙
POST /api/personal-vocab/batch # 批量操作
GET /api/personal-vocab/stats # 獲取學習統計
POST /api/personal-vocab/practice # 記錄練習結果
GET /api/personal-vocab/review # 獲取需要複習的詞彙
GET /api/learning-sessions # 獲取學習會話記錄
POST /api/learning-sessions # 記錄學習會話
```
#### 2. 資料庫設計
```sql
-- 個人詞彙表
CREATE TABLE personal_vocabularies (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
word VARCHAR(100) NOT NULL,
part_of_speech VARCHAR(20),
pronunciation VARCHAR(200),
definition TEXT,
translation TEXT,
personal_notes TEXT,
familiarity_score INTEGER DEFAULT 0,
recognition_score INTEGER DEFAULT 0,
recall_score INTEGER DEFAULT 0,
application_score INTEGER DEFAULT 0,
total_practices INTEGER DEFAULT 0,
correct_answers INTEGER DEFAULT 0,
incorrect_answers INTEGER DEFAULT 0,
last_practiced TIMESTAMP,
next_review TIMESTAMP,
priority VARCHAR(10) DEFAULT 'medium',
source VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙分類表
CREATE TABLE vocab_categories (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
name VARCHAR(50) NOT NULL,
color VARCHAR(7), -- HEX color
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙-分類關聯表
CREATE TABLE vocab_category_relations (
vocab_id UUID REFERENCES personal_vocabularies(id),
category_id UUID REFERENCES vocab_categories(id),
PRIMARY KEY (vocab_id, category_id)
);
-- 學習會話表
CREATE TABLE learning_sessions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
mode VARCHAR(50) NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
total_questions INTEGER DEFAULT 0,
correct_answers INTEGER DEFAULT 0,
time_spent INTEGER DEFAULT 0, -- 秒
created_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙練習記錄表
CREATE TABLE vocab_practice_records (
id UUID PRIMARY KEY,
session_id UUID REFERENCES learning_sessions(id),
vocab_id UUID REFERENCES personal_vocabularies(id),
test_mode VARCHAR(50) NOT NULL,
is_correct BOOLEAN NOT NULL,
response_time INTEGER, -- 毫秒
user_answer TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
```
## 🎨 使用者介面設計
### 主要頁面結構
#### 1. 詞彙庫總覽頁面 (`/personal-vocab`)
```
┌─────────────────────────────────────┐
│ 🏠 個人詞彙庫 (1,247 個詞彙) │
├─────────────────────────────────────┤
│ [搜尋框] [篩選] [排序] [新增詞彙] │
├─────────────────────────────────────┤
│ 📊 學習統計 │
│ • 今日學習12 個詞彙 │
│ • 本週進度85% 完成 │
│ • 平均正確率78% │
├─────────────────────────────────────┤
│ 📚 詞彙分類 │
│ [新學詞彙 124] [學習中 89] [熟悉 856] │
│ [困難詞彙 45] [我的收藏 67] │
├─────────────────────────────────────┤
│ 📝 詞彙列表 │
│ ┌─────────────────────────────────┐ │
│ │ brought (動詞) ⭐⭐⭐⭐☆ │ │
│ │ 發音: /brɔːt/ | 熟悉度: 80% │ │
│ │ 定義: Past tense of bring... │ │
│ │ [編輯] [練習] [刪除] │ │
│ └─────────────────────────────────┘ │
│ (更多詞彙卡片...) │
└─────────────────────────────────────┘
```
#### 2. 詞彙詳情頁面 (`/personal-vocab/:id`)
```
┌─────────────────────────────────────┐
│ ← 返回詞彙庫 │
├─────────────────────────────────────┤
│ 📝 brought │
│ 動詞 | 難度: B1 | 優先級: 高 │
├─────────────────────────────────────┤
│ 🔊 發音: /brɔːt/ [播放] │
│ 📖 定義: Past tense of bring... │
│ 🈲 翻譯: 提出、帶來 │
│ 📝 個人筆記: [編輯區域] │
├─────────────────────────────────────┤
│ 📊 學習統計 │
│ • 熟悉度: ████████░░ 80% │
│ • 總練習: 25 次 │
│ • 正確率: 85% │
│ • 上次練習: 2 小時前 │
│ • 下次複習: 明天 14:00 │
├─────────────────────────────────────┤
│ 🎯 各模式表現 │
│ • 翻卡記憶: 90% (15/15) │
│ • 詞彙選擇: 75% (12/16) │
│ • 例句填空: 80% (8/10) │
├─────────────────────────────────────┤
│ 🏷️ 分類標籤 │
│ [學習中] [商業英語] [重要詞彙] │
│ [+ 添加標籤] │
├─────────────────────────────────────┤
│ 🎮 快速練習 │
│ [翻卡記憶] [詞彙選擇] [例句填空] │
└─────────────────────────────────────┘
```
#### 3. 新增詞彙頁面 (`/personal-vocab/add`)
```
┌─────────────────────────────────────┐
新增詞彙到個人庫 │
├─────────────────────────────────────┤
│ 📝 英文詞彙: [輸入框] *必填 │
│ 🔍 [智能查詢] - 自動填入詞彙資訊 │
├─────────────────────────────────────┤
│ 📖 詞彙資訊 │
│ • 詞性: [下拉選單] │
│ • 發音: [輸入框] [生成] │
│ • 定義: [文字區域] [自動生成] │
│ • 翻譯: [輸入框] [自動翻譯] │
├─────────────────────────────────────┤
│ 🏷️ 分類設定 │
│ • 分類: [多選下拉] [新增分類] │
│ • 標籤: [標籤選擇器] [新增標籤] │
│ • 優先級: ⚫ 高 ⚪ 中 ⚪ 低 │
├─────────────────────────────────────┤
│ 📝 個人筆記 │
│ [多行文字輸入區域] │
├─────────────────────────────────────┤
│ [取消] [儲存詞彙] │
└─────────────────────────────────────┘
```
## 🔄 學習流程整合
### 1. 學習中的詞彙收集
- **收藏按鈕**:每個測驗頁面都有收藏功能
- **自動收集**:答錯的詞彙自動標記為需要加強
- **學習後提醒**:學習會話結束後推薦收藏的詞彙
### 2. 個人化測驗生成
- **我的詞彙測驗**:從個人庫選取詞彙生成測驗
- **弱點強化**:針對低熟悉度詞彙的專門練習
- **混合模式**:結合系統詞彙和個人詞彙的測驗
### 3. 複習提醒系統
- **智能排程**:基於遺忘曲線安排複習時間
- **推送通知**:瀏覽器通知提醒複習時間
- **複習優化**:根據表現調整複習頻率
## 📱 響應式設計考量
### 桌面版 (>= 1024px)
- **三欄布局**:側邊欄(分類)+ 詞彙列表 + 詳情面板
- **拖拉操作**:支援拖拉詞彙到不同分類
- **快速鍵**:鍵盤快速鍵支援
### 平板版 (768px - 1023px)
- **兩欄布局**:詞彙列表 + 詳情面板
- **觸控優化**:適合觸控操作的按鈕尺寸
### 手機版 (< 768px)
- **單欄布局**:全螢幕顯示當前頁面
- **底部導航**:快速切換功能
- **手勢支援**:滑動操作和長按功能
## 🚀 實施階段規劃
### 階段 1基礎詞彙管理 (第 1-2 週)
- [ ] 資料庫設計和建立
- [ ] 基本 CRUD API 開發
- [ ] 詞彙列表頁面
- [ ] 新增/編輯詞彙功能
- [ ] 基本搜尋和篩選
### 階段 2學習追蹤系統 (第 3-4 週)
- [ ] 熟悉度評分系統
- [ ] 學習歷史記錄
- [ ] 測驗結果整合
- [ ] 學習統計儀表板
### 階段 3智能化功能 (第 5-6 週)
- [ ] 遺忘曲線算法
- [ ] 複習提醒系統
- [ ] 個人化推薦
- [ ] 弱點分析
### 階段 4高級功能 (第 7-8 週)
- [ ] 批量導入/導出
- [ ] 學習計劃制定
- [ ] 成就系統
- [ ] 社交分享功能
## 📊 成功指標
### 用戶行為指標
- **詞彙庫使用率**> 80% 用戶建立個人詞彙庫
- **收藏率**> 60% 學習中的詞彙被收藏
- **複習完成率**> 70% 的複習提醒被完成
- **熟悉度提升**:平均熟悉度每週提升 5%
### 學習效果指標
- **記憶保持率**:複習詞彙的正確率 > 85%
- **學習效率**:個人詞彙的學習時間縮短 30%
- **長期記憶**30 天後的詞彙記憶率 > 70%
### 系統性能指標
- **回應時間**:詞彙庫載入時間 < 2
- **同步效率**:資料同步成功率 > 99%
- **儲存效率**:本地儲存空間使用 < 50MB
## 🔐 隱私和安全考量
### 資料隱私
- **用戶授權**:明確的隱私政策和使用條款
- **資料加密**:敏感資料的端到端加密
- **匿名化**:學習統計資料的匿名化處理
### 資料安全
- **備份機制**:定期備份用戶資料
- **版本控制**:資料變更的版本記錄
- **災難恢復**:資料遺失的恢復機制
## 🔮 未來擴展功能
### 社交學習功能
- **詞彙分享**:分享個人詞彙庫給其他用戶
- **學習小組**:創建詞彙學習小組
- **競賽模式**:與朋友的詞彙學習競賽
### AI 智能功能
- **智能生成**AI 生成個人化例句
- **發音評估**AI 評估發音準確度
- **學習建議**AI 提供個人化學習建議
### 多媒體功能
- **語音筆記**:錄音形式的個人筆記
- **圖片聯想**:為詞彙添加個人化圖片
- **影片連結**:連結相關的學習影片
---
## 📝 附註
本規格文件為個人化詞彙庫功能的完整設計,包含前後端實現細節和用戶體驗考量。實際開發時可根據優先級和資源情況分階段實施。
**建議優先實施階段 1 和階段 2**,建立穩固的基礎功能,再逐步添加智能化和高級功能。
---
*最後更新2025-09-20*
*版本v1.0*

57
note/plan/複習規格.md Normal file
View File

@ -0,0 +1,57 @@
方式:
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習母語者的表達)
A1學習者
- 複習方式:翻卡題、詞彙聽力題、選擇題
補充因為A1對於發音是完全沒概念所以詞彙聽力這時候是有幫助的
簡單 (學習者程度 > 詞彙程度)
- 複習方式:例句重組題、填空題
適中 (學習者程度 = 詞彙程度)
- 複習方式:填空題、例句重組題、例句口說題
困難 (學習者程度 < 詞彙程度)
- 複習方式:翻卡題、選擇題
詞彙口袋大複習
- 配對題:給圖片和詞彙,但有個問題就是,有時候詞彙和圖的意境其實相關性不高
- 克漏字:
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
- 翻卡記憶:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
- 詞彙選擇:給定義,選詞彙 (加深詞彙定義與詞彙連結)
- 詞彙聽力:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 例句聽力:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
- 例句填空:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
- 例句重組:打亂例句單字,重組 (快速練習組織句子)
- 例句口說:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
> 例句填空\
系統會提供例句\
然後例句會有挖空處,有可能是多個單字(因為片語就是多個單字)
使用者點選挖空處就可以輸入單字\
點選顯示提示,系統會顯示詞彙定義\
在例句上方是例句圖\
\
\
以上功能請協助修改

View File

@ -0,0 +1,509 @@
# 個人化詞彙庫功能規格
## 🎯 功能概述
個人化詞彙庫是一個用戶專屬的詞彙管理系統,允許用戶收集、組織和追蹤自己的學習詞彙,並根據學習表現提供個人化的學習建議。
## 📋 核心功能需求
### 1. 詞彙收集功能
#### 1.1 手動添加詞彙
- **功能描述**:用戶可以手動輸入新詞彙到個人詞彙庫
- **輸入欄位**
- 英文詞彙(必填)
- 詞性(可選,下拉選單)
- 發音(可選,自動生成或手動輸入)
- 定義(可選,自動生成或手動輸入)
- 中文翻譯(可選,自動生成或手動輸入)
- 個人筆記(可選,用戶自訂)
- **自動補全**:系統自動查詢並填入詞彙資訊
- **重複檢查**:避免添加重複詞彙
#### 1.2 學習中收集
- **學習頁面收藏**:在任何測驗模式中點擊「收藏」按鈕
- **困難詞彙標記**:答錯的詞彙自動標記為需要加強
- **快速收集**:一鍵添加當前學習的詞彙到個人庫
#### 1.3 批量導入
- **文字檔導入**:支援 .txt 格式的詞彙列表
- **CSV 導入**:支援結構化的詞彙資料
- **從學習記錄導入**:將過往答錯的詞彙批量加入
### 2. 詞彙組織功能
#### 2.1 分類管理
- **預設分類**
- 新學詞彙New
- 學習中Learning
- 熟悉Familiar
- 精通Mastered
- 困難詞彙Difficult
- **自訂分類**:用戶可創建自己的分類標籤
- **多重分類**:單一詞彙可屬於多個分類
#### 2.2 標籤系統
- **難度標籤**A1, A2, B1, B2, C1, C2
- **主題標籤**:商業、旅遊、學術、日常等
- **來源標籤**:書籍、電影、新聞、會話等
- **自訂標籤**:用戶可創建個人標籤
#### 2.3 優先級管理
- **高優先級**:急需掌握的詞彙
- **中優先級**:重要但不緊急的詞彙
- **低優先級**:選擇性學習的詞彙
### 3. 學習追蹤功能
#### 3.1 熟悉度評分
- **評分機制**0-100 分的熟悉度評分
- **多維度評估**
- 認識度Recognition看到詞彙能理解
- 回想度Recall能主動想起詞彙
- 應用度Application能在語境中正確使用
- **動態調整**:根據測驗表現自動調整評分
#### 3.2 學習歷史
- **學習次數**:詞彙被學習的總次數
- **正確率**:各種測驗模式的正確率統計
- **最後學習時間**:記錄最近一次學習時間
- **學習軌跡**:詳細的學習歷程記錄
#### 3.3 遺忘曲線追蹤
- **複習提醒**:基於遺忘曲線的智能提醒
- **複習間隔**:動態調整複習時間間隔
- **記憶強度**:評估詞彙在記憶中的鞏固程度
### 4. 個人化學習功能
#### 4.1 智能推薦
- **弱點分析**:識別用戶的學習弱點
- **相似詞彙**:推薦語義相關的詞彙
- **同根詞擴展**:推薦同詞根的相關詞彙
- **搭配詞推薦**:推薦常見的詞彙搭配
#### 4.2 個人化測驗
- **客製化題組**:根據個人詞彙庫生成測驗
- **弱點加強**:針對困難詞彙的專門訓練
- **複習模式**:基於遺忘曲線的複習測驗
- **混合練習**:結合不同來源詞彙的綜合測驗
#### 4.3 學習計劃
- **每日目標**:設定每日學習詞彙數量
- **週期計劃**:制定短期和長期學習目標
- **進度追蹤**:視覺化顯示學習進度
- **成就系統**:學習里程碑和獎勵機制
## 🗃️ 資料結構設計
### 個人詞彙資料模型
```typescript
interface PersonalVocabulary {
id: string;
userId: string;
word: string;
partOfSpeech?: string;
pronunciation?: string;
definition?: string;
translation?: string;
personalNotes?: string;
// 分類和標籤
categories: string[];
tags: string[];
priority: 'high' | 'medium' | 'low';
// 學習追蹤
familiarityScore: number; // 0-100
recognitionScore: number; // 0-100
recallScore: number; // 0-100
applicationScore: number; // 0-100
// 學習統計
totalPractices: number;
correctAnswers: number;
incorrectAnswers: number;
lastPracticed: Date;
nextReview: Date;
// 測驗模式統計
flipMemoryStats: TestModeStats;
vocabChoiceStats: TestModeStats;
sentenceFillStats: TestModeStats;
// ... 其他測驗模式
// 元資料
createdAt: Date;
updatedAt: Date;
source?: string; // 詞彙來源
}
interface TestModeStats {
attempts: number;
correct: number;
averageTime: number; // 平均回答時間(秒)
lastAttempt: Date;
}
```
### 學習會話記錄
```typescript
interface LearningSession {
id: string;
userId: string;
startTime: Date;
endTime: Date;
mode: string;
vocabulariesPracticed: string[]; // 詞彙 IDs
totalQuestions: number;
correctAnswers: number;
timeSpent: number; // 秒
performance: SessionPerformance;
}
interface SessionPerformance {
accuracy: number; // 正確率
speed: number; // 平均回答速度
improvement: number; // 相對上次的進步
weakWords: string[]; // 表現較差的詞彙
strongWords: string[]; // 表現較好的詞彙
}
```
## 🔧 技術實現方案
### 前端實現
#### 1. 狀態管理
```typescript
// 使用 Context API 或 Zustand
interface PersonalVocabStore {
vocabularies: PersonalVocabulary[];
currentSession: LearningSession | null;
filters: VocabFilters;
// Actions
addVocabulary: (vocab: Partial<PersonalVocabulary>) => void;
updateVocabulary: (id: string, updates: Partial<PersonalVocabulary>) => void;
deleteVocabulary: (id: string) => void;
updateFamiliarity: (id: string, testResult: TestResult) => void;
// ...
}
```
#### 2. 本地儲存策略
- **IndexedDB**:大量詞彙資料的本地儲存
- **localStorage**:用戶偏好和設定
- **同步機制**:與伺服器的雙向同步
#### 3. UI 組件結構
```
/components/PersonalVocab/
├── VocabLibrary.tsx # 詞彙庫主頁面
├── VocabCard.tsx # 單一詞彙卡片
├── VocabForm.tsx # 新增/編輯詞彙表單
├── VocabFilters.tsx # 篩選和搜尋
├── VocabStats.tsx # 學習統計
├── CategoryManager.tsx # 分類管理
├── TagManager.tsx # 標籤管理
└── ReviewScheduler.tsx # 複習排程
```
### 後端實現
#### 1. API 端點設計
```
GET /api/personal-vocab # 獲取用戶詞彙庫
POST /api/personal-vocab # 新增詞彙
PUT /api/personal-vocab/:id # 更新詞彙
DELETE /api/personal-vocab/:id # 刪除詞彙
POST /api/personal-vocab/batch # 批量操作
GET /api/personal-vocab/stats # 獲取學習統計
POST /api/personal-vocab/practice # 記錄練習結果
GET /api/personal-vocab/review # 獲取需要複習的詞彙
GET /api/learning-sessions # 獲取學習會話記錄
POST /api/learning-sessions # 記錄學習會話
```
#### 2. 資料庫設計
```sql
-- 個人詞彙表
CREATE TABLE personal_vocabularies (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
word VARCHAR(100) NOT NULL,
part_of_speech VARCHAR(20),
pronunciation VARCHAR(200),
definition TEXT,
translation TEXT,
personal_notes TEXT,
familiarity_score INTEGER DEFAULT 0,
recognition_score INTEGER DEFAULT 0,
recall_score INTEGER DEFAULT 0,
application_score INTEGER DEFAULT 0,
total_practices INTEGER DEFAULT 0,
correct_answers INTEGER DEFAULT 0,
incorrect_answers INTEGER DEFAULT 0,
last_practiced TIMESTAMP,
next_review TIMESTAMP,
priority VARCHAR(10) DEFAULT 'medium',
source VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙分類表
CREATE TABLE vocab_categories (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
name VARCHAR(50) NOT NULL,
color VARCHAR(7), -- HEX color
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙-分類關聯表
CREATE TABLE vocab_category_relations (
vocab_id UUID REFERENCES personal_vocabularies(id),
category_id UUID REFERENCES vocab_categories(id),
PRIMARY KEY (vocab_id, category_id)
);
-- 學習會話表
CREATE TABLE learning_sessions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
mode VARCHAR(50) NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
total_questions INTEGER DEFAULT 0,
correct_answers INTEGER DEFAULT 0,
time_spent INTEGER DEFAULT 0, -- 秒
created_at TIMESTAMP DEFAULT NOW()
);
-- 詞彙練習記錄表
CREATE TABLE vocab_practice_records (
id UUID PRIMARY KEY,
session_id UUID REFERENCES learning_sessions(id),
vocab_id UUID REFERENCES personal_vocabularies(id),
test_mode VARCHAR(50) NOT NULL,
is_correct BOOLEAN NOT NULL,
response_time INTEGER, -- 毫秒
user_answer TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
```
## 🎨 使用者介面設計
### 主要頁面結構
#### 1. 詞彙庫總覽頁面 (`/personal-vocab`)
```
┌─────────────────────────────────────┐
│ 🏠 個人詞彙庫 (1,247 個詞彙) │
├─────────────────────────────────────┤
│ [搜尋框] [篩選] [排序] [新增詞彙] │
├─────────────────────────────────────┤
│ 📊 學習統計 │
│ • 今日學習12 個詞彙 │
│ • 本週進度85% 完成 │
│ • 平均正確率78% │
├─────────────────────────────────────┤
│ 📚 詞彙分類 │
│ [新學詞彙 124] [學習中 89] [熟悉 856] │
│ [困難詞彙 45] [我的收藏 67] │
├─────────────────────────────────────┤
│ 📝 詞彙列表 │
│ ┌─────────────────────────────────┐ │
│ │ brought (動詞) ⭐⭐⭐⭐☆ │ │
│ │ 發音: /brɔːt/ | 熟悉度: 80% │ │
│ │ 定義: Past tense of bring... │ │
│ │ [編輯] [練習] [刪除] │ │
│ └─────────────────────────────────┘ │
│ (更多詞彙卡片...) │
└─────────────────────────────────────┘
```
#### 2. 詞彙詳情頁面 (`/personal-vocab/:id`)
```
┌─────────────────────────────────────┐
│ ← 返回詞彙庫 │
├─────────────────────────────────────┤
│ 📝 brought │
│ 動詞 | 難度: B1 | 優先級: 高 │
├─────────────────────────────────────┤
│ 🔊 發音: /brɔːt/ [播放] │
│ 📖 定義: Past tense of bring... │
│ 🈲 翻譯: 提出、帶來 │
│ 📝 個人筆記: [編輯區域] │
├─────────────────────────────────────┤
│ 📊 學習統計 │
│ • 熟悉度: ████████░░ 80% │
│ • 總練習: 25 次 │
│ • 正確率: 85% │
│ • 上次練習: 2 小時前 │
│ • 下次複習: 明天 14:00 │
├─────────────────────────────────────┤
│ 🎯 各模式表現 │
│ • 翻卡記憶: 90% (15/15) │
│ • 詞彙選擇: 75% (12/16) │
│ • 例句填空: 80% (8/10) │
├─────────────────────────────────────┤
│ 🏷️ 分類標籤 │
│ [學習中] [商業英語] [重要詞彙] │
│ [+ 添加標籤] │
├─────────────────────────────────────┤
│ 🎮 快速練習 │
│ [翻卡記憶] [詞彙選擇] [例句填空] │
└─────────────────────────────────────┘
```
#### 3. 新增詞彙頁面 (`/personal-vocab/add`)
```
┌─────────────────────────────────────┐
新增詞彙到個人庫 │
├─────────────────────────────────────┤
│ 📝 英文詞彙: [輸入框] *必填 │
│ 🔍 [智能查詢] - 自動填入詞彙資訊 │
├─────────────────────────────────────┤
│ 📖 詞彙資訊 │
│ • 詞性: [下拉選單] │
│ • 發音: [輸入框] [生成] │
│ • 定義: [文字區域] [自動生成] │
│ • 翻譯: [輸入框] [自動翻譯] │
├─────────────────────────────────────┤
│ 🏷️ 分類設定 │
│ • 分類: [多選下拉] [新增分類] │
│ • 標籤: [標籤選擇器] [新增標籤] │
│ • 優先級: ⚫ 高 ⚪ 中 ⚪ 低 │
├─────────────────────────────────────┤
│ 📝 個人筆記 │
│ [多行文字輸入區域] │
├─────────────────────────────────────┤
│ [取消] [儲存詞彙] │
└─────────────────────────────────────┘
```
## 🔄 學習流程整合
### 1. 學習中的詞彙收集
- **收藏按鈕**:每個測驗頁面都有收藏功能
- **自動收集**:答錯的詞彙自動標記為需要加強
- **學習後提醒**:學習會話結束後推薦收藏的詞彙
### 2. 個人化測驗生成
- **我的詞彙測驗**:從個人庫選取詞彙生成測驗
- **弱點強化**:針對低熟悉度詞彙的專門練習
- **混合模式**:結合系統詞彙和個人詞彙的測驗
### 3. 複習提醒系統
- **智能排程**:基於遺忘曲線安排複習時間
- **推送通知**:瀏覽器通知提醒複習時間
- **複習優化**:根據表現調整複習頻率
## 📱 響應式設計考量
### 桌面版 (>= 1024px)
- **三欄布局**:側邊欄(分類)+ 詞彙列表 + 詳情面板
- **拖拉操作**:支援拖拉詞彙到不同分類
- **快速鍵**:鍵盤快速鍵支援
### 平板版 (768px - 1023px)
- **兩欄布局**:詞彙列表 + 詳情面板
- **觸控優化**:適合觸控操作的按鈕尺寸
### 手機版 (< 768px)
- **單欄布局**:全螢幕顯示當前頁面
- **底部導航**:快速切換功能
- **手勢支援**:滑動操作和長按功能
## 🚀 實施階段規劃
### 階段 1基礎詞彙管理 (第 1-2 週)
- [ ] 資料庫設計和建立
- [ ] 基本 CRUD API 開發
- [ ] 詞彙列表頁面
- [ ] 新增/編輯詞彙功能
- [ ] 基本搜尋和篩選
### 階段 2學習追蹤系統 (第 3-4 週)
- [ ] 熟悉度評分系統
- [ ] 學習歷史記錄
- [ ] 測驗結果整合
- [ ] 學習統計儀表板
### 階段 3智能化功能 (第 5-6 週)
- [ ] 遺忘曲線算法
- [ ] 複習提醒系統
- [ ] 個人化推薦
- [ ] 弱點分析
### 階段 4高級功能 (第 7-8 週)
- [ ] 批量導入/導出
- [ ] 學習計劃制定
- [ ] 成就系統
- [ ] 社交分享功能
## 📊 成功指標
### 用戶行為指標
- **詞彙庫使用率**> 80% 用戶建立個人詞彙庫
- **收藏率**> 60% 學習中的詞彙被收藏
- **複習完成率**> 70% 的複習提醒被完成
- **熟悉度提升**:平均熟悉度每週提升 5%
### 學習效果指標
- **記憶保持率**:複習詞彙的正確率 > 85%
- **學習效率**:個人詞彙的學習時間縮短 30%
- **長期記憶**30 天後的詞彙記憶率 > 70%
### 系統性能指標
- **回應時間**:詞彙庫載入時間 < 2
- **同步效率**:資料同步成功率 > 99%
- **儲存效率**:本地儲存空間使用 < 50MB
## 🔐 隱私和安全考量
### 資料隱私
- **用戶授權**:明確的隱私政策和使用條款
- **資料加密**:敏感資料的端到端加密
- **匿名化**:學習統計資料的匿名化處理
### 資料安全
- **備份機制**:定期備份用戶資料
- **版本控制**:資料變更的版本記錄
- **災難恢復**:資料遺失的恢復機制
## 🔮 未來擴展功能
### 社交學習功能
- **詞彙分享**:分享個人詞彙庫給其他用戶
- **學習小組**:創建詞彙學習小組
- **競賽模式**:與朋友的詞彙學習競賽
### AI 智能功能
- **智能生成**AI 生成個人化例句
- **發音評估**AI 評估發音準確度
- **學習建議**AI 提供個人化學習建議
### 多媒體功能
- **語音筆記**:錄音形式的個人筆記
- **圖片聯想**:為詞彙添加個人化圖片
- **影片連結**:連結相關的學習影片
---
## 📝 附註
本規格文件為個人化詞彙庫功能的完整設計,包含前後端實現細節和用戶體驗考量。實際開發時可根據優先級和資源情況分階段實施。
**建議優先實施階段 1 和階段 2**,建立穩固的基礎功能,再逐步添加智能化和高級功能。
---
*最後更新2025-09-20*
*版本v1.0*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,711 @@
# 詞彙生成與儲存系統規格
## 系統概述
DramaLing詞彙學習系統是一個基於AI的英語詞彙學習平台主要功能包括詞彙生成、智能分析、儲存管理和學習追蹤。系統採用前後端分離架構前端使用Next.js 14後端使用.NET 8 Web API資料庫使用SQLite + Entity Framework Core。
## 1. 詞彙生成功能規格
### 1.1 句子分析功能
#### 核心特性
- **AI分析引擎**: 使用Google Gemini API進行句子深度分析
- **多層次分析**: 包含詞彙分析、語法檢查、整句翻譯
- **個人化適配**: 根據用戶英語程度(A1-C2)調整分析深度
- **智能快取**: 使用快取機制避免重複AI調用
- **使用量追蹤**: 追蹤用戶查詢使用量並實施限制
#### API端點
- **端點**: `POST /api/ai/analyze-sentence`
- **功能**: 句子綜合分析
- **輸入參數** (AnalyzeSentenceRequest):
```json
{
"inputText": "string", // 用戶輸入的英文句子 (最多1000字元)
"userLevel": "string", // 用戶英語程度 (A1-C2, 預設A2)
"forceRefresh": boolean, // 是否強制重新分析
"analysisMode": "string" // 分析模式 ("full")
}
```
- **回應格式**:
```json
{
"success": boolean,
"data": {
"wordAnalysis": {
"[word]": {
"word": "string",
"translation": "string",
"definition": "string",
"partOfSpeech": "string",
"pronunciation": "string",
"isHighValue": boolean,
"difficultyLevel": "string" // CEFR等級
}
},
"sentenceMeaning": {
"translation": "string",
"explanation": "string"
},
"grammarCorrection": {
"hasErrors": boolean,
"originalText": "string",
"correctedText": "string",
"corrections": [
{
"errorType": "string",
"original": "string",
"corrected": "string",
"reason": "string"
}
]
},
"finalAnalysisText": "string",
"highValueWords": ["string"]
}
}
```
#### 快取機制 (SentenceAnalysisCache)
- **表名**: `SentenceAnalysisCache`
- **主要欄位**:
```csharp
public class SentenceAnalysisCache
{
public Guid Id { get; set; }
public string InputTextHash { get; set; } // SHA-256雜湊
public string InputText { get; set; } // 原始輸入文本
public string? CorrectedText { get; set; } // 修正後文本
public bool HasGrammarErrors { get; set; } // 是否有語法錯誤
public string? GrammarCorrections { get; set; } // JSON格式語法修正
public string AnalysisResult { get; set; } // JSON格式分析結果
public string? HighValueWords { get; set; } // JSON格式高價值詞彙
public string? PhrasesDetected { get; set; } // JSON格式檢測片語
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int AccessCount { get; set; } // 存取次數
public DateTime? LastAccessedAt { get; set; }
}
```
#### 實現位置
- **前端**: `frontend/app/generate/page.tsx:29-100`
- **後端控制器**: `backend/DramaLing.Api/Controllers/AIController.cs`
- **服務層**: `backend/DramaLing.Api/Services/GeminiService.cs`
- **快取服務**: `backend/DramaLing.Api/Services/AnalysisCacheService.cs`
### 1.2 詞卡生成功能
#### 核心特性
- **智能萃取**: 支援詞彙萃取(vocabulary)和智能萃取(smart)兩種模式
- **批量生成**: 一次可生成1-20張詞卡
- **多元內容**: 包含單字、翻譯、定義、例句、同義詞、難度等級
- **測試模式**: 支援無認證的測試端點
#### API端點
- **端點**: `POST /api/ai/test/generate` (測試用,無需認證)
- **功能**: 生成詞卡
- **輸入參數** (GenerateCardsRequest):
```json
{
"inputText": "string", // 原始文本 (最多5000字元)
"extractionType": "string", // "vocabulary" | "smart"
"cardCount": number // 1-20
}
```
- **回應格式**:
```json
{
"success": boolean,
"data": {
"taskId": "guid",
"status": "completed",
"generatedCards": [
{
"word": "string",
"translation": "string",
"definition": "string",
"partOfSpeech": "string",
"pronunciation": "string",
"example": "string",
"exampleTranslation": "string",
"synonyms": ["string"],
"difficultyLevel": "string",
"score": number // AI生成評分
}
]
},
"message": "string"
}
```
#### 實現位置
- **前端**: `frontend/app/generate/page.tsx:114-151`
- **後端**: `backend/DramaLing.Api/Controllers/AIController.cs:42-100`
### 1.3 互動式詞彙查詢
#### 核心特性
- **點擊查詢**: 用戶可點擊句子中任意單字查看詳細資訊
- **即時分析**: 動態調用AI API獲取單字分析
- **高價值標示**: 自動標示高價值單字和片語
- **使用量計費**: 區分高價值(免費)和低價值(計費)詞彙
#### API端點
- **端點**: `POST /api/ai/query-word`
- **輸入參數** (QueryWordRequest):
```json
{
"word": "string", // 要查詢的單字
"sentence": "string", // 上下文句子
"analysisId": "guid?" // 分析ID (可選)
}
```
#### 使用量統計 (WordQueryUsageStats)
- **表名**: `WordQueryUsageStats`
- **主要欄位**:
```csharp
public class WordQueryUsageStats
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateOnly Date { get; set; } // 日期
public int SentenceAnalysisCount { get; set; } // 句子分析次數
public int HighValueWordClicks { get; set; } // 高價值詞彙點擊(免費)
public int LowValueWordClicks { get; set; } // 低價值詞彙點擊(收費)
public int TotalApiCalls { get; set; } // 總API調用次數
public int UniqueWordsQueried { get; set; } // 查詢的獨特詞彙數
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
#### 實現位置
- **組件**: `frontend/components/ClickableTextV2.tsx`
- **前端邏輯**: `frontend/app/generate/page.tsx:402-421`
- **使用量服務**: `backend/DramaLing.Api/Services/UsageTrackingService.cs`
## 2. 詞彙儲存系統規格
### 2.1 資料庫架構
#### 2.1.1 用戶管理 (User)
- **表名**: `user_profiles`
- **主要欄位**:
```csharp
public class User
{
public Guid Id { get; set; }
public string Username { get; set; } // 用戶名 (唯一)
public string Email { get; set; } // 信箱 (唯一)
public string PasswordHash { get; set; } // 密碼雜湊
public string? DisplayName { get; set; } // 顯示名稱
public string? AvatarUrl { get; set; } // 頭像URL
public string SubscriptionType { get; set; } = "free"; // 訂閱類型
public Dictionary<string, object> Preferences { get; set; } // JSON偏好設定
// 個人化學習相關
public string EnglishLevel { get; set; } = "A2"; // 英語程度(A1-C2)
public DateTime LevelUpdatedAt { get; set; } // 程度更新時間
public bool IsLevelVerified { get; set; } = false; // 是否通過測試驗證
public string? LevelNotes { get; set; } // 程度設定備註
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
#### 2.1.2 詞卡實體 (Flashcard)
- **表名**: `flashcards`
- **主要欄位**:
```csharp
public class Flashcard
{
public Guid Id { get; set; }
public Guid UserId { get; set; } // 所屬用戶
public Guid CardSetId { get; set; } // 所屬卡組
// 詞卡內容
[Required, MaxLength(255)]
public string Word { get; set; } // 單字
[Required]
public string Translation { get; set; } // 翻譯
[Required]
public string Definition { get; set; } // 定義
[MaxLength(50)]
public string? PartOfSpeech { get; set; } // 詞性
[MaxLength(255)]
public string? Pronunciation { get; set; } // 發音
public string? Example { get; set; } // 例句
public string? ExampleTranslation { get; set; } // 例句翻譯
// SM-2 間隔重複算法參數
public float EasinessFactor { get; set; } = 2.5f; // 難易度係數
public int Repetitions { get; set; } = 0; // 重複次數
public int IntervalDays { get; set; } = 1; // 間隔天數
public DateTime NextReviewDate { get; set; } // 下次複習日期
// 學習統計
[Range(0, 100)]
public int MasteryLevel { get; set; } = 0; // 掌握程度(0-100)
public int TimesReviewed { get; set; } = 0; // 複習次數
public int TimesCorrect { get; set; } = 0; // 正確次數
public DateTime? LastReviewedAt { get; set; } // 最後複習時間
// 狀態管理
public bool IsFavorite { get; set; } = false; // 是否收藏
public bool IsArchived { get; set; } = false; // 是否封存
[MaxLength(10)]
public string? DifficultyLevel { get; set; } // 難度等級(A1-C2)
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
#### 2.1.3 卡組實體 (CardSet)
- **表名**: `card_sets`
- **主要欄位**:
```csharp
public class CardSet
{
public Guid Id { get; set; }
public Guid UserId { get; set; } // 所屬用戶
[Required, MaxLength(255)]
public string Name { get; set; } // 卡組名稱
public string? Description { get; set; } // 描述
[MaxLength(50)]
public string Color { get; set; } = "bg-blue-500"; // 顏色標籤
public int CardCount { get; set; } = 0; // 詞卡數量
public bool IsDefault { get; set; } = false; // 是否為預設卡組
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
#### 2.1.4 標籤系統 (Tag & FlashcardTag)
- **表名**: `tags`, `flashcard_tags`
- **主要欄位**:
```csharp
public class Tag
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; } // 標籤名稱
[Required, MaxLength(50)]
public string Color { get; set; } // 標籤顏色
public int UsageCount { get; set; } = 0; // 使用次數
public DateTime CreatedAt { get; set; }
}
public class FlashcardTag // 多對多關聯表
{
public Guid FlashcardId { get; set; }
public Guid TagId { get; set; }
}
```
#### 2.1.5 學習追蹤 (StudySession & StudyRecord)
- **表名**: `study_sessions`, `study_records`
- **主要欄位**:
```csharp
public class StudySession
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
[Required, MaxLength(50)]
public string SessionType { get; set; } // 學習模式
public DateTime StartedAt { get; set; } // 開始時間
public DateTime? EndedAt { get; set; } // 結束時間
public int TotalCards { get; set; } = 0; // 總詞卡數
public int CorrectCount { get; set; } = 0; // 正確數量
public int DurationSeconds { get; set; } = 0; // 持續時間(秒)
public int AverageResponseTimeMs { get; set; } = 0; // 平均回應時間(毫秒)
}
public class StudyRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid FlashcardId { get; set; }
public Guid SessionId { get; set; }
[Required, MaxLength(50)]
public string StudyMode { get; set; } // 學習模式
public int QualityRating { get; set; } // 品質評分(0-5)
public int? ResponseTimeMs { get; set; } // 回應時間(毫秒)
public string? UserAnswer { get; set; } // 用戶答案
public bool IsCorrect { get; set; } // 是否正確
public DateTime StudiedAt { get; set; } // 學習時間
// SM-2算法歷史記錄
public float PreviousEasinessFactor { get; set; }
public int PreviousRepetitions { get; set; }
public int PreviousIntervalDays { get; set; }
public float NewEasinessFactor { get; set; }
public int NewRepetitions { get; set; }
public int NewIntervalDays { get; set; }
public DateTime NextReviewDate { get; set; }
}
```
#### 2.1.6 錯誤回報 (ErrorReport)
- **表名**: `error_reports`
- **主要欄位**:
```csharp
public class ErrorReport
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid FlashcardId { get; set; }
[Required, MaxLength(100)]
public string ReportType { get; set; } // 錯誤類型
public string? Description { get; set; } // 描述
[MaxLength(50)]
public string? StudyMode { get; set; } // 學習模式
[Required, MaxLength(50)]
public string Status { get; set; } = "pending"; // 狀態
public string? AdminNotes { get; set; } // 管理員備註
public Guid? ResolvedBy { get; set; } // 解決者ID
public DateTime? ResolvedAt { get; set; } // 解決時間
public DateTime CreatedAt { get; set; }
}
```
#### 2.1.7 每日統計 (DailyStats)
- **表名**: `daily_stats`
- **主要欄位**:
```csharp
public class DailyStats
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateOnly Date { get; set; } // 日期
public int WordsStudied { get; set; } = 0; // 學習單字數
public int WordsCorrect { get; set; } = 0; // 正確單字數
public int StudyTimeSeconds { get; set; } = 0; // 學習時間(秒)
public int SessionCount { get; set; } = 0; // 學習場次
public int CardsGenerated { get; set; } = 0; // 生成詞卡數
public int AiApiCalls { get; set; } = 0; // AI API調用次數
public DateTime CreatedAt { get; set; }
}
```
### 2.2 儲存服務API
#### 2.2.1 詞卡管理API
- **取得詞卡列表**: `GET /api/flashcards`
- **查詢參數**:
- `setId` (Guid?): 指定卡組ID
- `search` (string?): 搜尋關鍵字(詞彙/翻譯)
- `favoritesOnly` (bool): 僅顯示收藏
- `limit` (int): 限制數量(預設50最多100)
- `offset` (int): 偏移量(分頁用)
- **回應格式**:
```json
{
"success": true,
"data": {
"flashcards": [...],
"total": number,
"hasMore": boolean
}
}
```
- **實現位置**: `FlashcardsController.cs:58-135`
- **建立詞卡**: `POST /api/flashcards`
- **請求體** (CreateFlashcardRequest):
```json
{
"cardSetId": "guid?", // 可選,未指定則使用預設卡組
"word": "string", // 必填
"translation": "string", // 必填
"definition": "string", // 必填
"partOfSpeech": "string?",
"pronunciation": "string?",
"example": "string?",
"exampleTranslation": "string?"
}
```
- **自動功能**: 如無指定卡組,自動分配到預設卡組或創建新的預設卡組
- **實現位置**: `FlashcardsController.cs:137-200`
- **更新詞卡**: `PUT /api/flashcards/{id}`
- **請求體** (UpdateFlashcardRequest): 支援部分欄位更新
- **實現位置**: `FlashcardsController.cs:233-287`
- **刪除詞卡**: `DELETE /api/flashcards/{id}`
- **實現位置**: `FlashcardsController.cs:289-324`
- **切換收藏狀態**: `POST /api/flashcards/{id}/favorite`
- **功能**: 切換詞卡的收藏狀態
#### 2.2.2 卡組管理API
- **取得卡組列表**: `GET /api/cardsets`
- **回應**: 包含卡組基本資訊和詞卡數量統計
- **建立卡組**: `POST /api/cardsets`
- **請求體** (CreateCardSetRequest):
```json
{
"name": "string", // 必填
"description": "string?", // 可選
"isPublic": boolean // 可選預設false
}
```
- **更新卡組**: `PUT /api/cardsets/{id}`
- **請求體** (UpdateCardSetRequest): 支援部分欄位更新
- **刪除卡組**: `DELETE /api/cardsets/{id}`
- **限制**: 無法刪除預設卡組
- **確保預設卡組**: `POST /api/cardsets/ensure-default`
- **功能**: 確保用戶有預設卡組,如無則自動創建
#### 2.2.3 前端服務層
- **檔案位置**: `frontend/lib/services/flashcards.ts`
- **核心介面**:
```typescript
export interface Flashcard {
id: string;
word: string;
translation: string;
definition: string;
partOfSpeech: string;
pronunciation: string;
example: string;
exampleTranslation?: string;
masteryLevel: number; // 0-100掌握程度
timesReviewed: number; // 複習次數
isFavorite: boolean; // 是否收藏
nextReviewDate: string; // 下次複習日期
createdAt: string;
cardSet: {
name: string;
color: string;
};
}
export interface CardSet {
id: string;
name: string;
description: string;
color: string;
cardCount: number; // 詞卡數量
createdAt: string;
updatedAt: string;
isDefault: boolean; // 是否為預設卡組
progress: number; // 學習進度
lastStudied: string; // 最後學習時間
tags: string[]; // 標籤
}
class FlashcardsService {
// 詞卡CRUD操作
async getFlashcards(cardSetId?: string): Promise<ApiResponse<{flashcards: Flashcard[]; total: number; hasMore: boolean}>>
async createFlashcard(data: CreateFlashcardRequest): Promise<ApiResponse<Flashcard>>
async updateFlashcard(id: string, data: Partial<CreateFlashcardRequest>): Promise<ApiResponse<Flashcard>>
async deleteFlashcard(id: string): Promise<ApiResponse<void>>
async toggleFavorite(id: string): Promise<ApiResponse<Flashcard>>
// 卡組CRUD操作
async getCardSets(): Promise<ApiResponse<{sets: CardSet[]}>>
async createCardSet(data: CreateCardSetRequest): Promise<ApiResponse<CardSet>>
async deleteCardSet(id: string): Promise<ApiResponse<void>>
async ensureDefaultCardSet(): Promise<ApiResponse<CardSet>>
}
```
#### 2.2.4 資料庫關聯與索引
- **外鍵關聯**:
- `flashcards.user_id``user_profiles.id` (CASCADE)
- `flashcards.card_set_id``card_sets.id` (CASCADE)
- `card_sets.user_id``user_profiles.id` (CASCADE)
- `flashcard_tags.flashcard_id``flashcards.id` (CASCADE)
- `flashcard_tags.tag_id``tags.id` (CASCADE)
- **重要索引**:
- `user_profiles`: email(UNIQUE), username(UNIQUE)
- `flashcards`: user_id, card_set_id
- `card_sets`: user_id
- `tags`: user_id
- `daily_stats`: (user_id, date)(UNIQUE)
- `SentenceAnalysisCache`: input_text_hash(UNIQUE), expires_at
## 3. 系統整合流程
### 3.1 完整學習流程
1. **用戶認證** → JWT Token驗證與用戶程度讀取
2. **句子輸入** → 用戶在生成頁面輸入英文句子(最多300字元)
3. **快取檢查** → 檢查是否已有分析結果快取
4. **AI分析** → 調用Gemini API進行句子深度分析
5. **結果快取** → 將分析結果儲存到`SentenceAnalysisCache`
6. **互動探索** → 用戶點擊單字查看詳細分析(使用量追蹤)
7. **詞卡生成** → 基於分析結果生成個人化詞卡
8. **儲存管理** → 詞卡儲存到預設或指定卡組
9. **學習追蹤** → 使用SM-2算法追蹤學習進度
### 3.2 資料流向圖
```
用戶請求 → JWT認證 → 程度檢查 → 輸入驗證 → 快取檢查 → AI API調用
結果快取 → 使用量記錄 → 前端顯示 → 用戶互動 → 詞卡生成 → 資料庫儲存
學習記錄 → SM-2更新 → 統計更新 → 進度追蹤
```
### 3.3 關鍵業務邏輯
#### 3.3.1 預設卡組管理
- 用戶首次創建詞卡時,系統自動創建名為「未分類」的預設卡組
- 預設卡組無法刪除,確保用戶始終有存放詞卡的地方
- 實現位置: `FlashcardsController.cs:33-56`
#### 3.3.2 SM-2間隔重複算法
- 基於用戶答題品質(0-5分)調整複習間隔
- 記錄詳細的學習歷史用於算法優化
- 實現位置: `backend/DramaLing.Api/Services/SM2Algorithm.cs`
#### 3.3.3 使用量限制機制
- 免費用戶3小時內最多5次句子分析
- 高價值詞彙點擊免費,低價值詞彙計費
- 實現位置: `UsageTrackingService.cs`
## 4. 技術架構
### 4.1 前端技術棧
- **框架**: Next.js 14 (App Router)
- **語言**: TypeScript
- **樣式**: Tailwind CSS
- **狀態管理**: React Hooks + Context API
- **HTTP客戶端**: Fetch API
- **認證**: JWT + localStorage
- **路由保護**: ProtectedRoute組件
### 4.2 後端技術棧
- **框架**: .NET 8 Web API
- **ORM**: Entity Framework Core 8.0
- **資料庫**: SQLite (開發)
- **認證**: JWT Bearer Token
- **AI服務**: Google Gemini API
- **快取**: 內建EF快取 + 自定義快取服務
- **日誌**: Microsoft.Extensions.Logging
### 4.3 資料庫設計特點
- **Snake_case命名**: 所有資料表和欄位使用snake_case
- **GUID主鍵**: 所有實體使用Guid作為主鍵
- **軟刪除**: 支援IsArchived標記而非硬刪除
- **審計欄位**: CreatedAt、UpdatedAt自動管理
- **JSON欄位**: 使用JSON存儲複雜結構(如Preferences)
### 4.4 API設計原則
- **RESTful風格**: 遵循REST慣例
- **統一回應格式**:
```json
{
"success": boolean,
"data": object,
"error": string,
"message": string,
"timestamp": datetime
}
```
- **認證保護**: 所有業務API需JWT認證
- **錯誤處理**: 統一錯誤處理中間件
- **請求驗證**: DataAnnotations + 自定義驗證
- **分頁支援**: 統一的limit/offset分頁機制
## 5. 效能與優化
### 5.1 快取策略
- **句子分析快取**:
- 使用SHA-256雜湊避重
- 設定過期時間(ExpiresAt)
- 記錄存取次數和最後存取時間
- **詞彙查詢快取**:
- 減少重複AI API調用
- 提升查詞響應速度
- **清理機制**:
- 定期清理過期快取
- 實現位置: `CacheCleanupService.cs`
### 5.2 效能監控
- **日常統計**: DailyStats記錄用戶活動指標
- **使用量追蹤**: WordQueryUsageStats追蹤API使用
- **錯誤報告**: ErrorReport系統收集問題回饋
### 5.3 限制與配額
- **免費用戶限制**:
- 句子分析: 3小時內最多5次
- 手動輸入: 最多300字元
- 詞卡生成: 一次最多20張
- **付費用戶**: 無限制使用
- **API速率限制**: 防止濫用攻擊
## 6. 安全性考量
### 6.1 認證與授權
- **JWT Token**: 包含用戶ID和過期時間
- **用戶隔離**: 所有資料按UserId嚴格隔離
- **端點保護**: [Authorize]屬性保護敏感API
- **測試端點**: 部分功能提供[AllowAnonymous]測試
### 6.2 資料安全
- **密碼安全**: BCrypt雜湊儲存
- **輸入驗證**: 前後端雙重驗證
- **SQL注入防護**: EF Core參數化查詢
- **XSS防護**: 自動HTML編碼
### 6.3 API安全
- **CORS設定**: 限制來源域名
- **請求大小限制**: 防止大檔案攻擊
- **錯誤資訊隱藏**: 生產環境隱藏敏感錯誤
## 7. 監控與維護
### 7.1 系統監控
- **健康檢查**: API健康狀態監控
- **效能指標**: 回應時間、吞吐量追蹤
- **錯誤追蹤**: 異常日誌和錯誤報告
- **資源監控**: 資料庫和儲存空間監控
### 7.2 維護策略
- **資料備份**: 定期資料庫備份機制
- **日誌清理**: 定期清理舊日誌和快取
- **版本控制**: Git版本管理和部署追蹤
- **文件更新**: 與程式碼同步更新文件
## 8. 未來擴展規劃
### 8.1 短期功能擴展
- **語音功能**: 整合Azure Speech Service
- **個人化推薦**: 基於學習歷史的智能推薦
- **社群功能**: 卡組分享和協作學習
- **多語言支援**: 擴展到其他語言學習
### 8.2 技術架構升級
- **資料庫升級**: 從SQLite遷移到PostgreSQL
- **快取優化**: 引入Redis分散式快取
- **微服務化**: 拆分AI服務和業務服務
- **容器化部署**: Docker + Kubernetes部署
### 8.3 擴展性考量
- **水平擴展**: 支援多實例負載平衡
- **資料分割**: 大規模用戶資料分割策略
- **CDN整合**: 靜態資源和多媒體加速
- **國際化**: 多地區部署和資料同步
---
**文件版本**: 2.0
**最後更新**: 2025-09-20
**維護者**: DramaLing開發團隊
**更新說明**: 基於實際程式碼架構重新整理,修正與系統實現的差距