diff --git a/frontend/components/review/NavigationController.tsx b/frontend/components/review/NavigationController.tsx new file mode 100644 index 0000000..6d272ae --- /dev/null +++ b/frontend/components/review/NavigationController.tsx @@ -0,0 +1,242 @@ +import React, { memo, useCallback, useMemo } from 'react' +import { useTestQueueStore } from '@/store/useTestQueueStore' + +/** + * 智能導航控制器 + * 根據PRD US-008要求實現: + * - 答題前狀態:只顯示「跳過」按鈕 + * - 答題後狀態:只顯示「繼續」按鈕 + * - 答題提交與導航按鈕完全分離 + */ + +export interface NavigationState { + status: 'unanswered' | 'answered' | 'skipped' + canSkip: boolean + canContinue: boolean + isLastTest: boolean + hasAnswered: boolean +} + +interface NavigationControllerProps { + // 當前測驗狀態 + hasAnswered: boolean + isLastTest?: boolean + + // 導航動作 + onSkip: () => void + onContinue: () => void + + // 樣式配置 + className?: string + buttonClassName?: string + + // 禁用狀態 + disabled?: boolean +} + +export const NavigationController: React.FC = memo(({ + hasAnswered, + isLastTest = false, + onSkip, + onContinue, + className = '', + buttonClassName = '', + disabled = false +}) => { + // 計算導航狀態 + const navigationState: NavigationState = useMemo(() => ({ + status: hasAnswered ? 'answered' : 'unanswered', + canSkip: !hasAnswered && !disabled, + canContinue: hasAnswered && !disabled, + isLastTest, + hasAnswered + }), [hasAnswered, isLastTest, disabled]) + + // 處理跳過動作 + const handleSkip = useCallback(() => { + if (navigationState.canSkip) { + onSkip() + } + }, [navigationState.canSkip, onSkip]) + + // 處理繼續動作 + const handleContinue = useCallback(() => { + if (navigationState.canContinue) { + onContinue() + } + }, [navigationState.canContinue, onContinue]) + + // 獲取按鈕文字 + const getButtonText = () => { + if (navigationState.status === 'answered') { + return isLastTest ? '完成學習' : '繼續' + } + return '跳過' + } + + // 獲取按鈕樣式 + const getButtonStyles = () => { + const baseStyles = `px-6 py-3 rounded-lg font-medium transition-all ${buttonClassName}` + + if (navigationState.status === 'answered') { + // 答題後 - 繼續按鈕(主要動作) + return `${baseStyles} bg-blue-600 text-white hover:bg-blue-700 ${ + disabled ? 'opacity-50 cursor-not-allowed' : 'shadow-lg hover:shadow-xl' + }` + } else { + // 答題前 - 跳過按鈕(次要動作) + return `${baseStyles} bg-gray-200 text-gray-700 hover:bg-gray-300 ${ + disabled ? 'opacity-50 cursor-not-allowed' : '' + }` + } + } + + // 獲取按鈕圖示 + const getButtonIcon = () => { + if (navigationState.status === 'answered') { + return isLastTest ? '🎉' : '➡️' + } + return '⏭️' + } + + return ( +
+ {/* 狀態驅動的單一按鈕 */} +
+ +
+ + {/* 狀態提示(開發模式可見) */} + {process.env.NODE_ENV === 'development' && ( +
+ 狀態: {navigationState.status} | + 可跳過: {navigationState.canSkip ? '是' : '否'} | + 可繼續: {navigationState.canContinue ? '是' : '否'} +
+ )} +
+ ) +}) + +NavigationController.displayName = 'NavigationController' + +/** + * 整合測試隊列的高階導航控制器 + * 自動處理隊列狀態更新和測驗導航 + */ +interface SmartNavigationControllerProps { + hasAnswered: boolean + className?: string + buttonClassName?: string + disabled?: boolean + onSkipCallback?: () => void + onContinueCallback?: () => void +} + +export const SmartNavigationController: React.FC = memo(({ + hasAnswered, + className, + buttonClassName, + disabled, + onSkipCallback, + onContinueCallback +}) => { + const { + currentTestIndex, + testItems, + skipCurrentTest, + goToNextTest + } = useTestQueueStore() + + // 判斷是否為最後一個測驗 + const isLastTest = useMemo(() => { + const remainingTests = testItems.filter(item => !item.isCompleted) + return remainingTests.length <= 1 + }, [testItems]) + + // 處理跳過 + const handleSkip = useCallback(() => { + skipCurrentTest() + onSkipCallback?.() + }, [skipCurrentTest, onSkipCallback]) + + // 處理繼續 + const handleContinue = useCallback(() => { + goToNextTest() + onContinueCallback?.() + }, [goToNextTest, onContinueCallback]) + + return ( + + ) +}) + +SmartNavigationController.displayName = 'SmartNavigationController' + +/** + * 導航狀態判斷工具函數 + */ +export const getNavigationState = ( + hasAnswered: boolean, + isSkipped: boolean = false, + disabled: boolean = false +): NavigationState => { + let status: NavigationState['status'] = 'unanswered' + + if (isSkipped) { + status = 'skipped' + } else if (hasAnswered) { + status = 'answered' + } + + return { + status, + canSkip: !hasAnswered && !isSkipped && !disabled, + canContinue: hasAnswered && !disabled, + isLastTest: false, + hasAnswered + } +} + +/** + * 導航控制器的設定選項 + */ +export interface NavigationConfig { + showSkipButton: boolean + showContinueButton: boolean + skipButtonText: string + continueButtonText: string + completeButtonText: string + skipButtonIcon: string + continueButtonIcon: string + completeButtonIcon: string +} + +export const defaultNavigationConfig: NavigationConfig = { + showSkipButton: true, + showContinueButton: true, + skipButtonText: '跳過', + continueButtonText: '繼續', + completeButtonText: '完成學習', + skipButtonIcon: '⏭️', + continueButtonIcon: '➡️', + completeButtonIcon: '🎉' +} \ No newline at end of file diff --git a/frontend/components/review/TaskListModal.tsx b/frontend/components/review/TaskListModal.tsx index 2a30e85..1ae9cea 100644 --- a/frontend/components/review/TaskListModal.tsx +++ b/frontend/components/review/TaskListModal.tsx @@ -1,13 +1,6 @@ -interface TestItem { - id: string - cardId: string - word: string - testType: string - testName: string - isCompleted: boolean - isCurrent: boolean - order: number -} +import React from 'react' +import { TestItem } from '@/store/useTestQueueStore' +import { TestStatusIndicator, TestStats, TestProgressBar, TestStatusList } from './TestStatusIndicator' interface TaskListModalProps { isOpen: boolean @@ -26,10 +19,14 @@ export const TaskListModal: React.FC = ({ }) => { if (!isOpen) return null - const progressPercentage = totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0 - const completedCount = testItems.filter(item => item.isCompleted).length - const currentCount = testItems.filter(item => item.isCurrent).length - const pendingCount = testItems.filter(item => !item.isCompleted && !item.isCurrent).length + // 使用新的統計邏輯 + const stats = { + total: totalTests, + completed: testItems.filter(item => item.isCompleted).length, + skipped: testItems.filter(item => item.isSkipped && !item.isCompleted).length, + incorrect: testItems.filter(item => item.isIncorrect && !item.isCompleted).length, + remaining: testItems.filter(item => !item.isCompleted).length + } return (
@@ -49,62 +46,22 @@ export const TaskListModal: React.FC = ({ {/* Content */}
- {/* 進度統計 */} + {/* 智能進度統計 */}
-
- - 測驗進度: {completedTests} / {totalTests} ({progressPercentage}%) - -
- ✅ 已完成: {completedCount} - ⏳ 進行中: {currentCount} - ⚪ 待完成: {pendingCount} -
-
-
-
+
+

學習進度

+
+
- {/* 測驗清單 */} + {/* 智能測驗狀態列表 */}
{testItems.length > 0 ? ( -
- {testItems.map((item) => ( -
- {/* 狀態圖標 */} - - {item.isCompleted ? '✅' : item.isCurrent ? '⏳' : '⚪'} - - - {/* 測驗資訊 */} -
-
- {item.order}. {item.word} - {item.testName} -
-
- {item.isCompleted ? '已完成' : - item.isCurrent ? '進行中' : '待完成'} -
-
-
- ))} -
+ ) : (
📚
@@ -112,6 +69,19 @@ export const TaskListModal: React.FC = ({
)}
+ + {/* 隊列優先級說明 */} + {stats.total > 0 && ( +
+

智能隊列說明

+
+
未完成測驗:優先處理,保持學習動機
+
答錯測驗:移到隊列後方重複練習
+
跳過測驗:暫時跳過,稍後自動回歸
+
已完成測驗:永久從當日清單移除
+
+
+ )}
{/* Footer */} diff --git a/frontend/components/review/TestStatusIndicator.tsx b/frontend/components/review/TestStatusIndicator.tsx new file mode 100644 index 0000000..987c3d4 --- /dev/null +++ b/frontend/components/review/TestStatusIndicator.tsx @@ -0,0 +1,323 @@ +import React, { memo } from 'react' +import { TestItem } from '@/store/useTestQueueStore' + +/** + * 測驗狀態指示器 + * 根據PRD US-009要求實現視覺化狀態顯示: + * ✅ 已答對(綠色)- 已從當日清單移除 + * ❌ 已答錯(紅色)- 移到隊列最後 + * ⏭️ 已跳過(黃色)- 移到隊列最後 + * ⚪ 未完成(灰色)- 優先處理 + */ + +export interface TestStatusIndicatorProps { + test: TestItem + showDetails?: boolean + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export const TestStatusIndicator: React.FC = memo(({ + test, + showDetails = false, + size = 'md', + className = '' +}) => { + // 獲取狀態配置 + const getStatusConfig = () => { + if (test.isCompleted) { + return { + icon: '✅', + label: '已答對', + bgColor: 'bg-green-100', + textColor: 'text-green-800', + borderColor: 'border-green-300', + description: '已從當日清單移除' + } + } + + if (test.isIncorrect) { + return { + icon: '❌', + label: '已答錯', + bgColor: 'bg-red-100', + textColor: 'text-red-800', + borderColor: 'border-red-300', + description: '移到隊列最後重複練習' + } + } + + if (test.isSkipped) { + return { + icon: '⏭️', + label: '已跳過', + bgColor: 'bg-yellow-100', + textColor: 'text-yellow-800', + borderColor: 'border-yellow-300', + description: '移到隊列最後稍後處理' + } + } + + if (test.isCurrent) { + return { + icon: '▶️', + label: '進行中', + bgColor: 'bg-blue-100', + textColor: 'text-blue-800', + borderColor: 'border-blue-300', + description: '當前正在進行的測驗' + } + } + + return { + icon: '⚪', + label: '未完成', + bgColor: 'bg-gray-100', + textColor: 'text-gray-800', + borderColor: 'border-gray-300', + description: '優先處理的測驗' + } + } + + // 獲取尺寸樣式 + const getSizeClasses = () => { + switch (size) { + case 'sm': + return { + container: 'px-2 py-1 text-xs', + icon: 'text-sm', + text: 'text-xs' + } + case 'lg': + return { + container: 'px-4 py-3 text-base', + icon: 'text-lg', + text: 'text-base' + } + default: // md + return { + container: 'px-3 py-2 text-sm', + icon: 'text-base', + text: 'text-sm' + } + } + } + + const statusConfig = getStatusConfig() + const sizeClasses = getSizeClasses() + + return ( +
+ {/* 狀態圖示 */} + + {statusConfig.icon} + + + {/* 狀態文字 */} + + {statusConfig.label} + + + {/* 優先級顯示(開發模式) */} + {process.env.NODE_ENV === 'development' && ( + + P{test.priority} + + )} + + {/* 詳細資訊 */} + {showDetails && ( +
+ ({test.testName}) +
+ )} +
+ ) +}) + +TestStatusIndicator.displayName = 'TestStatusIndicator' + +/** + * 測驗狀態統計元件 + */ +interface TestStatsProps { + stats: { + total: number + completed: number + skipped: number + incorrect: number + remaining: number + } + className?: string +} + +export const TestStats: React.FC = memo(({ + stats, + className = '' +}) => { + const items = [ + { key: 'completed', label: '已完成', count: stats.completed, color: 'text-green-600', icon: '✅' }, + { key: 'incorrect', label: '需重做', count: stats.incorrect, color: 'text-red-600', icon: '❌' }, + { key: 'skipped', label: '已跳過', count: stats.skipped, color: 'text-yellow-600', icon: '⏭️' }, + { key: 'remaining', label: '剩餘', count: stats.remaining, color: 'text-gray-600', icon: '⚪' } + ] + + return ( +
+ {items.map(({ key, label, count, color, icon }) => ( +
+ {icon} + {count} + {label} +
+ ))} +
+ ) +}) + +TestStats.displayName = 'TestStats' + +/** + * 測驗狀態進度條 + */ +interface TestProgressBarProps { + stats: { + total: number + completed: number + skipped: number + incorrect: number + remaining: number + } + className?: string +} + +export const TestProgressBar: React.FC = memo(({ + stats, + className = '' +}) => { + const { total, completed, skipped, incorrect, remaining } = stats + + if (total === 0) return null + + const completedPercent = (completed / total) * 100 + const incorrectPercent = (incorrect / total) * 100 + const skippedPercent = (skipped / total) * 100 + + return ( +
+ {/* 進度條 */} +
+
+ {/* 已完成 */} + {completed > 0 && ( +
+ )} + {/* 答錯 */} + {incorrect > 0 && ( +
+ )} + {/* 跳過 */} + {skipped > 0 && ( +
+ )} +
+
+ + {/* 數據統計 */} +
+ 已完成: {completed}/{total} + {((completed / total) * 100).toFixed(0)}% +
+
+ ) +}) + +TestProgressBar.displayName = 'TestProgressBar' + +/** + * 測驗狀態列表 + */ +interface TestStatusListProps { + tests: TestItem[] + groupByCard?: boolean + className?: string +} + +export const TestStatusList: React.FC = memo(({ + tests, + groupByCard = true, + className = '' +}) => { + if (groupByCard) { + // 按詞卡分組 + const groupedTests = tests.reduce((acc, test) => { + if (!acc[test.cardId]) { + acc[test.cardId] = { + word: test.word, + tests: [] + } + } + acc[test.cardId].tests.push(test) + return acc + }, {} as Record) + + return ( +
+ {Object.entries(groupedTests).map(([cardId, { word, tests }]) => ( +
+

{word}

+
+ {tests.map(test => ( + + ))} +
+
+ ))} +
+ ) + } + + // 平面列表 + return ( +
+ {tests.map(test => ( +
+
+ {test.word} + {test.testName} +
+ +
+ ))} +
+ ) +}) + +TestStatusList.displayName = 'TestStatusList' \ No newline at end of file diff --git a/frontend/components/review/shared/AnswerActions.tsx b/frontend/components/review/shared/AnswerActions.tsx new file mode 100644 index 0000000..b0a135b --- /dev/null +++ b/frontend/components/review/shared/AnswerActions.tsx @@ -0,0 +1,296 @@ +import React, { memo } from 'react' + +/** + * 答題動作元件集合 + * 提供標準化的使用者互動介面,支援各種答題類型 + */ + +// 選擇題選項元件 +interface ChoiceOptionProps { + option: string + index: number + isSelected?: boolean + isCorrect?: boolean + isIncorrect?: boolean + showResult?: boolean + onSelect: (option: string) => void + disabled?: boolean +} + +export const ChoiceOption: React.FC = memo(({ + option, + index, + isSelected = false, + isCorrect = false, + isIncorrect = false, + showResult = false, + onSelect, + disabled = false +}) => { + const getOptionStyles = () => { + if (showResult) { + if (isCorrect) { + return 'border-green-500 bg-green-50 text-green-700' + } + if (isIncorrect && isSelected) { + return 'border-red-500 bg-red-50 text-red-700' + } + return 'border-gray-200 bg-gray-50 text-gray-500' + } + + if (isSelected) { + return 'border-blue-500 bg-blue-50 text-blue-700' + } + + return 'border-gray-200 hover:border-blue-300 hover:bg-blue-50' + } + + return ( + + ) +}) + +ChoiceOption.displayName = 'ChoiceOption' + +// 選擇題選項網格 +interface ChoiceGridProps { + options: string[] + selectedOption?: string | null + correctAnswer?: string + showResult?: boolean + onSelect: (option: string) => void + disabled?: boolean + className?: string +} + +export const ChoiceGrid: React.FC = memo(({ + options, + selectedOption, + correctAnswer, + showResult = false, + onSelect, + disabled = false, + className = '' +}) => { + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ) +}) + +ChoiceGrid.displayName = 'ChoiceGrid' + +// 文字輸入元件 +interface TextInputProps { + value: string + onChange: (value: string) => void + onSubmit: (value: string) => void + placeholder?: string + disabled?: boolean + showResult?: boolean + isCorrect?: boolean + correctAnswer?: string +} + +export const TextInput: React.FC = memo(({ + value, + onChange, + onSubmit, + placeholder = '請輸入答案...', + disabled = false, + showResult = false, + isCorrect = false, + correctAnswer = '' +}) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !disabled && !showResult && value.trim()) { + onSubmit(value.trim()) + } + } + + const handleSubmit = () => { + if (!disabled && !showResult && value.trim()) { + onSubmit(value.trim()) + } + } + + const getInputStyles = () => { + if (showResult) { + return isCorrect + ? 'border-green-500 bg-green-50' + : 'border-red-500 bg-red-50' + } + return 'border-gray-300 focus:border-blue-500 focus:ring-blue-500' + } + + return ( +
+
+ onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled || showResult} + className={`w-full p-3 border rounded-lg text-lg ${getInputStyles()}`} + autoFocus + /> + {!showResult && ( + + )} +
+ + {showResult && !isCorrect && correctAnswer && ( +
+ 正確答案:{correctAnswer} +
+ )} +
+ ) +}) + +TextInput.displayName = 'TextInput' + +// 信心度按鈕組 +interface ConfidenceLevelProps { + selectedLevel?: number | null + onSelect: (level: number) => void + disabled?: boolean +} + +export const ConfidenceLevel: React.FC = memo(({ + selectedLevel, + onSelect, + disabled = false +}) => { + const levels = [ + { level: 1, label: '完全不熟', color: 'bg-red-500', hoverColor: 'hover:bg-red-600' }, + { level: 2, label: '有點印象', color: 'bg-orange-500', hoverColor: 'hover:bg-orange-600' }, + { level: 3, label: '還算熟悉', color: 'bg-yellow-500', hoverColor: 'hover:bg-yellow-600' }, + { level: 4, label: '很熟悉', color: 'bg-blue-500', hoverColor: 'hover:bg-blue-600' }, + { level: 5, label: '完全掌握', color: 'bg-green-500', hoverColor: 'hover:bg-green-600' } + ] + + return ( +
+

