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:
parent
b8aa0214f0
commit
4e69030bc2
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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] || ''
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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*
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
方式:
|
||||
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習母語者的表達)
|
||||
|
||||
A1學習者
|
||||
- 複習方式:翻卡題、詞彙聽力題、選擇題
|
||||
補充:因為A1對於發音是完全沒概念,所以詞彙聽力這時候是有幫助的
|
||||
|
||||
簡單 (學習者程度 > 詞彙程度)
|
||||
- 複習方式:例句重組題、填空題
|
||||
|
||||
適中 (學習者程度 = 詞彙程度)
|
||||
- 複習方式:填空題、例句重組題、例句口說題
|
||||
|
||||
困難 (學習者程度 < 詞彙程度)
|
||||
- 複習方式:翻卡題、選擇題
|
||||
|
||||
|
||||
詞彙口袋大複習
|
||||
- 配對題:給圖片和詞彙,但有個問題就是,有時候詞彙和圖的意境其實相關性不高
|
||||
- 克漏字:
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
|
||||
|
||||
|
||||
|
||||
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
|
||||
|
||||
- 翻卡記憶:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 詞彙選擇:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句填空:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
|
||||
|
||||
> 例句填空\
|
||||
系統會提供例句\
|
||||
然後例句會有挖空處,有可能是多個單字(因為片語就是多個單字)
|
||||
使用者點選挖空處就可以輸入單字\
|
||||
點選顯示提示,系統會顯示詞彙定義\
|
||||
在例句上方是例句圖\
|
||||
\
|
||||
\
|
||||
以上功能請協助修改
|
||||
|
|
@ -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
|
|
@ -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開發團隊
|
||||
**更新說明**: 基於實際程式碼架構重新整理,修正與系統實現的差距
|
||||
Loading…
Reference in New Issue