diff --git a/frontend/app/review/page.tsx b/frontend/app/review/page.tsx
index 93cf57b..4ca7683 100644
--- a/frontend/app/review/page.tsx
+++ b/frontend/app/review/page.tsx
@@ -13,7 +13,10 @@ import { LoadingStates } from '@/components/review/LoadingStates'
import { ReviewRunner } from '@/components/review/ReviewRunner'
// 狀態管理
-import { useReviewStore } from '@/store/useReviewStore'
+import { useReviewSessionStore } from '@/store/useReviewSessionStore'
+import { useTestQueueStore } from '@/store/useTestQueueStore'
+import { useTestResultStore } from '@/store/useTestResultStore'
+import { useReviewDataStore } from '@/store/useReviewDataStore'
import { useUIStore } from '@/store/useUIStore'
import { ReviewService } from '@/lib/services/review/reviewService'
@@ -21,23 +24,24 @@ export default function LearnPage() {
const router = useRouter()
// Zustand stores
+ const { mounted, currentCard, error, setMounted, resetSession: resetSessionState } = useReviewSessionStore()
const {
- mounted,
- isLoading,
- currentCard,
- dueCards,
testItems,
completedTests,
totalTests,
- score,
+ initializeTestQueue,
+ resetQueue
+ } = useTestQueueStore()
+ const { score, resetScore } = useTestResultStore()
+ const {
+ dueCards,
showComplete,
showNoDueCards,
- error,
- setMounted,
+ isLoadingCards,
loadDueCards,
- initializeTestQueue,
- resetSession
- } = useReviewStore()
+ resetData,
+ setShowComplete
+ } = useReviewDataStore()
const {
showTaskListModal,
@@ -61,28 +65,62 @@ export default function LearnPage() {
const initializeSession = async () => {
try {
await loadDueCards()
-
- if (dueCards.length > 0) {
- const cardIds = dueCards.map(c => c.id)
- const completedTests = await ReviewService.loadCompletedTests(cardIds)
- initializeTestQueue(completedTests)
- }
} catch (error) {
console.error('初始化複習會話失敗:', error)
}
}
+ // 監聽dueCards變化,初始化測試隊列
+ useEffect(() => {
+ if (dueCards.length > 0) {
+ const initQueue = async () => {
+ try {
+ const cardIds = dueCards.map(c => c.id)
+ const completedTests = await ReviewService.loadCompletedTests(cardIds)
+ initializeTestQueue(dueCards, completedTests)
+ } catch (error) {
+ console.error('初始化測試隊列失敗:', error)
+ }
+ }
+ initQueue()
+ }
+ }, [dueCards, initializeTestQueue])
+
+ // 監聽測試隊列變化,設置當前卡片
+ useEffect(() => {
+ if (testItems.length > 0 && dueCards.length > 0) {
+ const currentTestItem = testItems.find(item => item.isCurrent)
+ if (currentTestItem) {
+ const card = dueCards.find(c => c.id === currentTestItem.cardId)
+ if (card) {
+ const { setCurrentCard } = useReviewSessionStore.getState()
+ setCurrentCard(card)
+ }
+ }
+ }
+ }, [testItems, dueCards])
+
+ // 監聽測試完成狀態
+ useEffect(() => {
+ if (totalTests > 0 && completedTests >= totalTests) {
+ setShowComplete(true)
+ }
+ }, [completedTests, totalTests, setShowComplete])
+
// 重新開始
const handleRestart = async () => {
- resetSession()
+ resetSessionState()
+ resetQueue()
+ resetScore()
+ resetData()
await initializeSession()
}
// 載入狀態
- if (!mounted || isLoading) {
+ if (!mounted || isLoadingCards) {
return (
)
diff --git a/frontend/components/review/ReviewRunner.tsx b/frontend/components/review/ReviewRunner.tsx
index 0a888b4..bcd5faa 100644
--- a/frontend/components/review/ReviewRunner.tsx
+++ b/frontend/components/review/ReviewRunner.tsx
@@ -1,5 +1,7 @@
import { useEffect } from 'react'
-import { useReviewStore } from '@/store/useReviewStore'
+import { useReviewSessionStore } from '@/store/useReviewSessionStore'
+import { useTestQueueStore } from '@/store/useTestQueueStore'
+import { useTestResultStore } from '@/store/useTestResultStore'
import { useUIStore } from '@/store/useUIStore'
import {
FlipMemoryTest,
@@ -16,13 +18,9 @@ interface TestRunnerProps {
}
export const ReviewRunner: React.FC = ({ className }) => {
- const {
- currentCard,
- currentMode,
- updateScore,
- recordTestResult,
- error
- } = useReviewStore()
+ const { currentCard, error } = useReviewSessionStore()
+ const { currentMode, testItems, currentTestIndex, markTestCompleted, goToNextTest } = useTestQueueStore()
+ const { updateScore, recordTestResult } = useTestResultStore()
const {
openReportModal,
@@ -40,7 +38,24 @@ export const ReviewRunner: React.FC = ({ className }) => {
updateScore(isCorrect)
// 記錄到後端
- await recordTestResult(isCorrect, answer, confidenceLevel)
+ const success = await recordTestResult({
+ flashcardId: currentCard.id,
+ testType: currentMode,
+ isCorrect,
+ userAnswer: answer,
+ confidenceLevel,
+ responseTimeMs: 2000
+ })
+
+ if (success) {
+ // 標記測驗為完成
+ markTestCompleted(currentTestIndex)
+
+ // 延遲進入下一個測驗
+ setTimeout(() => {
+ goToNextTest()
+ }, 1500)
+ }
}
// 檢查答案正確性
@@ -110,16 +125,23 @@ export const ReviewRunner: React.FC = ({ className }) => {
}
// 共同的 props
- const commonProps = {
+ const cardData = {
+ id: currentCard.id,
word: currentCard.word,
definition: currentCard.definition,
example: currentCard.example,
+ translation: currentCard.translation || '',
exampleTranslation: currentCard.translation || '',
pronunciation: currentCard.pronunciation,
difficultyLevel: currentCard.difficultyLevel || 'A2',
- onReportError: () => openReportModal(currentCard),
- onImageClick: openImageModal,
- exampleImage: currentCard.exampleImage
+ exampleImage: currentCard.exampleImage,
+ synonyms: currentCard.synonyms || []
+ }
+
+ const commonProps = {
+ cardData,
+ onAnswer: handleAnswer,
+ onReportError: () => openReportModal(currentCard)
}
// 渲染對應的測驗組件
@@ -127,9 +149,15 @@ export const ReviewRunner: React.FC = ({ className }) => {
case 'flip-memory':
return (
handleAnswer('', level)}
+ onReportError={() => openReportModal(currentCard)}
/>
)
@@ -138,15 +166,23 @@ export const ReviewRunner: React.FC = ({ className }) => {
)
case 'sentence-fill':
return (
openReportModal(currentCard)}
+ onImageClick={openImageModal}
/>
)
@@ -154,33 +190,48 @@ export const ReviewRunner: React.FC = ({ className }) => {
return (
)
case 'vocab-listening':
return (
openReportModal(currentCard)}
/>
)
case 'sentence-listening':
return (
openReportModal(currentCard)}
/>
)
case 'sentence-speaking':
return (
openReportModal(currentCard)}
+ onImageClick={openImageModal}
/>
)
diff --git a/frontend/hooks/useReviewLogic.ts b/frontend/hooks/useReviewLogic.ts
index 558e953..62f39db 100644
--- a/frontend/hooks/useReviewLogic.ts
+++ b/frontend/hooks/useReviewLogic.ts
@@ -69,7 +69,7 @@ export const useReviewLogic = ({ cardData, testType }: UseReviewLogicProps) => {
setUserAnswer('')
setFeedback(null)
setIsSubmitted(false)
- setConfidence(null)
+ setConfidence(undefined)
}, [])
return {
diff --git a/frontend/lib/errors/errorHandler.ts b/frontend/lib/errors/errorHandler.ts
index 6a9f7c1..cd669f2 100644
--- a/frontend/lib/errors/errorHandler.ts
+++ b/frontend/lib/errors/errorHandler.ts
@@ -1,3 +1,5 @@
+import { ExtendedFlashcard } from '@/store/useReviewSessionStore'
+
// 錯誤類型定義
export enum ErrorType {
NETWORK_ERROR = 'NETWORK_ERROR',
@@ -189,11 +191,9 @@ export class FallbackService {
definition: '你好,哈囉',
example: 'Hello, how are you?',
difficultyLevel: 'A1',
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
translation: '你好,你還好嗎?'
}
- ] as ExtendedFlashcard[]
+ ]
}
// 檢查是否需要使用降級模式
diff --git a/frontend/lib/services/flashcards.ts b/frontend/lib/services/flashcards.ts
index 090481c..bb22e38 100644
--- a/frontend/lib/services/flashcards.ts
+++ b/frontend/lib/services/flashcards.ts
@@ -351,7 +351,7 @@ class FlashcardsService {
return {
success: true,
- data: result.data || [],
+ data: (result as any).data || [],
error: undefined
};
} catch (error) {
@@ -390,7 +390,7 @@ class FlashcardsService {
return {
success: true,
- data: result.data || result,
+ data: (result as any).data || result,
error: undefined
};
} catch (error) {
diff --git a/frontend/lib/services/review/reviewService.ts b/frontend/lib/services/review/reviewService.ts
index 4556990..f0a0373 100644
--- a/frontend/lib/services/review/reviewService.ts
+++ b/frontend/lib/services/review/reviewService.ts
@@ -1,5 +1,6 @@
import { flashcardsService } from '@/lib/services/flashcards'
-import { ExtendedFlashcard, TestItem } from '@/store/useReviewStore'
+import { ExtendedFlashcard } from '@/store/useReviewSessionStore'
+import { TestItem } from '@/store/useTestQueueStore'
// 複習會話服務
export class ReviewService {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1963132..bbe708f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,11 +14,13 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"autoprefixer": "^10.4.21",
+ "clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"next": "^15.5.3",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
+ "tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"zustand": "^5.0.8"
@@ -1273,6 +1275,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -2729,6 +2740,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
+ "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 4f7d29c..3f2f68d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,11 +26,13 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"autoprefixer": "^10.4.21",
+ "clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"next": "^15.5.3",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
+ "tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"zustand": "^5.0.8"
diff --git a/frontend/store/README.md b/frontend/store/README.md
new file mode 100644
index 0000000..149ba10
--- /dev/null
+++ b/frontend/store/README.md
@@ -0,0 +1,332 @@
+# 狀態管理系統文件
+
+## 📋 概述
+
+這個目錄包含了應用程式的狀態管理系統,採用 **Zustand** 作為狀態管理工具。系統被設計為模組化架構,將原本單一巨大的 store 拆分為多個專門化的 stores,每個都有明確的職責範圍。
+
+## 🏗️ 架構設計
+
+### 設計原則
+- **單一職責原則**: 每個 store 只負責特定的狀態域
+- **最小重渲染**: 組件只訂閱需要的狀態,避免不必要的重渲染
+- **型別安全**: 使用 TypeScript 確保型別安全
+- **可測試性**: 小型、專注的 stores 更容易測試
+
+### Store 分類
+```
+/store/
+├── useReviewSessionStore.ts # 會話狀態管理
+├── useTestQueueStore.ts # 測試隊列管理
+├── useTestResultStore.ts # 測試結果管理
+├── useReviewDataStore.ts # 數據狀態管理
+└── useUIStore.ts # UI 狀態管理
+```
+
+## 📚 各 Store 詳細說明
+
+### 1. useReviewSessionStore.ts
+**職責**: 管理複習會話的核心狀態
+
+#### 狀態內容
+```typescript
+interface ReviewSessionState {
+ // 核心會話狀態
+ mounted: boolean // 組件是否已掛載
+ isLoading: boolean // 是否正在載入
+ error: string | null // 錯誤訊息
+
+ // 當前卡片狀態
+ currentCard: ExtendedFlashcard | null // 當前顯示的詞卡
+ currentCardIndex: number // 當前卡片索引
+}
+```
+
+#### 主要功能
+- **會話生命週期管理**: 控制會話的開始、結束
+- **當前卡片追蹤**: 追蹤使用者正在學習的詞卡
+- **錯誤處理**: 統一管理會話相關錯誤
+
+#### 使用範例
+```typescript
+const { currentCard, error, setCurrentCard } = useReviewSessionStore()
+
+// 設置當前卡片
+setCurrentCard(newCard)
+```
+
+---
+
+### 2. useTestQueueStore.ts
+**職責**: 管理測試隊列和測試流程
+
+#### 狀態內容
+```typescript
+interface TestQueueState {
+ testItems: TestItem[] // 測試項目清單
+ currentTestIndex: number // 當前測試索引
+ completedTests: number // 已完成測試數量
+ totalTests: number // 總測試數量
+ currentMode: ReviewMode // 當前測試模式
+}
+```
+
+#### 主要功能
+- **測試隊列初始化**: 根據詞卡和已完成測試建立隊列
+- **測試進度管理**: 追蹤測試進度和完成狀態
+- **測試流程控制**: 控制測試的前進、跳過等操作
+
+#### 核心方法
+```typescript
+// 初始化測試隊列
+initializeTestQueue(dueCards, completedTests)
+
+// 進入下一個測試
+goToNextTest()
+
+// 跳過當前測試
+skipCurrentTest()
+
+// 標記測試完成
+markTestCompleted(testIndex)
+```
+
+#### 測試類型
+- `flip-memory`: 翻卡記憶
+- `vocab-choice`: 詞彙選擇
+- `vocab-listening`: 詞彙聽力
+- `sentence-listening`: 例句聽力
+- `sentence-fill`: 例句填空
+- `sentence-reorder`: 例句重組
+- `sentence-speaking`: 例句口說
+
+---
+
+### 3. useTestResultStore.ts
+**職責**: 管理測試結果和分數統計
+
+#### 狀態內容
+```typescript
+interface TestResultState {
+ score: { correct: number; total: number } // 分數統計
+ isRecordingResult: boolean // 是否正在記錄結果
+ recordingError: string | null // 記錄錯誤
+}
+```
+
+#### 主要功能
+- **分數追蹤**: 記錄正確和總答題數
+- **結果記錄**: 將測試結果發送到後端
+- **統計計算**: 提供準確率等統計資訊
+
+#### 核心方法
+```typescript
+// 更新分數
+updateScore(isCorrect: boolean)
+
+// 記錄測試結果到後端
+recordTestResult({
+ flashcardId,
+ testType,
+ isCorrect,
+ userAnswer,
+ confidenceLevel,
+ responseTimeMs
+})
+
+// 獲取準確率
+getAccuracyPercentage()
+```
+
+---
+
+### 4. useReviewDataStore.ts
+**職責**: 管理複習數據和UI顯示狀態
+
+#### 狀態內容
+```typescript
+interface ReviewDataState {
+ dueCards: ExtendedFlashcard[] // 到期詞卡清單
+ showComplete: boolean // 是否顯示完成畫面
+ showNoDueCards: boolean // 是否顯示無詞卡畫面
+ isLoadingCards: boolean // 是否正在載入詞卡
+ loadingError: string | null // 載入錯誤
+}
+```
+
+#### 主要功能
+- **詞卡資料管理**: 載入和管理到期的詞卡
+- **UI 狀態控制**: 控制不同UI狀態的顯示
+- **資料快取**: 快取詞卡資料避免重複請求
+
+#### 核心方法
+```typescript
+// 載入到期詞卡
+loadDueCards()
+
+// 根據ID查找詞卡
+findCardById(cardId)
+
+// 獲取詞卡數量
+getDueCardsCount()
+```
+
+---
+
+### 5. useUIStore.ts
+**職責**: 管理全域UI狀態
+
+#### 狀態內容
+```typescript
+interface UIState {
+ showTaskListModal: boolean // 任務清單Modal
+ showReportModal: boolean // 錯誤回報Modal
+ modalImage: string | null // 圖片Modal
+ reportReason: string // 回報原因
+ reportingCard: any | null // 正在回報的詞卡
+ isAutoSelecting: boolean // 自動選擇狀態
+}
+```
+
+## 🔄 Store 之間的協作
+
+### 資料流向
+```mermaid
+graph TD
+ A[useReviewDataStore] -->|詞卡資料| B[useTestQueueStore]
+ B -->|當前測試| C[useReviewSessionStore]
+ C -->|測試互動| D[useTestResultStore]
+ D -->|結果回饋| B
+ E[useUIStore] -.->|UI狀態| A
+ E -.->|UI狀態| B
+ E -.->|UI狀態| C
+ E -.->|UI狀態| D
+```
+
+### 協作流程
+1. **初始化階段**:
+ - `useReviewDataStore` 載入到期詞卡
+ - `useTestQueueStore` 根據詞卡建立測試隊列
+ - `useReviewSessionStore` 設置當前詞卡
+
+2. **測試階段**:
+ - `useReviewSessionStore` 管理當前測試狀態
+ - `useTestResultStore` 記錄測試結果
+ - `useTestQueueStore` 控制測試進度
+
+3. **完成階段**:
+ - `useTestQueueStore` 檢查是否完成所有測試
+ - `useReviewDataStore` 顯示完成狀態
+
+## 🎯 使用最佳實踐
+
+### 1. 選擇性訂閱
+```typescript
+// ❌ 避免:訂閱整個 store
+const store = useReviewSessionStore()
+
+// ✅ 推薦:只訂閱需要的狀態
+const { currentCard, error } = useReviewSessionStore()
+```
+
+### 2. 狀態更新模式
+```typescript
+// ✅ 推薦:使用專門的 actions
+const { setCurrentCard } = useReviewSessionStore()
+setCurrentCard(newCard)
+
+// ❌ 避免:直接修改狀態
+// store.currentCard = newCard // 這樣不會觸發重渲染
+```
+
+### 3. 錯誤處理
+```typescript
+// ✅ 推薦:檢查錯誤狀態
+const { error, isLoading } = useReviewSessionStore()
+
+if (error) {
+ return
+}
+
+if (isLoading) {
+ return
+}
+```
+
+## 🧪 測試策略
+
+### 單元測試
+```typescript
+// 測試 store 的 actions
+describe('useTestResultStore', () => {
+ it('should update score correctly', () => {
+ const { updateScore, score } = useTestResultStore.getState()
+
+ updateScore(true)
+ expect(score.correct).toBe(1)
+ expect(score.total).toBe(1)
+ })
+})
+```
+
+### 整合測試
+```typescript
+// 測試多個 stores 的協作
+describe('Review Flow Integration', () => {
+ it('should coordinate between stores correctly', () => {
+ // 測試資料載入 → 隊列建立 → 測試執行的流程
+ })
+})
+```
+
+## 🔧 開發工具
+
+### Zustand DevTools
+```typescript
+import { subscribeWithSelector, devtools } from 'zustand/middleware'
+
+export const useReviewSessionStore = create()(
+ devtools(
+ subscribeWithSelector((set, get) => ({
+ // store implementation
+ })),
+ { name: 'review-session-store' }
+ )
+)
+```
+
+## 📈 效能考量
+
+### 重渲染優化
+- **狀態分離**: 不相關的狀態變更不會觸發組件重渲染
+- **選擇性訂閱**: 組件只訂閱需要的狀態片段
+- **記憶化**: 使用 `useMemo` 和 `useCallback` 優化計算
+
+### 記憶體管理
+- **自動清理**: stores 會在適當時機重置狀態
+- **垃圾回收**: 移除不再需要的資料引用
+
+## 🚀 未來擴展
+
+### 新增 Store
+1. 建立新的 store 檔案
+2. 定義 interface 和初始狀態
+3. 實作 actions 和 getters
+4. 加入適當的 TypeScript 型別
+5. 更新文件
+
+### Store 拆分指導原則
+- 當 store 超過 150 行時考慮拆分
+- 根據業務邏輯邊界進行拆分
+- 確保拆分後的 stores 職責清晰
+
+---
+
+## 📞 支援
+
+如有問題或需要協助,請參考:
+- [Zustand 官方文件](https://zustand-demo.pmnd.rs/)
+- [TypeScript 最佳實踐](https://www.typescriptlang.org/docs/)
+- 團隊內部技術文件
+
+**維護者**: 開發團隊
+**最後更新**: 2025-09-28
\ No newline at end of file
diff --git a/frontend/store/useReviewDataStore.ts b/frontend/store/useReviewDataStore.ts
new file mode 100644
index 0000000..7912549
--- /dev/null
+++ b/frontend/store/useReviewDataStore.ts
@@ -0,0 +1,106 @@
+import { create } from 'zustand'
+import { subscribeWithSelector } from 'zustand/middleware'
+import { flashcardsService } from '@/lib/services/flashcards'
+import { ExtendedFlashcard } from './useReviewSessionStore'
+
+// 數據狀態接口
+interface ReviewDataState {
+ // 詞卡數據
+ dueCards: ExtendedFlashcard[]
+
+ // UI 顯示狀態
+ showComplete: boolean
+ showNoDueCards: boolean
+
+ // 數據載入狀態
+ isLoadingCards: boolean
+ loadingError: string | null
+
+ // Actions
+ setDueCards: (cards: ExtendedFlashcard[]) => void
+ setShowComplete: (show: boolean) => void
+ setShowNoDueCards: (show: boolean) => void
+ setLoadingCards: (loading: boolean) => void
+ setLoadingError: (error: string | null) => void
+ loadDueCards: () => Promise
+ resetData: () => void
+
+ // 輔助方法
+ getDueCardsCount: () => number
+ findCardById: (cardId: string) => ExtendedFlashcard | undefined
+}
+
+export const useReviewDataStore = create()(
+ subscribeWithSelector((set, get) => ({
+ // 初始狀態
+ dueCards: [],
+ showComplete: false,
+ showNoDueCards: false,
+ isLoadingCards: false,
+ loadingError: null,
+
+ // Actions
+ setDueCards: (cards) => set({ dueCards: cards }),
+
+ setShowComplete: (show) => set({ showComplete: show }),
+
+ setShowNoDueCards: (show) => set({ showNoDueCards: show }),
+
+ setLoadingCards: (loading) => set({ isLoadingCards: loading }),
+
+ setLoadingError: (error) => set({ loadingError: error }),
+
+ loadDueCards: async () => {
+ const { setLoadingCards, setLoadingError, setDueCards, setShowNoDueCards, setShowComplete } = get()
+
+ try {
+ setLoadingCards(true)
+ setLoadingError(null)
+ console.log('🔍 開始載入到期詞卡...')
+
+ const apiResult = await flashcardsService.getDueFlashcards(50)
+ console.log('📡 API回應結果:', apiResult)
+
+ if (apiResult.success && apiResult.data && apiResult.data.length > 0) {
+ const cards = apiResult.data
+ console.log('✅ 載入後端API數據成功:', cards.length, '張詞卡')
+
+ setDueCards(cards)
+ setShowNoDueCards(false)
+ setShowComplete(false)
+ } else {
+ console.log('❌ 沒有到期詞卡')
+ setDueCards([])
+ setShowNoDueCards(true)
+ setShowComplete(false)
+ }
+ } catch (error) {
+ console.error('💥 載入到期詞卡失敗:', error)
+ setLoadingError('載入詞卡失敗')
+ setDueCards([])
+ setShowNoDueCards(true)
+ } finally {
+ setLoadingCards(false)
+ }
+ },
+
+ resetData: () => set({
+ dueCards: [],
+ showComplete: false,
+ showNoDueCards: false,
+ isLoadingCards: false,
+ loadingError: null
+ }),
+
+ // 輔助方法
+ getDueCardsCount: () => {
+ const { dueCards } = get()
+ return dueCards.length
+ },
+
+ findCardById: (cardId) => {
+ const { dueCards } = get()
+ return dueCards.find(card => card.id === cardId)
+ }
+ }))
+)
\ No newline at end of file
diff --git a/frontend/store/useReviewSessionStore.ts b/frontend/store/useReviewSessionStore.ts
new file mode 100644
index 0000000..b92a8db
--- /dev/null
+++ b/frontend/store/useReviewSessionStore.ts
@@ -0,0 +1,71 @@
+import { create } from 'zustand'
+import { subscribeWithSelector } from 'zustand/middleware'
+
+// 會話相關的類型定義
+export interface ExtendedFlashcard {
+ id: string
+ word: string
+ definition: string
+ example: string
+ translation?: string
+ pronunciation?: string
+ difficultyLevel?: string
+ nextReviewDate?: string
+ currentInterval?: number
+ isOverdue?: boolean
+ overdueDays?: number
+ baseMasteryLevel?: number
+ lastReviewDate?: string
+ synonyms?: string[]
+ exampleImage?: string
+}
+
+// 會話狀態接口
+interface ReviewSessionState {
+ // 核心會話狀態
+ mounted: boolean
+ isLoading: boolean
+ error: string | null
+
+ // 當前卡片狀態
+ currentCard: ExtendedFlashcard | null
+ currentCardIndex: number
+
+ // Actions
+ setMounted: (mounted: boolean) => void
+ setLoading: (loading: boolean) => void
+ setError: (error: string | null) => void
+ setCurrentCard: (card: ExtendedFlashcard | null) => void
+ setCurrentCardIndex: (index: number) => void
+ resetSession: () => void
+}
+
+export const useReviewSessionStore = create()(
+ subscribeWithSelector((set) => ({
+ // 初始狀態
+ mounted: false,
+ isLoading: false,
+ error: null,
+ currentCard: null,
+ currentCardIndex: 0,
+
+ // Actions
+ setMounted: (mounted) => set({ mounted }),
+
+ setLoading: (loading) => set({ isLoading: loading }),
+
+ setError: (error) => set({ error }),
+
+ setCurrentCard: (card) => set({ currentCard: card }),
+
+ setCurrentCardIndex: (index) => set({ currentCardIndex: index }),
+
+ resetSession: () => set({
+ currentCard: null,
+ currentCardIndex: 0,
+ error: null,
+ mounted: false,
+ isLoading: false
+ })
+ }))
+)
\ No newline at end of file
diff --git a/frontend/store/useReviewStore.ts b/frontend/store/useReviewStore.ts
deleted file mode 100644
index 03189e1..0000000
--- a/frontend/store/useReviewStore.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import { create } from 'zustand'
-import { subscribeWithSelector } from 'zustand/middleware'
-import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
-import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils'
-
-// 複習模式類型
-export type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'
-
-// 擴展的詞卡接口
-export interface ExtendedFlashcard extends Omit {
- nextReviewDate?: string
- currentInterval?: number
- isOverdue?: boolean
- overdueDays?: number
- baseMasteryLevel?: number
- lastReviewDate?: string
- synonyms?: string[]
- exampleImage?: string
-}
-
-// 測驗項目接口
-export interface TestItem {
- id: string
- cardId: string
- word: string
- testType: ReviewMode
- testName: string
- isCompleted: boolean
- isCurrent: boolean
- order: number
-}
-
-// 複習會話狀態
-interface ReviewState {
- // 核心狀態
- mounted: boolean
- isLoading: boolean
- currentCard: ExtendedFlashcard | null
- dueCards: ExtendedFlashcard[]
- currentCardIndex: number
-
- // 測驗狀態
- currentMode: ReviewMode
- testItems: TestItem[]
- currentTestIndex: number
- completedTests: number
- totalTests: number
-
- // 進度狀態
- score: { correct: number; total: number }
-
- // UI狀態
- showComplete: boolean
- showNoDueCards: boolean
-
- // 錯誤狀態
- error: string | null
-
- // Actions
- setMounted: (mounted: boolean) => void
- setLoading: (loading: boolean) => void
- loadDueCards: () => Promise
- initializeTestQueue: (completedTests: any[]) => void
- goToNextTest: () => void
- recordTestResult: (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => Promise
- skipCurrentTest: () => void
- resetSession: () => void
- updateScore: (isCorrect: boolean) => void
- setError: (error: string | null) => void
-}
-
-export const useReviewStore = create()(
- subscribeWithSelector((set, get) => ({
- // 初始狀態
- mounted: false,
- isLoading: false,
- currentCard: null,
- dueCards: [],
- currentCardIndex: 0,
-
- currentMode: 'flip-memory',
- testItems: [],
- currentTestIndex: 0,
- completedTests: 0,
- totalTests: 0,
-
- score: { correct: 0, total: 0 },
-
- showComplete: false,
- showNoDueCards: false,
-
- error: null,
-
- // Actions
- setMounted: (mounted) => set({ mounted }),
-
- setLoading: (loading) => set({ isLoading: loading }),
-
- loadDueCards: async () => {
- try {
- set({ isLoading: true, error: null })
- console.log('🔍 開始載入到期詞卡...')
-
- const apiResult = await flashcardsService.getDueFlashcards(50)
- console.log('📡 API回應結果:', apiResult)
-
- if (apiResult.success && apiResult.data && apiResult.data.length > 0) {
- const cards = apiResult.data
- console.log('✅ 載入後端API數據成功:', cards.length, '張詞卡')
-
- set({
- dueCards: cards,
- currentCard: cards[0],
- currentCardIndex: 0,
- showNoDueCards: false,
- showComplete: false
- })
- } else {
- console.log('❌ 沒有到期詞卡')
- set({
- dueCards: [],
- currentCard: null,
- showNoDueCards: true,
- showComplete: false
- })
- }
- } catch (error) {
- console.error('💥 載入到期詞卡失敗:', error)
- set({
- error: '載入詞卡失敗',
- dueCards: [],
- currentCard: null,
- showNoDueCards: true
- })
- } finally {
- set({ isLoading: false })
- }
- },
-
- initializeTestQueue: (completedTests = []) => {
- const { dueCards } = get()
- const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'
- let remainingTestItems: TestItem[] = []
- let order = 1
-
- dueCards.forEach(card => {
- const wordCEFRLevel = card.difficultyLevel || 'A2'
- const allTestTypes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel)
-
- const completedTestTypes = completedTests
- .filter(ct => ct.flashcardId === card.id)
- .map(ct => ct.testType)
-
- const remainingTestTypes = allTestTypes.filter(testType =>
- !completedTestTypes.includes(testType)
- )
-
- console.log(`🎯 詞卡 ${card.word}: 總共${allTestTypes.length}個測驗, 已完成${completedTestTypes.length}個, 剩餘${remainingTestTypes.length}個`)
-
- remainingTestTypes.forEach(testType => {
- remainingTestItems.push({
- id: `${card.id}-${testType}`,
- cardId: card.id,
- word: card.word,
- testType: testType as ReviewMode,
- testName: getTestTypeName(testType),
- isCompleted: false,
- isCurrent: false,
- order
- })
- order++
- })
- })
-
- if (remainingTestItems.length === 0) {
- console.log('🎉 所有測驗都已完成!')
- set({ showComplete: true })
- return
- }
-
- // 標記第一個測驗為當前
- remainingTestItems[0].isCurrent = true
-
- set({
- testItems: remainingTestItems,
- totalTests: remainingTestItems.length,
- currentTestIndex: 0,
- completedTests: 0,
- currentMode: remainingTestItems[0].testType
- })
-
- console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個')
- },
-
- goToNextTest: () => {
- const { testItems, currentTestIndex } = get()
-
- if (currentTestIndex + 1 < testItems.length) {
- const nextIndex = currentTestIndex + 1
- const updatedTestItems = testItems.map((item, index) => ({
- ...item,
- isCurrent: index === nextIndex
- }))
-
- const nextTestItem = updatedTestItems[nextIndex]
- const { dueCards } = get()
- const nextCard = dueCards.find(c => c.id === nextTestItem.cardId)
-
- set({
- testItems: updatedTestItems,
- currentTestIndex: nextIndex,
- currentMode: nextTestItem.testType,
- currentCard: nextCard || null
- })
-
- console.log(`🔄 載入下一個測驗: ${nextTestItem.word} - ${nextTestItem.testType}`)
- } else {
- console.log('🎉 所有測驗完成!')
- set({ showComplete: true })
- }
- },
-
- recordTestResult: async (isCorrect, userAnswer, confidenceLevel) => {
- const { testItems, currentTestIndex } = get()
- const currentTestItem = testItems[currentTestIndex]
-
- if (!currentTestItem) return
-
- try {
- console.log('🔄 開始記錄測驗結果...', {
- flashcardId: currentTestItem.cardId,
- testType: currentTestItem.testType,
- isCorrect
- })
-
- const result = await flashcardsService.recordTestCompletion({
- flashcardId: currentTestItem.cardId,
- testType: currentTestItem.testType,
- isCorrect,
- userAnswer,
- confidenceLevel,
- responseTimeMs: 2000
- })
-
- if (result.success) {
- console.log('✅ 測驗結果已記錄')
-
- // 更新本地狀態
- const updatedTestItems = testItems.map((item, index) =>
- index === currentTestIndex
- ? { ...item, isCompleted: true, isCurrent: false }
- : item
- )
-
- set({
- testItems: updatedTestItems,
- completedTests: get().completedTests + 1
- })
-
- // 延遲進入下一個測驗
- setTimeout(() => {
- get().goToNextTest()
- }, 1500)
- } else {
- console.error('❌ 記錄測驗結果失敗:', result.error)
- set({ error: '記錄測驗結果失敗' })
- }
- } catch (error) {
- console.error('💥 記錄測驗結果異常:', error)
- set({ error: '記錄測驗結果異常' })
- }
- },
-
- skipCurrentTest: () => {
- const { testItems, currentTestIndex } = get()
- const currentTest = testItems[currentTestIndex]
-
- if (!currentTest) return
-
- // 將當前測驗移到隊列最後
- const newItems = [...testItems]
- newItems.splice(currentTestIndex, 1)
- newItems.push({ ...currentTest, isCurrent: false })
-
- // 標記新的當前項目
- if (newItems[currentTestIndex]) {
- newItems[currentTestIndex].isCurrent = true
- }
-
- set({ testItems: newItems })
- console.log(`⏭️ 跳過測驗: ${currentTest.word} - ${currentTest.testType}`)
- },
-
- updateScore: (isCorrect) => {
- set(state => ({
- score: {
- correct: isCorrect ? state.score.correct + 1 : state.score.correct,
- total: state.score.total + 1
- }
- }))
- },
-
- resetSession: () => {
- set({
- currentCard: null,
- dueCards: [],
- currentCardIndex: 0,
- currentMode: 'flip-memory',
- testItems: [],
- currentTestIndex: 0,
- completedTests: 0,
- totalTests: 0,
- score: { correct: 0, total: 0 },
- showComplete: false,
- showNoDueCards: false,
- error: null
- })
- },
-
- setError: (error) => set({ error })
- }))
-)
-
-// 工具函數
-function getTestTypeName(testType: string): string {
- const names = {
- 'flip-memory': '翻卡記憶',
- 'vocab-choice': '詞彙選擇',
- 'sentence-fill': '例句填空',
- 'sentence-reorder': '例句重組',
- 'vocab-listening': '詞彙聽力',
- 'sentence-listening': '例句聽力',
- 'sentence-speaking': '例句口說'
- }
- return names[testType as keyof typeof names] || testType
-}
\ No newline at end of file
diff --git a/frontend/store/useTestQueueStore.ts b/frontend/store/useTestQueueStore.ts
new file mode 100644
index 0000000..1dfa8be
--- /dev/null
+++ b/frontend/store/useTestQueueStore.ts
@@ -0,0 +1,195 @@
+import { create } from 'zustand'
+import { subscribeWithSelector } from 'zustand/middleware'
+import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils'
+
+// 複習模式類型
+export type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'
+
+// 測驗項目接口
+export interface TestItem {
+ id: string
+ cardId: string
+ word: string
+ testType: ReviewMode
+ testName: string
+ isCompleted: boolean
+ isCurrent: boolean
+ order: number
+}
+
+// 測驗隊列狀態接口
+interface TestQueueState {
+ // 測驗隊列狀態
+ testItems: TestItem[]
+ currentTestIndex: number
+ completedTests: number
+ totalTests: number
+ currentMode: ReviewMode
+
+ // Actions
+ setTestItems: (items: TestItem[]) => void
+ setCurrentTestIndex: (index: number) => void
+ setCompletedTests: (completed: number) => void
+ setTotalTests: (total: number) => void
+ setCurrentMode: (mode: ReviewMode) => void
+ initializeTestQueue: (dueCards: any[], completedTests: any[]) => void
+ goToNextTest: () => void
+ skipCurrentTest: () => void
+ markTestCompleted: (testIndex: number) => void
+ resetQueue: () => void
+}
+
+// 工具函數
+function getTestTypeName(testType: string): string {
+ const names = {
+ 'flip-memory': '翻卡記憶',
+ 'vocab-choice': '詞彙選擇',
+ 'sentence-fill': '例句填空',
+ 'sentence-reorder': '例句重組',
+ 'vocab-listening': '詞彙聽力',
+ 'sentence-listening': '例句聽力',
+ 'sentence-speaking': '例句口說'
+ }
+ return names[testType as keyof typeof names] || testType
+}
+
+export const useTestQueueStore = create()(
+ subscribeWithSelector((set, get) => ({
+ // 初始狀態
+ testItems: [],
+ currentTestIndex: 0,
+ completedTests: 0,
+ totalTests: 0,
+ currentMode: 'flip-memory',
+
+ // Actions
+ setTestItems: (items) => set({ testItems: items }),
+
+ setCurrentTestIndex: (index) => set({ currentTestIndex: index }),
+
+ setCompletedTests: (completed) => set({ completedTests: completed }),
+
+ setTotalTests: (total) => set({ totalTests: total }),
+
+ setCurrentMode: (mode) => set({ currentMode: mode }),
+
+ initializeTestQueue: (dueCards = [], completedTests = []) => {
+ const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'
+ let remainingTestItems: TestItem[] = []
+ let order = 1
+
+ dueCards.forEach(card => {
+ const wordCEFRLevel = card.difficultyLevel || 'A2'
+ const allTestTypes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel)
+
+ const completedTestTypes = completedTests
+ .filter(ct => ct.flashcardId === card.id)
+ .map(ct => ct.testType)
+
+ const remainingTestTypes = allTestTypes.filter(testType =>
+ !completedTestTypes.includes(testType)
+ )
+
+ console.log(`🎯 詞卡 ${card.word}: 總共${allTestTypes.length}個測驗, 已完成${completedTestTypes.length}個, 剩餘${remainingTestTypes.length}個`)
+
+ remainingTestTypes.forEach(testType => {
+ remainingTestItems.push({
+ id: `${card.id}-${testType}`,
+ cardId: card.id,
+ word: card.word,
+ testType: testType as ReviewMode,
+ testName: getTestTypeName(testType),
+ isCompleted: false,
+ isCurrent: false,
+ order
+ })
+ order++
+ })
+ })
+
+ if (remainingTestItems.length === 0) {
+ console.log('🎉 所有測驗都已完成!')
+ return
+ }
+
+ // 標記第一個測驗為當前
+ remainingTestItems[0].isCurrent = true
+
+ set({
+ testItems: remainingTestItems,
+ totalTests: remainingTestItems.length,
+ currentTestIndex: 0,
+ completedTests: 0,
+ currentMode: remainingTestItems[0].testType
+ })
+
+ console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個')
+ },
+
+ goToNextTest: () => {
+ const { testItems, currentTestIndex } = get()
+
+ if (currentTestIndex + 1 < testItems.length) {
+ const nextIndex = currentTestIndex + 1
+ const updatedTestItems = testItems.map((item, index) => ({
+ ...item,
+ isCurrent: index === nextIndex
+ }))
+
+ const nextTestItem = updatedTestItems[nextIndex]
+
+ set({
+ testItems: updatedTestItems,
+ currentTestIndex: nextIndex,
+ currentMode: nextTestItem.testType
+ })
+
+ console.log(`🔄 載入下一個測驗: ${nextTestItem.word} - ${nextTestItem.testType}`)
+ } else {
+ console.log('🎉 所有測驗完成!')
+ }
+ },
+
+ skipCurrentTest: () => {
+ const { testItems, currentTestIndex } = get()
+ const currentTest = testItems[currentTestIndex]
+
+ if (!currentTest) return
+
+ // 將當前測驗移到隊列最後
+ const newItems = [...testItems]
+ newItems.splice(currentTestIndex, 1)
+ newItems.push({ ...currentTest, isCurrent: false })
+
+ // 標記新的當前項目
+ if (newItems[currentTestIndex]) {
+ newItems[currentTestIndex].isCurrent = true
+ }
+
+ set({ testItems: newItems })
+ console.log(`⏭️ 跳過測驗: ${currentTest.word} - ${currentTest.testType}`)
+ },
+
+ markTestCompleted: (testIndex) => {
+ const { testItems } = get()
+ const updatedTestItems = testItems.map((item, index) =>
+ index === testIndex
+ ? { ...item, isCompleted: true, isCurrent: false }
+ : item
+ )
+
+ set({
+ testItems: updatedTestItems,
+ completedTests: get().completedTests + 1
+ })
+ },
+
+ resetQueue: () => set({
+ testItems: [],
+ currentTestIndex: 0,
+ completedTests: 0,
+ totalTests: 0,
+ currentMode: 'flip-memory'
+ })
+ }))
+)
\ No newline at end of file
diff --git a/frontend/store/useTestResultStore.ts b/frontend/store/useTestResultStore.ts
new file mode 100644
index 0000000..d008a64
--- /dev/null
+++ b/frontend/store/useTestResultStore.ts
@@ -0,0 +1,110 @@
+import { create } from 'zustand'
+import { subscribeWithSelector } from 'zustand/middleware'
+import { flashcardsService } from '@/lib/services/flashcards'
+import { ReviewMode } from './useTestQueueStore'
+
+// 測試結果狀態接口
+interface TestResultState {
+ // 分數狀態
+ score: { correct: number; total: number }
+
+ // 測試進行狀態
+ isRecordingResult: boolean
+ recordingError: string | null
+
+ // Actions
+ updateScore: (isCorrect: boolean) => void
+ resetScore: () => void
+ recordTestResult: (params: {
+ flashcardId: string
+ testType: ReviewMode
+ isCorrect: boolean
+ userAnswer?: string
+ confidenceLevel?: number
+ responseTimeMs?: number
+ }) => Promise
+ setRecordingResult: (isRecording: boolean) => void
+ setRecordingError: (error: string | null) => void
+
+ // 統計方法
+ getAccuracyPercentage: () => number
+ getTotalAttempts: () => number
+}
+
+export const useTestResultStore = create()(
+ subscribeWithSelector((set, get) => ({
+ // 初始狀態
+ score: { correct: 0, total: 0 },
+ isRecordingResult: false,
+ recordingError: null,
+
+ // Actions
+ updateScore: (isCorrect) => {
+ set(state => ({
+ score: {
+ correct: isCorrect ? state.score.correct + 1 : state.score.correct,
+ total: state.score.total + 1
+ }
+ }))
+ },
+
+ resetScore: () => set({
+ score: { correct: 0, total: 0 },
+ recordingError: null
+ }),
+
+ recordTestResult: async (params) => {
+ const { setRecordingResult, setRecordingError } = get()
+
+ try {
+ setRecordingResult(true)
+ setRecordingError(null)
+
+ console.log('🔄 開始記錄測驗結果...', {
+ flashcardId: params.flashcardId,
+ testType: params.testType,
+ isCorrect: params.isCorrect
+ })
+
+ const result = await flashcardsService.recordTestCompletion({
+ flashcardId: params.flashcardId,
+ testType: params.testType,
+ isCorrect: params.isCorrect,
+ userAnswer: params.userAnswer,
+ confidenceLevel: params.confidenceLevel,
+ responseTimeMs: params.responseTimeMs || 2000
+ })
+
+ if (result.success) {
+ console.log('✅ 測驗結果已記錄')
+ return true
+ } else {
+ console.error('❌ 記錄測驗結果失敗:', result.error)
+ setRecordingError('記錄測驗結果失敗')
+ return false
+ }
+ } catch (error) {
+ console.error('💥 記錄測驗結果異常:', error)
+ setRecordingError('記錄測驗結果異常')
+ return false
+ } finally {
+ setRecordingResult(false)
+ }
+ },
+
+ setRecordingResult: (isRecording) => set({ isRecordingResult: isRecording }),
+
+ setRecordingError: (error) => set({ recordingError: error }),
+
+ // 統計方法
+ getAccuracyPercentage: () => {
+ const { score } = get()
+ return score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0
+ },
+
+ getTotalAttempts: () => {
+ const { score } = get()
+ return score.total
+ }
+ }))
+)
\ No newline at end of file