Compare commits

...

2 Commits

Author SHA1 Message Date
鄭沛軒 63c42fd72c 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>
2025-09-29 00:07:56 +08:00
鄭沛軒 426e772e2a feat: 新增智能複習系統開發計劃文件
- 基於產品需求規格書v2.0制定完整開發計劃
- 分析現有元件架構並設計新增元件需求
- 規劃5階段開發流程(共14天)
- 包含技術實施細節、風險評估、測試策略
- 重點實現智能導航系統和跳過隊列管理

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 23:55:45 +08:00
10 changed files with 2189 additions and 82 deletions

View File

@ -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: '🎉'
}

View File

@ -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 */}

View File

@ -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'

View File

@ -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'

View File

@ -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
}

View File

@ -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}
/>
)
}

View File

@ -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'

View File

@ -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
}
}))
)

View File

@ -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
**審核狀態**: 待測試
**下次更新**: 整合測試完成後

View File

@ -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
**負責人**: 開發團隊