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:
鄭沛軒 2025-10-07 00:29:53 +08:00
parent 006dcfee86
commit ce0455df3d
8 changed files with 479 additions and 33 deletions

View File

@ -346,6 +346,26 @@ public class FlashcardsController : BaseController
return ErrorResponse("INTERNAL_ERROR", "提交複習結果失敗"); 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 類別 // DTO 類別

View File

@ -93,6 +93,7 @@ public class FlashcardReviewRepository : BaseRepository<FlashcardReview>, IFlash
}; };
await _context.FlashcardReviews.AddAsync(newReview); await _context.FlashcardReviews.AddAsync(newReview);
await _context.SaveChangesAsync(); // 立即保存新記錄
return newReview; return newReview;
} }

View File

@ -19,4 +19,9 @@ public interface IReviewService
/// 獲取複習統計 /// 獲取複習統計
/// </summary> /// </summary>
Task<ApiResponse<ReviewStats>> GetReviewStatsAsync(Guid userId, string period = "today"); Task<ApiResponse<ReviewStats>> GetReviewStatsAsync(Guid userId, string period = "today");
/// <summary>
/// 標記詞彙為已掌握,更新下次複習時間
/// </summary>
Task<ApiResponse<object>> MarkWordMasteredAsync(Guid userId, Guid flashcardId);
} }

View File

@ -4,6 +4,7 @@ using DramaLing.Api.Repositories;
using DramaLing.Api.Controllers; using DramaLing.Api.Controllers;
using DramaLing.Api.Utils; using DramaLing.Api.Utils;
using DramaLing.Api.Services; using DramaLing.Api.Services;
using DramaLing.Api.Data;
namespace DramaLing.Api.Services.Review; 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>
/// 處理複習結果的核心算法 /// 處理複習結果的核心算法
/// </summary> /// </summary>

View File

