diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index b11ff54..0f2a1b8 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -346,6 +346,26 @@ public class FlashcardsController : BaseController return ErrorResponse("INTERNAL_ERROR", "提交複習結果失敗"); } } + + [HttpPost("{id}/mastered")] + public async Task MarkWordMastered(Guid id) + { + try + { + var userId = await GetCurrentUserIdAsync(); + var response = await _reviewService.MarkWordMasteredAsync(userId, id); + return Ok(response); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking flashcard {FlashcardId} as mastered", id); + return ErrorResponse("INTERNAL_ERROR", "標記詞彙掌握失敗"); + } + } } // DTO 類別 diff --git a/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs b/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs index 1e306cd..710620a 100644 --- a/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs +++ b/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs @@ -93,6 +93,7 @@ public class FlashcardReviewRepository : BaseRepository, IFlash }; await _context.FlashcardReviews.AddAsync(newReview); + await _context.SaveChangesAsync(); // 立即保存新記錄 return newReview; } diff --git a/backend/DramaLing.Api/Services/Review/IReviewService.cs b/backend/DramaLing.Api/Services/Review/IReviewService.cs index 57d06b2..c51be22 100644 --- a/backend/DramaLing.Api/Services/Review/IReviewService.cs +++ b/backend/DramaLing.Api/Services/Review/IReviewService.cs @@ -19,4 +19,9 @@ public interface IReviewService /// 獲取複習統計 /// Task> GetReviewStatsAsync(Guid userId, string period = "today"); + + /// + /// 標記詞彙為已掌握,更新下次複習時間 + /// + Task> MarkWordMasteredAsync(Guid userId, Guid flashcardId); } \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/Review/ReviewService.cs b/backend/DramaLing.Api/Services/Review/ReviewService.cs index d8f74fa..86b8cfc 100644 --- a/backend/DramaLing.Api/Services/Review/ReviewService.cs +++ b/backend/DramaLing.Api/Services/Review/ReviewService.cs @@ -4,6 +4,7 @@ using DramaLing.Api.Repositories; using DramaLing.Api.Controllers; using DramaLing.Api.Utils; using DramaLing.Api.Services; +using DramaLing.Api.Data; namespace DramaLing.Api.Services.Review; @@ -182,6 +183,51 @@ public class ReviewService : IReviewService } } + public async Task> MarkWordMasteredAsync(Guid userId, Guid flashcardId) + { + try + { + // 使用 repository 的 GetOrCreate 方法 + var review = await _reviewRepository.GetOrCreateReviewAsync(userId, flashcardId); + + // 簡化邏輯:直接標記為掌握 + review.SuccessCount++; + review.TotalCorrectCount++; + review.LastSuccessDate = DateTime.UtcNow; + review.LastReviewDate = DateTime.UtcNow; + + // 核心算法:間隔 = 2^成功次數 天,最大180天 + var intervalDays = (int)Math.Pow(2, review.SuccessCount); + var maxInterval = 180; + var finalInterval = Math.Min(intervalDays, maxInterval); + + review.NextReviewDate = DateTime.UtcNow.AddDays(finalInterval); + review.UpdatedAt = DateTime.UtcNow; + + // 使用 repository 的更新方法 + await _reviewRepository.UpdateReviewAsync(review); + + return new ApiResponse + { + Success = true, + Data = new + { + nextReviewDate = review.NextReviewDate.ToString("yyyy-MM-ddTHH:mm:ssZ"), + intervalDays = finalInterval, + successCount = review.SuccessCount, + message = "詞彙已標記為掌握" + }, + Message = "詞彙掌握狀態已更新", + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking flashcard {FlashcardId} as mastered for user {UserId}", flashcardId, userId); + throw; + } + } + /// /// 處理複習結果的核心算法 /// diff --git a/frontend/app/review-simple/page.tsx b/frontend/app/review-simple/page.tsx index 14e29d1..3c6f04c 100644 --- a/frontend/app/review-simple/page.tsx +++ b/frontend/app/review-simple/page.tsx @@ -5,7 +5,6 @@ import { FlipMemory } from '@/components/review/quiz/FlipMemory' import { VocabChoiceQuiz } from '@/components/review/quiz/VocabChoiceQuiz' import { QuizProgress } from '@/components/review/ui/QuizProgress' import { QuizResult } from '@/components/review/quiz/QuizResult' -import { SIMPLE_CARDS } from '@/lib/data/reviewSimpleData' import { useReviewSession } from '@/hooks/review/useReviewSession' export default function SimpleReviewPage() { @@ -21,9 +20,54 @@ export default function SimpleReviewPage() { completedQuizItems, handleAnswer, handleSkip, - handleRestart + handleRestart, + isLoading, + error, + flashcards } = useReviewSession() + // 顯示載入狀態 + if (isLoading) { + return ( +
+ +
+
+
+
+

