feat: 完成智能複習系統核心架構開發
## 新增功能 - BaseTestComponent: 統一測驗元件基礎架構 - NavigationController: 智能導航控制系統(實現PRD US-008) - TestStatusIndicator: 測驗狀態視覺化元件 - AnswerActions: 標準化答題動作元件集合 - TestContainer: 統一測驗布局容器 ## 擴充功能 - TestQueueStore: 新增跳過隊列管理和優先級演算法(實現PRD US-009) - TaskListModal: 整合新視覺化元件 ## 核心特色 - 狀態驅動導航:答題前顯示「跳過」,答題後顯示「繼續」 - 智能優先級排序:未嘗試(100) > 答錯(20) > 跳過(10) - 四狀態視覺化:✅已答對 ❌已答錯 ⏭️已跳過 ⚪未完成 ## 技術實現 - 完整TypeScript類型支援 - React.memo效能優化 - 統一元件介面規範 - 優先級演算法實現 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
426e772e2a
commit
63c42fd72c
|
|
@ -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
|
||||
**審核狀態**: 待測試
|
||||
**下次更新**: 整合測試完成後
|
||||
Loading…
Reference in New Issue