feat: 實現詞彙完全掌握時自動更新複習時間功能
## 後端改進
- 新增 POST /flashcards/{id}/mastered 簡化API端點
- 實作 MarkWordMasteredAsync 方法,專門處理詞彙掌握
- 修復 GetOrCreateReviewAsync 立即保存新記錄問題
- 使用 2^成功次數 演算法計算下次複習間隔
## 前端整合
- 更新 useReviewSession 支援詞彙級別完成檢測
- 新增 checkWordCompleteAndCorrect 檢查所有測驗項目
- 實作 submitWordCompletion 自動提交詞彙掌握
- 新增 markWordMastered API 方法呼叫簡化端點
- 改用真實後端資料替代靜態測試資料
## 核心功能
- 詞彙所有測驗(flip-card + vocab-choice)完成且全對時自動觸發
- 背景呼叫 /mastered API 更新複習演算法
- Console 顯示詳細掌握訊息和新複習時間
- 容錯設計:API失敗不影響複習流程繼續
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
006dcfee86
commit
ce0455df3d
|
|
@ -346,6 +346,26 @@ public class FlashcardsController : BaseController
|
|||
return ErrorResponse("INTERNAL_ERROR", "提交複習結果失敗");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/mastered")]
|
||||
public async Task<IActionResult> 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 類別
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ public class FlashcardReviewRepository : BaseRepository<FlashcardReview>, IFlash
|
|||
};
|
||||
|
||||
await _context.FlashcardReviews.AddAsync(newReview);
|
||||
await _context.SaveChangesAsync(); // 立即保存新記錄
|
||||
return newReview;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,4 +19,9 @@ public interface IReviewService
|
|||
/// 獲取複習統計
|
||||
/// </summary>
|
||||
Task<ApiResponse<ReviewStats>> GetReviewStatsAsync(Guid userId, string period = "today");
|
||||
|
||||
/// <summary>
|
||||
/// 標記詞彙為已掌握,更新下次複習時間
|
||||
/// </summary>
|
||||
Task<ApiResponse<object>> MarkWordMasteredAsync(Guid userId, Guid flashcardId);
|
||||
}
|
||||
|
|
@ -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<ApiResponse<object>> 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<object>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 處理複習結果的核心算法
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<Navigation />
|
||||
<div className="py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-700">載入詞卡中...</h2>
|
||||
<p className="text-gray-500 mt-2">正在從後端獲取您的複習詞卡</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 顯示錯誤狀態
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<Navigation />
|
||||
<div className="py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="text-red-500 text-4xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-semibold text-red-700 mb-2">載入失敗</h2>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重新載入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 顯示結果頁面
|
||||
if (isComplete) {
|
||||
return (
|
||||
|
|
@ -33,7 +77,7 @@ export default function SimpleReviewPage() {
|
|||
<div className="max-w-4xl mx-auto px-4">
|
||||
<QuizResult
|
||||
score={score}
|
||||
totalCards={SIMPLE_CARDS.length}
|
||||
totalCards={flashcards.length}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
||||
|
|
@ -46,12 +90,12 @@ export default function SimpleReviewPage() {
|
|||
<div className="text-sm text-gray-600">完成測驗項目</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{SIMPLE_CARDS.length}</div>
|
||||
<div className="text-2xl font-bold text-green-600">{flashcards.length}</div>
|
||||
<div className="text-sm text-gray-600">練習詞卡數</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{Math.round((score.correct / score.total) * 100)}%
|
||||
{score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">正確率</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string> // 正在提交的詞彙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,10 +225,58 @@ 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<string>()
|
||||
}
|
||||
|
||||
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:
|
||||
|
|
@ -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<string>()
|
||||
})
|
||||
|
||||
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<string>()
|
||||
}
|
||||
})
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse<{ nextReviewDate: string; intervalDays: number; successCount: number }>> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取已完成的測驗記錄
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue