Compare commits
2 Commits
cba33c326c
...
63c42fd72c
| Author | SHA1 | Date |
|---|---|---|
|
|
63c42fd72c | |
|
|
426e772e2a |
|
|
@ -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<NavigationControllerProps> = 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 (
|
||||
<div className={`navigation-controller ${className}`}>
|
||||
{/* 狀態驅動的單一按鈕 */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={navigationState.status === 'answered' ? handleContinue : handleSkip}
|
||||
disabled={disabled}
|
||||
className={getButtonStyles()}
|
||||
aria-label={getButtonText()}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{getButtonIcon()}</span>
|
||||
<span>{getButtonText()}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 狀態提示(開發模式可見) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-2 text-xs text-gray-500 text-center">
|
||||
狀態: {navigationState.status} |
|
||||
可跳過: {navigationState.canSkip ? '是' : '否'} |
|
||||
可繼續: {navigationState.canContinue ? '是' : '否'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
NavigationController.displayName = 'NavigationController'
|
||||
|
||||
/**
|
||||
* 整合測試隊列的高階導航控制器
|
||||
* 自動處理隊列狀態更新和測驗導航
|
||||
*/
|
||||
interface SmartNavigationControllerProps {
|
||||
hasAnswered: boolean
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
disabled?: boolean
|
||||
onSkipCallback?: () => void
|
||||
onContinueCallback?: () => void
|
||||
}
|
||||
|
||||
export const SmartNavigationController: React.FC<SmartNavigationControllerProps> = 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 (
|
||||
<NavigationController
|
||||
hasAnswered={hasAnswered}
|
||||
isLastTest={isLastTest}
|
||||
onSkip={handleSkip}
|
||||
onContinue={handleContinue}
|
||||
className={className}
|
||||
buttonClassName={buttonClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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: '🎉'
|
||||
}
|
||||
|
|
@ -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<TaskListModalProps> = ({
|
|||
}) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
|
@ -49,62 +46,22 @@ export const TaskListModal: React.FC<TaskListModalProps> = ({
|
|||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* 進度統計 */}
|
||||
{/* 智能進度統計 */}
|
||||
<div className="mb-6 bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-blue-900 font-medium">
|
||||
測驗進度: {completedTests} / {totalTests} ({progressPercentage}%)
|
||||
</span>
|
||||
<div className="flex items-center gap-4 text-blue-800">
|
||||
<span>✅ 已完成: {completedCount}</span>
|
||||
<span>⏳ 進行中: {currentCount}</span>
|
||||
<span>⚪ 待完成: {pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
></div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">學習進度</h3>
|
||||
<TestStats stats={stats} />
|
||||
</div>
|
||||
<TestProgressBar stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* 測驗清單 */}
|
||||
{/* 智能測驗狀態列表 */}
|
||||
<div className="space-y-4">
|
||||
{testItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{testItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-all ${
|
||||
item.isCompleted
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-50 border border-blue-300 shadow-sm'
|
||||
: 'bg-gray-50 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{/* 狀態圖標 */}
|
||||
<span className="text-lg">
|
||||
{item.isCompleted ? '✅' : item.isCurrent ? '⏳' : '⚪'}
|
||||
</span>
|
||||
|
||||
{/* 測驗資訊 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">
|
||||
{item.order}. {item.word} - {item.testName}
|
||||
</div>
|
||||
<div className={`text-xs ${
|
||||
item.isCompleted ? 'text-green-600' :
|
||||
item.isCurrent ? 'text-blue-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{item.isCompleted ? '已完成' :
|
||||
item.isCurrent ? '進行中' : '待完成'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TestStatusList
|
||||
tests={testItems}
|
||||
groupByCard={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-4xl mb-2">📚</div>
|
||||
|
|
@ -112,6 +69,19 @@ export const TaskListModal: React.FC<TaskListModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隊列優先級說明 */}
|
||||
{stats.total > 0 && (
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">智能隊列說明</h4>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<div>• <span className="font-medium">未完成測驗</span>:優先處理,保持學習動機</div>
|
||||
<div>• <span className="font-medium">答錯測驗</span>:移到隊列後方重複練習</div>
|
||||
<div>• <span className="font-medium">跳過測驗</span>:暫時跳過,稍後自動回歸</div>
|
||||
<div>• <span className="font-medium">已完成測驗</span>:永久從當日清單移除</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
|
|
|||
|
|
@ -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<TestStatusIndicatorProps> = 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 (
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center gap-2 rounded-lg border
|
||||
${statusConfig.bgColor}
|
||||
${statusConfig.textColor}
|
||||
${statusConfig.borderColor}
|
||||
${sizeClasses.container}
|
||||
${className}
|
||||
`}
|
||||
title={showDetails ? statusConfig.description : statusConfig.label}
|
||||
>
|
||||
{/* 狀態圖示 */}
|
||||
<span className={sizeClasses.icon}>
|
||||
{statusConfig.icon}
|
||||
</span>
|
||||
|
||||
{/* 狀態文字 */}
|
||||
<span className={`font-medium ${sizeClasses.text}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
|
||||
{/* 優先級顯示(開發模式) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<span className="text-xs opacity-60">
|
||||
P{test.priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 詳細資訊 */}
|
||||
{showDetails && (
|
||||
<div className="ml-2 text-xs opacity-80">
|
||||
({test.testName})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TestStatusIndicator.displayName = 'TestStatusIndicator'
|
||||
|
||||
/**
|
||||
* 測驗狀態統計元件
|
||||
*/
|
||||
interface TestStatsProps {
|
||||
stats: {
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
incorrect: number
|
||||
remaining: number
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const TestStats: React.FC<TestStatsProps> = 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 (
|
||||
<div className={`flex flex-wrap gap-4 ${className}`}>
|
||||
{items.map(({ key, label, count, color, icon }) => (
|
||||
<div key={key} className="flex items-center gap-1 text-sm">
|
||||
<span>{icon}</span>
|
||||
<span className={`font-medium ${color}`}>{count}</span>
|
||||
<span className="text-gray-600">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TestStats.displayName = 'TestStats'
|
||||
|
||||
/**
|
||||
* 測驗狀態進度條
|
||||
*/
|
||||
interface TestProgressBarProps {
|
||||
stats: {
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
incorrect: number
|
||||
remaining: number
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const TestProgressBar: React.FC<TestProgressBarProps> = 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 (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{/* 進度條 */}
|
||||
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
{/* 已完成 */}
|
||||
{completed > 0 && (
|
||||
<div
|
||||
className="bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${completedPercent}%` }}
|
||||
title={`已完成: ${completed}/${total}`}
|
||||
/>
|
||||
)}
|
||||
{/* 答錯 */}
|
||||
{incorrect > 0 && (
|
||||
<div
|
||||
className="bg-red-500 transition-all duration-300"
|
||||
style={{ width: `${incorrectPercent}%` }}
|
||||
title={`需重做: ${incorrect}/${total}`}
|
||||
/>
|
||||
)}
|
||||
{/* 跳過 */}
|
||||
{skipped > 0 && (
|
||||
<div
|
||||
className="bg-yellow-500 transition-all duration-300"
|
||||
style={{ width: `${skippedPercent}%` }}
|
||||
title={`已跳過: ${skipped}/${total}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 數據統計 */}
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>已完成: {completed}/{total}</span>
|
||||
<span>{((completed / total) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TestProgressBar.displayName = 'TestProgressBar'
|
||||
|
||||
/**
|
||||
* 測驗狀態列表
|
||||
*/
|
||||
interface TestStatusListProps {
|
||||
tests: TestItem[]
|
||||
groupByCard?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const TestStatusList: React.FC<TestStatusListProps> = 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<string, { word: string; tests: TestItem[] }>)
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{Object.entries(groupedTests).map(([cardId, { word, tests }]) => (
|
||||
<div key={cardId} className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">{word}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tests.map(test => (
|
||||
<TestStatusIndicator
|
||||
key={test.id}
|
||||
test={test}
|
||||
showDetails
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 平面列表
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{tests.map(test => (
|
||||
<div key={test.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-gray-900">{test.word}</span>
|
||||
<span className="text-sm text-gray-600">{test.testName}</span>
|
||||
</div>
|
||||
<TestStatusIndicator test={test} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TestStatusList.displayName = 'TestStatusList'
|
||||
|
|
@ -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<ChoiceOptionProps> = 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 (
|
||||
<button
|
||||
onClick={() => !disabled && !showResult && onSelect(option)}
|
||||
disabled={disabled || showResult}
|
||||
className={`p-4 text-center rounded-lg border-2 transition-all ${getOptionStyles()}`}
|
||||
aria-label={`選項 ${index + 1}: ${option}`}
|
||||
>
|
||||
<div className="text-lg font-medium">{option}</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
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<ChoiceGridProps> = memo(({
|
||||
options,
|
||||
selectedOption,
|
||||
correctAnswer,
|
||||
showResult = false,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-3 ${className}`}>
|
||||
{options.map((option, index) => (
|
||||
<ChoiceOption
|
||||
key={`${option}-${index}`}
|
||||
option={option}
|
||||
index={index}
|
||||
isSelected={selectedOption === option}
|
||||
isCorrect={showResult && option === correctAnswer}
|
||||
isIncorrect={showResult && option !== correctAnswer}
|
||||
showResult={showResult}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
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<TextInputProps> = 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 (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || showResult}
|
||||
className={`w-full p-3 border rounded-lg text-lg ${getInputStyles()}`}
|
||||
autoFocus
|
||||
/>
|
||||
{!showResult && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-4 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResult && !isCorrect && correctAnswer && (
|
||||
<div className="text-sm text-gray-600">
|
||||
正確答案:<span className="font-medium text-green-600">{correctAnswer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TextInput.displayName = 'TextInput'
|
||||
|
||||
// 信心度按鈕組
|
||||
interface ConfidenceLevelProps {
|
||||
selectedLevel?: number | null
|
||||
onSelect: (level: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ConfidenceLevel: React.FC<ConfidenceLevelProps> = 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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">請評估你對這個單字的熟悉程度:</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||||
{levels.map(({ level, label, color, hoverColor }) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => !disabled && onSelect(level)}
|
||||
disabled={disabled}
|
||||
className={`p-3 rounded-lg text-white font-medium transition-all ${
|
||||
selectedLevel === level
|
||||
? `${color} ring-4 ring-opacity-50 ring-offset-2 ring-current`
|
||||
: `${color} ${hoverColor} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm">{level}</div>
|
||||
<div className="text-xs">{label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfidenceLevel.displayName = 'ConfidenceLevel'
|
||||
|
||||
// 錄音控制元件
|
||||
interface RecordingControlProps {
|
||||
isRecording: boolean
|
||||
hasRecording: boolean
|
||||
onStartRecording: () => void
|
||||
onStopRecording: () => void
|
||||
onPlayback: () => void
|
||||
onSubmit: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RecordingControl: React.FC<RecordingControlProps> = memo(({
|
||||
isRecording,
|
||||
hasRecording,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onPlayback,
|
||||
onSubmit,
|
||||
disabled = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* 錄音按鈕 */}
|
||||
<button
|
||||
onClick={isRecording ? onStopRecording : onStartRecording}
|
||||
disabled={disabled}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center text-white font-bold transition-all ${
|
||||
isRecording
|
||||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||||
: 'bg-blue-500 hover:bg-blue-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isRecording ? '⏹️' : '🎤'}
|
||||
</button>
|
||||
|
||||
{/* 狀態文字 */}
|
||||
<div className="text-center">
|
||||
{isRecording ? (
|
||||
<p className="text-red-600 font-medium">錄音中... 點擊停止</p>
|
||||
) : hasRecording ? (
|
||||
<p className="text-green-600 font-medium">錄音完成</p>
|
||||
) : (
|
||||
<p className="text-gray-600">點擊開始錄音</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制按鈕 */}
|
||||
{hasRecording && !isRecording && (
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onPlayback}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
播放
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
提交錄音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RecordingControl.displayName = 'RecordingControl'
|
||||
|
|
@ -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<BaseTestComponentProps> = ({
|
||||
cardData,
|
||||
testTitle,
|
||||
instructions,
|
||||
children,
|
||||
showResult = false,
|
||||
resultContent,
|
||||
onReportError,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const [testState, setTestState] = useState<TestState>({
|
||||
hasAnswered: false,
|
||||
userAnswer: null,
|
||||
showResult: false
|
||||
})
|
||||
|
||||
// 更新測驗狀態
|
||||
const updateTestState = useCallback((updates: Partial<TestState>) => {
|
||||
setTestState(prev => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
// 提供給子元件的狀態和方法
|
||||
const testContext = {
|
||||
testState,
|
||||
updateTestState,
|
||||
cardData,
|
||||
disabled: disabled || testState.showResult
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* 錯誤回報按鈕 */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<ErrorReportButton onClick={onReportError} />
|
||||
</div>
|
||||
|
||||
{/* 主要測驗容器 */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
{/* 測驗標題 */}
|
||||
<TestHeader
|
||||
title={testTitle}
|
||||
difficultyLevel={cardData.difficultyLevel}
|
||||
/>
|
||||
|
||||
{/* 說明文字 */}
|
||||
{instructions && (
|
||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
||||
{instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 測驗內容區域 */}
|
||||
<div className="test-content">
|
||||
{React.cloneElement(children as React.ReactElement, { testContext })}
|
||||
</div>
|
||||
|
||||
{/* 結果顯示區域 */}
|
||||
{(showResult || testState.showResult) && resultContent && (
|
||||
<div className="mt-6">
|
||||
{resultContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing test answer state
|
||||
* 提供測驗答題狀態管理的標準化邏輯
|
||||
*/
|
||||
export const useTestAnswer = (onAnswer: (answer: string) => void) => {
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(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
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
import { BaseTestComponent, BaseTestComponentProps } from './BaseTestComponent'
|
||||
|
||||
/**
|
||||
* 測驗容器元件 - 提供統一的測驗布局結構
|
||||
* 整合導航控制和進度顯示
|
||||
*/
|
||||
|
||||
export interface TestContainerProps extends Omit<BaseTestComponentProps, 'children'> {
|
||||
// 測驗內容區域
|
||||
contentArea: ReactNode
|
||||
|
||||
// 答題區域
|
||||
answerArea?: ReactNode
|
||||
|
||||
// 結果顯示區域
|
||||
resultArea?: ReactNode
|
||||
|
||||
// 導航控制(預留給未來的NavigationController)
|
||||
navigationArea?: ReactNode
|
||||
|
||||
// 布局配置
|
||||
layout?: 'standard' | 'split' | 'fullscreen'
|
||||
|
||||
// 自定義樣式
|
||||
contentClassName?: string
|
||||
answerClassName?: string
|
||||
resultClassName?: string
|
||||
}
|
||||
|
||||
export const TestContainer: React.FC<TestContainerProps> = ({
|
||||
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 = () => (
|
||||
<div className={getLayoutClasses()}>
|
||||
{/* 內容展示區域 */}
|
||||
<div className={`test-content-area ${contentClassName}`}>
|
||||
{contentArea}
|
||||
</div>
|
||||
|
||||
{/* 答題互動區域 */}
|
||||
{answerArea && (
|
||||
<div className={`test-answer-area ${answerClassName}`}>
|
||||
{answerArea}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 結果展示區域 */}
|
||||
{resultArea && (
|
||||
<div className={`test-result-area mt-6 ${resultClassName}`}>
|
||||
{resultArea}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 導航控制區域 */}
|
||||
{navigationArea && (
|
||||
<div className="test-navigation-area mt-8">
|
||||
{navigationArea}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<BaseTestComponent {...baseProps}>
|
||||
{renderContent()}
|
||||
</BaseTestComponent>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 專用於不同測驗類型的容器變體
|
||||
*/
|
||||
|
||||
// 選擇題容器
|
||||
export interface ChoiceTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
||||
questionArea: ReactNode
|
||||
optionsArea: ReactNode
|
||||
}
|
||||
|
||||
export const ChoiceTestContainer: React.FC<ChoiceTestContainerProps> = ({
|
||||
questionArea,
|
||||
optionsArea,
|
||||
resultArea,
|
||||
navigationArea,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TestContainer
|
||||
{...props}
|
||||
layout="standard"
|
||||
contentArea={
|
||||
<div className="space-y-6">
|
||||
<div className="question-area">
|
||||
{questionArea}
|
||||
</div>
|
||||
<div className="options-area">
|
||||
{optionsArea}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
resultArea={resultArea}
|
||||
navigationArea={navigationArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 填空題容器
|
||||
export interface FillTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
||||
sentenceArea: ReactNode
|
||||
inputArea: ReactNode
|
||||
}
|
||||
|
||||
export const FillTestContainer: React.FC<FillTestContainerProps> = ({
|
||||
sentenceArea,
|
||||
inputArea,
|
||||
resultArea,
|
||||
navigationArea,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TestContainer
|
||||
{...props}
|
||||
layout="standard"
|
||||
contentArea={sentenceArea}
|
||||
answerArea={inputArea}
|
||||
resultArea={resultArea}
|
||||
navigationArea={navigationArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 聽力測驗容器
|
||||
export interface ListeningTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
||||
audioArea: ReactNode
|
||||
questionArea: ReactNode
|
||||
answerArea: ReactNode
|
||||
}
|
||||
|
||||
export const ListeningTestContainer: React.FC<ListeningTestContainerProps> = ({
|
||||
audioArea,
|
||||
questionArea,
|
||||
answerArea,
|
||||
resultArea,
|
||||
navigationArea,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TestContainer
|
||||
{...props}
|
||||
layout="standard"
|
||||
contentArea={
|
||||
<div className="space-y-6">
|
||||
<div className="audio-area text-center">
|
||||
{audioArea}
|
||||
</div>
|
||||
<div className="question-area">
|
||||
{questionArea}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
answerArea={answerArea}
|
||||
resultArea={resultArea}
|
||||
navigationArea={navigationArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 口說測驗容器
|
||||
export interface SpeakingTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
||||
promptArea: ReactNode
|
||||
recordingArea: ReactNode
|
||||
}
|
||||
|
||||
export const SpeakingTestContainer: React.FC<SpeakingTestContainerProps> = ({
|
||||
promptArea,
|
||||
recordingArea,
|
||||
resultArea,
|
||||
navigationArea,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TestContainer
|
||||
{...props}
|
||||
layout="standard"
|
||||
contentArea={promptArea}
|
||||
answerArea={recordingArea}
|
||||
resultArea={resultArea}
|
||||
navigationArea={navigationArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 翻卡測驗容器
|
||||
export interface FlipTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
||||
cardArea: ReactNode
|
||||
confidenceArea: ReactNode
|
||||
}
|
||||
|
||||
export const FlipTestContainer: React.FC<FlipTestContainerProps> = ({
|
||||
cardArea,
|
||||
confidenceArea,
|
||||
navigationArea,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TestContainer
|
||||
{...props}
|
||||
layout="standard"
|
||||
contentArea={cardArea}
|
||||
answerArea={confidenceArea}
|
||||
navigationArea={navigationArea}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,4 +4,36 @@ export { SentenceInput } from './SentenceInput'
|
|||
export { TestResultDisplay } from './TestResultDisplay'
|
||||
export { HintPanel } from './HintPanel'
|
||||
export { ConfidenceButtons } from './ConfidenceButtons'
|
||||
export { TestHeader } from './TestHeader'
|
||||
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'
|
||||
|
|
@ -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<string>
|
||||
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<TestQueueState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// 初始狀態
|
||||
|
|
@ -61,6 +136,8 @@ export const useTestQueueStore = create<TestQueueState>()(
|
|||
completedTests: 0,
|
||||
totalTests: 0,
|
||||
currentMode: 'flip-memory',
|
||||
skippedTests: new Set<string>(),
|
||||
priorityQueue: [],
|
||||
|
||||
// Actions
|
||||
setTestItems: (items) => set({ testItems: items }),
|
||||
|
|
@ -101,7 +178,11 @@ export const useTestQueueStore = create<TestQueueState>()(
|
|||
testName: getTestTypeName(testType),
|
||||
isCompleted: false,
|
||||
isCurrent: false,
|
||||
order
|
||||
order,
|
||||
// 新增狀態欄位
|
||||
isSkipped: false,
|
||||
isIncorrect: false,
|
||||
priority: 100 // 新測驗預設最高優先級
|
||||
})
|
||||
order++
|
||||
})
|
||||
|
|
@ -120,7 +201,9 @@ export const useTestQueueStore = create<TestQueueState>()(
|
|||
totalTests: remainingTestItems.length,
|
||||
currentTestIndex: 0,
|
||||
completedTests: 0,
|
||||
currentMode: remainingTestItems[0].testType
|
||||
currentMode: remainingTestItems[0].testType,
|
||||
skippedTests: new Set<string>(),
|
||||
priorityQueue: reorderTestItems(remainingTestItems)
|
||||
})
|
||||
|
||||
console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個')
|
||||
|
|
@ -151,45 +234,161 @@ export const useTestQueueStore = create<TestQueueState>()(
|
|||
},
|
||||
|
||||
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<string>(),
|
||||
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
|
||||
}
|
||||
}))
|
||||
)
|
||||
|
|
@ -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
|
||||
**審核狀態**: 待測試
|
||||
**下次更新**: 整合測試完成後
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
# 智能複習系統開發計劃
|
||||
|
||||
**版本**: 1.0
|
||||
**日期**: 2025-09-28
|
||||
**基於**: 產品需求規格書 v2.0 (/note/智能複習/智能複習系統-產品需求規格書.md)
|
||||
|
||||
## 一、專案概述
|
||||
|
||||
### 1.1 系統目標
|
||||
建構一個基於CEFR標準的智能複習系統,透過間隔重複算法和智能題型適配,提供個人化的語言學習體驗。
|
||||
|
||||
### 1.2 當前系統狀態分析
|
||||
|
||||
#### 已完成功能(V2.0)
|
||||
- ✅ 7種複習題型實現
|
||||
- ✅ 間隔重複算法(SM2)
|
||||
- ✅ CEFR智能適配系統
|
||||
- ✅ 前後端API完全串接
|
||||
- ✅ 測驗狀態持久化
|
||||
|
||||
#### 待開發功能(來自PRD US-008 & US-009)
|
||||
- 🔄 智能測驗導航系統
|
||||
- 🔄 跳過題目管理系統
|
||||
- 🔄 狀態驅動的導航邏輯
|
||||
|
||||
## 二、元件架構設計
|
||||
|
||||
### 2.1 現有元件結構
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/review/page.tsx # 主頁面入口
|
||||
├── components/review/
|
||||
│ ├── ReviewRunner.tsx # 測驗執行器
|
||||
│ ├── ProgressTracker.tsx # 進度追蹤器
|
||||
│ ├── TaskListModal.tsx # 任務清單彈窗
|
||||
│ ├── LoadingStates.tsx # 載入狀態組件
|
||||
│ └── review-tests/ # 7種測驗組件
|
||||
│ ├── FlipMemoryTest.tsx
|
||||
│ ├── VocabChoiceTest.tsx
|
||||
│ ├── SentenceFillTest.tsx
|
||||
│ ├── SentenceReorderTest.tsx
|
||||
│ ├── VocabListeningTest.tsx
|
||||
│ ├── SentenceListeningTest.tsx
|
||||
│ └── SentenceSpeakingTest.tsx
|
||||
├── store/ # 狀態管理
|
||||
│ ├── useReviewSessionStore.ts # 會話狀態
|
||||
│ ├── useTestQueueStore.ts # 測試隊列
|
||||
│ ├── useTestResultStore.ts # 測試結果
|
||||
│ ├── useReviewDataStore.ts # 複習數據
|
||||
│ └── useUIStore.ts # UI狀態
|
||||
└── lib/services/
|
||||
└── review/reviewService.ts # API服務層
|
||||
```
|
||||
|
||||
### 2.2 需新增/修改的元件
|
||||
|
||||
根據PRD新需求(US-008 & US-009),需要新增或修改以下元件:
|
||||
|
||||
#### 2.2.1 核心元件重構
|
||||
|
||||
```typescript
|
||||
// 1. NavigationController 智能導航控制器
|
||||
components/review/NavigationController.tsx
|
||||
- 狀態驅動的按鈕顯示邏輯
|
||||
- 跳過/繼續按鈕的條件渲染
|
||||
- 與TestQueueStore深度整合
|
||||
|
||||
// 2. TestQueueManager 測試隊列管理器
|
||||
components/review/TestQueueManager.tsx
|
||||
- 優先級排序邏輯
|
||||
- 跳過題目的隊列管理
|
||||
- 智能回歸機制實現
|
||||
|
||||
// 3. TestStatusIndicator 測試狀態指示器
|
||||
components/review/TestStatusIndicator.tsx
|
||||
- 視覺化顯示不同狀態
|
||||
- ✅已答對 ❌已答錯 ⏭️已跳過 ⚪未完成
|
||||
```
|
||||
|
||||
#### 2.2.2 共用元件抽離
|
||||
|
||||
```typescript
|
||||
// 基礎測驗元件介面
|
||||
components/review/shared/BaseTestComponent.tsx
|
||||
- 統一的答題狀態管理
|
||||
- 標準化的導航整合點
|
||||
- 共用的錯誤處理邏輯
|
||||
|
||||
// 答題動作元件
|
||||
components/review/shared/AnswerActions.tsx
|
||||
- 選擇題的選項點擊
|
||||
- 填空題的輸入提交
|
||||
- 錄音的完成確認
|
||||
|
||||
// 測驗容器元件
|
||||
components/review/shared/TestContainer.tsx
|
||||
- 統一的布局結構
|
||||
- 進度顯示整合
|
||||
- 導航控制器嵌入點
|
||||
```
|
||||
|
||||
### 2.3 資料流程設計
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[使用者進入學習頁面] --> B[載入到期詞卡]
|
||||
B --> C[查詢已完成測驗]
|
||||
C --> D[生成測試隊列]
|
||||
|
||||
D --> E{測驗狀態判斷}
|
||||
E -->|未答題| F[顯示跳過按鈕]
|
||||
E -->|已答題| G[顯示繼續按鈕]
|
||||
|
||||
F -->|點擊跳過| H[標記為跳過狀態]
|
||||
H --> I[移到隊列最後]
|
||||
|
||||
G -->|點擊繼續| J[進入下一題]
|
||||
|
||||
K[答題動作] --> L{判斷結果}
|
||||
L -->|答對| M[從清單移除]
|
||||
L -->|答錯| N[移到隊列最後]
|
||||
|
||||
I --> O[優先級重排]
|
||||
N --> O
|
||||
O --> P[載入下個測驗]
|
||||
```
|
||||
|
||||
## 三、狀態管理設計
|
||||
|
||||
### 3.1 TestQueueStore 擴充
|
||||
|
||||
```typescript
|
||||
interface TestQueueStore {
|
||||
// 現有狀態
|
||||
testItems: TestItem[]
|
||||
currentTestIndex: number
|
||||
|
||||
// 新增狀態
|
||||
skippedTests: Set<string> // 跳過的測驗ID集合
|
||||
priorityQueue: TestItem[] // 優先級排序後的隊列
|
||||
|
||||
// 新增方法
|
||||
skipTest: (testId: string) => void
|
||||
reorderByPriority: () => void
|
||||
getNextTest: () => TestItem | null
|
||||
isAllTestsCompleted: () => boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 NavigationStore(新增)
|
||||
|
||||
```typescript
|
||||
interface NavigationStore {
|
||||
// 導航狀態
|
||||
currentTestStatus: 'unanswered' | 'answered' | 'skipped'
|
||||
canSkip: boolean
|
||||
canContinue: boolean
|
||||
|
||||
// 導航方法
|
||||
updateNavigationState: (status: string) => void
|
||||
handleSkip: () => void
|
||||
handleContinue: () => void
|
||||
}
|
||||
```
|
||||
|
||||
## 四、開發階段規劃
|
||||
|
||||
### 第一階段:基礎架構調整(2-3天)
|
||||
|
||||
**目標**: 重構現有元件結構,建立共用元件基礎
|
||||
|
||||
**任務清單**:
|
||||
1. ⬜ 抽離共用測驗元件邏輯
|
||||
2. ⬜ 建立BaseTestComponent基礎類別
|
||||
3. ⬜ 統一7種測驗的介面規範
|
||||
4. ⬜ 整合答題動作處理邏輯
|
||||
|
||||
**交付物**:
|
||||
- 重構後的測驗元件
|
||||
- 共用元件文檔
|
||||
|
||||
### 第二階段:智能導航系統(3-4天)
|
||||
|
||||
**目標**: 實現狀態驅動的導航邏輯
|
||||
|
||||
**任務清單**:
|
||||
1. ⬜ 開發NavigationController元件
|
||||
2. ⬜ 實現狀態判斷邏輯
|
||||
3. ⬜ 整合答題與導航分離
|
||||
4. ⬜ 建立NavigationStore
|
||||
|
||||
**交付物**:
|
||||
- NavigationController元件
|
||||
- 狀態驅動導航文檔
|
||||
|
||||
### 第三階段:跳過隊列管理(3-4天)
|
||||
|
||||
**目標**: 實現智能隊列管理系統
|
||||
|
||||
**任務清單**:
|
||||
1. ⬜ 擴充TestQueueStore功能
|
||||
2. ⬜ 實現優先級排序演算法
|
||||
3. ⬜ 開發TestQueueManager元件
|
||||
4. ⬜ 實現跳過題目回歸邏輯
|
||||
|
||||
**交付物**:
|
||||
- 智能隊列管理系統
|
||||
- 優先級演算法文檔
|
||||
|
||||
### 第四階段:狀態視覺化(2天)
|
||||
|
||||
**目標**: 提供清晰的測驗狀態回饋
|
||||
|
||||
**任務清單**:
|
||||
1. ⬜ 開發TestStatusIndicator元件
|
||||
2. ⬜ 更新TaskListModal視覺呈現
|
||||
3. ⬜ 整合進度條顯示邏輯
|
||||
4. ⬜ 實現狀態圖示系統
|
||||
|
||||
**交付物**:
|
||||
- 狀態指示器元件
|
||||
- 視覺化設計規範
|
||||
|
||||
### 第五階段:整合測試與優化(2-3天)
|
||||
|
||||
**目標**: 確保系統穩定性與效能
|
||||
|
||||
**任務清單**:
|
||||
1. ⬜ 端對端測試案例撰寫
|
||||
2. ⬜ 效能優化(減少重新渲染)
|
||||
3. ⬜ 錯誤處理機制完善
|
||||
4. ⬜ 使用者體驗優化
|
||||
|
||||
**交付物**:
|
||||
- 測試報告
|
||||
- 效能優化報告
|
||||
|
||||
## 五、技術實施細節
|
||||
|
||||
### 5.1 元件通訊模式
|
||||
|
||||
```typescript
|
||||
// 使用事件驅動模式處理答題動作
|
||||
interface AnswerEvent {
|
||||
type: 'select' | 'input' | 'record'
|
||||
payload: {
|
||||
answer: string
|
||||
confidence?: number
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
// 統一的答題處理器
|
||||
class AnswerHandler {
|
||||
process(event: AnswerEvent): void {
|
||||
// 1. 提交答案到後端
|
||||
// 2. 更新測驗狀態
|
||||
// 3. 觸發導航狀態更新
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 優先級演算法
|
||||
|
||||
```typescript
|
||||
interface PriorityAlgorithm {
|
||||
// 計算測驗優先級分數
|
||||
calculatePriority(test: TestItem): number {
|
||||
if (test.status === 'unattempted') return 100
|
||||
if (test.status === 'incorrect') return 20
|
||||
if (test.status === 'skipped') return 10
|
||||
return 0
|
||||
}
|
||||
|
||||
// 重新排序隊列
|
||||
reorderQueue(tests: TestItem[]): TestItem[] {
|
||||
return tests.sort((a, b) =>
|
||||
this.calculatePriority(b) - this.calculatePriority(a)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 狀態持久化策略
|
||||
|
||||
```typescript
|
||||
// 使用IndexedDB儲存學習進度
|
||||
interface PersistenceLayer {
|
||||
// 儲存跳過狀態
|
||||
saveSkippedTests(testIds: string[]): Promise<void>
|
||||
|
||||
// 恢復學習進度
|
||||
restoreProgress(userId: string): Promise<LearningProgress>
|
||||
|
||||
// 清理過期數據
|
||||
cleanupOldData(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## 六、風險評估與緩解策略
|
||||
|
||||
### 6.1 技術風險
|
||||
|
||||
| 風險項目 | 可能影響 | 緩解策略 |
|
||||
|---------|---------|---------|
|
||||
| 狀態管理複雜度 | 開發延期 | 採用明確的狀態機模式 |
|
||||
| 優先級演算法效能 | 使用者體驗 | 實施漸進式載入 |
|
||||
| 跨元件通訊 | 維護困難 | 建立統一事件總線 |
|
||||
|
||||
### 6.2 使用者體驗風險
|
||||
|
||||
| 風險項目 | 可能影響 | 緩解策略 |
|
||||
|---------|---------|---------|
|
||||
| 導航邏輯不直觀 | 使用者困惑 | A/B測試驗證 |
|
||||
| 跳過機制濫用 | 學習效果降低 | 設置跳過次數限制 |
|
||||
| 狀態切換延遲 | 操作不流暢 | 優化渲染效能 |
|
||||
|
||||
## 七、測試策略
|
||||
|
||||
### 7.1 單元測試
|
||||
|
||||
```typescript
|
||||
// 測試優先級演算法
|
||||
describe('PriorityAlgorithm', () => {
|
||||
test('未嘗試題目應有最高優先級', () => {
|
||||
// 測試邏輯
|
||||
})
|
||||
|
||||
test('跳過題目應排在最後', () => {
|
||||
// 測試邏輯
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 7.2 整合測試
|
||||
|
||||
- 測試完整學習流程
|
||||
- 驗證狀態持久化
|
||||
- 確認API整合正確性
|
||||
|
||||
### 7.3 E2E測試場景
|
||||
|
||||
1. 正常學習流程
|
||||
2. 大量跳過後的處理
|
||||
3. 頁面刷新後的狀態恢復
|
||||
4. 網路中斷的容錯處理
|
||||
|
||||
## 八、效能優化策略
|
||||
|
||||
### 8.1 渲染優化
|
||||
|
||||
- 使用React.memo減少不必要的重新渲染
|
||||
- 實施虛擬滾動處理大量測驗項目
|
||||
- 懶載入非關鍵元件
|
||||
|
||||
### 8.2 狀態管理優化
|
||||
|
||||
- 使用選擇器避免全域狀態更新
|
||||
- 實施狀態分片減少更新範圍
|
||||
- 採用不可變數據結構
|
||||
|
||||
### 8.3 網路請求優化
|
||||
|
||||
- 實施請求快取機制
|
||||
- 批次處理測驗結果提交
|
||||
- 預載下一個測驗資料
|
||||
|
||||
## 九、監控與維護
|
||||
|
||||
### 9.1 關鍵指標監控
|
||||
|
||||
- 平均答題時間
|
||||
- 跳過率統計
|
||||
- 完成率追蹤
|
||||
- 錯誤率分析
|
||||
|
||||
### 9.2 日誌記錄
|
||||
|
||||
```typescript
|
||||
interface LearningAnalytics {
|
||||
// 記錄使用者行為
|
||||
trackUserAction(action: UserAction): void
|
||||
|
||||
// 記錄系統事件
|
||||
logSystemEvent(event: SystemEvent): void
|
||||
|
||||
// 錯誤追蹤
|
||||
captureError(error: Error, context: any): void
|
||||
}
|
||||
```
|
||||
|
||||
## 十、里程碑與交付時程
|
||||
|
||||
| 里程碑 | 預計完成日期 | 交付物 |
|
||||
|--------|------------|--------|
|
||||
| M1: 基礎架構完成 | 2025-10-01 | 重構元件、共用邏輯 |
|
||||
| M2: 導航系統上線 | 2025-10-05 | 智能導航控制器 |
|
||||
| M3: 隊列管理完成 | 2025-10-09 | 跳過題目管理系統 |
|
||||
| M4: 視覺化完成 | 2025-10-11 | 狀態指示器 |
|
||||
| M5: 系統上線 | 2025-10-14 | 完整功能、測試報告 |
|
||||
|
||||
## 十一、成功指標
|
||||
|
||||
### 11.1 技術指標
|
||||
|
||||
- 程式碼覆蓋率 > 80%
|
||||
- 頁面載入時間 < 2秒
|
||||
- API回應時間 < 500ms
|
||||
- 零重大錯誤
|
||||
|
||||
### 11.2 業務指標
|
||||
|
||||
- 完成率提升 15%
|
||||
- 跳過率 < 20%
|
||||
- 使用者滿意度 > 85%
|
||||
- 學習效率提升 20%
|
||||
|
||||
## 十二、參考文件
|
||||
|
||||
1. [產品需求規格書](/note/智能複習/智能複習系統-產品需求規格書.md)
|
||||
2. [前端Review功能架構評估報告](/前端Review功能架構評估報告.md)
|
||||
3. [智能填空題系統設計規格](/智能填空題系統設計規格.md)
|
||||
|
||||
---
|
||||
|
||||
**批准**: 待確認
|
||||
**預計開始日期**: 2025-09-29
|
||||
**預計完成日期**: 2025-10-14
|
||||
**負責人**: 開發團隊
|
||||
Loading…
Reference in New Issue