feat: 新增複習系統完整架構 + 前端重構統一命名
主要新增: - FlashcardReview 實體 + ReviewDTOs (後端複習系統基礎) - DbContext 配置複習記錄關聯和唯一約束 - 前端技術規格實作版文檔 (含完整SA圖表) - 後端規格v2.0 (基於前端需求更新) 前端重構: - TestItem → QuizItem 統一命名 - testType → quizType 屬性統一 - 所有組件和Hook命名保持一致 - QuizProgress 組件增強視覺化顯示 架構改善: - 數據庫設計支援間隔重複算法 (2^n天) - API端點設計配合前端需求 - 完整的狀態管理和持久化策略 - 詳細的前端架構圖表和流程說明 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3783be0fcd
commit
c8330d2b78
|
|
@ -28,6 +28,7 @@ public class DramaLingDbContext : DbContext
|
|||
public DbSet<FlashcardExampleImage> FlashcardExampleImages { get; set; }
|
||||
public DbSet<ImageGenerationRequest> ImageGenerationRequests { get; set; }
|
||||
public DbSet<OptionsVocabulary> OptionsVocabularies { get; set; }
|
||||
public DbSet<FlashcardReview> FlashcardReviews { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
|
@ -246,6 +247,24 @@ public class DramaLingDbContext : DbContext
|
|||
|
||||
// Study relationships 已移除 - StudyRecord 實體已清理
|
||||
|
||||
// FlashcardReview relationships
|
||||
modelBuilder.Entity<FlashcardReview>()
|
||||
.HasOne(fr => fr.Flashcard)
|
||||
.WithMany()
|
||||
.HasForeignKey(fr => fr.FlashcardId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<FlashcardReview>()
|
||||
.HasOne(fr => fr.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(fr => fr.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// 複習記錄唯一性約束 (每個用戶每張卡片只能有一條記錄)
|
||||
modelBuilder.Entity<FlashcardReview>()
|
||||
.HasIndex(fr => new { fr.FlashcardId, fr.UserId })
|
||||
.IsUnique();
|
||||
|
||||
// Tag relationships
|
||||
modelBuilder.Entity<FlashcardTag>()
|
||||
.HasOne(ft => ft.Flashcard)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// 複習請求 DTO
|
||||
/// </summary>
|
||||
public class ReviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 信心度等級 (1=模糊, 2=一般, 3=熟悉)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, 3, ErrorMessage = "信心度必須在 0-3 之間")]
|
||||
public int Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否答對 (基於 confidence >= 2 判斷,或由前端直接提供)
|
||||
/// </summary>
|
||||
public bool? IsCorrect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 複習類型 (flip-memory 或 vocab-choice)
|
||||
/// </summary>
|
||||
public string? ReviewType { get; set; } = "flip-memory";
|
||||
|
||||
/// <summary>
|
||||
/// 回應時間 (毫秒)
|
||||
/// </summary>
|
||||
public int? ResponseTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否跳過
|
||||
/// </summary>
|
||||
public bool WasSkipped { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 會話中的跳過次數 (前端統計)
|
||||
/// </summary>
|
||||
public int SessionSkipCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 會話中的錯誤次數 (前端統計)
|
||||
/// </summary>
|
||||
public int SessionWrongCount { get; set; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習結果響應 DTO
|
||||
/// </summary>
|
||||
public class ReviewResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 詞卡ID
|
||||
/// </summary>
|
||||
public Guid FlashcardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新的連續成功次數
|
||||
/// </summary>
|
||||
public int NewSuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次複習日期
|
||||
/// </summary>
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 間隔天數
|
||||
/// </summary>
|
||||
public int IntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 熟練度變化 (可選)
|
||||
/// </summary>
|
||||
public double MasteryLevelChange { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// 是否為新記錄
|
||||
/// </summary>
|
||||
public bool IsNewRecord { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 待複習詞卡查詢參數 DTO
|
||||
/// </summary>
|
||||
public class DueFlashcardsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// 限制數量 (默認 10)
|
||||
/// </summary>
|
||||
[Range(1, 100, ErrorMessage = "限制數量必須在 1-100 之間")]
|
||||
public int Limit { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 包含今天到期的卡片
|
||||
/// </summary>
|
||||
public bool IncludeToday { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 包含過期的卡片
|
||||
/// </summary>
|
||||
public bool IncludeOverdue { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 只返回用戶收藏的卡片
|
||||
/// </summary>
|
||||
public bool FavoritesOnly { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習統計 DTO
|
||||
/// </summary>
|
||||
public class ReviewStats
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日複習數量
|
||||
/// </summary>
|
||||
public int TodayReviewed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日到期數量
|
||||
/// </summary>
|
||||
public int TodayDue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 過期未複習數量
|
||||
/// </summary>
|
||||
public int Overdue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 總複習次數
|
||||
/// </summary>
|
||||
public int TotalReviews { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均正確率
|
||||
/// </summary>
|
||||
public double AverageAccuracy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 學習連續天數
|
||||
/// </summary>
|
||||
public int StudyStreak { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡複習記錄實體 - 支援間隔重複系統
|
||||
/// </summary>
|
||||
public class FlashcardReview
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡ID (外鍵)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid FlashcardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用戶ID (外鍵)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 連續成功次數 - 用於間隔重複算法 (2^n 天數計算)
|
||||
/// 答對時增加,答錯時重置為0
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 下次複習日期 - 基於間隔重複算法計算
|
||||
/// 公式: 今天 + 2^SuccessCount 天
|
||||
/// </summary>
|
||||
public DateTime NextReviewDate { get; set; } = DateTime.UtcNow.AddDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// 最後複習日期
|
||||
/// </summary>
|
||||
public DateTime? LastReviewDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最後成功複習日期 (答對的日期)
|
||||
/// </summary>
|
||||
public DateTime? LastSuccessDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累計跳過次數 (統計用)
|
||||
/// </summary>
|
||||
public int TotalSkipCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 累計錯誤次數 (統計用)
|
||||
/// </summary>
|
||||
public int TotalWrongCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 累計正確次數 (統計用)
|
||||
/// </summary>
|
||||
public int TotalCorrectCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 創建時間
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 更新時間
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Flashcard Flashcard { get; set; } = null!;
|
||||
public virtual User User { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -11,14 +11,14 @@ import { useReviewSession } from '@/hooks/review/useReviewSession'
|
|||
export default function SimpleReviewPage() {
|
||||
// 使用重構後的 Hook 管理線性複習狀態
|
||||
const {
|
||||
testItems,
|
||||
quizItems,
|
||||
score,
|
||||
isComplete,
|
||||
currentTestItem,
|
||||
currentQuizItem,
|
||||
currentCard,
|
||||
vocabOptions,
|
||||
totalTestItems,
|
||||
completedTestItems,
|
||||
totalQuizItems,
|
||||
completedQuizItems,
|
||||
handleAnswer,
|
||||
handleSkip,
|
||||
handleRestart
|
||||
|
|
@ -42,7 +42,7 @@ export default function SimpleReviewPage() {
|
|||
<h3 className="text-lg font-semibold text-gray-900 mb-4">測驗統計</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">{completedTestItems}</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{completedQuizItems}</div>
|
||||
<div className="text-sm text-gray-600">完成測驗項目</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -72,17 +72,17 @@ export default function SimpleReviewPage() {
|
|||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* 使用修改後的 SimpleProgress 組件 */}
|
||||
<QuizProgress
|
||||
currentTestItem={currentTestItem}
|
||||
totalTestItems={totalTestItems}
|
||||
completedTestItems={completedTestItems}
|
||||
currentQuizItem={currentQuizItem}
|
||||
totalQuizItems={totalQuizItems}
|
||||
completedQuizItems={completedQuizItems}
|
||||
score={score}
|
||||
testItems={testItems}
|
||||
quizItems={quizItems}
|
||||
/>
|
||||
|
||||
{/* 根據當前測驗項目類型渲染對應組件 */}
|
||||
{currentTestItem && currentCard && (
|
||||
{currentQuizItem && currentCard && (
|
||||
<>
|
||||
{currentTestItem.testType === 'flip-card' && (
|
||||
{currentQuizItem.quizType === 'flip-card' && (
|
||||
<FlipMemory
|
||||
card={currentCard}
|
||||
onAnswer={handleAnswer}
|
||||
|
|
@ -90,7 +90,7 @@ export default function SimpleReviewPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentTestItem.testType === 'vocab-choice' && (
|
||||
{currentQuizItem.quizType === 'vocab-choice' && (
|
||||
<VocabChoiceQuiz
|
||||
card={currentCard}
|
||||
options={vocabOptions}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { TestItem } from '@/lib/data/reviewSimpleData'
|
||||
import { QuizItem } from '@/lib/data/reviewSimpleData'
|
||||
|
||||
interface SimpleProgressProps {
|
||||
currentTestItem?: TestItem
|
||||
totalTestItems: number
|
||||
completedTestItems: number
|
||||
currentQuizItem?: QuizItem
|
||||
totalQuizItems: number
|
||||
completedQuizItems: number
|
||||
score: { correct: number; total: number }
|
||||
testItems?: TestItem[] // 用於顯示測驗項目統計
|
||||
quizItems?: QuizItem[] // 用於顯示測驗項目統計
|
||||
}
|
||||
|
||||
export function QuizProgress({ currentTestItem, totalTestItems, completedTestItems, score, testItems }: SimpleProgressProps) {
|
||||
const progress = (completedTestItems / totalTestItems) * 100
|
||||
export function QuizProgress({ currentQuizItem, totalQuizItems, completedQuizItems, score, quizItems }: SimpleProgressProps) {
|
||||
const progress = (completedQuizItems / totalQuizItems) * 100
|
||||
const accuracy = score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0
|
||||
|
||||
// 測驗項目延遲統計計算
|
||||
const delayStats = testItems ? {
|
||||
totalSkips: testItems.reduce((sum, item) => sum + item.skipCount, 0),
|
||||
totalWrongs: testItems.reduce((sum, item) => sum + item.wrongCount, 0),
|
||||
delayedItems: testItems.filter(item => item.skipCount + item.wrongCount > 0).length
|
||||
const delayStats = quizItems ? {
|
||||
totalSkips: quizItems.reduce((sum, item) => sum + item.skipCount, 0),
|
||||
totalWrongs: quizItems.reduce((sum, item) => sum + item.wrongCount, 0),
|
||||
delayedItems: quizItems.filter(item => item.skipCount + item.wrongCount > 0).length
|
||||
} : null
|
||||
|
||||
return (
|
||||
|
|
@ -24,20 +24,20 @@ export function QuizProgress({ currentTestItem, totalTestItems, completedTestIte
|
|||
<div className="flex justify-between items-center mb-3">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-600">線性複習進度</span>
|
||||
{currentTestItem && (
|
||||
{currentQuizItem && (
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="text-lg mr-2">
|
||||
{currentTestItem.testType === 'flip-card' ? '🔄' : '🎯'}
|
||||
{currentQuizItem.quizType === 'flip-card' ? '🔄' : '🎯'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{currentTestItem.testType === 'flip-card' ? '翻卡記憶' : '詞彙選擇'} • {currentTestItem.cardData.word}
|
||||
{currentQuizItem.quizType === 'flip-card' ? '翻卡記憶' : '詞彙選擇'} • {currentQuizItem.cardData.word}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-right">
|
||||
<span className="text-gray-600">
|
||||
{completedTestItems}/{totalTestItems} 項目
|
||||
{completedQuizItems}/{totalQuizItems} 項目
|
||||
</span>
|
||||
{score.total > 0 && (
|
||||
<span className="text-green-600 font-medium">
|
||||
|
|
@ -87,14 +87,14 @@ export function QuizProgress({ currentTestItem, totalTestItems, completedTestIte
|
|||
</div>
|
||||
|
||||
{/* 測驗項目順序可視化 */}
|
||||
{testItems && currentTestItem && (
|
||||
{quizItems && currentQuizItem && (
|
||||
<div className="mt-4">
|
||||
<div className="bg-white/50 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-600 mb-2">測驗項目序列 (線性流程):</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testItems.slice(0, 12).map((item) => {
|
||||
{quizItems.slice(0, 12).map((item) => {
|
||||
const isCompleted = item.isCompleted
|
||||
const isCurrent = item.id === currentTestItem?.id
|
||||
const isCurrent = item.id === currentQuizItem?.id
|
||||
const delayScore = item.skipCount + item.wrongCount
|
||||
|
||||
// 狀態顏色
|
||||
|
|
@ -127,7 +127,7 @@ export function QuizProgress({ currentTestItem, totalTestItems, completedTestIte
|
|||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs">
|
||||
{item.testType === 'flip-card' ? '🔄' : '🎯'}
|
||||
{item.quizType === 'flip-card' ? '🔄' : '🎯'}
|
||||
</span>
|
||||
<span>{item.cardData.word}</span>
|
||||
{statusText && (
|
||||
|
|
@ -137,9 +137,9 @@ export function QuizProgress({ currentTestItem, totalTestItems, completedTestIte
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
{testItems.length > 12 && (
|
||||
{quizItems.length > 12 && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
...還有 {testItems.length - 12} 個項目
|
||||
...還有 {quizItems.length - 12} 個項目
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,32 @@
|
|||
import { useReducer, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
INITIAL_TEST_ITEMS,
|
||||
TestItem,
|
||||
CardState,
|
||||
sortTestItemsByPriority,
|
||||
QuizItem,
|
||||
sortQuizItemsByPriority,
|
||||
generateVocabOptions,
|
||||
SIMPLE_CARDS
|
||||
} from '@/lib/data/reviewSimpleData'
|
||||
|
||||
interface ReviewState {
|
||||
testItems: TestItem[]
|
||||
quizItems: QuizItem[]
|
||||
score: { correct: number; total: number }
|
||||
isComplete: boolean
|
||||
}
|
||||
|
||||
type ReviewAction =
|
||||
| { type: 'LOAD_PROGRESS'; payload: ReviewState }
|
||||
| { type: 'ANSWER_TEST_ITEM'; payload: { testItemId: string; confidence: number } }
|
||||
| { type: 'SKIP_TEST_ITEM'; payload: { testItemId: string } }
|
||||
| { type: 'ANSWER_TEST_ITEM'; payload: { quizItemId: string; confidence: number } }
|
||||
| { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } }
|
||||
| { type: 'RESTART' }
|
||||
|
||||
// 內部測驗項目更新函數
|
||||
const updateTestItem = (
|
||||
testItems: TestItem[],
|
||||
testItemId: string,
|
||||
updates: Partial<TestItem>
|
||||
): TestItem[] => {
|
||||
return testItems.map((item) =>
|
||||
item.id === testItemId
|
||||
const updateQuizItem = (
|
||||
quizItems: QuizItem[],
|
||||
quizItemId: string,
|
||||
updates: Partial<QuizItem>
|
||||
): QuizItem[] => {
|
||||
return quizItems.map((item) =>
|
||||
item.id === quizItemId
|
||||
? { ...item, ...updates }
|
||||
: item
|
||||
)
|
||||
|
|
@ -39,17 +38,17 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
|||
return action.payload
|
||||
|
||||
case 'ANSWER_TEST_ITEM': {
|
||||
const { testItemId, confidence } = action.payload
|
||||
const { quizItemId, confidence } = action.payload
|
||||
const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對
|
||||
|
||||
const testItem = state.testItems.find(item => item.id === testItemId)
|
||||
if (!testItem) return state
|
||||
const quizItem = state.quizItems.find(item => item.id === quizItemId)
|
||||
if (!quizItem) return state
|
||||
|
||||
// 修正:只有答對才標記為完成,答錯只增加錯誤次數
|
||||
const updatedTestItems = updateTestItem(state.testItems, testItemId,
|
||||
const updatedQuizItems = updateQuizItem(state.quizItems, quizItemId,
|
||||
isCorrect
|
||||
? { isCompleted: true } // 答對:標記完成
|
||||
: { wrongCount: testItem.wrongCount + 1 } // 答錯:只增加錯誤次數,不完成
|
||||
: { wrongCount: quizItem.wrongCount + 1 } // 答錯:只增加錯誤次數,不完成
|
||||
)
|
||||
|
||||
const newScore = {
|
||||
|
|
@ -57,31 +56,31 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
|||
total: state.score.total + 1
|
||||
}
|
||||
|
||||
const remainingTestItems = updatedTestItems.filter(item => !item.isCompleted)
|
||||
const isComplete = remainingTestItems.length === 0
|
||||
const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted)
|
||||
const isComplete = remainingQuizItems.length === 0
|
||||
|
||||
return {
|
||||
testItems: updatedTestItems,
|
||||
quizItems: updatedQuizItems,
|
||||
score: newScore,
|
||||
isComplete
|
||||
}
|
||||
}
|
||||
|
||||
case 'SKIP_TEST_ITEM': {
|
||||
const { testItemId } = action.payload
|
||||
const { quizItemId } = action.payload
|
||||
|
||||
const testItem = state.testItems.find(item => item.id === testItemId)
|
||||
if (!testItem) return state
|
||||
const quizItem = state.quizItems.find(item => item.id === quizItemId)
|
||||
if (!quizItem) return state
|
||||
|
||||
const updatedTestItems = updateTestItem(state.testItems, testItemId, {
|
||||
skipCount: testItem.skipCount + 1
|
||||
const updatedQuizItems = updateQuizItem(state.quizItems, quizItemId, {
|
||||
skipCount: quizItem.skipCount + 1
|
||||
})
|
||||
|
||||
const remainingTestItems = updatedTestItems.filter(item => !item.isCompleted)
|
||||
const isComplete = remainingTestItems.length === 0
|
||||
const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted)
|
||||
const isComplete = remainingQuizItems.length === 0
|
||||
|
||||
return {
|
||||
testItems: updatedTestItems,
|
||||
quizItems: updatedQuizItems,
|
||||
score: state.score,
|
||||
isComplete
|
||||
}
|
||||
|
|
@ -89,7 +88,7 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
|||
|
||||
case 'RESTART':
|
||||
return {
|
||||
testItems: INITIAL_TEST_ITEMS,
|
||||
quizItems: INITIAL_TEST_ITEMS,
|
||||
score: { correct: 0, total: 0 },
|
||||
isComplete: false
|
||||
}
|
||||
|
|
@ -102,21 +101,21 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
|||
export function useReviewSession() {
|
||||
// 使用 useReducer 統一狀態管理
|
||||
const [state, dispatch] = useReducer(reviewReducer, {
|
||||
testItems: INITIAL_TEST_ITEMS,
|
||||
quizItems: INITIAL_TEST_ITEMS,
|
||||
score: { correct: 0, total: 0 },
|
||||
isComplete: false
|
||||
})
|
||||
|
||||
const { testItems, score, isComplete } = state
|
||||
const { quizItems, score, isComplete } = state
|
||||
|
||||
// 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能
|
||||
const sortedTestItems = useMemo(() => sortTestItemsByPriority(testItems), [testItems])
|
||||
const incompleteTestItems = useMemo(() =>
|
||||
sortedTestItems.filter((item: TestItem) => !item.isCompleted),
|
||||
[sortedTestItems]
|
||||
const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems])
|
||||
const incompleteQuizItems = useMemo(() =>
|
||||
sortedQuizItems.filter((item: QuizItem) => !item.isCompleted),
|
||||
[sortedQuizItems]
|
||||
)
|
||||
const currentTestItem = incompleteTestItems[0] // 總是選擇優先級最高的未完成測驗項目
|
||||
const currentCard = currentTestItem?.cardData // 當前詞卡數據
|
||||
const currentQuizItem = incompleteQuizItems[0] // 總是選擇優先級最高的未完成測驗項目
|
||||
const currentCard = currentQuizItem?.cardData // 當前詞卡數據
|
||||
|
||||
// localStorage進度保存和載入
|
||||
useEffect(() => {
|
||||
|
|
@ -129,11 +128,11 @@ export function useReviewSession() {
|
|||
const now = new Date()
|
||||
const isToday = saveTime.toDateString() === now.toDateString()
|
||||
|
||||
if (isToday && parsed.testItems) {
|
||||
if (isToday && parsed.quizItems) {
|
||||
dispatch({
|
||||
type: 'LOAD_PROGRESS',
|
||||
payload: {
|
||||
testItems: parsed.testItems,
|
||||
quizItems: parsed.quizItems,
|
||||
score: parsed.score || { correct: 0, total: 0 },
|
||||
isComplete: parsed.isComplete || false
|
||||
}
|
||||
|
|
@ -150,7 +149,7 @@ export function useReviewSession() {
|
|||
// 保存進度到localStorage
|
||||
const saveProgress = () => {
|
||||
const progress = {
|
||||
testItems,
|
||||
quizItems,
|
||||
score,
|
||||
isComplete,
|
||||
timestamp: new Date().toISOString()
|
||||
|
|
@ -161,11 +160,11 @@ export function useReviewSession() {
|
|||
|
||||
// 處理測驗項目答題
|
||||
const handleAnswer = (confidence: number) => {
|
||||
if (!currentTestItem) return
|
||||
if (!currentQuizItem) return
|
||||
|
||||
dispatch({
|
||||
type: 'ANSWER_TEST_ITEM',
|
||||
payload: { testItemId: currentTestItem.id, confidence }
|
||||
payload: { quizItemId: currentQuizItem.id, confidence }
|
||||
})
|
||||
|
||||
// 保存進度
|
||||
|
|
@ -174,11 +173,11 @@ export function useReviewSession() {
|
|||
|
||||
// 處理測驗項目跳過
|
||||
const handleSkip = () => {
|
||||
if (!currentTestItem) return
|
||||
if (!currentQuizItem) return
|
||||
|
||||
dispatch({
|
||||
type: 'SKIP_TEST_ITEM',
|
||||
payload: { testItemId: currentTestItem.id }
|
||||
payload: { quizItemId: currentQuizItem.id }
|
||||
})
|
||||
|
||||
// 保存進度
|
||||
|
|
@ -194,25 +193,25 @@ export function useReviewSession() {
|
|||
|
||||
// 生成詞彙選擇選項 (僅當當前是詞彙選擇測驗時)
|
||||
const vocabOptions = useMemo(() => {
|
||||
if (currentTestItem?.testType === 'vocab-choice' && currentCard) {
|
||||
if (currentQuizItem?.quizType === 'vocab-choice' && currentCard) {
|
||||
return generateVocabOptions(currentCard.word, SIMPLE_CARDS)
|
||||
}
|
||||
return []
|
||||
}, [currentTestItem, currentCard])
|
||||
}, [currentQuizItem, currentCard])
|
||||
|
||||
return {
|
||||
// 狀態
|
||||
testItems,
|
||||
quizItems,
|
||||
score,
|
||||
isComplete,
|
||||
currentTestItem,
|
||||
currentQuizItem,
|
||||
currentCard,
|
||||
vocabOptions,
|
||||
sortedTestItems,
|
||||
sortedQuizItems,
|
||||
|
||||
// 計算屬性
|
||||
totalTestItems: testItems.length,
|
||||
completedTestItems: testItems.filter(item => item.isCompleted).length,
|
||||
totalQuizItems: quizItems.length,
|
||||
completedQuizItems: quizItems.filter(item => item.isCompleted).length,
|
||||
|
||||
// 動作
|
||||
handleAnswer,
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ export interface CardState extends ApiFlashcard {
|
|||
}
|
||||
|
||||
// 測驗項目接口 (線性流程核心)
|
||||
export interface TestItem {
|
||||
export interface QuizItem {
|
||||
id: string // 測驗項目ID
|
||||
cardId: string // 所屬詞卡ID
|
||||
testType: 'flip-card' | 'vocab-choice' // 測驗類型
|
||||
quizType: 'flip-card' | 'vocab-choice' // 測驗類型
|
||||
isCompleted: boolean // 個別測驗完成狀態
|
||||
skipCount: number // 跳過次數
|
||||
wrongCount: number // 答錯次數
|
||||
|
|
@ -69,16 +69,16 @@ const addStateFields = (flashcard: ApiFlashcard, index: number): CardState => ({
|
|||
export const SIMPLE_CARDS = MOCK_API_RESPONSE.data.flashcards.map(addStateFields)
|
||||
|
||||
// 生成線性測驗項目序列
|
||||
export const generateTestItems = (cards: CardState[]): TestItem[] => {
|
||||
const testItems: TestItem[] = []
|
||||
export const generateQuizItems = (cards: CardState[]): QuizItem[] => {
|
||||
const quizItems: QuizItem[] = []
|
||||
let order = 0
|
||||
|
||||
cards.forEach((card) => {
|
||||
// 為每張詞卡生成兩個測驗項目:先翻卡記憶,再詞彙選擇
|
||||
const flipCardTest: TestItem = {
|
||||
const flipCardQuiz: QuizItem = {
|
||||
id: `${card.id}-flip-card`,
|
||||
cardId: card.id,
|
||||
testType: 'flip-card',
|
||||
quizType: 'flip-card',
|
||||
isCompleted: false,
|
||||
skipCount: 0,
|
||||
wrongCount: 0,
|
||||
|
|
@ -86,10 +86,10 @@ export const generateTestItems = (cards: CardState[]): TestItem[] => {
|
|||
cardData: card
|
||||
}
|
||||
|
||||
const vocabChoiceTest: TestItem = {
|
||||
const vocabChoiceQuiz: QuizItem = {
|
||||
id: `${card.id}-vocab-choice`,
|
||||
cardId: card.id,
|
||||
testType: 'vocab-choice',
|
||||
quizType: 'vocab-choice',
|
||||
isCompleted: false,
|
||||
skipCount: 0,
|
||||
wrongCount: 0,
|
||||
|
|
@ -97,15 +97,15 @@ export const generateTestItems = (cards: CardState[]): TestItem[] => {
|
|||
cardData: card
|
||||
}
|
||||
|
||||
testItems.push(flipCardTest, vocabChoiceTest)
|
||||
quizItems.push(flipCardQuiz, vocabChoiceQuiz)
|
||||
})
|
||||
|
||||
return testItems
|
||||
return quizItems
|
||||
}
|
||||
|
||||
// 測驗項目優先級排序 (修正後的延遲計數系統)
|
||||
export const sortTestItemsByPriority = (testItems: TestItem[]): TestItem[] => {
|
||||
return testItems.sort((a, b) => {
|
||||
export const sortQuizItemsByPriority = (quizItems: QuizItem[]): QuizItem[] => {
|
||||
return quizItems.sort((a, b) => {
|
||||
// 1. 已完成的測驗項目排到最後
|
||||
if (a.isCompleted && !b.isCompleted) return 1
|
||||
if (!a.isCompleted && b.isCompleted) return -1
|
||||
|
|
@ -153,4 +153,4 @@ export const generateVocabOptions = (correctWord: string, allCards: CardState[])
|
|||
}
|
||||
|
||||
// 初始化測驗項目列表
|
||||
export const INITIAL_TEST_ITEMS = generateTestItems(SIMPLE_CARDS)
|
||||
export const INITIAL_TEST_ITEMS = generateQuizItems(SIMPLE_CARDS)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,779 +0,0 @@
|
|||
# 複習系統前端規格書
|
||||
|
||||
**版本**: 1.0
|
||||
**對應**: 技術實作規格.md + 產品需求規格.md
|
||||
**技術棧**: React 18 + TypeScript + Tailwind CSS
|
||||
**狀態管理**: React useState (極簡架構)
|
||||
**最後更新**: 2025-10-03
|
||||
|
||||
---
|
||||
|
||||
## 📱 **前端架構設計**
|
||||
|
||||
### **目錄結構**
|
||||
```
|
||||
app/review-simple/
|
||||
├── page.tsx # 主頁面邏輯
|
||||
├── data.ts # 數據和類型定義
|
||||
├── globals.css # 翻卡動畫樣式
|
||||
└── components/
|
||||
├── SimpleFlipCard.tsx # 翻卡記憶組件
|
||||
├── SimpleChoiceTest.tsx # 詞彙選擇組件 (階段2)
|
||||
├── SimpleProgress.tsx # 進度顯示組件
|
||||
├── SimpleResults.tsx # 結果統計組件
|
||||
└── SimpleTestHeader.tsx # 測試標題組件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗃️ **數據結構設計**
|
||||
|
||||
### **卡片狀態接口**
|
||||
```typescript
|
||||
interface CardState extends ApiFlashcard {
|
||||
// 前端狀態管理欄位
|
||||
skipCount: number // 跳過次數
|
||||
wrongCount: number // 答錯次數
|
||||
successCount: number // 答對次數
|
||||
isCompleted: boolean // 是否已完成
|
||||
originalOrder: number // 原始順序索引
|
||||
|
||||
// 計算屬性
|
||||
delayScore: number // 延遲分數 = skipCount + wrongCount
|
||||
lastAttemptAt: Date // 最後嘗試時間
|
||||
}
|
||||
```
|
||||
|
||||
### **學習會話狀態**
|
||||
```typescript
|
||||
interface ReviewSessionState {
|
||||
// 卡片管理
|
||||
cards: CardState[]
|
||||
currentIndex: number
|
||||
|
||||
// 進度統計
|
||||
score: {
|
||||
correct: number // 答對總數
|
||||
total: number // 嘗試總數
|
||||
}
|
||||
|
||||
// 會話控制
|
||||
isComplete: boolean
|
||||
startTime: Date
|
||||
|
||||
// UI狀態
|
||||
currentMode: 'flip' | 'choice' // 階段2需要
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ **核心邏輯函數**
|
||||
|
||||
### **延遲計數管理**
|
||||
```typescript
|
||||
// 跳過處理
|
||||
const handleSkip = useCallback((cards: CardState[], currentIndex: number) => {
|
||||
const updatedCards = cards.map((card, index) =>
|
||||
index === currentIndex
|
||||
? {
|
||||
...card,
|
||||
skipCount: card.skipCount + 1,
|
||||
delayScore: card.skipCount + 1 + card.wrongCount,
|
||||
lastAttemptAt: new Date()
|
||||
}
|
||||
: card
|
||||
)
|
||||
|
||||
return {
|
||||
updatedCards,
|
||||
nextIndex: getNextCardIndex(updatedCards, currentIndex)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 答錯處理
|
||||
const handleWrongAnswer = useCallback((cards: CardState[], currentIndex: number) => {
|
||||
const updatedCards = cards.map((card, index) =>
|
||||
index === currentIndex
|
||||
? {
|
||||
...card,
|
||||
wrongCount: card.wrongCount + 1,
|
||||
delayScore: card.skipCount + card.wrongCount + 1,
|
||||
lastAttemptAt: new Date()
|
||||
}
|
||||
: card
|
||||
)
|
||||
|
||||
return {
|
||||
updatedCards,
|
||||
nextIndex: getNextCardIndex(updatedCards, currentIndex)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 答對處理
|
||||
const handleCorrectAnswer = useCallback((cards: CardState[], currentIndex: number) => {
|
||||
const updatedCards = cards.map((card, index) =>
|
||||
index === currentIndex
|
||||
? {
|
||||
...card,
|
||||
isCompleted: true,
|
||||
successCount: card.successCount + 1,
|
||||
lastAttemptAt: new Date()
|
||||
}
|
||||
: card
|
||||
)
|
||||
|
||||
return {
|
||||
updatedCards,
|
||||
nextIndex: getNextCardIndex(updatedCards, currentIndex)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
### **智能排序系統**
|
||||
```typescript
|
||||
// 卡片優先級排序 (您的核心需求)
|
||||
const sortCardsByPriority = useCallback((cards: CardState[]): CardState[] => {
|
||||
return cards.sort((a, b) => {
|
||||
// 1. 已完成的卡片排到最後
|
||||
if (a.isCompleted && !b.isCompleted) return 1
|
||||
if (!a.isCompleted && b.isCompleted) return -1
|
||||
|
||||
// 2. 未完成卡片按延遲分數排序 (越少越前面)
|
||||
const aDelayScore = a.skipCount + a.wrongCount
|
||||
const bDelayScore = b.skipCount + b.wrongCount
|
||||
|
||||
if (aDelayScore !== bDelayScore) {
|
||||
return aDelayScore - bDelayScore
|
||||
}
|
||||
|
||||
// 3. 延遲分數相同時按原始順序
|
||||
return a.originalOrder - b.originalOrder
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 獲取下一張卡片索引
|
||||
const getNextCardIndex = (cards: CardState[], currentIndex: number): number => {
|
||||
const sortedCards = sortCardsByPriority(cards)
|
||||
const incompleteCards = sortedCards.filter(card => !card.isCompleted)
|
||||
|
||||
if (incompleteCards.length === 0) return -1 // 全部完成
|
||||
|
||||
// 返回排序後第一張未完成卡片的索引
|
||||
const nextCard = incompleteCards[0]
|
||||
return cards.findIndex(card => card.id === nextCard.id)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **組件設計規格**
|
||||
|
||||
### **SimpleFlipCard.tsx (階段1)**
|
||||
```typescript
|
||||
interface SimpleFlipCardProps {
|
||||
card: CardState
|
||||
onAnswer: (confidence: 1|2|3) => void // 簡化為3選項
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
// 內部狀態
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
|
||||
const [cardHeight, setCardHeight] = useState<number>(400)
|
||||
|
||||
// 信心度選項 (簡化版)
|
||||
const confidenceOptions = [
|
||||
{ level: 1, label: '模糊', color: 'bg-orange-100 text-orange-700 border-orange-200' },
|
||||
{ level: 2, label: '一般', color: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
|
||||
{ level: 3, label: '熟悉', color: 'bg-green-100 text-green-700 border-green-200' }
|
||||
]
|
||||
```
|
||||
|
||||
### **SimpleChoiceTest.tsx (階段2)**
|
||||
```typescript
|
||||
interface SimpleChoiceTestProps {
|
||||
card: CardState
|
||||
options: string[] // 4選1選項
|
||||
onAnswer: (answer: string) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
// 內部狀態
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||
const [showResult, setShowResult] = useState(false)
|
||||
|
||||
// 答案驗證
|
||||
const isCorrect = useMemo(() => selectedAnswer === card.word, [selectedAnswer, card.word])
|
||||
```
|
||||
|
||||
### **SimpleProgress.tsx**
|
||||
```typescript
|
||||
interface SimpleProgressProps {
|
||||
cards: CardState[]
|
||||
currentIndex: number
|
||||
score: { correct: number; total: number }
|
||||
}
|
||||
|
||||
// 進度計算
|
||||
const completedCount = cards.filter(card => card.isCompleted).length
|
||||
const totalCount = cards.length
|
||||
const progressPercentage = (completedCount / totalCount) * 100
|
||||
|
||||
// 延遲統計 (顯示跳過次數)
|
||||
const delayedCards = cards.filter(card => card.skipCount + card.wrongCount > 0)
|
||||
const totalSkips = cards.reduce((sum, card) => sum + card.skipCount, 0)
|
||||
const totalWrongs = cards.reduce((sum, card) => sum + card.wrongCount, 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **API呼叫策略** (各階段明確區分)
|
||||
|
||||
### **階段1: 純靜態數據 (當前MVP)**
|
||||
```typescript
|
||||
// 完全不呼叫任何API
|
||||
export default function SimpleReviewPage() {
|
||||
useEffect(() => {
|
||||
// 直接使用靜態數據,無網路依賴
|
||||
const staticCards = SIMPLE_CARDS.map((card, index) => ({
|
||||
...card,
|
||||
skipCount: 0,
|
||||
wrongCount: 0,
|
||||
// ... 其他前端狀態
|
||||
}))
|
||||
setCards(staticCards)
|
||||
}, [])
|
||||
|
||||
// 答題完成時:只更新前端狀態,不呼叫API
|
||||
const handleAnswer = (confidence: number) => {
|
||||
// 純前端邏輯,無API調用
|
||||
updateLocalState(confidence)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **階段2: 本地持久化 (localStorage)**
|
||||
```typescript
|
||||
// 仍然不呼叫API,只添加本地存儲
|
||||
export default function SimpleReviewPage() {
|
||||
useEffect(() => {
|
||||
// 1. 嘗試從localStorage載入
|
||||
const savedProgress = loadFromLocalStorage()
|
||||
|
||||
if (savedProgress && isSameDay(savedProgress.timestamp)) {
|
||||
setCards(savedProgress.cards)
|
||||
setCurrentIndex(savedProgress.currentIndex)
|
||||
} else {
|
||||
// 2. 無有效存檔則使用靜態數據
|
||||
setCards(SIMPLE_CARDS.map(addStateFields))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 答題時:更新狀態 + 保存到localStorage
|
||||
const handleAnswer = (confidence: number) => {
|
||||
const newState = updateLocalState(confidence)
|
||||
saveToLocalStorage(newState) // 本地持久化
|
||||
// 仍然不呼叫API
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **階段3: API集成 (遠期)**
|
||||
```typescript
|
||||
// 明確的API呼叫時機和策略
|
||||
export default function SimpleReviewPage() {
|
||||
const [dataSource, setDataSource] = useState<'static' | 'api'>('static')
|
||||
|
||||
useEffect(() => {
|
||||
const loadCards = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 嘗試API呼叫
|
||||
const response = await fetch('/api/flashcards/due?limit=10', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getAuthToken()}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const apiData = await response.json()
|
||||
setCards(apiData.data.flashcards.map(addStateFields))
|
||||
setDataSource('api')
|
||||
} else {
|
||||
throw new Error('API failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('API unavailable, using static data:', error)
|
||||
// 降級到靜態數據
|
||||
setCards(SIMPLE_CARDS.map(addStateFields))
|
||||
setDataSource('static')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadCards()
|
||||
}, [])
|
||||
|
||||
// 答題時的API呼叫邏輯
|
||||
const handleAnswer = async (confidence: number) => {
|
||||
// 1. 立即更新前端狀態 (即時響應)
|
||||
const newState = updateLocalState(confidence)
|
||||
|
||||
// 2. 如果使用API,同步到後端
|
||||
if (dataSource === 'api') {
|
||||
try {
|
||||
await fetch(`/api/flashcards/${currentCard.id}/review`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getAuthToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confidence: confidence,
|
||||
isCorrect: confidence >= 2
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync to backend:', error)
|
||||
// 前端狀態已更新,API失敗不影響用戶體驗
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **API呼叫判斷邏輯**
|
||||
```typescript
|
||||
// 何時使用API vs 靜態數據
|
||||
const determineDataSource = () => {
|
||||
// 檢查是否有認證Token
|
||||
const hasAuth = getAuthToken() !== null
|
||||
|
||||
// 檢查是否在生產環境
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
// 檢查是否明確要求使用API
|
||||
const forceApi = window.location.search.includes('api=true')
|
||||
|
||||
return (hasAuth && isProduction) || forceApi ? 'api' : 'static'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **狀態管理設計**
|
||||
|
||||
### **主頁面狀態 (page.tsx)**
|
||||
```typescript
|
||||
export default function SimpleReviewPage() {
|
||||
// 核心狀態
|
||||
const [cards, setCards] = useState<CardState[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [mode, setMode] = useState<'flip' | 'choice'>('flip') // 階段2需要
|
||||
|
||||
// 初始化卡片狀態
|
||||
useEffect(() => {
|
||||
const initialCards: CardState[] = SIMPLE_CARDS.map((card, index) => ({
|
||||
...card,
|
||||
skipCount: 0,
|
||||
wrongCount: 0,
|
||||
successCount: 0,
|
||||
isCompleted: false,
|
||||
originalOrder: index,
|
||||
delayScore: 0,
|
||||
lastAttemptAt: new Date()
|
||||
}))
|
||||
setCards(initialCards)
|
||||
}, [])
|
||||
|
||||
// 答題處理
|
||||
const handleAnswer = useCallback((confidence: number) => {
|
||||
const isCorrect = confidence >= 2 // 一般以上算答對
|
||||
|
||||
if (isCorrect) {
|
||||
const result = handleCorrectAnswer(cards, currentIndex)
|
||||
setCards(result.updatedCards)
|
||||
setCurrentIndex(result.nextIndex)
|
||||
} else {
|
||||
const result = handleWrongAnswer(cards, currentIndex)
|
||||
setCards(result.updatedCards)
|
||||
setCurrentIndex(result.nextIndex)
|
||||
}
|
||||
|
||||
// 更新分數統計
|
||||
setScore(prev => ({
|
||||
correct: prev.correct + (isCorrect ? 1 : 0),
|
||||
total: prev.total + 1
|
||||
}))
|
||||
|
||||
// 檢查是否完成
|
||||
checkIfComplete(result.updatedCards)
|
||||
}, [cards, currentIndex])
|
||||
|
||||
// 跳過處理
|
||||
const handleSkipCard = useCallback(() => {
|
||||
const result = handleSkip(cards, currentIndex)
|
||||
setCards(result.updatedCards)
|
||||
setCurrentIndex(result.nextIndex)
|
||||
|
||||
checkIfComplete(result.updatedCards)
|
||||
}, [cards, currentIndex])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **UI/UX規格**
|
||||
|
||||
### **翻卡動畫CSS** (您調教過的)
|
||||
```css
|
||||
/* 3D翻卡動畫 */
|
||||
.flip-card-container {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.flip-card {
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.flip-card.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card-front,
|
||||
.flip-card-back {
|
||||
backface-visibility: hidden;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.flip-card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
```
|
||||
|
||||
### **響應式設計規格**
|
||||
```typescript
|
||||
// 智能高度計算 (您的原設計)
|
||||
const calculateCardHeight = useCallback(() => {
|
||||
if (backRef.current) {
|
||||
const backHeight = backRef.current.scrollHeight
|
||||
const minHeight = window.innerWidth <= 480 ? 300 :
|
||||
window.innerWidth <= 768 ? 350 : 400
|
||||
return Math.max(minHeight, backHeight)
|
||||
}
|
||||
return 400
|
||||
}, [])
|
||||
|
||||
// 信心度按鈕響應式
|
||||
const buttonLayout = window.innerWidth <= 640
|
||||
? 'grid-cols-1 gap-2' // 手機版: 垂直排列
|
||||
: 'grid-cols-3 gap-3' // 桌面版: 水平排列
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **本地存儲設計** (階段2+)
|
||||
|
||||
### **進度持久化**
|
||||
```typescript
|
||||
// localStorage 結構
|
||||
interface StoredProgress {
|
||||
sessionId: string
|
||||
cards: CardState[]
|
||||
currentIndex: number
|
||||
score: { correct: number; total: number }
|
||||
lastSaveTime: string
|
||||
}
|
||||
|
||||
// 儲存進度
|
||||
const saveProgress = (cards: CardState[], currentIndex: number, score: any) => {
|
||||
const progress: StoredProgress = {
|
||||
sessionId: `review_${Date.now()}`,
|
||||
cards,
|
||||
currentIndex,
|
||||
score,
|
||||
lastSaveTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
localStorage.setItem('review-progress', JSON.stringify(progress))
|
||||
}
|
||||
|
||||
// 載入進度
|
||||
const loadProgress = (): StoredProgress | null => {
|
||||
const saved = localStorage.getItem('review-progress')
|
||||
if (!saved) return null
|
||||
|
||||
try {
|
||||
const progress = JSON.parse(saved)
|
||||
// 檢查是否是當日進度 (避免過期數據)
|
||||
const saveTime = new Date(progress.lastSaveTime)
|
||||
const now = new Date()
|
||||
const isToday = saveTime.toDateString() === now.toDateString()
|
||||
|
||||
return isToday ? progress : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **路由和導航設計**
|
||||
|
||||
### **頁面路由**
|
||||
```typescript
|
||||
// 路由配置
|
||||
const reviewRoutes = {
|
||||
main: '/review-simple', // 主複習頁面
|
||||
maintenance: '/review', // 維護頁面 (舊版本隔離)
|
||||
backup: '/review-old' // 備份頁面 (複雜版本)
|
||||
}
|
||||
|
||||
// 導航更新
|
||||
const navigationItems = [
|
||||
{ href: '/dashboard', label: '儀表板' },
|
||||
{ href: '/flashcards', label: '詞卡' },
|
||||
{ href: '/review-simple', label: '複習' }, // 指向可用版本
|
||||
{ href: '/generate', label: 'AI 生成' }
|
||||
]
|
||||
```
|
||||
|
||||
### **頁面跳轉邏輯**
|
||||
```typescript
|
||||
// 會話完成後的跳轉
|
||||
const handleComplete = () => {
|
||||
setIsComplete(true)
|
||||
// 可選: 3秒後自動跳轉
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard')
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 中途退出處理
|
||||
const handleExit = () => {
|
||||
if (window.confirm('確定要退出複習嗎?進度將不會保存。')) {
|
||||
router.push('/flashcards')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **性能優化規格**
|
||||
|
||||
### **React性能優化**
|
||||
```typescript
|
||||
// 使用 memo 避免不必要重渲染
|
||||
export const SimpleFlipCard = memo(SimpleFlipCardComponent)
|
||||
export const SimpleProgress = memo(SimpleProgressComponent)
|
||||
|
||||
// useCallback 穩定化函數引用
|
||||
const handleAnswer = useCallback((confidence: number) => {
|
||||
// ... 邏輯
|
||||
}, [cards, currentIndex])
|
||||
|
||||
// useMemo 緩存計算結果
|
||||
const sortedCards = useMemo(() =>
|
||||
sortCardsByPriority(cards),
|
||||
[cards]
|
||||
)
|
||||
|
||||
const currentCard = useMemo(() =>
|
||||
cards[currentIndex],
|
||||
[cards, currentIndex]
|
||||
)
|
||||
```
|
||||
|
||||
### **載入性能目標**
|
||||
```typescript
|
||||
// 性能指標
|
||||
const PERFORMANCE_TARGETS = {
|
||||
INITIAL_LOAD: 1500, // 初始載入 < 1.5秒
|
||||
CARD_FLIP: 300, // 翻卡動畫 < 300ms
|
||||
SORT_OPERATION: 100, // 排序計算 < 100ms
|
||||
STATE_UPDATE: 50, // 狀態更新 < 50ms
|
||||
NAVIGATION: 200 // 頁面跳轉 < 200ms
|
||||
}
|
||||
|
||||
// 性能監控
|
||||
const measurePerformance = (operation: string, fn: Function) => {
|
||||
const start = performance.now()
|
||||
const result = fn()
|
||||
const end = performance.now()
|
||||
|
||||
console.log(`${operation}: ${end - start}ms`)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **測試架構**
|
||||
|
||||
### **測試文件結構**
|
||||
```
|
||||
__tests__/
|
||||
├── delay-counting-system.test.ts # 延遲計數邏輯測試
|
||||
├── card-sorting.test.ts # 排序算法測試
|
||||
├── confidence-mapping.test.ts # 信心度映射測試
|
||||
└── components/
|
||||
├── SimpleFlipCard.test.tsx # 翻卡組件測試
|
||||
├── SimpleProgress.test.tsx # 進度組件測試
|
||||
└── integration.test.tsx # 完整流程集成測試
|
||||
```
|
||||
|
||||
### **Mock數據設計**
|
||||
```typescript
|
||||
// 測試用的 Mock 數據
|
||||
export const MOCK_CARDS: CardState[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
word: 'evidence',
|
||||
definition: 'facts or information indicating truth',
|
||||
skipCount: 0,
|
||||
wrongCount: 0,
|
||||
successCount: 0,
|
||||
isCompleted: false,
|
||||
originalOrder: 0,
|
||||
delayScore: 0,
|
||||
// ... 其他 API 欄位
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
word: 'priority',
|
||||
definition: 'the fact of being more important',
|
||||
skipCount: 2,
|
||||
wrongCount: 1,
|
||||
successCount: 0,
|
||||
isCompleted: false,
|
||||
originalOrder: 1,
|
||||
delayScore: 3,
|
||||
// ... 其他 API 欄位
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **開發工具配置**
|
||||
|
||||
### **TypeScript 配置**
|
||||
```typescript
|
||||
// 嚴格的類型檢查
|
||||
interface 必須完整定義
|
||||
Props 必須有明確類型
|
||||
回調函數必須有返回值類型
|
||||
狀態更新必須使用正確的類型
|
||||
|
||||
// 避免 any 類型
|
||||
禁止: any, object, Function
|
||||
建議: 具體的接口定義
|
||||
```
|
||||
|
||||
### **ESLint 規則**
|
||||
```typescript
|
||||
// 代碼品質規則
|
||||
'react-hooks/exhaustive-deps': 'error' // 確保 useEffect 依賴正確
|
||||
'react/no-array-index-key': 'warn' // 避免使用 index 作為 key
|
||||
'@typescript-eslint/no-unused-vars': 'error' // 禁止未使用變數
|
||||
|
||||
// 複雜度控制
|
||||
'max-lines': ['error', 200] // 組件最多200行
|
||||
'max-params': ['error', 5] // 函數最多5個參數
|
||||
'complexity': ['error', 10] // 圈複雜度最多10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 **使用者體驗規格**
|
||||
|
||||
### **載入狀態處理**
|
||||
```typescript
|
||||
// 載入狀態
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// 載入動畫
|
||||
const LoadingSpinner = () => (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
<span className="ml-3 text-gray-600">準備詞卡中...</span>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
### **錯誤處理**
|
||||
```typescript
|
||||
// 錯誤狀態
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 錯誤邊界
|
||||
const ErrorBoundary = ({ error, onRetry }) => (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-700 mb-2">發生錯誤</h3>
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
重新嘗試
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
### **無障礙設計**
|
||||
```typescript
|
||||
// 鍵盤操作支援
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': handleSkip(); break
|
||||
case 'ArrowRight': handleAnswer(2); break // 一般
|
||||
case 'ArrowUp': handleAnswer(3); break // 熟悉
|
||||
case 'ArrowDown': handleAnswer(1); break // 模糊
|
||||
case ' ': handleFlip(); break // 空格翻卡
|
||||
}
|
||||
}
|
||||
|
||||
// ARIA 標籤
|
||||
<button
|
||||
aria-label={`信心度選擇: ${label}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **開發檢查清單**
|
||||
|
||||
### **組件開發完成標準**
|
||||
- [ ] TypeScript 無錯誤和警告
|
||||
- [ ] 所有 props 都有預設值或必填檢查
|
||||
- [ ] 使用 memo/useCallback 優化性能
|
||||
- [ ] 響應式設計在手機和桌面都正常
|
||||
- [ ] 無障礙功能完整 (鍵盤、ARIA)
|
||||
- [ ] 錯誤狀態有適當處理
|
||||
|
||||
### **功能測試標準**
|
||||
- [ ] 所有延遲計數測試通過
|
||||
- [ ] 排序邏輯測試通過
|
||||
- [ ] 信心度映射測試通過
|
||||
- [ ] 完整流程集成測試通過
|
||||
- [ ] 邊界條件測試通過
|
||||
|
||||
---
|
||||
|
||||
*前端規格維護: 開發團隊*
|
||||
*更新觸發: 產品需求變更或技術實作調整*
|
||||
*目標: 確保前端實作準確性和一致性*
|
||||
|
|
@ -0,0 +1,560 @@
|
|||
# 複習系統後端規格書 (更新版)
|
||||
|
||||
**版本**: 2.0
|
||||
**基於**: 前端技術規格實作版 + 實際系統需求
|
||||
**技術棧**: .NET 8 + Entity Framework + SQLite
|
||||
**架構**: RESTful API + Clean Architecture
|
||||
**最後更新**: 2025-10-06
|
||||
**狀態**: 🚧 **準備實作階段** - 前端已完成,需要後端API支援
|
||||
|
||||
---
|
||||
|
||||
## 📊 **前端需求分析**
|
||||
|
||||
### **✅ 前端已實現功能**
|
||||
- 完整的複習流程 (翻卡記憶 + 詞彙選擇)
|
||||
- 延遲計數系統 (skipCount + wrongCount)
|
||||
- 智能排序算法 (優先級排序)
|
||||
- localStorage 進度保存
|
||||
- 線性測驗項目系統
|
||||
- 信心度評估 (0=不熟悉, 1=一般, 2=熟悉)
|
||||
|
||||
### **❗ 前端急需的API**
|
||||
1. **獲取詞卡數據** - 替換靜態 api_seeds.json
|
||||
2. **記錄複習結果** - 實現間隔重複算法
|
||||
3. **進度同步** - 支援多設備學習
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ **API端點設計 (實作優先級)**
|
||||
|
||||
### **🔥 階段1: 核心API (立即需要)**
|
||||
|
||||
#### **1.1 獲取待複習詞卡**
|
||||
```http
|
||||
GET /api/flashcards/due
|
||||
Headers:
|
||||
- Authorization: Bearer {token}
|
||||
Query Parameters:
|
||||
- limit: number (default: 10, max: 50)
|
||||
- includeToday: boolean (default: true)
|
||||
- includeOverdue: boolean (default: true)
|
||||
- favoritesOnly: boolean (default: false)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"flashcards": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"word": "evidence",
|
||||
"translation": "證據",
|
||||
"definition": "facts or information indicating whether a belief is true",
|
||||
"partOfSpeech": "noun",
|
||||
"pronunciation": "/ˈevɪdəns/",
|
||||
"example": "There was evidence of forced entry.",
|
||||
"exampleTranslation": "有強行進入的證據。",
|
||||
"cefr": "B2", // 字串格式 (前端需要)
|
||||
"difficultyLevelNumeric": 4, // 數字格式 (後端計算)
|
||||
"isFavorite": true,
|
||||
"hasExampleImage": false,
|
||||
"primaryImageUrl": null,
|
||||
"synonyms": ["proof", "testimony"], // 前端需要
|
||||
"createdAt": "2025-10-01T12:48:11Z",
|
||||
"updatedAt": "2025-10-01T13:37:22Z",
|
||||
|
||||
// 複習相關信息
|
||||
"reviewInfo": {
|
||||
"successCount": 2,
|
||||
"nextReviewDate": "2025-10-06T10:00:00Z",
|
||||
"lastReviewDate": "2025-10-04T15:30:00Z",
|
||||
"totalCorrectCount": 5,
|
||||
"totalWrongCount": 2,
|
||||
"totalSkipCount": 1,
|
||||
"isOverdue": false,
|
||||
"daysSinceLastReview": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"count": 8,
|
||||
"metadata": {
|
||||
"todayDue": 5,
|
||||
"overdue": 3,
|
||||
"totalReviews": 45,
|
||||
"studyStreak": 7
|
||||
}
|
||||
},
|
||||
"message": null,
|
||||
"timestamp": "2025-10-06T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### **1.2 記錄複習結果**
|
||||
```http
|
||||
POST /api/flashcards/{flashcardId}/review
|
||||
Headers:
|
||||
- Authorization: Bearer {token}
|
||||
- Content-Type: application/json
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"confidence": 1, // 0=不熟悉, 1=一般, 2=熟悉 (配合前端)
|
||||
"reviewType": "flip-card", // "flip-card" | "vocab-choice"
|
||||
"responseTimeMs": 4200, // 回應時間
|
||||
"wasSkipped": false, // 是否跳過
|
||||
"sessionSkipCount": 0, // 本次會話跳過次數 (前端統計)
|
||||
"sessionWrongCount": 1, // 本次會話錯誤次數 (前端統計)
|
||||
|
||||
// 可選: 詳細信息
|
||||
"testItemId": "card-123-flip-card", // 前端測驗項目ID
|
||||
"sessionData": { // 會話數據 (可選)
|
||||
"totalItems": 20,
|
||||
"completedItems": 8,
|
||||
"sessionScore": { "correct": 6, "total": 8 }
|
||||
}
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"flashcardId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"reviewId": "review-uuid-123",
|
||||
"result": {
|
||||
"isCorrect": true, // confidence >= 1 算答對
|
||||
"newSuccessCount": 3, // 更新後的連續成功次數
|
||||
"nextReviewDate": "2025-10-14T09:00:00Z", // 下次複習時間
|
||||
"intervalDays": 8, // 間隔天數 (2^3)
|
||||
"masteryProgress": 0.75, // 熟練度進度 (0-1)
|
||||
"studyStreak": 8 // 學習連續天數
|
||||
},
|
||||
"statistics": {
|
||||
"totalCorrectCount": 6, // 累計正確次數
|
||||
"totalWrongCount": 2, // 累計錯誤次數
|
||||
"totalSkipCount": 1, // 累計跳過次數
|
||||
"averageResponseTime": 3800, // 平均回應時間
|
||||
"lastCorrectStreak": 3 // 最近連續正確次數
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-10-06T09:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### **1.3 獲取複習統計**
|
||||
```http
|
||||
GET /api/review/stats
|
||||
Headers:
|
||||
- Authorization: Bearer {token}
|
||||
Query Parameters:
|
||||
- period: string ("today" | "week" | "month" | "all")
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"today": {
|
||||
"reviewed": 12,
|
||||
"due": 15,
|
||||
"accuracy": 0.83,
|
||||
"averageTime": 3200
|
||||
},
|
||||
"week": {
|
||||
"reviewed": 85,
|
||||
"accuracy": 0.79,
|
||||
"studyDays": 6,
|
||||
"streak": 8
|
||||
},
|
||||
"overall": {
|
||||
"totalReviews": 456,
|
||||
"totalCards": 89,
|
||||
"masteryLevel": 0.67,
|
||||
"averageInterval": 12.5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **⚡ 階段2: 增強功能 (次要優先)**
|
||||
|
||||
#### **2.1 批量複習結果提交**
|
||||
```http
|
||||
POST /api/review/batch
|
||||
Request Body:
|
||||
{
|
||||
"reviews": [
|
||||
{
|
||||
"flashcardId": "uuid-1",
|
||||
"confidence": 2,
|
||||
"reviewType": "flip-card",
|
||||
"timestamp": "2025-10-06T09:10:00Z"
|
||||
},
|
||||
{
|
||||
"flashcardId": "uuid-2",
|
||||
"confidence": 0,
|
||||
"reviewType": "vocab-choice",
|
||||
"timestamp": "2025-10-06T09:12:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### **2.2 複習計劃推薦**
|
||||
```http
|
||||
GET /api/review/plan
|
||||
Response:
|
||||
{
|
||||
"recommendedDailyGoal": 20,
|
||||
"optimalStudyTime": "09:00-11:00",
|
||||
"priorityCards": [...],
|
||||
"estimatedCompletionTime": 15
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏰ **間隔重複算法 (核心業務邏輯)**
|
||||
|
||||
### **算法公式 (配合前端信心度)**
|
||||
```csharp
|
||||
public class SpacedRepetitionAlgorithm
|
||||
{
|
||||
public ReviewResult ProcessReview(FlashcardReview review, int confidence, bool wasSkipped)
|
||||
{
|
||||
if (wasSkipped)
|
||||
{
|
||||
// 跳過: 不改變成功次數,明天再複習
|
||||
review.TotalSkipCount++;
|
||||
review.NextReviewDate = DateTime.UtcNow.AddDays(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
var isCorrect = confidence >= 1; // 前端: 0=不熟悉, 1=一般, 2=熟悉
|
||||
|
||||
if (isCorrect)
|
||||
{
|
||||
// 答對: 增加成功次數,計算新間隔
|
||||
review.SuccessCount++;
|
||||
review.TotalCorrectCount++;
|
||||
review.LastSuccessDate = DateTime.UtcNow;
|
||||
|
||||
// 核心公式: 間隔 = 2^成功次數 天
|
||||
var intervalDays = Math.Pow(2, review.SuccessCount);
|
||||
var maxInterval = 180; // 最大半年
|
||||
var finalInterval = Math.Min(intervalDays, maxInterval);
|
||||
|
||||
review.NextReviewDate = DateTime.UtcNow.AddDays(finalInterval);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 答錯: 重置成功次數,明天再複習
|
||||
review.SuccessCount = 0;
|
||||
review.TotalWrongCount++;
|
||||
review.NextReviewDate = DateTime.UtcNow.AddDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
review.LastReviewDate = DateTime.UtcNow;
|
||||
review.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return new ReviewResult
|
||||
{
|
||||
IsCorrect = !wasSkipped && confidence >= 1,
|
||||
NewSuccessCount = review.SuccessCount,
|
||||
NextReviewDate = review.NextReviewDate,
|
||||
IntervalDays = CalculateIntervalDays(review.NextReviewDate)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **信心度映射表**
|
||||
| 前端信心度 | 標籤 | 後端判定 | 下次間隔 |
|
||||
|-----------|------|----------|----------|
|
||||
| 0 | 不熟悉 | ❌ 答錯 | 明天 (重置) |
|
||||
| 1 | 一般 | ✅ 答對 | 2^(n+1) 天 |
|
||||
| 2 | 熟悉 | ✅ 答對 | 2^(n+1) 天 |
|
||||
| skip | 跳過 | ⏭️ 跳過 | 明天 (不變) |
|
||||
|
||||
### **間隔計算示例**
|
||||
```
|
||||
成功次數 0 → 1: 明天 (1天)
|
||||
成功次數 1 → 2: 後天 (2天)
|
||||
成功次數 2 → 3: 4天後
|
||||
成功次數 3 → 4: 8天後
|
||||
成功次數 4 → 5: 16天後
|
||||
成功次數 5 → 6: 32天後
|
||||
成功次數 6 → 7: 64天後
|
||||
成功次數 7+: 128天後 (最大 180天)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗃️ **數據庫設計**
|
||||
|
||||
### **FlashcardReviews 表**
|
||||
```sql
|
||||
CREATE TABLE FlashcardReviews (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
|
||||
FlashcardId UNIQUEIDENTIFIER NOT NULL,
|
||||
UserId UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
-- 核心間隔重複欄位
|
||||
SuccessCount INT DEFAULT 0, -- 連續成功次數
|
||||
NextReviewDate DATETIME2 NOT NULL, -- 下次複習時間
|
||||
LastReviewDate DATETIME2 NULL, -- 最後複習時間
|
||||
LastSuccessDate DATETIME2 NULL, -- 最後成功時間
|
||||
|
||||
-- 統計欄位
|
||||
TotalCorrectCount INT DEFAULT 0, -- 累計正確次數
|
||||
TotalWrongCount INT DEFAULT 0, -- 累計錯誤次數
|
||||
TotalSkipCount INT DEFAULT 0, -- 累計跳過次數
|
||||
|
||||
-- 系統欄位
|
||||
CreatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||||
UpdatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||||
|
||||
-- 外鍵約束
|
||||
FOREIGN KEY (FlashcardId) REFERENCES Flashcards(Id),
|
||||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||||
|
||||
-- 唯一性約束 (每個用戶每張卡片只能有一條記錄)
|
||||
UNIQUE(FlashcardId, UserId)
|
||||
);
|
||||
|
||||
-- 性能索引
|
||||
CREATE INDEX IX_FlashcardReviews_NextReviewDate ON FlashcardReviews(NextReviewDate);
|
||||
CREATE INDEX IX_FlashcardReviews_UserId_NextReviewDate ON FlashcardReviews(UserId, NextReviewDate);
|
||||
```
|
||||
|
||||
### **ReviewSessions 表 (可選)**
|
||||
```sql
|
||||
-- 會話記錄表 (用於分析和統計)
|
||||
CREATE TABLE ReviewSessions (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
|
||||
UserId UNIQUEIDENTIFIER NOT NULL,
|
||||
StartTime DATETIME2 NOT NULL,
|
||||
EndTime DATETIME2 NULL,
|
||||
TotalItems INT DEFAULT 0,
|
||||
CompletedItems INT DEFAULT 0,
|
||||
CorrectItems INT DEFAULT 0,
|
||||
SkippedItems INT DEFAULT 0,
|
||||
AverageResponseTime INT NULL,
|
||||
SessionType NVARCHAR(50) DEFAULT 'mixed', -- 'flip-only', 'choice-only', 'mixed'
|
||||
CreatedAt DATETIME2 DEFAULT GETUTCDATE()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **業務邏輯服務設計**
|
||||
|
||||
### **IReviewService 接口**
|
||||
```csharp
|
||||
public interface IReviewService
|
||||
{
|
||||
// 核心功能
|
||||
Task<ApiResponse<List<FlashcardDto>>> GetDueFlashcardsAsync(
|
||||
string userId,
|
||||
DueFlashcardsQuery query);
|
||||
|
||||
Task<ApiResponse<ReviewResult>> SubmitReviewAsync(
|
||||
string userId,
|
||||
Guid flashcardId,
|
||||
ReviewRequest request);
|
||||
|
||||
// 統計功能
|
||||
Task<ApiResponse<ReviewStats>> GetReviewStatsAsync(
|
||||
string userId,
|
||||
string period = "today");
|
||||
|
||||
// 批量處理
|
||||
Task<ApiResponse<List<ReviewResult>>> SubmitBatchReviewAsync(
|
||||
string userId,
|
||||
List<ReviewRequest> reviews);
|
||||
}
|
||||
```
|
||||
|
||||
### **ReviewService 實作重點**
|
||||
```csharp
|
||||
public class ReviewService : IReviewService
|
||||
{
|
||||
public async Task<ApiResponse<List<FlashcardDto>>> GetDueFlashcardsAsync(
|
||||
string userId, DueFlashcardsQuery query)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 1. 獲取用戶的詞卡
|
||||
var flashcardsQuery = _context.Flashcards
|
||||
.Where(f => f.UserId == Guid.Parse(userId) && !f.IsArchived);
|
||||
|
||||
// 2. Left Join 複習記錄
|
||||
var flashcardsWithReviews = await flashcardsQuery
|
||||
.GroupJoin(_context.FlashcardReviews,
|
||||
f => f.Id,
|
||||
r => r.FlashcardId,
|
||||
(flashcard, reviews) => new {
|
||||
Flashcard = flashcard,
|
||||
Review = reviews.FirstOrDefault()
|
||||
})
|
||||
.Where(x =>
|
||||
// 沒有複習記錄的新卡片
|
||||
x.Review == null ||
|
||||
// 或者到期需要複習的卡片
|
||||
x.Review.NextReviewDate <= now.AddDays(query.IncludeToday ? 1 : 0))
|
||||
.Take(query.Limit)
|
||||
.ToListAsync();
|
||||
|
||||
// 3. 轉換為 DTO
|
||||
return new ApiResponse<List<FlashcardDto>>
|
||||
{
|
||||
Success = true,
|
||||
Data = flashcardsWithReviews.Select(x => new FlashcardDto
|
||||
{
|
||||
// 基本詞卡信息
|
||||
Id = x.Flashcard.Id,
|
||||
Word = x.Flashcard.Word,
|
||||
// ... 其他欄位
|
||||
|
||||
// 複習信息
|
||||
ReviewInfo = x.Review != null ? new ReviewInfo
|
||||
{
|
||||
SuccessCount = x.Review.SuccessCount,
|
||||
NextReviewDate = x.Review.NextReviewDate,
|
||||
LastReviewDate = x.Review.LastReviewDate,
|
||||
// ... 統計信息
|
||||
} : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<ReviewResult>> SubmitReviewAsync(
|
||||
string userId, Guid flashcardId, ReviewRequest request)
|
||||
{
|
||||
// 1. 獲取或創建複習記錄
|
||||
var review = await GetOrCreateReviewAsync(userId, flashcardId);
|
||||
|
||||
// 2. 使用間隔重複算法處理
|
||||
var algorithm = new SpacedRepetitionAlgorithm();
|
||||
var result = algorithm.ProcessReview(review, request.Confidence, request.WasSkipped);
|
||||
|
||||
// 3. 保存到數據庫
|
||||
_context.FlashcardReviews.Update(review);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 4. 返回結果
|
||||
return new ApiResponse<ReviewResult>
|
||||
{
|
||||
Success = true,
|
||||
Data = result
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **前端集成策略**
|
||||
|
||||
### **階段性集成計劃**
|
||||
```typescript
|
||||
// 階段1: API降級策略
|
||||
const useReviewData = () => {
|
||||
const [dataSource, setDataSource] = useState<'static' | 'api'>('static');
|
||||
|
||||
const loadFlashcards = async () => {
|
||||
try {
|
||||
// 嘗試 API 調用
|
||||
const response = await fetch('/api/flashcards/due');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDataSource('api');
|
||||
return data.data.flashcards;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('API 不可用,使用靜態數據');
|
||||
}
|
||||
|
||||
// 降級到靜態數據
|
||||
setDataSource('static');
|
||||
return SIMPLE_CARDS;
|
||||
};
|
||||
};
|
||||
|
||||
// 階段2: 複習結果同步
|
||||
const submitReview = async (flashcardId: string, confidence: number) => {
|
||||
// 立即更新前端狀態
|
||||
updateLocalState(confidence);
|
||||
|
||||
// 異步提交到後端
|
||||
if (dataSource === 'api') {
|
||||
try {
|
||||
await apiService.submitReview(flashcardId, {
|
||||
confidence,
|
||||
reviewType: currentTestItem.testType,
|
||||
responseTimeMs: calculateResponseTime(),
|
||||
wasSkipped: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('API 提交失敗,保持本地狀態');
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **開發優先級與時程**
|
||||
|
||||
### **🔥 第1週: 核心API (必須完成)**
|
||||
- [x] FlashcardReview 實體設計 ✅
|
||||
- [ ] FlashcardsController 基礎端點
|
||||
- [ ] 間隔重複算法實作
|
||||
- [ ] 數據庫遷移
|
||||
|
||||
### **⚡ 第2週: 前端集成 (關鍵)**
|
||||
- [ ] API服務層封裝
|
||||
- [ ] 前端降級處理
|
||||
- [ ] 信心度映射統一
|
||||
- [ ] 錯誤處理完善
|
||||
|
||||
### **📊 第3週: 優化與測試**
|
||||
- [ ] 性能優化
|
||||
- [ ] 批量處理
|
||||
- [ ] 統計功能
|
||||
- [ ] 完整測試
|
||||
|
||||
### **🎯 第4週: 完善與部署**
|
||||
- [ ] 文檔更新
|
||||
- [ ] 監控集成
|
||||
- [ ] 生產部署
|
||||
- [ ] 用戶驗收
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **成功標準**
|
||||
|
||||
### **功能驗收標準**
|
||||
- ✅ 前端可以從 API 獲取真實詞卡數據
|
||||
- ✅ 複習結果正確提交並計算間隔時間
|
||||
- ✅ 間隔重複算法按 2^n 公式運作
|
||||
- ✅ API 失敗時前端仍可正常運作
|
||||
- ✅ 多設備間複習進度同步
|
||||
|
||||
### **性能標準**
|
||||
- ✅ 獲取詞卡 API < 500ms
|
||||
- ✅ 提交複習結果 < 200ms
|
||||
- ✅ 支援並發用戶 > 100
|
||||
- ✅ 數據庫查詢優化 < 100ms
|
||||
|
||||
### **可靠性標準**
|
||||
- ✅ API 可用性 > 99.5%
|
||||
- ✅ 數據一致性保證
|
||||
- ✅ 錯誤處理完善
|
||||
- ✅ 監控和日誌完整
|
||||
|
||||
---
|
||||
|
||||
*此規格書基於前端實作需求撰寫,確保後端API能完美支援現有前端功能*
|
||||
*維護責任: 後端開發團隊*
|
||||
*更新觸發: 前端需求變更或API優化*
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
|
||||
#### **2.2 狀態追蹤架構**
|
||||
```typescript
|
||||
interface TestItem {
|
||||
interface QuizItem {
|
||||
id: string // 測驗項目ID
|
||||
cardId: string // 所屬詞卡ID
|
||||
testType: 'flip-card' | 'vocab-choice'
|
||||
|
|
|
|||
Loading…
Reference in New Issue