@ -5,7 +5,6 @@ import { FlipMemory } from '@/components/review/quiz/FlipMemory'
import { VocabChoiceQuiz } from '@/components/review/quiz/VocabChoiceQuiz' import { VocabChoiceQuiz } from '@/components/review/quiz/VocabChoiceQuiz'
import { QuizProgress } from '@/components/review/ui/QuizProgress' import { QuizProgress } from '@/components/review/ui/QuizProgress'
import { QuizResult } from '@/components/review/quiz/QuizResult' import { QuizResult } from '@/components/review/quiz/QuizResult'
import { SIMPLE_CARDS } from '@/lib/data/reviewSimpleData'
import { useReviewSession } from '@/hooks/review/useReviewSession' import { useReviewSession } from '@/hooks/review/useReviewSession'
export default function SimpleReviewPage() { export default function SimpleReviewPage() {
@ -21,9 +20,54 @@ export default function SimpleReviewPage() {
completedQuizItems, completedQuizItems,
handleAnswer, handleAnswer,
handleSkip, handleSkip,
handleRestart handleRestart,
isLoading,
error,
flashcards
} = useReviewSession() } = 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) { if (isComplete) {
return ( return (
@ -33,7 +77,7 @@ export default function SimpleReviewPage() {
<div className="max-w-4xl mx-auto px-4"> <div className="max-w-4xl mx-auto px-4">
<QuizResult <QuizResult
score={score} score={score}
totalCards={SIMPLE_CARDS.length} totalCards={flashcards.length}
onRestart={handleRestart} onRestart={handleRestart}
/> />
@ -46,12 +90,12 @@ export default function SimpleReviewPage() {
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </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 className="text-sm text-gray-600"></div>
</div> </div>
<div> <div>
<div className="text-2xl font-bold text-purple-600"> <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>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>

View File

@ -1,16 +1,35 @@
import { useReducer, useEffect, useMemo } from 'react' import { useReducer, useEffect, useMemo, useState } from 'react'
import { import { flashcardsService, Flashcard } from '@/lib/services/flashcards'
INITIAL_TEST_ITEMS,
QuizItem, // 重新定義所需的介面
sortQuizItemsByPriority, interface CardState extends Flashcard {
generateVocabOptions, skipCount: number
SIMPLE_CARDS wrongCount: number
} from '@/lib/data/reviewSimpleData' 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 { interface ReviewState {
quizItems: QuizItem[] quizItems: QuizItem[]
score: { correct: number; total: number } score: { correct: number; total: number }
isComplete: boolean isComplete: boolean
flashcards: Flashcard[]
isLoading: boolean
error: string | null
pendingWordSubmission: string | null // 等待提交的詞彙ID
submittingWords: Set<string> // 正在提交的詞彙ID集合
} }
type ReviewAction = type ReviewAction =
@ -18,6 +37,95 @@ type ReviewAction =
| { type: 'ANSWER_TEST_ITEM'; payload: { quizItemId: string; confidence: number } } | { type: 'ANSWER_TEST_ITEM'; payload: { quizItemId: string; confidence: number } }
| { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } } | { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } }
| { type: 'RESTART' } | { 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 = ( const updateQuizItem = (
@ -37,6 +145,29 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
case 'LOAD_PROGRESS': case 'LOAD_PROGRESS':
return action.payload 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': { case 'ANSWER_TEST_ITEM': {
const { quizItemId, confidence } = action.payload const { quizItemId, confidence } = action.payload
const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對 const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對
@ -59,10 +190,16 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted) const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted)
const isComplete = remainingQuizItems.length === 0 const isComplete = remainingQuizItems.length === 0
// 檢查該詞彙是否完全掌握
const wordCompleteAndCorrect = checkWordCompleteAndCorrect(quizItem.cardId, updatedQuizItems)
return { return {
...state,
quizItems: updatedQuizItems, quizItems: updatedQuizItems,
score: newScore, 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 const isComplete = remainingQuizItems.length === 0
return { return {
...state,
quizItems: updatedQuizItems, quizItems: updatedQuizItems,
score: state.score, score: state.score,
isComplete isComplete
@ -87,10 +225,58 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
} }
case 'RESTART': case 'RESTART':
const restartQuizItems = state.flashcards.length > 0
? generateQuizItemsFromFlashcards(state.flashcards)
: []
return { return {
quizItems: INITIAL_TEST_ITEMS, ...state,
quizItems: restartQuizItems,
score: { correct: 0, total: 0 }, 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: default:
@ -101,12 +287,17 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
export function useReviewSession() { export function useReviewSession() {
// 使用 useReducer 統一狀態管理 // 使用 useReducer 統一狀態管理
const [state, dispatch] = useReducer(reviewReducer, { const [state, dispatch] = useReducer(reviewReducer, {
quizItems: INITIAL_TEST_ITEMS, quizItems: [],
score: { correct: 0, total: 0 }, 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 優化性能 // 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能
const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems]) const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems])
@ -117,9 +308,40 @@ export function useReviewSession() {
const currentQuizItem = incompleteQuizItems[0] // 總是選擇優先級最高的未完成測驗項目 const currentQuizItem = incompleteQuizItems[0] // 總是選擇優先級最高的未完成測驗項目
const currentCard = currentQuizItem?.cardData // 當前詞卡數據 const currentCard = currentQuizItem?.cardData // 當前詞卡數據
// localStorage進度保存和載入 // 載入後端資料和進度
useEffect(() => { 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') const savedProgress = localStorage.getItem('review-linear-progress')
if (savedProgress) { if (savedProgress) {
try { try {
@ -128,36 +350,91 @@ export function useReviewSession() {
const now = new Date() const now = new Date()
const isToday = saveTime.toDateString() === now.toDateString() const isToday = saveTime.toDateString() === now.toDateString()
if (isToday && parsed.quizItems) { if (isToday && parsed.quizItems && parsed.flashcards) {
dispatch({ dispatch({
type: 'LOAD_PROGRESS', type: 'LOAD_PROGRESS',
payload: { payload: {
quizItems: parsed.quizItems, quizItems: parsed.quizItems,
score: parsed.score || { correct: 0, total: 0 }, 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('📖 載入保存的線性複習進度') console.log('📖 載入保存的線性複習進度')
return // 如果有保存的進度就不重新載入
} }
} catch (error) { } catch (error) {
console.warn('進度載入失敗:', error) console.warn('進度載入失敗:', error)
localStorage.removeItem('review-linear-progress') localStorage.removeItem('review-linear-progress')
} }
} }
// 載入新的詞卡資料
loadFlashcards()
}, []) }, [])
// 監聽 pendingWordSubmission自動提交詞彙完成
useEffect(() => {
if (pendingWordSubmission && !submittingWords.has(pendingWordSubmission)) {
console.log('🔄 監測到詞彙完成,準備提交:', pendingWordSubmission)
submitWordCompletion(pendingWordSubmission)
}
}, [pendingWordSubmission])
// 保存進度到localStorage // 保存進度到localStorage
const saveProgress = () => { const saveProgress = () => {
const progress = { const progress = {
quizItems, quizItems,
score, score,
isComplete, isComplete,
flashcards,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
} }
localStorage.setItem('review-linear-progress', JSON.stringify(progress)) localStorage.setItem('review-linear-progress', JSON.stringify(progress))
console.log('💾 線性進度已保存') 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) => { const handleAnswer = (confidence: number) => {
if (!currentQuizItem) return if (!currentQuizItem) return
@ -191,13 +468,21 @@ export function useReviewSession() {
console.log('🔄 線性複習進度已重置') console.log('🔄 線性複習進度已重置')
} }
// 生成詞彙選擇選項 (僅當當前是詞彙選擇測驗時) // 生成詞彙選擇選項 (使用後端提供的 quizOptions)
const vocabOptions = useMemo(() => { const vocabOptions = useMemo(() => {
if (currentQuizItem?.quizType === 'vocab-choice' && currentCard) { 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 [] return []
}, [currentQuizItem, currentCard]) }, [currentQuizItem, currentCard, flashcards])
return { return {
// 狀態 // 狀態
@ -208,6 +493,9 @@ export function useReviewSession() {
currentCard, currentCard,
vocabOptions, vocabOptions,
sortedQuizItems, sortedQuizItems,
isLoading,
error,
flashcards,
// 計算屬性 // 計算屬性
totalQuizItems: quizItems.length, totalQuizItems: quizItems.length,

View File

@ -19,7 +19,8 @@
"hasExampleImage": false, "hasExampleImage": false,
"primaryImageUrl": null, "primaryImageUrl": null,
"synonyms":["proof", "testimony", "documentation"], "synonyms":["proof", "testimony", "documentation"],
"quizOptions": ["excuse", "opinion", "prediction"] }, "quizOptions": ["excuse", "opinion", "prediction"]
},
{ {
"id": "5b854991-c64b-464f-b69b-f8946a165257", "id": "5b854991-c64b-464f-b69b-f8946a165257",
"word": "warrants", "word": "warrants",

View File

@ -30,6 +30,9 @@ export interface Flashcard {
exampleImages: ExampleImage[]; exampleImages: ExampleImage[];
hasExampleImage: boolean; hasExampleImage: boolean;
primaryImageUrl?: string; primaryImageUrl?: string;
// 測驗選項 (後端提供的混淆選項)
quizOptions?: string[];
} }
export interface CreateFlashcardRequest { export interface CreateFlashcardRequest {
@ -217,12 +220,15 @@ class FlashcardsService {
try { try {
console.log('🚀 API調用開始:', `/flashcards/due?limit=${limit}`); 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('🔍 makeRequest回應:', response);
console.log('📊 response.data類型:', typeof response.data, '長度:', response.data?.length);
if (!response.data || !Array.isArray(response.data)) { // 處理新的後端資料結構:{ flashcards: [...], count: number, metadata: {...} }
console.log('❌ response.data不是數組:', response.data); const flashcardsArray = response?.data?.flashcards || response?.data || [];
console.log('📊 flashcards資料:', typeof flashcardsArray, '長度:', flashcardsArray?.length);
if (!Array.isArray(flashcardsArray)) {
console.log('❌ flashcards不是數組:', flashcardsArray);
return { return {
success: false, success: false,
error: 'Invalid response data format', 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, id: card.id,
word: card.word, word: card.word,
translation: card.translation, translation: card.translation,
@ -255,10 +261,13 @@ class FlashcardsService {
// 圖片相關欄位 // 圖片相關欄位
exampleImages: card.exampleImages || [], exampleImages: card.exampleImages || [],
hasExampleImage: card.hasExampleImage || false, hasExampleImage: card.hasExampleImage || false,
primaryImageUrl: card.primaryImageUrl primaryImageUrl: card.primaryImageUrl,
// 測驗選項(新增:來自後端的 AI 生成混淆選項)
quizOptions: card.quizOptions || []
})); }));
console.log('✅ 數據轉換完成:', flashcards.length, '張詞卡'); console.log('✅ 數據轉換完成:', flashcards.length, '張詞卡');
console.log('🎯 首張詞卡的quizOptions:', flashcards[0]?.quizOptions);
return { return {
success: true, 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',
};
}
}
/** /**
* *
*/ */