載入詞卡中...

+

正在從後端獲取您的複習詞卡

+
+
+
+
+ ) + } + + // 顯示錯誤狀態 + if (error) { + return ( +
+ +
+
+
+
⚠️
+

載入失敗

+

{error}

+ +
+
+
+
+ ) + } + // 顯示結果頁面 if (isComplete) { return ( @@ -33,7 +77,7 @@ export default function SimpleReviewPage() {
@@ -46,12 +90,12 @@ export default function SimpleReviewPage() {
完成測驗項目
-
{SIMPLE_CARDS.length}
+
{flashcards.length}
練習詞卡數
- {Math.round((score.correct / score.total) * 100)}% + {score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0}%
正確率
diff --git a/frontend/hooks/review/useReviewSession.ts b/frontend/hooks/review/useReviewSession.ts index 62c5b8f..da7adc5 100644 --- a/frontend/hooks/review/useReviewSession.ts +++ b/frontend/hooks/review/useReviewSession.ts @@ -1,16 +1,35 @@ -import { useReducer, useEffect, useMemo } from 'react' -import { - INITIAL_TEST_ITEMS, - QuizItem, - sortQuizItemsByPriority, - generateVocabOptions, - SIMPLE_CARDS -} from '@/lib/data/reviewSimpleData' +import { useReducer, useEffect, useMemo, useState } from 'react' +import { flashcardsService, Flashcard } from '@/lib/services/flashcards' + +// 重新定義所需的介面 +interface CardState extends Flashcard { + skipCount: number + wrongCount: number + isCompleted: boolean + originalOrder: number + synonyms?: string[] +} + +interface QuizItem { + id: string + cardId: string + cardData: CardState + quizType: 'flip-card' | 'vocab-choice' + order: number + isCompleted: boolean + wrongCount: number + skipCount: number +} interface ReviewState { quizItems: QuizItem[] score: { correct: number; total: number } isComplete: boolean + flashcards: Flashcard[] + isLoading: boolean + error: string | null + pendingWordSubmission: string | null // 等待提交的詞彙ID + submittingWords: Set // 正在提交的詞彙ID集合 } type ReviewAction = @@ -18,6 +37,95 @@ type ReviewAction = | { type: 'ANSWER_TEST_ITEM'; payload: { quizItemId: string; confidence: number } } | { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } } | { type: 'RESTART' } + | { type: 'LOAD_FLASHCARDS_START' } + | { type: 'LOAD_FLASHCARDS_SUCCESS'; payload: { flashcards: Flashcard[]; quizItems: QuizItem[] } } + | { type: 'LOAD_FLASHCARDS_ERROR'; payload: { error: string } } + | { type: 'WORD_SUBMIT_START'; payload: { cardId: string } } + | { type: 'WORD_SUBMIT_SUCCESS'; payload: { cardId: string; nextReviewDate: string } } + | { type: 'WORD_SUBMIT_ERROR'; payload: { cardId: string; error: string } } + +// 工具函數 +const sortQuizItemsByPriority = (quizItems: QuizItem[]): QuizItem[] => { + return quizItems.sort((a, b) => { + if (a.isCompleted && !b.isCompleted) return 1 + if (!a.isCompleted && b.isCompleted) return -1 + const aDelayScore = a.skipCount + a.wrongCount + const bDelayScore = b.skipCount + b.wrongCount + if (aDelayScore !== bDelayScore) { + return aDelayScore - bDelayScore + } + return a.order - b.order + }) +} + +const generateVocabOptions = (correctWord: string, allCards: CardState[]): string[] => { + const allWords = allCards.map(card => card.word).filter(word => word !== correctWord) + const shuffledWords = allWords.sort(() => Math.random() - 0.5) + const distractors = shuffledWords.slice(0, 3) + const options = [correctWord, ...distractors] + return options.sort(() => Math.random() - 0.5) +} + +// 檢查詞彙是否完全完成且全部正確 +const checkWordCompleteAndCorrect = (cardId: string, quizItems: QuizItem[]): boolean => { + const wordQuizItems = quizItems.filter(item => item.cardId === cardId) + + // 必須有測驗項目 + if (wordQuizItems.length === 0) return false + + // 所有測驗項目都必須完成 + const allCompleted = wordQuizItems.every(item => item.isCompleted) + + // 所有測驗項目都必須沒有錯誤 + const allCorrect = wordQuizItems.every(item => item.wrongCount === 0) + + return allCompleted && allCorrect +} + +// 從 Flashcard 生成 QuizItem 的函數 +const generateQuizItemsFromFlashcards = (flashcards: Flashcard[]): QuizItem[] => { + const quizItems: QuizItem[] = [] + let order = 0 + + flashcards.forEach((card) => { + // 轉換 Flashcard 為 CardState 格式 + const cardState: CardState = { + ...card, + exampleTranslation: card.exampleTranslation || '', // 確保 exampleTranslation 不為 undefined + skipCount: 0, + wrongCount: 0, + isCompleted: false, + originalOrder: order / 2, // 原始詞卡的順序 + synonyms: [] + } + + // 為每張詞卡生成兩種測驗模式 + quizItems.push( + { + id: `${card.id}-flip-card`, + cardId: card.id, + cardData: cardState, + quizType: 'flip-card', + order: order++, + isCompleted: false, + wrongCount: 0, + skipCount: 0 + }, + { + id: `${card.id}-vocab-choice`, + cardId: card.id, + cardData: cardState, + quizType: 'vocab-choice', + order: order++, + isCompleted: false, + wrongCount: 0, + skipCount: 0 + } + ) + }) + + return quizItems +} // 內部測驗項目更新函數 const updateQuizItem = ( @@ -37,6 +145,29 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => case 'LOAD_PROGRESS': return action.payload + case 'LOAD_FLASHCARDS_START': + return { + ...state, + isLoading: true, + error: null + } + + case 'LOAD_FLASHCARDS_SUCCESS': + return { + ...state, + isLoading: false, + error: null, + flashcards: action.payload.flashcards, + quizItems: action.payload.quizItems + } + + case 'LOAD_FLASHCARDS_ERROR': + return { + ...state, + isLoading: false, + error: action.payload.error + } + case 'ANSWER_TEST_ITEM': { const { quizItemId, confidence } = action.payload const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對 @@ -59,10 +190,16 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted) const isComplete = remainingQuizItems.length === 0 + // 檢查該詞彙是否完全掌握 + const wordCompleteAndCorrect = checkWordCompleteAndCorrect(quizItem.cardId, updatedQuizItems) + return { + ...state, quizItems: updatedQuizItems, score: newScore, - isComplete + isComplete, + // 如果詞彙完全掌握,標記為等待提交 + pendingWordSubmission: wordCompleteAndCorrect ? quizItem.cardId : state.pendingWordSubmission } } @@ -80,6 +217,7 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => const isComplete = remainingQuizItems.length === 0 return { + ...state, quizItems: updatedQuizItems, score: state.score, isComplete @@ -87,12 +225,60 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => } case 'RESTART': + const restartQuizItems = state.flashcards.length > 0 + ? generateQuizItemsFromFlashcards(state.flashcards) + : [] return { - quizItems: INITIAL_TEST_ITEMS, + ...state, + quizItems: restartQuizItems, score: { correct: 0, total: 0 }, - isComplete: false + isComplete: false, + pendingWordSubmission: null, + submittingWords: new Set() } + case 'WORD_SUBMIT_START': { + const { cardId } = action.payload + const newSubmittingWords = new Set(state.submittingWords) + newSubmittingWords.add(cardId) + + return { + ...state, + submittingWords: newSubmittingWords, + // 清除 pending 狀態,因為已經開始處理 + pendingWordSubmission: state.pendingWordSubmission === cardId ? null : state.pendingWordSubmission + } + } + + case 'WORD_SUBMIT_SUCCESS': { + const { cardId, nextReviewDate } = action.payload + const newSubmittingWords = new Set(state.submittingWords) + newSubmittingWords.delete(cardId) + + return { + ...state, + submittingWords: newSubmittingWords, + // 更新該詞彙的下次複習時間 + flashcards: state.flashcards.map(card => + card.id === cardId + ? { ...card, nextReviewDate } + : card + ) + } + } + + case 'WORD_SUBMIT_ERROR': { + const { cardId } = action.payload + const newSubmittingWords = new Set(state.submittingWords) + newSubmittingWords.delete(cardId) + + return { + ...state, + submittingWords: newSubmittingWords + // 錯誤時不阻塞用戶繼續複習,只記錄 log + } + } + default: return state } @@ -101,12 +287,17 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => export function useReviewSession() { // 使用 useReducer 統一狀態管理 const [state, dispatch] = useReducer(reviewReducer, { - quizItems: INITIAL_TEST_ITEMS, + quizItems: [], score: { correct: 0, total: 0 }, - isComplete: false + isComplete: false, + flashcards: [], + isLoading: false, + error: null, + pendingWordSubmission: null, + submittingWords: new Set() }) - const { quizItems, score, isComplete } = state + const { quizItems, score, isComplete, flashcards, isLoading, error, pendingWordSubmission, submittingWords } = state // 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能 const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems]) @@ -117,9 +308,40 @@ export function useReviewSession() { const currentQuizItem = incompleteQuizItems[0] // 總是選擇優先級最高的未完成測驗項目 const currentCard = currentQuizItem?.cardData // 當前詞卡數據 - // localStorage進度保存和載入 + // 載入後端資料和進度 useEffect(() => { - // 載入保存的進度 + const loadFlashcards = async () => { + dispatch({ type: 'LOAD_FLASHCARDS_START' }) + + try { + const response = await flashcardsService.getDueFlashcards(10) + + if (response.success && response.data) { + const flashcards = response.data + const quizItems = generateQuizItemsFromFlashcards(flashcards) + + dispatch({ + type: 'LOAD_FLASHCARDS_SUCCESS', + payload: { flashcards, quizItems } + }) + + console.log('✅ 成功載入', flashcards.length, '張詞卡') + console.log('🎯 生成', quizItems.length, '個測驗項目') + } else { + dispatch({ + type: 'LOAD_FLASHCARDS_ERROR', + payload: { error: response.error || '載入詞卡失敗' } + }) + } + } catch (error) { + dispatch({ + type: 'LOAD_FLASHCARDS_ERROR', + payload: { error: error instanceof Error ? error.message : '載入詞卡失敗' } + }) + } + } + + // 先嘗試載入保存的進度 const savedProgress = localStorage.getItem('review-linear-progress') if (savedProgress) { try { @@ -128,36 +350,91 @@ export function useReviewSession() { const now = new Date() const isToday = saveTime.toDateString() === now.toDateString() - if (isToday && parsed.quizItems) { + if (isToday && parsed.quizItems && parsed.flashcards) { dispatch({ type: 'LOAD_PROGRESS', payload: { quizItems: parsed.quizItems, score: parsed.score || { correct: 0, total: 0 }, - isComplete: parsed.isComplete || false + isComplete: parsed.isComplete || false, + flashcards: parsed.flashcards, + isLoading: false, + error: null, + pendingWordSubmission: null, + submittingWords: new Set() } }) console.log('📖 載入保存的線性複習進度') + return // 如果有保存的進度就不重新載入 } } catch (error) { console.warn('進度載入失敗:', error) localStorage.removeItem('review-linear-progress') } } + + // 載入新的詞卡資料 + loadFlashcards() }, []) + // 監聽 pendingWordSubmission,自動提交詞彙完成 + useEffect(() => { + if (pendingWordSubmission && !submittingWords.has(pendingWordSubmission)) { + console.log('🔄 監測到詞彙完成,準備提交:', pendingWordSubmission) + submitWordCompletion(pendingWordSubmission) + } + }, [pendingWordSubmission]) + // 保存進度到localStorage const saveProgress = () => { const progress = { quizItems, score, isComplete, + flashcards, timestamp: new Date().toISOString() } localStorage.setItem('review-linear-progress', JSON.stringify(progress)) console.log('💾 線性進度已保存') } + // 提交詞彙完成到後端(簡化版) + const submitWordCompletion = async (cardId: string) => { + dispatch({ type: 'WORD_SUBMIT_START', payload: { cardId } }) + + try { + console.log('🎯 詞彙完全掌握,提交到後端:', cardId) + + // 使用簡化的 API,只需要詞卡 ID + const result = await flashcardsService.markWordMastered(cardId) + + if (result.success && result.data) { + dispatch({ + type: 'WORD_SUBMIT_SUCCESS', + payload: { + cardId, + nextReviewDate: result.data.nextReviewDate + } + }) + console.log('🎉 詞彙已掌握!複習時間已更新至:', result.data.nextReviewDate) + console.log('📅 間隔天數:', result.data.intervalDays, '天') + console.log('🏆 成功次數:', result.data.successCount) + } else { + throw new Error(result.error || '標記詞彙掌握失敗') + } + } catch (error) { + dispatch({ + type: 'WORD_SUBMIT_ERROR', + payload: { + cardId, + error: error instanceof Error ? error.message : '標記詞彙掌握失敗' + } + }) + console.warn('❌ 詞彙掌握標記失敗:', error) + // 不影響用戶繼續複習 + } + } + // 處理測驗項目答題 const handleAnswer = (confidence: number) => { if (!currentQuizItem) return @@ -191,13 +468,21 @@ export function useReviewSession() { console.log('🔄 線性複習進度已重置') } - // 生成詞彙選擇選項 (僅當當前是詞彙選擇測驗時) + // 生成詞彙選擇選項 (使用後端提供的 quizOptions) const vocabOptions = useMemo(() => { if (currentQuizItem?.quizType === 'vocab-choice' && currentCard) { - return generateVocabOptions(currentCard.word, SIMPLE_CARDS) + // 優先使用後端提供的 quizOptions + if (currentCard.quizOptions && currentCard.quizOptions.length > 0) { + // 將正確答案和混淆選項組合,並隨機排序 + const allOptions = [currentCard.word, ...currentCard.quizOptions] + return allOptions.sort(() => Math.random() - 0.5) + } + // 後備方案:使用本地生成的選項 + const cardStates = quizItems.map(item => item.cardData) + return generateVocabOptions(currentCard.word, cardStates) } return [] - }, [currentQuizItem, currentCard]) + }, [currentQuizItem, currentCard, flashcards]) return { // 狀態 @@ -208,6 +493,9 @@ export function useReviewSession() { currentCard, vocabOptions, sortedQuizItems, + isLoading, + error, + flashcards, // 計算屬性 totalQuizItems: quizItems.length, diff --git a/frontend/lib/data/api_seeds.json b/frontend/lib/data/api_seeds.json index 201741b..68e7c7e 100644 --- a/frontend/lib/data/api_seeds.json +++ b/frontend/lib/data/api_seeds.json @@ -19,7 +19,8 @@ "hasExampleImage": false, "primaryImageUrl": null, "synonyms":["proof", "testimony", "documentation"], - "quizOptions": ["excuse", "opinion", "prediction"] }, + "quizOptions": ["excuse", "opinion", "prediction"] + }, { "id": "5b854991-c64b-464f-b69b-f8946a165257", "word": "warrants", diff --git a/frontend/lib/services/flashcards.ts b/frontend/lib/services/flashcards.ts index bb21b76..4f040f1 100644 --- a/frontend/lib/services/flashcards.ts +++ b/frontend/lib/services/flashcards.ts @@ -30,6 +30,9 @@ export interface Flashcard { exampleImages: ExampleImage[]; hasExampleImage: boolean; primaryImageUrl?: string; + + // 測驗選項 (後端提供的混淆選項) + quizOptions?: string[]; } export interface CreateFlashcardRequest { @@ -217,12 +220,15 @@ class FlashcardsService { try { console.log('🚀 API調用開始:', `/flashcards/due?limit=${limit}`); - const response = await this.makeRequest<{ success: boolean; data: any[]; count: number }>(`/flashcards/due?limit=${limit}`); + const response = await this.makeRequest<{ success: boolean; data: any; count: number }>(`/flashcards/due?limit=${limit}`); console.log('🔍 makeRequest回應:', response); - console.log('📊 response.data類型:', typeof response.data, '長度:', response.data?.length); - if (!response.data || !Array.isArray(response.data)) { - console.log('❌ response.data不是數組:', response.data); + // 處理新的後端資料結構:{ flashcards: [...], count: number, metadata: {...} } + const flashcardsArray = response?.data?.flashcards || response?.data || []; + console.log('📊 flashcards資料:', typeof flashcardsArray, '長度:', flashcardsArray?.length); + + if (!Array.isArray(flashcardsArray)) { + console.log('❌ flashcards不是數組:', flashcardsArray); return { success: false, error: 'Invalid response data format', @@ -230,7 +236,7 @@ class FlashcardsService { } // 轉換後端格式為前端期望格式 - const flashcards = response.data.map((card: any) => ({ + const flashcards = flashcardsArray.map((card: any) => ({ id: card.id, word: card.word, translation: card.translation, @@ -255,10 +261,13 @@ class FlashcardsService { // 圖片相關欄位 exampleImages: card.exampleImages || [], hasExampleImage: card.hasExampleImage || false, - primaryImageUrl: card.primaryImageUrl + primaryImageUrl: card.primaryImageUrl, + // 測驗選項(新增:來自後端的 AI 生成混淆選項) + quizOptions: card.quizOptions || [] })); console.log('✅ 數據轉換完成:', flashcards.length, '張詞卡'); + console.log('🎯 首張詞卡的quizOptions:', flashcards[0]?.quizOptions); return { success: true, @@ -364,6 +373,38 @@ class FlashcardsService { } } + /** + * 標記詞彙為已掌握(簡化版API) + */ + async markWordMastered(id: string): Promise> { + try { + console.log('🎯 標記詞彙為已掌握:', id) + + const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${id}/mastered`, { + method: 'POST', + }); + + if (response.success && response.data) { + return { + success: true, + data: { + nextReviewDate: response.data.nextReviewDate, + intervalDays: response.data.intervalDays, + successCount: response.data.successCount + } + }; + } else { + throw new Error('API 回應格式錯誤'); + } + } catch (error) { + console.error('❌ 標記詞彙掌握失敗:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to mark word as mastered', + }; + } + } + /** * 獲取已完成的測驗記錄 */