請評估你對這個單字的熟悉程度:

+
+ {levels.map(({ level, label, color, hoverColor }) => ( + + ))} +
+
+ ) +}) + +ConfidenceLevel.displayName = 'ConfidenceLevel' + +// 錄音控制元件 +interface RecordingControlProps { + isRecording: boolean + hasRecording: boolean + onStartRecording: () => void + onStopRecording: () => void + onPlayback: () => void + onSubmit: () => void + disabled?: boolean +} + +export const RecordingControl: React.FC = memo(({ + isRecording, + hasRecording, + onStartRecording, + onStopRecording, + onPlayback, + onSubmit, + disabled = false +}) => { + return ( +
+ {/* 錄音按鈕 */} + + + {/* 狀態文字 */} +
+ {isRecording ? ( +

錄音中... 點擊停止

+ ) : hasRecording ? ( +

錄音完成

+ ) : ( +

點擊開始錄音

+ )} +
+ + {/* 控制按鈕 */} + {hasRecording && !isRecording && ( +
+ + +
+ )} +
+ ) +}) + +RecordingControl.displayName = 'RecordingControl' \ No newline at end of file diff --git a/frontend/components/review/shared/BaseTestComponent.tsx b/frontend/components/review/shared/BaseTestComponent.tsx new file mode 100644 index 0000000..31d776c --- /dev/null +++ b/frontend/components/review/shared/BaseTestComponent.tsx @@ -0,0 +1,136 @@ +import React, { useState, useCallback, ReactNode } from 'react' +import { ErrorReportButton, TestHeader } from '@/components/review/shared' +import { BaseReviewProps } from '@/types/review' + +/** + * 基礎測驗元件 - 提供所有測驗元件的共用功能 + * 包含:標題、錯誤回報、測驗狀態管理、統一布局結構 + */ + +export interface BaseTestComponentProps extends BaseReviewProps { + testTitle: string + instructions?: string + children: ReactNode + showResult?: boolean + resultContent?: ReactNode + className?: string +} + +interface TestState { + hasAnswered: boolean + userAnswer: string | null + showResult: boolean +} + +export const BaseTestComponent: React.FC = ({ + cardData, + testTitle, + instructions, + children, + showResult = false, + resultContent, + onReportError, + disabled = false, + className = '' +}) => { + const [testState, setTestState] = useState({ + hasAnswered: false, + userAnswer: null, + showResult: false + }) + + // 更新測驗狀態 + const updateTestState = useCallback((updates: Partial) => { + setTestState(prev => ({ ...prev, ...updates })) + }, []) + + // 提供給子元件的狀態和方法 + const testContext = { + testState, + updateTestState, + cardData, + disabled: disabled || testState.showResult + } + + return ( +
+ {/* 錯誤回報按鈕 */} +
+ +
+ + {/* 主要測驗容器 */} +
+ {/* 測驗標題 */} + + + {/* 說明文字 */} + {instructions && ( +

+ {instructions} +

+ )} + + {/* 測驗內容區域 */} +
+ {React.cloneElement(children as React.ReactElement, { testContext })} +
+ + {/* 結果顯示區域 */} + {(showResult || testState.showResult) && resultContent && ( +
+ {resultContent} +
+ )} +
+
+ ) +} + +/** + * Hook for managing test answer state + * 提供測驗答題狀態管理的標準化邏輯 + */ +export const useTestAnswer = (onAnswer: (answer: string) => void) => { + const [selectedAnswer, setSelectedAnswer] = useState(null) + const [showResult, setShowResult] = useState(false) + + const handleAnswer = useCallback((answer: string) => { + if (showResult) return + + setSelectedAnswer(answer) + setShowResult(true) + onAnswer(answer) + }, [showResult, onAnswer]) + + const resetAnswer = useCallback(() => { + setSelectedAnswer(null) + setShowResult(false) + }, []) + + return { + selectedAnswer, + showResult, + handleAnswer, + resetAnswer + } +} + +/** + * Navigation integration types + * 為後續的導航系統整合做準備 + */ +export interface TestNavigationState { + status: 'unanswered' | 'answered' | 'skipped' + canSkip: boolean + canContinue: boolean +} + +export interface TestNavigationProps { + navigationState: TestNavigationState + onSkip?: () => void + onContinue?: () => void +} \ No newline at end of file diff --git a/frontend/components/review/shared/TestContainer.tsx b/frontend/components/review/shared/TestContainer.tsx new file mode 100644 index 0000000..29e5179 --- /dev/null +++ b/frontend/components/review/shared/TestContainer.tsx @@ -0,0 +1,234 @@ +import React, { ReactNode } from 'react' +import { BaseTestComponent, BaseTestComponentProps } from './BaseTestComponent' + +/** + * 測驗容器元件 - 提供統一的測驗布局結構 + * 整合導航控制和進度顯示 + */ + +export interface TestContainerProps extends Omit { + // 測驗內容區域 + contentArea: ReactNode + + // 答題區域 + answerArea?: ReactNode + + // 結果顯示區域 + resultArea?: ReactNode + + // 導航控制(預留給未來的NavigationController) + navigationArea?: ReactNode + + // 布局配置 + layout?: 'standard' | 'split' | 'fullscreen' + + // 自定義樣式 + contentClassName?: string + answerClassName?: string + resultClassName?: string +} + +export const TestContainer: React.FC = ({ + contentArea, + answerArea, + resultArea, + navigationArea, + layout = 'standard', + contentClassName = '', + answerClassName = '', + resultClassName = '', + ...baseProps +}) => { + const getLayoutClasses = () => { + switch (layout) { + case 'split': + return 'lg:grid lg:grid-cols-2 lg:gap-8' + case 'fullscreen': + return 'min-h-screen flex flex-col' + default: + return 'space-y-6' + } + } + + const renderContent = () => ( +
+ {/* 內容展示區域 */} +
+ {contentArea} +
+ + {/* 答題互動區域 */} + {answerArea && ( +
+ {answerArea} +
+ )} + + {/* 結果展示區域 */} + {resultArea && ( +
+ {resultArea} +
+ )} + + {/* 導航控制區域 */} + {navigationArea && ( +
+ {navigationArea} +
+ )} +
+ ) + + return ( + + {renderContent()} + + ) +} + +/** + * 專用於不同測驗類型的容器變體 + */ + +// 選擇題容器 +export interface ChoiceTestContainerProps extends Omit { + questionArea: ReactNode + optionsArea: ReactNode +} + +export const ChoiceTestContainer: React.FC = ({ + questionArea, + optionsArea, + resultArea, + navigationArea, + ...props +}) => { + return ( + +
+ {questionArea} +
+
+ {optionsArea} +
+
+ } + resultArea={resultArea} + navigationArea={navigationArea} + /> + ) +} + +// 填空題容器 +export interface FillTestContainerProps extends Omit { + sentenceArea: ReactNode + inputArea: ReactNode +} + +export const FillTestContainer: React.FC = ({ + sentenceArea, + inputArea, + resultArea, + navigationArea, + ...props +}) => { + return ( + + ) +} + +// 聽力測驗容器 +export interface ListeningTestContainerProps extends Omit { + audioArea: ReactNode + questionArea: ReactNode + answerArea: ReactNode +} + +export const ListeningTestContainer: React.FC = ({ + audioArea, + questionArea, + answerArea, + resultArea, + navigationArea, + ...props +}) => { + return ( + +
+ {audioArea} +
+
+ {questionArea} +
+
+ } + answerArea={answerArea} + resultArea={resultArea} + navigationArea={navigationArea} + /> + ) +} + +// 口說測驗容器 +export interface SpeakingTestContainerProps extends Omit { + promptArea: ReactNode + recordingArea: ReactNode +} + +export const SpeakingTestContainer: React.FC = ({ + promptArea, + recordingArea, + resultArea, + navigationArea, + ...props +}) => { + return ( + + ) +} + +// 翻卡測驗容器 +export interface FlipTestContainerProps extends Omit { + cardArea: ReactNode + confidenceArea: ReactNode +} + +export const FlipTestContainer: React.FC = ({ + cardArea, + confidenceArea, + navigationArea, + ...props +}) => { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/index.ts b/frontend/components/review/shared/index.ts index 9a02bca..bc3efe8 100644 --- a/frontend/components/review/shared/index.ts +++ b/frontend/components/review/shared/index.ts @@ -4,4 +4,36 @@ export { SentenceInput } from './SentenceInput' export { TestResultDisplay } from './TestResultDisplay' export { HintPanel } from './HintPanel' export { ConfidenceButtons } from './ConfidenceButtons' -export { TestHeader } from './TestHeader' \ No newline at end of file +export { TestHeader } from './TestHeader' + +// 新架構基礎組件 +export { + BaseTestComponent, + useTestAnswer, + type BaseTestComponentProps, + type TestNavigationState, + type TestNavigationProps +} from './BaseTestComponent' + +export { + ChoiceOption, + ChoiceGrid, + TextInput, + ConfidenceLevel, + RecordingControl +} from './AnswerActions' + +export { + TestContainer, + ChoiceTestContainer, + FillTestContainer, + ListeningTestContainer, + SpeakingTestContainer, + FlipTestContainer, + type TestContainerProps, + type ChoiceTestContainerProps, + type FillTestContainerProps, + type ListeningTestContainerProps, + type SpeakingTestContainerProps, + type FlipTestContainerProps +} from './TestContainer' \ No newline at end of file diff --git a/frontend/store/useTestQueueStore.ts b/frontend/store/useTestQueueStore.ts index 1dfa8be..242a0a0 100644 --- a/frontend/store/useTestQueueStore.ts +++ b/frontend/store/useTestQueueStore.ts @@ -15,6 +15,12 @@ export interface TestItem { isCompleted: boolean isCurrent: boolean order: number + // 新增狀態欄位 + isSkipped: boolean + isIncorrect: boolean + priority: number + skippedAt?: number + lastAttemptAt?: number } // 測驗隊列狀態接口 @@ -26,6 +32,10 @@ interface TestQueueState { totalTests: number currentMode: ReviewMode + // 新增跳過隊列管理 + skippedTests: Set + priorityQueue: TestItem[] + // Actions setTestItems: (items: TestItem[]) => void setCurrentTestIndex: (index: number) => void @@ -36,7 +46,20 @@ interface TestQueueState { goToNextTest: () => void skipCurrentTest: () => void markTestCompleted: (testIndex: number) => void + markTestIncorrect: (testIndex: number) => void resetQueue: () => void + + // 新增智能隊列管理方法 + reorderByPriority: () => void + getNextPriorityTest: () => TestItem | null + isAllTestsCompleted: () => boolean + getTestStats: () => { + total: number + completed: number + skipped: number + incorrect: number + remaining: number + } } // 工具函數 @@ -53,6 +76,58 @@ function getTestTypeName(testType: string): string { return names[testType as keyof typeof names] || testType } +// 優先級演算法 +function calculateTestPriority(test: TestItem): number { + const now = Date.now() + + // 基礎優先級分數 + let priority = 0 + + // 1. 未嘗試的測驗有最高優先級 + if (!test.isCompleted && !test.isSkipped && !test.isIncorrect) { + priority = 100 + } + // 2. 答錯的測驗需要重複練習 + else if (test.isIncorrect) { + priority = 20 + // 如果是最近答錯的,稍微降低優先級避免連續重複 + if (test.lastAttemptAt && (now - test.lastAttemptAt) < 60000) { // 1分鐘內 + priority = 15 + } + } + // 3. 跳過的測驗排在最後 + else if (test.isSkipped) { + priority = 10 + // 跳過時間越久,優先級稍微提高 + if (test.skippedAt) { + const timeSinceSkipped = now - test.skippedAt + const hours = timeSinceSkipped / (1000 * 60 * 60) + priority += Math.min(hours * 0.5, 5) // 最多增加5分 + } + } + + return priority +} + +function reorderTestItems(testItems: TestItem[]): TestItem[] { + // 更新每個測驗的優先級 + const itemsWithPriority = testItems.map(item => ({ + ...item, + priority: calculateTestPriority(item) + })) + + // 按優先級排序 + return itemsWithPriority.sort((a, b) => { + // 1. 優先級分數高的在前 + if (b.priority !== a.priority) { + return b.priority - a.priority + } + + // 2. 相同優先級時,按原始順序 + return a.order - b.order + }) +} + export const useTestQueueStore = create()( subscribeWithSelector((set, get) => ({ // 初始狀態 @@ -61,6 +136,8 @@ export const useTestQueueStore = create()( completedTests: 0, totalTests: 0, currentMode: 'flip-memory', + skippedTests: new Set(), + priorityQueue: [], // Actions setTestItems: (items) => set({ testItems: items }), @@ -101,7 +178,11 @@ export const useTestQueueStore = create()( testName: getTestTypeName(testType), isCompleted: false, isCurrent: false, - order + order, + // 新增狀態欄位 + isSkipped: false, + isIncorrect: false, + priority: 100 // 新測驗預設最高優先級 }) order++ }) @@ -120,7 +201,9 @@ export const useTestQueueStore = create()( totalTests: remainingTestItems.length, currentTestIndex: 0, completedTests: 0, - currentMode: remainingTestItems[0].testType + currentMode: remainingTestItems[0].testType, + skippedTests: new Set(), + priorityQueue: reorderTestItems(remainingTestItems) }) console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個') @@ -151,45 +234,161 @@ export const useTestQueueStore = create()( }, skipCurrentTest: () => { - const { testItems, currentTestIndex } = get() + const { testItems, currentTestIndex, skippedTests } = 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 + // 標記測驗為跳過狀態 + const updatedTest = { + ...currentTest, + isSkipped: true, + skippedAt: Date.now(), + isCurrent: false, + priority: calculateTestPriority({ ...currentTest, isSkipped: true }) } - set({ testItems: newItems }) + // 更新跳過測驗集合 + const newSkippedTests = new Set(skippedTests) + newSkippedTests.add(currentTest.id) + + // 重新排序隊列 + const updatedItems = testItems.map((item, index) => + index === currentTestIndex ? updatedTest : item + ) + const reorderedItems = reorderTestItems(updatedItems) + + // 找到下一個高優先級測驗 + const nextTestIndex = reorderedItems.findIndex(item => + !item.isCompleted && item.id !== currentTest.id + ) + + // 標記新的當前測驗 + if (nextTestIndex >= 0) { + reorderedItems[nextTestIndex].isCurrent = true + } + + set({ + testItems: reorderedItems, + currentTestIndex: Math.max(0, nextTestIndex), + skippedTests: newSkippedTests, + priorityQueue: reorderedItems, + currentMode: nextTestIndex >= 0 ? reorderedItems[nextTestIndex].testType : 'flip-memory' + }) + console.log(`⏭️ 跳過測驗: ${currentTest.word} - ${currentTest.testType}`) }, markTestCompleted: (testIndex) => { - const { testItems } = get() + const { testItems, skippedTests } = get() + const completedTest = testItems[testIndex] + const updatedTestItems = testItems.map((item, index) => index === testIndex - ? { ...item, isCompleted: true, isCurrent: false } + ? { ...item, isCompleted: true, isCurrent: false, priority: 0 } : item ) + // 從跳過列表中移除(如果存在) + const newSkippedTests = new Set(skippedTests) + if (completedTest) { + newSkippedTests.delete(completedTest.id) + } + set({ testItems: updatedTestItems, - completedTests: get().completedTests + 1 + completedTests: get().completedTests + 1, + skippedTests: newSkippedTests, + priorityQueue: reorderTestItems(updatedTestItems) }) }, + markTestIncorrect: (testIndex) => { + const { testItems } = get() + const incorrectTest = testItems[testIndex] + + if (!incorrectTest) return + + const updatedTest = { + ...incorrectTest, + isIncorrect: true, + lastAttemptAt: Date.now(), + isCurrent: false, + priority: calculateTestPriority({ ...incorrectTest, isIncorrect: true }) + } + + const updatedItems = testItems.map((item, index) => + index === testIndex ? updatedTest : item + ) + const reorderedItems = reorderTestItems(updatedItems) + + // 找到下一個測驗 + const nextTestIndex = reorderedItems.findIndex(item => + !item.isCompleted && item.id !== incorrectTest.id + ) + + if (nextTestIndex >= 0) { + reorderedItems[nextTestIndex].isCurrent = true + } + + set({ + testItems: reorderedItems, + currentTestIndex: Math.max(0, nextTestIndex), + priorityQueue: reorderedItems, + currentMode: nextTestIndex >= 0 ? reorderedItems[nextTestIndex].testType : 'flip-memory' + }) + + console.log(`❌ 測驗答錯: ${incorrectTest.word} - ${incorrectTest.testType}`) + }, + resetQueue: () => set({ testItems: [], currentTestIndex: 0, completedTests: 0, totalTests: 0, - currentMode: 'flip-memory' - }) + currentMode: 'flip-memory', + skippedTests: new Set(), + priorityQueue: [] + }), + + // 新增智能隊列管理方法 + reorderByPriority: () => { + const { testItems } = get() + const reorderedItems = reorderTestItems(testItems) + + // 找到當前應該顯示的測驗 + const currentTestIndex = reorderedItems.findIndex(item => item.isCurrent) + const nextAvailableIndex = reorderedItems.findIndex(item => !item.isCompleted) + + set({ + testItems: reorderedItems, + priorityQueue: reorderedItems, + currentTestIndex: Math.max(0, currentTestIndex >= 0 ? currentTestIndex : nextAvailableIndex) + }) + }, + + getNextPriorityTest: () => { + const { testItems } = get() + return testItems.find(item => !item.isCompleted && !item.isCurrent) || null + }, + + isAllTestsCompleted: () => { + const { testItems } = get() + return testItems.length > 0 && testItems.every(item => item.isCompleted) + }, + + getTestStats: () => { + const { testItems } = get() + + const stats = { + total: testItems.length, + completed: testItems.filter(item => item.isCompleted).length, + skipped: testItems.filter(item => item.isSkipped && !item.isCompleted).length, + incorrect: testItems.filter(item => item.isIncorrect && !item.isCompleted).length, + remaining: testItems.filter(item => !item.isCompleted).length + } + + return stats + } })) ) \ No newline at end of file diff --git a/智能複習系統開發成果報告.md b/智能複習系統開發成果報告.md new file mode 100644 index 0000000..5915cbf --- /dev/null +++ b/智能複習系統開發成果報告.md @@ -0,0 +1,245 @@ +# 智能複習系統開發成果報告 + +**開發時間**: 2025-09-28 +**基於**: 智能複習系統開發計劃.md +**狀態**: 第一階段完成,第二階段核心功能實現 + +## 一、開發成果概述 + +### ✅ 已完成功能 + +#### 1. 基礎架構重構(第一階段) +- ✅ **BaseTestComponent** - 所有測驗元件的統一基礎類別 +- ✅ **AnswerActions** - 標準化答題動作元件集合 +- ✅ **TestContainer** - 統一測驗布局容器 +- ✅ **共用元件整合** - 更新 shared/index.ts 匯出 + +#### 2. 智能導航系統(第二階段核心) +- ✅ **NavigationController** - 狀態驅動的導航控制器 +- ✅ **SmartNavigationController** - 整合測試隊列的高階控制器 +- ✅ **導航狀態判斷邏輯** - 實現 US-008 的導航需求 + +#### 3. 跳過隊列管理系統(第三階段核心) +- ✅ **擴充 TestQueueStore** - 新增跳過狀態和優先級管理 +- ✅ **智能優先級演算法** - 實現動態測驗排序 +- ✅ **隊列管理方法** - 跳過、答錯、完成的狀態處理 + +#### 4. 狀態視覺化系統(第四階段) +- ✅ **TestStatusIndicator** - 測驗狀態指示器元件 +- ✅ **TestStats & TestProgressBar** - 統計和進度條元件 +- ✅ **TaskListModal 更新** - 整合新視覺化元件 + +## 二、核心實現細節 + +### 2.1 智能導航系統 + +**實現**: `NavigationController.tsx` + +**核心特色**: +- 狀態驅動:根據答題狀態自動切換按鈕 +- 答題前:只顯示「跳過」按鈕 +- 答題後:只顯示「繼續」按鈕 +- 答題與導航完全分離 + +```typescript +interface NavigationState { + status: 'unanswered' | 'answered' | 'skipped' + canSkip: boolean + canContinue: boolean + isLastTest: boolean + hasAnswered: boolean +} +``` + +### 2.2 優先級演算法 + +**實現**: `useTestQueueStore.ts` 中的 `calculateTestPriority` + +**優先級規則**: +1. **未嘗試測驗**: 100分(最高優先級) +2. **答錯測驗**: 20分(需要重複練習) +3. **跳過測驗**: 10分(最低優先級) +4. **時間因子**: 避免連續重複和跳過時間衰減 + +```typescript +function calculateTestPriority(test: TestItem): number { + if (!test.isCompleted && !test.isSkipped && !test.isIncorrect) { + return 100 // 未嘗試 + } + if (test.isIncorrect) { + return 20 // 答錯 + } + if (test.isSkipped) { + return 10 // 跳過 + } + return 0 +} +``` + +### 2.3 測驗狀態管理 + +**新增 TestItem 欄位**: +```typescript +interface TestItem { + // 原有欄位... + isSkipped: boolean + isIncorrect: boolean + priority: number + skippedAt?: number + lastAttemptAt?: number +} +``` + +**新增 Store 方法**: +- `markTestIncorrect()` - 標記測驗答錯 +- `reorderByPriority()` - 重新排序隊列 +- `getTestStats()` - 獲取統計數據 +- `isAllTestsCompleted()` - 檢查完成狀態 + +### 2.4 視覺化系統 + +**TestStatusIndicator** 支援四種狀態顯示: +- ✅ 已答對(綠色)- 已從當日清單移除 +- ❌ 已答錯(紅色)- 移到隊列最後 +- ⏭️ 已跳過(黃色)- 移到隊列最後 +- ⚪ 未完成(灰色)- 優先處理 + +## 三、技術架構改進 + +### 3.1 元件層次結構 + +``` +BaseTestComponent (基礎) +├── TestContainer (容器) +│ ├── ChoiceTestContainer +│ ├── FillTestContainer +│ ├── ListeningTestContainer +│ └── SpeakingTestContainer +└── AnswerActions (動作) + ├── ChoiceGrid + ├── TextInput + ├── ConfidenceLevel + └── RecordingControl +``` + +### 3.2 狀態管理優化 + +**原有 Store**: +- useTestQueueStore (測試隊列) +- useReviewSessionStore (會話狀態) +- useTestResultStore (測試結果) + +**新增功能**: +- 智能隊列管理 +- 優先級自動排序 +- 跳過狀態追蹤 +- 統計數據計算 + +### 3.3 Hook 模式 + +**新增 Hook**: +- `useTestAnswer()` - 標準化答題狀態管理 +- 集成到 BaseTestComponent 中 + +## 四、與 PRD 對照檢查 + +### ✅ US-008: 智能測驗導航系統 +- ✅ 答題前狀態:只顯示「跳過」按鈕 +- ✅ 答題後狀態:只顯示「繼續」按鈕 +- ✅ 答題提交分離:通過答題動作觸發 + +### ✅ US-009: 跳過題目智能管理系統 +- ✅ 智能隊列管理:動態調整測驗順序 +- ✅ 優先級處理邏輯:新題目優先,跳過排後 +- ✅ 狀態可視化:清楚標示不同狀態題目 + +## 五、測試狀態 + +### 5.1 編譯狀態 +- ✅ TypeScript 編譯通過 +- ✅ Next.js 開發服務器運行正常 +- ✅ 沒有編譯錯誤或警告 + +### 5.2 功能驗證(待測試) +- ⏳ 導航控制器功能測試 +- ⏳ 跳過隊列管理測試 +- ⏳ 優先級演算法驗證 +- ⏳ 視覺化顯示測試 + +## 六、下一步工作 + +### 6.1 整合現有測驗元件 +需要將 7 種測驗元件重構為使用新的基礎架構: +1. FlipMemoryTest +2. VocabChoiceTest +3. SentenceFillTest +4. SentenceReorderTest +5. VocabListeningTest +6. SentenceListeningTest +7. SentenceSpeakingTest + +### 6.2 ReviewRunner 更新 +需要整合新的 NavigationController 和狀態管理 + +### 6.3 完整測試 +- 端對端功能測試 +- 使用者體驗驗證 +- 效能測試 + +## 七、技術債務與改進 + +### 7.1 待優化項目 +- 測驗元件的記憶體優化 +- 狀態更新效能優化 +- 動畫效果增強 + +### 7.2 程式碼品質 +- 新增的元件都有適當的 TypeScript 類型 +- 使用 React.memo 進行效能優化 +- 遵循統一的命名規範 + +## 八、影響範圍 + +### 8.1 新增檔案 +``` +frontend/components/review/shared/ +├── BaseTestComponent.tsx (新增) +├── AnswerActions.tsx (新增) +├── TestContainer.tsx (新增) +└── index.ts (更新) + +frontend/components/review/ +├── NavigationController.tsx (新增) +├── TestStatusIndicator.tsx (新增) +└── TaskListModal.tsx (更新) + +frontend/store/ +└── useTestQueueStore.ts (重大更新) +``` + +### 8.2 更新檔案 +- TaskListModal.tsx - 整合新視覺化元件 +- useTestQueueStore.ts - 新增智能隊列管理 +- shared/index.ts - 新增元件匯出 + +### 8.3 相容性 +- 現有 API 保持相容 +- 漸進式升級策略 +- 向下相容的介面設計 + +## 九、結論 + +第一階段的基礎架構重構和第二、三階段的核心功能已經成功實現。新架構提供了: + +1. **更好的程式碼組織** - 共用元件抽離和統一介面 +2. **智能導航系統** - 完全符合 PRD US-008 要求 +3. **跳過隊列管理** - 實現 PRD US-009 的智能優先級排序 +4. **豐富的視覺回饋** - 讓使用者清楚了解學習狀態 + +系統已準備好進入整合測試階段,預期能顯著提升學習體驗和完成率。 + +--- + +**開發者**: Claude Code +**審核狀態**: 待測試 +**下次更新**: 整合測試完成後 \ No newline at end of file