Compare commits
2 Commits
63c42fd72c
...
9286d3cd12
| Author | SHA1 | Date |
|---|---|---|
|
|
9286d3cd12 | |
|
|
e808598cc0 |
|
|
@ -61,6 +61,7 @@ export default function ReviewTestsPage() {
|
||||||
filledQuestionText: undefined,
|
filledQuestionText: undefined,
|
||||||
exampleTranslation: "載入中...",
|
exampleTranslation: "載入中...",
|
||||||
pronunciation: "",
|
pronunciation: "",
|
||||||
|
synonyms: [],
|
||||||
difficultyLevel: "A1",
|
difficultyLevel: "A1",
|
||||||
translation: "載入中",
|
translation: "載入中",
|
||||||
exampleImage: undefined
|
exampleImage: undefined
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@ import { useState, useRef, useEffect, memo, useCallback } from 'react'
|
||||||
import AudioPlayer from '@/components/AudioPlayer'
|
import AudioPlayer from '@/components/AudioPlayer'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
ErrorReportButton,
|
||||||
ConfidenceButtons,
|
|
||||||
TestHeader,
|
TestHeader,
|
||||||
HintPanel
|
ConfidenceLevel
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
import { ConfidenceTestProps } from '@/types/review'
|
import { ConfidenceTestProps } from '@/types/review'
|
||||||
|
|
||||||
interface FlipMemoryTestProps extends ConfidenceTestProps {
|
interface FlipMemoryTestProps extends ConfidenceTestProps {}
|
||||||
// FlipMemoryTest specific props (if any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
cardData,
|
cardData,
|
||||||
|
|
@ -24,6 +21,9 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
const frontRef = useRef<HTMLDivElement>(null)
|
const frontRef = useRef<HTMLDivElement>(null)
|
||||||
const backRef = useRef<HTMLDivElement>(null)
|
const backRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 判斷是否已答題(選擇了信心等級)
|
||||||
|
const hasAnswered = selectedConfidence !== null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCardHeight = () => {
|
const updateCardHeight = () => {
|
||||||
if (backRef.current) {
|
if (backRef.current) {
|
||||||
|
|
@ -54,11 +54,10 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
}, [disabled, isFlipped])
|
}, [disabled, isFlipped])
|
||||||
|
|
||||||
const handleConfidenceSelect = useCallback((level: number) => {
|
const handleConfidenceSelect = useCallback((level: number) => {
|
||||||
if (disabled) return
|
if (disabled || hasAnswered) return
|
||||||
setSelectedConfidence(level)
|
setSelectedConfidence(level)
|
||||||
onConfidenceSubmit(level)
|
onConfidenceSubmit(level)
|
||||||
}, [disabled, onConfidenceSubmit])
|
}, [disabled, hasAnswered, onConfidenceSubmit])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -68,18 +67,29 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
|
|
||||||
{/* 翻卡容器 */}
|
{/* 翻卡容器 */}
|
||||||
<div
|
<div
|
||||||
className={`card-container ${disabled ? 'pointer-events-none opacity-75' : 'cursor-pointer'}`}
|
className={`relative w-full ${disabled ? 'pointer-events-none opacity-75' : 'cursor-pointer'}`}
|
||||||
onClick={handleFlip}
|
onClick={handleFlip}
|
||||||
style={{ perspective: '1000px', height: `${cardHeight}px` }}
|
style={{
|
||||||
|
perspective: '1000px',
|
||||||
|
height: `${cardHeight}px`,
|
||||||
|
minHeight: '400px',
|
||||||
|
transition: 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`card bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-600 ${isFlipped ? 'rotate-y-180' : ''}`}
|
style={{
|
||||||
style={{ transformStyle: 'preserve-3d', height: '100%' }}
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 正面 */}
|
{/* 正面 */}
|
||||||
<div
|
<div
|
||||||
ref={frontRef}
|
ref={frontRef}
|
||||||
className="card-face card-front absolute w-full h-full"
|
className="absolute w-full h-full bg-white rounded-xl shadow-lg hover:shadow-xl"
|
||||||
style={{ backfaceVisibility: 'hidden' }}
|
style={{ backfaceVisibility: 'hidden' }}
|
||||||
>
|
>
|
||||||
<div className="p-8 h-full">
|
<div className="p-8 h-full">
|
||||||
|
|
@ -115,8 +125,11 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
{/* 背面 */}
|
{/* 背面 */}
|
||||||
<div
|
<div
|
||||||
ref={backRef}
|
ref={backRef}
|
||||||
className="card-face card-back absolute w-full h-full"
|
className="absolute w-full h-full bg-white rounded-xl shadow-lg"
|
||||||
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
|
style={{
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
transform: 'rotateY(180deg)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="p-8 h-full">
|
<div className="p-8 h-full">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
|
@ -161,75 +174,20 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信心等級評估區 */}
|
{/* 信心等級評估區 - 使用新元件 */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<ConfidenceButtons
|
<ConfidenceLevel
|
||||||
selectedLevel={selectedConfidence}
|
selectedLevel={selectedConfidence}
|
||||||
onSelect={handleConfidenceSelect}
|
onSelect={handleConfidenceSelect}
|
||||||
disabled={disabled}
|
disabled={disabled || hasAnswered}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.card-container {
|
|
||||||
perspective: 1000px;
|
|
||||||
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotate-y-180 {
|
|
||||||
transform: rotateY(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-face {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-front .p-8 {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-back .p-8 {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.card-container {
|
|
||||||
min-height: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.card-container {
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-face .p-8 {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import React, { useState, useMemo, useCallback, memo } from 'react'
|
import React, { useState, useMemo, useCallback, memo } from 'react'
|
||||||
import { getCorrectAnswer } from '@/utils/answerExtractor'
|
import { getCorrectAnswer } from '@/utils/answerExtractor'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
|
||||||
SentenceInput,
|
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
HintPanel
|
HintPanel,
|
||||||
|
FillTestContainer,
|
||||||
|
TextInput
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
import { FillTestProps } from '@/types/review'
|
import { FillTestProps } from '@/types/review'
|
||||||
|
|
||||||
interface SentenceFillTestProps extends FillTestProps {
|
interface SentenceFillTestProps extends FillTestProps {}
|
||||||
// SentenceFillTest specific props (if any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
||||||
cardData,
|
cardData,
|
||||||
|
|
@ -22,11 +20,12 @@ const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
const [showHint, setShowHint] = useState(false)
|
const [showHint, setShowHint] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (disabled || showResult || !fillAnswer.trim()) return
|
const handleSubmit = useCallback((answer: string) => {
|
||||||
|
if (disabled || showResult) return
|
||||||
setShowResult(true)
|
setShowResult(true)
|
||||||
onAnswer(fillAnswer)
|
onAnswer(answer)
|
||||||
}, [disabled, showResult, fillAnswer, onAnswer])
|
}, [disabled, showResult, onAnswer])
|
||||||
|
|
||||||
const handleToggleHint = useCallback(() => {
|
const handleToggleHint = useCallback(() => {
|
||||||
setShowHint(prev => !prev)
|
setShowHint(prev => !prev)
|
||||||
|
|
@ -55,14 +54,18 @@ const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
{part}
|
{part}
|
||||||
{index < parts.length - 1 && (
|
{index < parts.length - 1 && (
|
||||||
<SentenceInput
|
<span className="inline-block mx-1">
|
||||||
value={fillAnswer}
|
<TextInput
|
||||||
onChange={setFillAnswer}
|
value={fillAnswer}
|
||||||
onSubmit={handleSubmit}
|
onChange={setFillAnswer}
|
||||||
disabled={disabled}
|
onSubmit={handleSubmit}
|
||||||
showResult={showResult}
|
disabled={disabled}
|
||||||
targetWordLength={correctAnswer.length}
|
showResult={showResult}
|
||||||
/>
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={correctAnswer}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -79,14 +82,18 @@ const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
{part}
|
{part}
|
||||||
{index < matches.length && (
|
{index < matches.length && (
|
||||||
<SentenceInput
|
<span className="inline-block mx-1">
|
||||||
value={fillAnswer}
|
<TextInput
|
||||||
onChange={setFillAnswer}
|
value={fillAnswer}
|
||||||
onSubmit={handleSubmit}
|
onChange={setFillAnswer}
|
||||||
disabled={disabled}
|
onSubmit={handleSubmit}
|
||||||
showResult={showResult}
|
disabled={disabled}
|
||||||
targetWordLength={correctAnswer.length}
|
showResult={showResult}
|
||||||
/>
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={correctAnswer}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -101,95 +108,98 @@ const SentenceFillTestComponent: React.FC<SentenceFillTestProps> = ({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
disabled,
|
disabled,
|
||||||
showResult,
|
showResult,
|
||||||
correctAnswer.length
|
isCorrect,
|
||||||
|
correctAnswer
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
// 句子顯示區域
|
||||||
<div className="relative">
|
const sentenceArea = (
|
||||||
<div className="flex justify-end mb-2">
|
<div className="space-y-6">
|
||||||
<ErrorReportButton onClick={onReportError} />
|
{/* 圖片區(如果有) */}
|
||||||
</div>
|
{cardData.exampleImage && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<img
|
||||||
{/* 標題區 */}
|
src={cardData.exampleImage}
|
||||||
<div className="flex justify-between items-start mb-6">
|
alt="Example illustration"
|
||||||
<h2 className="text-2xl font-bold text-gray-900">例句填空</h2>
|
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
||||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
onClick={() => {
|
||||||
{cardData.difficultyLevel}
|
// 圖片點擊處理 - 後續可以添加放大功能
|
||||||
</span>
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 圖片區(如果有) */}
|
{/* 指示文字 */}
|
||||||
{cardData.exampleImage && (
|
<p className="text-lg text-gray-700 text-left">
|
||||||
<div className="mb-6">
|
請點擊例句中的空白處輸入正確的單字:
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
</p>
|
||||||
<img
|
|
||||||
src={cardData.exampleImage}
|
|
||||||
alt="Example illustration"
|
|
||||||
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
// 這裡需要處理圖片點擊,但我們暫時移除 onImageClick
|
|
||||||
// 因為新的 cardData 接口可能不包含這個功能
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 指示文字 */}
|
{/* 填空句子區域 */}
|
||||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
<div className="bg-gray-50 rounded-lg p-6">
|
||||||
請點擊例句中的空白處輸入正確的單字:
|
{renderFilledSentence()}
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 填空句子區域 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-6">
|
|
||||||
{renderFilledSentence()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按鈕 */}
|
|
||||||
<div className="flex gap-3 mb-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!fillAnswer.trim() || showResult}
|
|
||||||
className={`px-6 py-2 rounded-lg transition-colors ${
|
|
||||||
!fillAnswer.trim() || showResult
|
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{!fillAnswer.trim() ? '請先輸入答案' : showResult ? '已確認' : '確認答案'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleToggleHint}
|
|
||||||
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
{showHint ? '隱藏提示' : '顯示提示'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提示區域 */}
|
|
||||||
<HintPanel
|
|
||||||
isVisible={showHint}
|
|
||||||
definition={cardData.definition}
|
|
||||||
synonyms={cardData.synonyms}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 結果反饋區 */}
|
|
||||||
<TestResultDisplay
|
|
||||||
isCorrect={isCorrect}
|
|
||||||
correctAnswer={correctAnswer}
|
|
||||||
userAnswer={fillAnswer}
|
|
||||||
word={cardData.word}
|
|
||||||
pronunciation={cardData.pronunciation}
|
|
||||||
example={cardData.example}
|
|
||||||
exampleTranslation={cardData.exampleTranslation}
|
|
||||||
showResult={showResult}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 輸入區域(包含操作按鈕和提示)
|
||||||
|
const inputArea = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 操作按鈕 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(fillAnswer)}
|
||||||
|
disabled={!fillAnswer.trim() || showResult}
|
||||||
|
className={`px-6 py-2 rounded-lg transition-colors ${
|
||||||
|
!fillAnswer.trim() || showResult
|
||||||
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!fillAnswer.trim() ? '請先輸入答案' : showResult ? '已確認' : '確認答案'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleHint}
|
||||||
|
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{showHint ? '隱藏提示' : '顯示提示'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示區域 */}
|
||||||
|
<HintPanel
|
||||||
|
isVisible={showHint}
|
||||||
|
definition={cardData.definition}
|
||||||
|
synonyms={cardData.synonyms}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult ? (
|
||||||
|
<TestResultDisplay
|
||||||
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={correctAnswer}
|
||||||
|
userAnswer={fillAnswer}
|
||||||
|
word={cardData.word}
|
||||||
|
pronunciation={cardData.pronunciation}
|
||||||
|
example={cardData.example}
|
||||||
|
exampleTranslation={cardData.exampleTranslation}
|
||||||
|
showResult={showResult}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FillTestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="例句填空"
|
||||||
|
sentenceArea={sentenceArea}
|
||||||
|
inputArea={inputArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SentenceFillTest = memo(SentenceFillTestComponent)
|
export const SentenceFillTest = memo(SentenceFillTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useCallback, useMemo, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/AudioPlayer'
|
import AudioPlayer from '@/components/AudioPlayer'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
TestHeader
|
ListeningTestContainer,
|
||||||
|
ChoiceGrid
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
import { ChoiceTestProps } from '@/types/review'
|
import { ChoiceTestProps } from '@/types/review'
|
||||||
|
|
||||||
|
|
@ -24,6 +24,9 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
|
||||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
|
||||||
|
// 判斷是否已答題(選擇了答案)
|
||||||
|
const hasAnswered = selectedAnswer !== null
|
||||||
|
|
||||||
const handleAnswerSelect = useCallback((answer: string) => {
|
const handleAnswerSelect = useCallback((answer: string) => {
|
||||||
if (disabled || showResult) return
|
if (disabled || showResult) return
|
||||||
setSelectedAnswer(answer)
|
setSelectedAnswer(answer)
|
||||||
|
|
@ -33,84 +36,73 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
|
||||||
|
|
||||||
const isCorrect = useMemo(() => selectedAnswer === cardData.example, [selectedAnswer, cardData.example])
|
const isCorrect = useMemo(() => selectedAnswer === cardData.example, [selectedAnswer, cardData.example])
|
||||||
|
|
||||||
return (
|
// 音頻播放區域
|
||||||
<div className="relative">
|
const audioArea = (
|
||||||
{/* 錯誤回報按鈕 */}
|
<div className="text-center">
|
||||||
<div className="flex justify-end mb-2">
|
<AudioPlayer text={cardData.example} />
|
||||||
<ErrorReportButton onClick={onReportError} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
|
||||||
{/* 標題區 */}
|
|
||||||
<TestHeader
|
|
||||||
title="例句聽力"
|
|
||||||
difficultyLevel={cardData.difficultyLevel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 指示文字 */}
|
|
||||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
|
||||||
請聽例句並選擇正確的選項:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 音頻播放區 */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
<AudioPlayer text={cardData.example} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 圖片區(如果有) */}
|
|
||||||
{exampleImage && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<img
|
|
||||||
src={exampleImage}
|
|
||||||
alt="Example illustration"
|
|
||||||
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
|
||||||
onClick={() => onImageClick?.(exampleImage)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 選項區域 - 響應式網格布局 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
|
||||||
{options.map((sentence, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
onClick={() => handleAnswerSelect(sentence)}
|
|
||||||
disabled={disabled || showResult}
|
|
||||||
className={`p-4 text-center rounded-lg border-2 transition-all ${
|
|
||||||
showResult
|
|
||||||
? sentence === cardData.example
|
|
||||||
? 'border-green-500 bg-green-50 text-green-700'
|
|
||||||
: sentence === selectedAnswer
|
|
||||||
? 'border-red-500 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-500'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-medium">{sentence}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 結果反饋區 */}
|
|
||||||
{showResult && (
|
|
||||||
<TestResultDisplay
|
|
||||||
isCorrect={isCorrect}
|
|
||||||
correctAnswer={cardData.example}
|
|
||||||
userAnswer={selectedAnswer || ''}
|
|
||||||
word={cardData.word}
|
|
||||||
pronunciation={cardData.pronunciation}
|
|
||||||
example={cardData.example}
|
|
||||||
exampleTranslation={cardData.exampleTranslation}
|
|
||||||
showResult={showResult}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 問題顯示區域(包含圖片)
|
||||||
|
const questionArea = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-lg text-gray-700 text-left">
|
||||||
|
請聽例句並選擇正確的選項:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 圖片區(如果有) */}
|
||||||
|
{exampleImage && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<img
|
||||||
|
src={exampleImage}
|
||||||
|
alt="Example illustration"
|
||||||
|
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
||||||
|
onClick={() => onImageClick?.(exampleImage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 答題區域
|
||||||
|
const answerArea = (
|
||||||
|
<ChoiceGrid
|
||||||
|
options={options}
|
||||||
|
selectedOption={selectedAnswer}
|
||||||
|
correctAnswer={cardData.example}
|
||||||
|
showResult={showResult}
|
||||||
|
onSelect={handleAnswerSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult ? (
|
||||||
|
<TestResultDisplay
|
||||||
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={cardData.example}
|
||||||
|
userAnswer={selectedAnswer || ''}
|
||||||
|
word={cardData.word}
|
||||||
|
pronunciation={cardData.pronunciation}
|
||||||
|
example={cardData.example}
|
||||||
|
exampleTranslation={cardData.exampleTranslation}
|
||||||
|
showResult={showResult}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListeningTestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="例句聽力"
|
||||||
|
audioArea={audioArea}
|
||||||
|
questionArea={questionArea}
|
||||||
|
answerArea={answerArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SentenceListeningTest = memo(SentenceListeningTestComponent)
|
export const SentenceListeningTest = memo(SentenceListeningTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useEffect, useCallback, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/AudioPlayer'
|
|
||||||
import { ReorderTestProps } from '@/types/review'
|
import { ReorderTestProps } from '@/types/review'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
TestHeader
|
TestContainer
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
|
|
||||||
interface SentenceReorderTestProps extends ReorderTestProps {
|
interface SentenceReorderTestProps extends ReorderTestProps {
|
||||||
|
|
@ -25,6 +23,9 @@ const SentenceReorderTestComponent: React.FC<SentenceReorderTestProps> = ({
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
const [reorderResult, setReorderResult] = useState<boolean | null>(null)
|
const [reorderResult, setReorderResult] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
// 判斷是否已答題(完成重組後設定 hasAnswered = true)
|
||||||
|
const hasAnswered = showResult
|
||||||
|
|
||||||
// 初始化單字順序
|
// 初始化單字順序
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = cardData.example.split(/\s+/).filter(word => word.length > 0)
|
const words = cardData.example.split(/\s+/).filter(word => word.length > 0)
|
||||||
|
|
@ -63,126 +64,129 @@ const SentenceReorderTestComponent: React.FC<SentenceReorderTestProps> = ({
|
||||||
setReorderResult(null)
|
setReorderResult(null)
|
||||||
}, [disabled, showResult, cardData.example])
|
}, [disabled, showResult, cardData.example])
|
||||||
|
|
||||||
return (
|
// 主要內容區域
|
||||||
<div className="relative">
|
const contentArea = (
|
||||||
<div className="flex justify-end mb-4">
|
<div className="space-y-6">
|
||||||
<ErrorReportButton onClick={onReportError} />
|
{/* 圖片區(如果有) */}
|
||||||
</div>
|
{exampleImage && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<img
|
||||||
{/* 標題區 */}
|
src={exampleImage}
|
||||||
<div className="flex justify-between items-start mb-6">
|
alt="Example illustration"
|
||||||
<h2 className="text-2xl font-bold text-gray-900">例句重組</h2>
|
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
||||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
onClick={() => onImageClick?.(exampleImage)}
|
||||||
{cardData.difficultyLevel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 圖片區(如果有) */}
|
|
||||||
{exampleImage && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<img
|
|
||||||
src={exampleImage}
|
|
||||||
alt="Example illustration"
|
|
||||||
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
|
||||||
onClick={() => onImageClick?.(exampleImage)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 重組區域 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left">重組區域:</h3>
|
|
||||||
<div className="relative min-h-[120px] bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300">
|
|
||||||
{arrangedWords.length === 0 ? (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-lg">
|
|
||||||
請嘗試組成完整句子
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{arrangedWords.map((word, index) => (
|
|
||||||
<div
|
|
||||||
key={`arranged-${index}`}
|
|
||||||
className="inline-flex items-center bg-blue-100 text-blue-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-blue-200 transition-colors"
|
|
||||||
onClick={() => handleRemoveFromArranged(word)}
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
<span className="ml-2 text-blue-600 hover:text-blue-800">×</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 指示文字 */}
|
|
||||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
|
||||||
點擊下方單字,依序重組成正確的句子:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 可用單字區域 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left">可用單字:</h3>
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4 min-h-[80px]">
|
|
||||||
{shuffledWords.length === 0 ? (
|
|
||||||
<div className="text-center text-gray-400">
|
|
||||||
所有單字都已使用
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{shuffledWords.map((word, index) => (
|
|
||||||
<button
|
|
||||||
key={`shuffled-${index}`}
|
|
||||||
onClick={() => handleWordClick(word)}
|
|
||||||
disabled={disabled || showResult}
|
|
||||||
className="bg-gray-100 text-gray-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-gray-200 active:bg-gray-300 transition-colors select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 控制按鈕 */}
|
|
||||||
<div className="flex gap-3 mb-6">
|
|
||||||
{arrangedWords.length > 0 && !showResult && (
|
|
||||||
<button
|
|
||||||
onClick={handleCheckAnswer}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
檢查答案
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={disabled || showResult}
|
|
||||||
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
重新開始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 結果反饋區 */}
|
|
||||||
{showResult && reorderResult !== null && (
|
|
||||||
<TestResultDisplay
|
|
||||||
isCorrect={reorderResult}
|
|
||||||
correctAnswer={cardData.example}
|
|
||||||
userAnswer={arrangedWords.join(' ')}
|
|
||||||
word={cardData.word}
|
|
||||||
pronunciation={cardData.pronunciation}
|
|
||||||
example={cardData.example}
|
|
||||||
exampleTranslation={cardData.exampleTranslation}
|
|
||||||
showResult={showResult}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 指示文字 */}
|
||||||
|
<p className="text-lg text-gray-700 text-left">
|
||||||
|
點擊下方單字,依序重組成正確的句子:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 重組區域 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left">重組區域:</h3>
|
||||||
|
<div className="relative min-h-[120px] bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300">
|
||||||
|
{arrangedWords.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-lg">
|
||||||
|
請嘗試組成完整句子
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{arrangedWords.map((word, index) => (
|
||||||
|
<div
|
||||||
|
key={`arranged-${index}`}
|
||||||
|
className="inline-flex items-center bg-blue-100 text-blue-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-blue-200 transition-colors"
|
||||||
|
onClick={() => handleRemoveFromArranged(word)}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
<span className="ml-2 text-blue-600 hover:text-blue-800">×</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 答題區域
|
||||||
|
const answerArea = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 可用單字區域 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left">可用單字:</h3>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 min-h-[80px]">
|
||||||
|
{shuffledWords.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
所有單字都已使用
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{shuffledWords.map((word, index) => (
|
||||||
|
<button
|
||||||
|
key={`shuffled-${index}`}
|
||||||
|
onClick={() => handleWordClick(word)}
|
||||||
|
disabled={disabled || showResult}
|
||||||
|
className="bg-gray-100 text-gray-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-gray-200 active:bg-gray-300 transition-colors select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制按鈕 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{arrangedWords.length > 0 && !showResult && (
|
||||||
|
<button
|
||||||
|
onClick={handleCheckAnswer}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
檢查答案
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={disabled || showResult}
|
||||||
|
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
重新開始
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult && reorderResult !== null ? (
|
||||||
|
<TestResultDisplay
|
||||||
|
isCorrect={reorderResult}
|
||||||
|
correctAnswer={cardData.example}
|
||||||
|
userAnswer={arrangedWords.join(' ')}
|
||||||
|
word={cardData.word}
|
||||||
|
pronunciation={cardData.pronunciation}
|
||||||
|
example={cardData.example}
|
||||||
|
exampleTranslation={cardData.exampleTranslation}
|
||||||
|
showResult={showResult}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="例句重組"
|
||||||
|
contentArea={contentArea}
|
||||||
|
answerArea={answerArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SentenceReorderTest = memo(SentenceReorderTestComponent)
|
export const SentenceReorderTest = memo(SentenceReorderTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import React, { useState, useCallback, memo } from 'react'
|
import React, { useState, useCallback, memo } from 'react'
|
||||||
import VoiceRecorder from '@/components/VoiceRecorder'
|
import VoiceRecorder from '@/components/VoiceRecorder'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
SpeakingTestContainer
|
||||||
TestHeader
|
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
import { BaseReviewProps } from '@/types/review'
|
import { BaseReviewProps } from '@/types/review'
|
||||||
|
|
||||||
|
|
@ -21,51 +20,71 @@ const SentenceSpeakingTestComponent: React.FC<SentenceSpeakingTestProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
|
||||||
|
// 判斷是否已答題(錄音提交後設定 hasAnswered = true)
|
||||||
|
const hasAnswered = showResult
|
||||||
|
|
||||||
const handleRecordingComplete = useCallback(() => {
|
const handleRecordingComplete = useCallback(() => {
|
||||||
if (disabled || showResult) return
|
if (disabled || showResult) return
|
||||||
setShowResult(true)
|
setShowResult(true)
|
||||||
onAnswer(cardData.example) // 語音測驗通常都算正確
|
onAnswer(cardData.example) // 語音測驗通常都算正確
|
||||||
}, [disabled, showResult, cardData.example, onAnswer])
|
}, [disabled, showResult, cardData.example, onAnswer])
|
||||||
|
|
||||||
return (
|
// 提示區域
|
||||||
<div className="relative">
|
const promptArea = (
|
||||||
{/* 錯誤回報按鈕 */}
|
<div className="text-center">
|
||||||
<div className="flex justify-end mb-2">
|
<p className="text-lg text-gray-700 text-left mb-4">
|
||||||
<ErrorReportButton onClick={onReportError} />
|
請看例句圖片並大聲說出完整的例句:
|
||||||
</div>
|
</p>
|
||||||
|
{exampleImage && (
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
{/* 標題區 */}
|
<img
|
||||||
<TestHeader
|
src={exampleImage}
|
||||||
title="例句口說"
|
alt="Example illustration"
|
||||||
difficultyLevel={cardData.difficultyLevel}
|
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
||||||
/>
|
onClick={() => onImageClick?.(exampleImage)}
|
||||||
|
|
||||||
{/* VoiceRecorder 組件區域 */}
|
|
||||||
<div className="w-full">
|
|
||||||
<VoiceRecorder
|
|
||||||
targetText={cardData.example}
|
|
||||||
targetTranslation={cardData.exampleTranslation}
|
|
||||||
exampleImage={exampleImage}
|
|
||||||
instructionText="請看例句圖片並大聲說出完整的例句:"
|
|
||||||
onRecordingComplete={handleRecordingComplete}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* 結果反饋區 */}
|
|
||||||
{showResult && (
|
|
||||||
<div className="mt-6 p-6 rounded-lg bg-blue-50 border border-blue-200 w-full">
|
|
||||||
<p className="text-blue-700 text-left text-xl font-semibold mb-2">
|
|
||||||
錄音完成!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-600 text-left">
|
|
||||||
系統正在評估你的發音...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 錄音區域
|
||||||
|
const recordingArea = (
|
||||||
|
<div className="w-full">
|
||||||
|
<VoiceRecorder
|
||||||
|
targetText={cardData.example}
|
||||||
|
targetTranslation={cardData.exampleTranslation}
|
||||||
|
exampleImage={exampleImage}
|
||||||
|
instructionText="請看例句圖片並大聲說出完整的例句:"
|
||||||
|
onRecordingComplete={handleRecordingComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult ? (
|
||||||
|
<div className="p-6 rounded-lg bg-blue-50 border border-blue-200 w-full">
|
||||||
|
<p className="text-blue-700 text-left text-xl font-semibold mb-2">
|
||||||
|
錄音完成!
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-left">
|
||||||
|
系統正在評估你的發音...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeakingTestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="例句口說"
|
||||||
|
promptArea={promptArea}
|
||||||
|
recordingArea={recordingArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SentenceSpeakingTest = memo(SentenceSpeakingTestComponent)
|
export const SentenceSpeakingTest = memo(SentenceSpeakingTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useCallback, useMemo, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/AudioPlayer'
|
|
||||||
import { ChoiceTestProps } from '@/types/review'
|
import { ChoiceTestProps } from '@/types/review'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
TestHeader
|
ChoiceTestContainer,
|
||||||
|
ChoiceGrid
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
|
|
||||||
interface VocabChoiceTestProps extends ChoiceTestProps {
|
interface VocabChoiceTestProps extends ChoiceTestProps {}
|
||||||
// VocabChoiceTest specific props (if any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const VocabChoiceTestComponent: React.FC<VocabChoiceTestProps> = ({
|
const VocabChoiceTestComponent: React.FC<VocabChoiceTestProps> = ({
|
||||||
cardData,
|
cardData,
|
||||||
|
|
@ -21,6 +18,7 @@ const VocabChoiceTestComponent: React.FC<VocabChoiceTestProps> = ({
|
||||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
const handleAnswerSelect = useCallback((answer: string) => {
|
const handleAnswerSelect = useCallback((answer: string) => {
|
||||||
if (disabled || showResult) return
|
if (disabled || showResult) return
|
||||||
setSelectedAnswer(answer)
|
setSelectedAnswer(answer)
|
||||||
|
|
@ -32,70 +30,57 @@ const VocabChoiceTestComponent: React.FC<VocabChoiceTestProps> = ({
|
||||||
selectedAnswer === cardData.word
|
selectedAnswer === cardData.word
|
||||||
, [selectedAnswer, cardData.word])
|
, [selectedAnswer, cardData.word])
|
||||||
|
|
||||||
return (
|
// 問題顯示區域
|
||||||
<div className="relative">
|
const questionArea = (
|
||||||
<div className="flex justify-end mb-2">
|
<div className="text-center">
|
||||||
<ErrorReportButton onClick={onReportError} />
|
<div className="bg-gray-50 rounded-lg p-6">
|
||||||
</div>
|
<h3 className="font-semibold text-gray-900 mb-3 text-left">定義</h3>
|
||||||
|
<p className="text-gray-700 text-left text-lg leading-relaxed">{cardData.definition}</p>
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
|
||||||
{/* 標題區 */}
|
|
||||||
<TestHeader
|
|
||||||
title="詞彙選擇"
|
|
||||||
difficultyLevel={cardData.difficultyLevel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 指示文字 */}
|
|
||||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
|
||||||
請選擇符合上述定義的英文詞彙:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 定義顯示區 */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">定義</h3>
|
|
||||||
<p className="text-gray-700 text-left">{cardData.definition}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 選項區域 - 響應式網格布局 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
|
||||||
{options.map((option, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
onClick={() => handleAnswerSelect(option)}
|
|
||||||
disabled={disabled || showResult}
|
|
||||||
className={`p-4 text-center rounded-lg border-2 transition-all ${
|
|
||||||
showResult
|
|
||||||
? option === cardData.word
|
|
||||||
? 'border-green-500 bg-green-50 text-green-700'
|
|
||||||
: option === selectedAnswer
|
|
||||||
? 'border-red-500 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-500'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-medium">{option}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 結果反饋區 */}
|
|
||||||
{showResult && (
|
|
||||||
<TestResultDisplay
|
|
||||||
isCorrect={isCorrect}
|
|
||||||
correctAnswer={cardData.word}
|
|
||||||
userAnswer={selectedAnswer || ''}
|
|
||||||
word={cardData.word}
|
|
||||||
pronunciation={cardData.pronunciation}
|
|
||||||
example={cardData.example}
|
|
||||||
exampleTranslation={cardData.exampleTranslation}
|
|
||||||
showResult={showResult}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-lg text-gray-700 mt-4 text-left">
|
||||||
|
請選擇符合上述定義的英文詞彙:
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 選項區域
|
||||||
|
const optionsArea = (
|
||||||
|
<ChoiceGrid
|
||||||
|
options={options}
|
||||||
|
selectedOption={selectedAnswer}
|
||||||
|
correctAnswer={cardData.word}
|
||||||
|
showResult={showResult}
|
||||||
|
onSelect={handleAnswerSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult ? (
|
||||||
|
<TestResultDisplay
|
||||||
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={cardData.word}
|
||||||
|
userAnswer={selectedAnswer || ''}
|
||||||
|
word={cardData.word}
|
||||||
|
pronunciation={cardData.pronunciation}
|
||||||
|
example={cardData.example}
|
||||||
|
exampleTranslation={cardData.exampleTranslation}
|
||||||
|
showResult={showResult}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChoiceTestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="詞彙選擇"
|
||||||
|
questionArea={questionArea}
|
||||||
|
optionsArea={optionsArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VocabChoiceTest = memo(VocabChoiceTestComponent)
|
export const VocabChoiceTest = memo(VocabChoiceTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useCallback, useMemo, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/AudioPlayer'
|
import AudioPlayer from '@/components/AudioPlayer'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
TestHeader
|
ListeningTestContainer,
|
||||||
|
ChoiceGrid
|
||||||
} from '@/components/review/shared'
|
} from '@/components/review/shared'
|
||||||
import { ChoiceTestProps } from '@/types/review'
|
import { ChoiceTestProps } from '@/types/review'
|
||||||
|
|
||||||
interface VocabListeningTestProps extends ChoiceTestProps {
|
interface VocabListeningTestProps extends ChoiceTestProps {}
|
||||||
// VocabListeningTest specific props (if any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
||||||
cardData,
|
cardData,
|
||||||
|
|
@ -21,6 +19,9 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
||||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
|
||||||
|
// 判斷是否已答題(選擇了答案)
|
||||||
|
const hasAnswered = selectedAnswer !== null
|
||||||
|
|
||||||
const handleAnswerSelect = useCallback((answer: string) => {
|
const handleAnswerSelect = useCallback((answer: string) => {
|
||||||
if (disabled || showResult) return
|
if (disabled || showResult) return
|
||||||
setSelectedAnswer(answer)
|
setSelectedAnswer(answer)
|
||||||
|
|
@ -30,74 +31,63 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
||||||
|
|
||||||
const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word])
|
const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word])
|
||||||
|
|
||||||
return (
|
// 音頻播放區域
|
||||||
<div className="relative">
|
const audioArea = (
|
||||||
{/* 錯誤回報按鈕 */}
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="flex justify-end mb-2">
|
<h3 className="font-semibold text-gray-900 mb-2 text-left">發音</h3>
|
||||||
<ErrorReportButton onClick={onReportError} />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
{cardData.pronunciation && <span className="text-gray-700">{cardData.pronunciation}</span>}
|
||||||
|
<AudioPlayer text={cardData.word} />
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
|
||||||
{/* 標題區 */}
|
|
||||||
<TestHeader
|
|
||||||
title="詞彙聽力"
|
|
||||||
difficultyLevel={cardData.difficultyLevel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 指示文字 */}
|
|
||||||
<p className="text-lg text-gray-700 mb-6 text-left">
|
|
||||||
請聽發音並選擇正確的英文單字:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 音頻播放區 */}
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">發音</h3>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{cardData.pronunciation && <span className="text-gray-700">{cardData.pronunciation}</span>}
|
|
||||||
<AudioPlayer text={cardData.word} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 選項區域 - 2x2網格布局 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
onClick={() => handleAnswerSelect(option)}
|
|
||||||
disabled={disabled || showResult}
|
|
||||||
className={`p-4 text-center rounded-lg border-2 transition-all ${
|
|
||||||
showResult
|
|
||||||
? option === cardData.word
|
|
||||||
? 'border-green-500 bg-green-50 text-green-700'
|
|
||||||
: option === selectedAnswer
|
|
||||||
? 'border-red-500 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-500'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-medium">{option}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 結果反饋區 */}
|
|
||||||
{showResult && (
|
|
||||||
<TestResultDisplay
|
|
||||||
isCorrect={isCorrect}
|
|
||||||
correctAnswer={cardData.word}
|
|
||||||
userAnswer={selectedAnswer || ''}
|
|
||||||
word={cardData.word}
|
|
||||||
pronunciation={cardData.pronunciation}
|
|
||||||
example={cardData.example}
|
|
||||||
exampleTranslation={cardData.exampleTranslation}
|
|
||||||
showResult={showResult}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 問題顯示區域
|
||||||
|
const questionArea = (
|
||||||
|
<p className="text-lg text-gray-700 text-left">
|
||||||
|
請聽發音並選擇正確的英文單字:
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 答題區域
|
||||||
|
const answerArea = (
|
||||||
|
<ChoiceGrid
|
||||||
|
options={options}
|
||||||
|
selectedOption={selectedAnswer}
|
||||||
|
correctAnswer={cardData.word}
|
||||||
|
showResult={showResult}
|
||||||
|
onSelect={handleAnswerSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 結果顯示區域
|
||||||
|
const resultArea = showResult ? (
|
||||||
|
<TestResultDisplay
|
||||||
|
isCorrect={isCorrect}
|
||||||
|
correctAnswer={cardData.word}
|
||||||
|
userAnswer={selectedAnswer || ''}
|
||||||
|
word={cardData.word}
|
||||||
|
pronunciation={cardData.pronunciation}
|
||||||
|
example={cardData.example}
|
||||||
|
exampleTranslation={cardData.exampleTranslation}
|
||||||
|
showResult={showResult}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListeningTestContainer
|
||||||
|
cardData={cardData}
|
||||||
|
testTitle="詞彙聽力"
|
||||||
|
audioArea={audioArea}
|
||||||
|
questionArea={questionArea}
|
||||||
|
answerArea={answerArea}
|
||||||
|
resultArea={resultArea}
|
||||||
|
onAnswer={onAnswer}
|
||||||
|
onReportError={onReportError}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VocabListeningTest = memo(VocabListeningTestComponent)
|
export const VocabListeningTest = memo(VocabListeningTestComponent)
|
||||||
|
|
|
||||||
|
|
@ -39,19 +39,6 @@ export const BaseTestComponent: React.FC<BaseTestComponentProps> = ({
|
||||||
showResult: false
|
showResult: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 更新測驗狀態
|
|
||||||
const updateTestState = useCallback((updates: Partial<TestState>) => {
|
|
||||||
setTestState(prev => ({ ...prev, ...updates }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 提供給子元件的狀態和方法
|
|
||||||
const testContext = {
|
|
||||||
testState,
|
|
||||||
updateTestState,
|
|
||||||
cardData,
|
|
||||||
disabled: disabled || testState.showResult
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
{/* 錯誤回報按鈕 */}
|
{/* 錯誤回報按鈕 */}
|
||||||
|
|
@ -76,7 +63,7 @@ export const BaseTestComponent: React.FC<BaseTestComponentProps> = ({
|
||||||
|
|
||||||
{/* 測驗內容區域 */}
|
{/* 測驗內容區域 */}
|
||||||
<div className="test-content">
|
<div className="test-content">
|
||||||
{React.cloneElement(children as React.ReactElement, { testContext })}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 結果顯示區域 */}
|
{/* 結果顯示區域 */}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export const TestContainer: React.FC<TestContainerProps> = ({
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 選擇題容器
|
// 選擇題容器
|
||||||
export interface ChoiceTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
export interface ChoiceTestContainerProps extends Omit<TestContainerProps, 'layout' | 'contentArea'> {
|
||||||
questionArea: ReactNode
|
questionArea: ReactNode
|
||||||
optionsArea: ReactNode
|
optionsArea: ReactNode
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ export const ChoiceTestContainer: React.FC<ChoiceTestContainerProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填空題容器
|
// 填空題容器
|
||||||
export interface FillTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
export interface FillTestContainerProps extends Omit<TestContainerProps, 'layout' | 'contentArea'> {
|
||||||
sentenceArea: ReactNode
|
sentenceArea: ReactNode
|
||||||
inputArea: ReactNode
|
inputArea: ReactNode
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +150,7 @@ export const FillTestContainer: React.FC<FillTestContainerProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聽力測驗容器
|
// 聽力測驗容器
|
||||||
export interface ListeningTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
export interface ListeningTestContainerProps extends Omit<TestContainerProps, 'layout' | 'contentArea'> {
|
||||||
audioArea: ReactNode
|
audioArea: ReactNode
|
||||||
questionArea: ReactNode
|
questionArea: ReactNode
|
||||||
answerArea: ReactNode
|
answerArea: ReactNode
|
||||||
|
|
@ -186,7 +186,7 @@ export const ListeningTestContainer: React.FC<ListeningTestContainerProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 口說測驗容器
|
// 口說測驗容器
|
||||||
export interface SpeakingTestContainerProps extends Omit<TestContainerProps, 'layout'> {
|
export interface SpeakingTestContainerProps extends Omit<TestContainerProps, 'layout' | 'contentArea'> {
|
||||||
promptArea: ReactNode
|
promptArea: ReactNode
|
||||||
recordingArea: ReactNode
|
recordingArea: ReactNode
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,504 @@
|
||||||
|
# 智能複習系統-第五階段開發計劃
|
||||||
|
|
||||||
|
**版本**: 1.0
|
||||||
|
**日期**: 2025-09-28
|
||||||
|
**基於**: 智能複習系統開發成果報告.md
|
||||||
|
**目標**: 測驗元件整合與導航系統實裝
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 階段概述
|
||||||
|
|
||||||
|
### 目標
|
||||||
|
將新開發的基礎架構整合到現有7種測驗元件,實現完整的智能導航和跳過隊列管理功能。
|
||||||
|
|
||||||
|
### 預計時間
|
||||||
|
3-4天
|
||||||
|
|
||||||
|
### 重點任務
|
||||||
|
- 元件重構:使用新基礎架構
|
||||||
|
- 導航整合:實現智能導航控制
|
||||||
|
- 狀態管理:優化答題和跳過邏輯
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 計劃文件結構
|
||||||
|
|
||||||
|
將在根目錄創建以下文件:
|
||||||
|
```
|
||||||
|
/智能複習系統-第五階段開發計劃.md # 本計劃文件 ✅
|
||||||
|
/智能複習系統-整合進度追蹤.md # 實時進度更新
|
||||||
|
/智能複習系統-測試案例文檔.md # 測試場景和結果
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 第一部分:測驗元件重構(Day 1-2)
|
||||||
|
|
||||||
|
### 1.1 FlipMemoryTest 重構
|
||||||
|
**目標**: 使用新架構並支援導航狀態
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 整合 `FlipTestContainer`
|
||||||
|
- 使用 `ConfidenceLevel` 元件
|
||||||
|
- 添加 `hasAnswered` 狀態追蹤
|
||||||
|
- 當選擇信心等級後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
**實現細節**:
|
||||||
|
```typescript
|
||||||
|
// 新的 FlipMemoryTest 結構
|
||||||
|
<FlipTestContainer
|
||||||
|
cardArea={翻卡區域}
|
||||||
|
confidenceArea={<ConfidenceLevel onSelect={handleConfidenceSelect} />}
|
||||||
|
navigationArea={<SmartNavigationController hasAnswered={hasAnswered} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 VocabChoiceTest 重構
|
||||||
|
**目標**: 使用統一選擇題架構
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 整合 `ChoiceTestContainer`
|
||||||
|
- 使用 `ChoiceGrid` 元件
|
||||||
|
- 整合 `useTestAnswer` Hook
|
||||||
|
- 選擇答案後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
**實現細節**:
|
||||||
|
```typescript
|
||||||
|
// 新的 VocabChoiceTest 結構
|
||||||
|
<ChoiceTestContainer
|
||||||
|
questionArea={定義顯示區}
|
||||||
|
optionsArea={<ChoiceGrid options={options} onSelect={handleAnswer} />}
|
||||||
|
resultArea={結果顯示}
|
||||||
|
navigationArea={<SmartNavigationController hasAnswered={showResult} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 SentenceFillTest 重構
|
||||||
|
**目標**: 使用統一填空題架構
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 整合 `FillTestContainer`
|
||||||
|
- 使用 `TextInput` 元件
|
||||||
|
- 添加答題狀態管理
|
||||||
|
- 提交答案後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
**實現細節**:
|
||||||
|
```typescript
|
||||||
|
// 新的 SentenceFillTest 結構
|
||||||
|
<FillTestContainer
|
||||||
|
sentenceArea={例句顯示區}
|
||||||
|
inputArea={<TextInput onSubmit={handleAnswer} />}
|
||||||
|
resultArea={結果顯示}
|
||||||
|
navigationArea={<SmartNavigationController hasAnswered={showResult} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 SentenceReorderTest 重構
|
||||||
|
**目標**: 保留拖拽功能,整合新架構
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 使用 `TestContainer` 基礎容器
|
||||||
|
- 保留現有拖拽邏輯
|
||||||
|
- 添加導航狀態支援
|
||||||
|
- 完成重組後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
### 1.5 聽力測驗重構(VocabListening & SentenceListening)
|
||||||
|
**目標**: 統一聽力測驗架構
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 整合 `ListeningTestContainer`
|
||||||
|
- 使用 `ChoiceGrid` 元件
|
||||||
|
- 添加音頻播放狀態管理
|
||||||
|
- 選擇答案後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
**實現細節**:
|
||||||
|
```typescript
|
||||||
|
// 新的聽力測驗結構
|
||||||
|
<ListeningTestContainer
|
||||||
|
audioArea={<AudioPlayer />}
|
||||||
|
questionArea={問題顯示}
|
||||||
|
answerArea={<ChoiceGrid options={options} />}
|
||||||
|
navigationArea={<SmartNavigationController hasAnswered={showResult} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 SentenceSpeakingTest 重構
|
||||||
|
**目標**: 整合錄音控制
|
||||||
|
|
||||||
|
**重構要點**:
|
||||||
|
- 使用 `SpeakingTestContainer`
|
||||||
|
- 整合 `RecordingControl` 元件
|
||||||
|
- 錄音提交後設定 `hasAnswered = true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 第二部分:ReviewRunner 整合(Day 2-3)
|
||||||
|
|
||||||
|
### 2.1 導航控制器整合
|
||||||
|
|
||||||
|
**新增功能**:
|
||||||
|
```typescript
|
||||||
|
// 在 ReviewRunner 中添加
|
||||||
|
import { SmartNavigationController } from '@/components/review/NavigationController'
|
||||||
|
|
||||||
|
// 狀態追蹤
|
||||||
|
const [hasAnswered, setHasAnswered] = useState(false)
|
||||||
|
|
||||||
|
// 重置狀態(切換測驗時)
|
||||||
|
useEffect(() => {
|
||||||
|
setHasAnswered(false)
|
||||||
|
}, [currentTestIndex])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 答錯處理機制
|
||||||
|
|
||||||
|
**實現邏輯**:
|
||||||
|
```typescript
|
||||||
|
const handleIncorrectAnswer = (testIndex: number) => {
|
||||||
|
// 1. 標記為答錯
|
||||||
|
markTestIncorrect(testIndex)
|
||||||
|
|
||||||
|
// 2. 設定已答題狀態
|
||||||
|
setHasAnswered(true)
|
||||||
|
|
||||||
|
// 3. 自動重排隊列(優先級 20)
|
||||||
|
// 4. 題目移到隊列最後
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 跳過處理機制
|
||||||
|
|
||||||
|
**實現邏輯**:
|
||||||
|
```typescript
|
||||||
|
const handleSkipTest = () => {
|
||||||
|
// 1. 調用跳過邏輯
|
||||||
|
skipCurrentTest()
|
||||||
|
|
||||||
|
// 2. 不記錄答題結果
|
||||||
|
// 3. 優先級設為 10
|
||||||
|
// 4. 移到隊列最後
|
||||||
|
|
||||||
|
// 5. 重置答題狀態
|
||||||
|
setHasAnswered(false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 狀態同步
|
||||||
|
|
||||||
|
**確保狀態一致性**:
|
||||||
|
- `hasAnswered` 狀態同步到導航控制器
|
||||||
|
- 測驗完成狀態更新到 store
|
||||||
|
- 處理頁面刷新恢復邏輯
|
||||||
|
- 防止狀態不一致問題
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 第三部分:整合測試(Day 3-4)
|
||||||
|
|
||||||
|
### 3.1 單元測試
|
||||||
|
|
||||||
|
**測試每個重構的元件**:
|
||||||
|
- ✅ 答題狀態追蹤正確
|
||||||
|
- ✅ 導航按鈕顯示邏輯
|
||||||
|
- ✅ 狀態更新正確性
|
||||||
|
- ✅ Props 傳遞正確
|
||||||
|
|
||||||
|
### 3.2 整合測試場景
|
||||||
|
|
||||||
|
**核心流程測試**:
|
||||||
|
|
||||||
|
#### 場景1: 正常答題流程
|
||||||
|
```
|
||||||
|
1. 載入測驗 → 顯示「跳過」按鈕
|
||||||
|
2. 答題 → 設定 hasAnswered = true
|
||||||
|
3. 顯示「繼續」按鈕
|
||||||
|
4. 點擊繼續 → 進入下一題
|
||||||
|
5. 重置 hasAnswered = false
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 場景2: 跳過流程
|
||||||
|
```
|
||||||
|
1. 載入測驗 → 顯示「跳過」按鈕
|
||||||
|
2. 點擊跳過 → skipCurrentTest()
|
||||||
|
3. 題目移到隊列最後
|
||||||
|
4. 載入下一個優先級測驗
|
||||||
|
5. 最終需要回來完成跳過的題目
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 場景3: 答錯流程
|
||||||
|
```
|
||||||
|
1. 載入測驗 → 顯示「跳過」按鈕
|
||||||
|
2. 答錯 → markTestIncorrect()
|
||||||
|
3. 設定 hasAnswered = true
|
||||||
|
4. 顯示「繼續」按鈕
|
||||||
|
5. 題目移到隊列最後重複練習
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 場景4: 混合流程
|
||||||
|
```
|
||||||
|
多種操作組合:答對+跳過+答錯的混合場景
|
||||||
|
驗證優先級排序正確性
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 邊界案例
|
||||||
|
|
||||||
|
**特殊情況處理**:
|
||||||
|
- 全部題目跳過的處理
|
||||||
|
- 最後一題的導航邏輯
|
||||||
|
- 網路中斷恢復
|
||||||
|
- 頁面刷新狀態保持
|
||||||
|
- 無題目時的狀態
|
||||||
|
- 連續答錯的處理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 第四部分:優化和調整(Day 4)
|
||||||
|
|
||||||
|
### 4.1 效能優化
|
||||||
|
|
||||||
|
**優化重點**:
|
||||||
|
- 減少不必要的重新渲染
|
||||||
|
- 優化狀態更新邏輯
|
||||||
|
- 防止記憶體洩漏
|
||||||
|
- 使用 React.memo 和 useCallback
|
||||||
|
|
||||||
|
### 4.2 使用者體驗優化
|
||||||
|
|
||||||
|
**改進項目**:
|
||||||
|
- 添加過渡動畫
|
||||||
|
- 優化按鈕響應速度
|
||||||
|
- 改善錯誤提示
|
||||||
|
- 增強視覺回饋
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 具體實施步驟
|
||||||
|
|
||||||
|
### Step 1: 創建開發分支
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/integrate-navigation-system
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 逐個重構測驗元件
|
||||||
|
1. **FlipMemoryTest** 開始(最簡單)
|
||||||
|
2. **VocabChoiceTest** 和 **VocabListeningTest**(選擇題類型)
|
||||||
|
3. **SentenceFillTest**(填空題類型)
|
||||||
|
4. **SentenceListeningTest**(聽力+選擇)
|
||||||
|
5. **SentenceReorderTest**(拖拽類型)
|
||||||
|
6. **SentenceSpeakingTest**(錄音類型,最複雜)
|
||||||
|
|
||||||
|
### Step 3: 更新 ReviewRunner
|
||||||
|
1. 添加狀態追蹤邏輯
|
||||||
|
2. 整合導航控制器
|
||||||
|
3. 實現答錯和跳過處理
|
||||||
|
4. 測試狀態同步
|
||||||
|
|
||||||
|
### Step 4: 測試和調試
|
||||||
|
1. 本地開發測試
|
||||||
|
2. 修復發現的問題
|
||||||
|
3. 優化效能
|
||||||
|
4. 邊界案例驗證
|
||||||
|
|
||||||
|
### Step 5: 文檔更新
|
||||||
|
1. 更新整合指南
|
||||||
|
2. 記錄測試結果
|
||||||
|
3. 撰寫使用說明
|
||||||
|
4. 更新開發成果報告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 風險和注意事項
|
||||||
|
|
||||||
|
### 技術風險
|
||||||
|
|
||||||
|
| 風險項目 | 影響程度 | 緩解策略 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 相容性問題 | 高 | 漸進式重構,保留備份 |
|
||||||
|
| 狀態同步複雜 | 中 | 充分測試,清楚文檔 |
|
||||||
|
| 效能下降 | 中 | 使用 React 優化技巧 |
|
||||||
|
| 測試覆蓋不足 | 高 | 建立完整測試場景 |
|
||||||
|
|
||||||
|
### 緩解策略
|
||||||
|
|
||||||
|
1. **保留原始檔案備份**
|
||||||
|
2. **漸進式重構**,一次一個元件
|
||||||
|
3. **充分測試**每個步驟
|
||||||
|
4. **及時提交**,避免大量變更
|
||||||
|
5. **文檔記錄**所有變更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 成功指標
|
||||||
|
|
||||||
|
### 技術指標
|
||||||
|
- ✅ 所有7種測驗元件成功重構
|
||||||
|
- ✅ 導航控制器正常運作
|
||||||
|
- ✅ 跳過隊列管理功能正常
|
||||||
|
- ✅ 無編譯錯誤和警告
|
||||||
|
- ✅ 效能無明顯下降
|
||||||
|
|
||||||
|
### 功能指標
|
||||||
|
- ✅ 答題前只顯示「跳過」按鈕
|
||||||
|
- ✅ 答題後只顯示「繼續」按鈕
|
||||||
|
- ✅ 跳過題目正確移到隊列最後
|
||||||
|
- ✅ 答錯題目能夠重複練習
|
||||||
|
- ✅ 優先級排序符合預期
|
||||||
|
|
||||||
|
### 使用者體驗指標
|
||||||
|
- ✅ 導航流暢無延遲
|
||||||
|
- ✅ 狀態切換即時響應
|
||||||
|
- ✅ 進度追蹤準確顯示
|
||||||
|
- ✅ 視覺回饋清晰明確
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 預期成果
|
||||||
|
|
||||||
|
完成第五階段後將實現:
|
||||||
|
|
||||||
|
### 1. 統一的元件架構
|
||||||
|
- 所有測驗使用相同基礎架構
|
||||||
|
- 程式碼重用度大幅提高
|
||||||
|
- 維護成本顯著降低
|
||||||
|
|
||||||
|
### 2. 智能導航系統
|
||||||
|
- 完全符合 PRD US-008 要求
|
||||||
|
- 狀態驅動的按鈕顯示
|
||||||
|
- 答題與導航完全分離
|
||||||
|
|
||||||
|
### 3. 跳過隊列管理
|
||||||
|
- 實現 PRD US-009 所有功能
|
||||||
|
- 靈活的學習節奏控制
|
||||||
|
- 智能優先級排序
|
||||||
|
|
||||||
|
### 4. 更好的開發體驗
|
||||||
|
- 統一的開發模式
|
||||||
|
- 清晰的架構規範
|
||||||
|
- 便於擴展和維護
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 檢查清單
|
||||||
|
|
||||||
|
### 開發前準備
|
||||||
|
- [ ] 創建開發分支
|
||||||
|
- [ ] 備份現有檔案
|
||||||
|
- [ ] 設置測試環境
|
||||||
|
- [ ] 準備測試數據
|
||||||
|
|
||||||
|
### 重構檢查
|
||||||
|
- [x] FlipMemoryTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] VocabChoiceTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] SentenceFillTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] SentenceReorderTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] VocabListeningTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] SentenceListeningTest 重構完成 ✅ (2025-09-28)
|
||||||
|
- [x] SentenceSpeakingTest 重構完成 ✅ (2025-09-28)
|
||||||
|
|
||||||
|
### 整合檢查
|
||||||
|
- [ ] ReviewRunner 更新完成
|
||||||
|
- [ ] 導航控制器整合
|
||||||
|
- [ ] 狀態管理優化
|
||||||
|
- [ ] 測試場景驗證
|
||||||
|
|
||||||
|
### 最終檢查
|
||||||
|
- [ ] 所有測試通過
|
||||||
|
- [ ] 效能符合要求
|
||||||
|
- [ ] 文檔更新完成
|
||||||
|
- [ ] 程式碼審查通過
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**批准**: 待確認
|
||||||
|
**預計開始日期**: 2025-09-28
|
||||||
|
**預計完成日期**: 2025-10-02
|
||||||
|
**負責人**: 開發團隊
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 實際開發進度
|
||||||
|
|
||||||
|
### 2025-09-28 開發日誌
|
||||||
|
|
||||||
|
#### ✅ 已完成
|
||||||
|
1. **FlipMemoryTest 重構** (2025-09-28 16:30)
|
||||||
|
- 成功整合 ConfidenceLevel 元件
|
||||||
|
- 實現 hasAnswered 狀態追蹤邏輯
|
||||||
|
- 保留完整翻卡動畫功能
|
||||||
|
- 使用 inline styles 替代 styled-jsx 避免編譯問題
|
||||||
|
- 編譯測試通過,review-design 頁面正常運行
|
||||||
|
|
||||||
|
2. **VocabChoiceTest 重構** (2025-09-28 16:40)
|
||||||
|
- 成功整合 ChoiceTestContainer 架構
|
||||||
|
- 使用新的 ChoiceGrid 元件替代原有選項實現
|
||||||
|
- 實現 hasAnswered 狀態追蹤(選擇答案後設為 true)
|
||||||
|
- 保留完整的答題結果顯示和反饋功能
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
3. **SentenceFillTest 重構** (2025-09-28 16:43)
|
||||||
|
- 成功整合 FillTestContainer 架構
|
||||||
|
- 使用新的 TextInput 元件替代原有 SentenceInput
|
||||||
|
- 保留複雜的填空邏輯(支援後端挖空和前端降級)
|
||||||
|
- 整合提示功能和操作按鈕
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
#### ✅ 已完成 (2025-09-28 16:50)
|
||||||
|
**階段性檢查和優化完成**:
|
||||||
|
- 所有重構元件編譯成功無錯誤
|
||||||
|
- 修復 BaseTestComponent.tsx TypeScript 錯誤
|
||||||
|
- 清理未使用的代碼和註釋
|
||||||
|
- 開發伺服器運行穩定,/review-design 頁面正常載入
|
||||||
|
- TypeScript 類型檢查通過
|
||||||
|
|
||||||
|
#### ✅ 已完成 (2025-09-28 17:00)
|
||||||
|
**所有7種測驗元件重構完成**:
|
||||||
|
|
||||||
|
**4. SentenceReorderTest 重構** (2025-09-28 16:55)
|
||||||
|
- 使用 TestContainer 基礎容器
|
||||||
|
- 保留完整拖拽重組邏輯
|
||||||
|
- 拆分為 contentArea(重組區域)和 answerArea(可用單字+控制按鈕)
|
||||||
|
- 實現 hasAnswered 狀態追蹤
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
**5. VocabListeningTest 重構** (2025-09-28 16:57)
|
||||||
|
- 使用 ListeningTestContainer 架構
|
||||||
|
- 整合新的 ChoiceGrid 元件
|
||||||
|
- 修復 ListeningTestContainer 介面問題(排除 contentArea)
|
||||||
|
- 音頻播放、問題顯示、答題區域分離
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
**6. SentenceListeningTest 重構** (2025-09-28 16:58)
|
||||||
|
- 使用 ListeningTestContainer 架構
|
||||||
|
- 保留圖片支援功能
|
||||||
|
- 使用 ChoiceGrid 統一選項介面
|
||||||
|
- 完整聽力+選擇題流程整合
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
**7. SentenceSpeakingTest 重構** (2025-09-28 17:00)
|
||||||
|
- 使用 SpeakingTestContainer 架構
|
||||||
|
- 修復 SpeakingTestContainer 介面問題(排除 contentArea)
|
||||||
|
- 保留完整 VoiceRecorder 整合功能
|
||||||
|
- 拆分為 promptArea(提示+圖片)和 recordingArea(錄音控制)
|
||||||
|
- 編譯成功無錯誤
|
||||||
|
|
||||||
|
#### ✅ 重構總結
|
||||||
|
**架構統一達成**:
|
||||||
|
- 7種測驗元件全部重構完成
|
||||||
|
- 統一使用容器組件模式
|
||||||
|
- 各元件都實現 hasAnswered 狀態追蹤
|
||||||
|
- 所有元件編譯無錯誤,TypeScript 檢查通過
|
||||||
|
|
||||||
|
#### 🔄 進行中
|
||||||
|
- 準備開始 ReviewRunner 導航系統整合
|
||||||
|
|
||||||
|
#### 📝 技術備註
|
||||||
|
- FlipMemoryTest 未使用 FlipTestContainer,因為該容器不支援翻卡特定功能
|
||||||
|
- 使用 React inline styles 實現 3D 翻卡效果,避免 styled-jsx 編譯問題
|
||||||
|
- 成功集成新的 ConfidenceLevel 元件,替代原有 ConfidenceButtons
|
||||||
|
- hasAnswered 狀態正確追蹤:選擇信心等級後設定為 true
|
||||||
|
|
||||||
|
#### ⚠️ 注意事項
|
||||||
|
- BaseTestComponent 的 cardData.difficultyLevel 存取錯誤已解決
|
||||||
|
- 確保所有 mockCardData 包含必要欄位(包括 synonyms)
|
||||||
|
- Next.js 編譯快取問題需要清除 .next 目錄解決
|
||||||
Loading…
Reference in New Issue