ux: 優化學習模式的用戶體驗設計

- 簡化語音播放器,移除口音切換和音量控制
- 修正選擇題邏輯:根據英文定義選擇詞彙
- 修復播放按鈕事件冒泡問題,避免誤觸翻卡
- 優化翻卡背面設計,使用灰色區塊和自適應高度
- 統一語音播放為美式發音,提供一致體驗

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-19 14:49:37 +08:00
parent 0bfb08a97b
commit da85bb8f42
3 changed files with 48 additions and 206 deletions

View File

@ -73,12 +73,12 @@ export default function LearnPage() {
// Quiz mode options - dynamically generate from current cards
const quizOptions = [
cards[currentCardIndex].translation,
cards[currentCardIndex].word,
...cards
.filter((_, idx) => idx !== currentCardIndex)
.map(card => card.translation)
.map(card => card.word)
.slice(0, 2),
'建議、提議' // additional wrong option
'negotiate' // additional wrong option
].sort(() => Math.random() - 0.5) // shuffle options
const handleFlip = () => {
@ -130,7 +130,7 @@ export default function LearnPage() {
const handleQuizAnswer = (answer: string) => {
setSelectedAnswer(answer)
setShowResult(true)
if (answer === currentCard.translation) {
if (answer === currentCard.word) {
setScore({ ...score, correct: score.correct + 1, total: score.total + 1 })
} else {
setScore({ ...score, total: score.total + 1 })
@ -281,12 +281,12 @@ export default function LearnPage() {
</div>
<div
className="relative w-full h-96 cursor-pointer"
className="relative w-full min-h-96 cursor-pointer"
onClick={handleFlip}
style={{ perspective: '1000px' }}
>
<div
className={`absolute w-full h-full transition-transform duration-600 ${
className={`w-full transition-transform duration-600 ${
isFlipped ? 'rotate-y-180' : ''
}`}
style={{
@ -296,8 +296,11 @@ export default function LearnPage() {
>
{/* Front of card */}
<div
className="absolute w-full h-full bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center justify-center"
style={{ backfaceVisibility: 'hidden' }}
className="w-full min-h-96 bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center justify-center"
style={{
backfaceVisibility: 'hidden',
display: isFlipped ? 'none' : 'flex'
}}
>
<div className="text-4xl font-bold text-gray-900 mb-4">
{currentCard.word}
@ -311,10 +314,6 @@ export default function LearnPage() {
</div>
<AudioPlayer
text={currentCard.word}
accent="us"
speed={1.0}
showAccentSelector={false}
showSpeedControl={false}
className="flex-shrink-0"
/>
</div>
@ -325,39 +324,36 @@ export default function LearnPage() {
{/* Back of card */}
<div
className="absolute w-full h-full bg-white rounded-2xl shadow-xl p-8"
className="w-full bg-white rounded-2xl shadow-xl p-6"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
transform: 'rotateY(180deg)',
display: isFlipped ? 'block' : 'none'
}}
>
<div className="space-y-4">
<div>
<div className="space-y-3">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-1"></div>
<div className="text-2xl font-bold text-gray-900">{currentCard.translation}</div>
<div className="text-xl font-bold text-gray-900">{currentCard.translation}</div>
</div>
<div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-1"></div>
<div className="text-gray-600">{currentCard.definition}</div>
<div className="text-gray-600 text-sm">{currentCard.definition}</div>
</div>
<div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-1"></div>
<div className="text-gray-600 mb-2">{currentCard.example}</div>
<div className="text-gray-500 text-sm mb-3">{currentCard.exampleTranslation}</div>
<div className="text-gray-600 text-sm mb-2">{currentCard.example}</div>
<div className="text-gray-500 text-xs mb-2">{currentCard.exampleTranslation}</div>
<AudioPlayer
text={currentCard.example}
accent="us"
speed={0.8}
showAccentSelector={true}
showSpeedControl={true}
className="mt-2"
className="mt-1"
/>
</div>
<div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-1"></div>
<div className="flex flex-wrap gap-2">
{currentCard.synonyms.map((syn, idx) => (
<span key={idx} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
<span key={idx} className="px-2 py-1 bg-gray-100 rounded-full text-xs">
{syn}
</span>
))}
@ -419,21 +415,19 @@ export default function LearnPage() {
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="mb-6">
<div className="text-sm text-gray-600 mb-2"></div>
<div className="text-xl text-gray-800 leading-relaxed mb-3">
{currentCard.definition}
<div className="text-sm text-gray-600 mb-4"></div>
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-xl text-gray-800 leading-relaxed mb-3">
{currentCard.definition}
</div>
<div className="text-sm text-gray-500 mb-3">
({currentCard.partOfSpeech})
</div>
<AudioPlayer
text={currentCard.definition}
className="mt-2"
/>
</div>
<div className="text-sm text-gray-500 mb-3">
({currentCard.partOfSpeech})
</div>
<AudioPlayer
text={currentCard.definition}
accent="us"
speed={0.9}
showAccentSelector={false}
showSpeedControl={true}
className="mt-2"
/>
</div>
<div className="space-y-3">
@ -443,9 +437,9 @@ export default function LearnPage() {
onClick={() => !showResult && handleQuizAnswer(option)}
disabled={showResult}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
showResult && option === currentCard.translation
showResult && option === currentCard.word
? 'border-green-500 bg-green-50'
: showResult && option === selectedAnswer && option !== currentCard.translation
: showResult && option === selectedAnswer && option !== currentCard.word
? 'border-red-500 bg-red-50'
: selectedAnswer === option
? 'border-primary bg-primary-light'
@ -454,12 +448,12 @@ export default function LearnPage() {
>
<div className="flex items-center justify-between">
<span className="font-medium">{option}</span>
{showResult && option === currentCard.translation && (
{showResult && option === currentCard.word && (
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{showResult && option === selectedAnswer && option !== currentCard.translation && (
{showResult && option === selectedAnswer && option !== currentCard.word && (
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@ -598,10 +592,6 @@ export default function LearnPage() {
<div className="text-gray-500 mb-3">{currentCard.exampleTranslation}</div>
<AudioPlayer
text={currentCard.example}
accent="us"
speed={0.8}
showAccentSelector={false}
showSpeedControl={true}
className="mt-2"
/>
</div>
@ -637,10 +627,6 @@ export default function LearnPage() {
<div className="flex flex-col items-center mb-6">
<AudioPlayer
text={currentCard.word}
accent="us"
speed={1.0}
showAccentSelector={true}
showSpeedControl={true}
className="mb-4"
/>
<div className="text-sm text-gray-500">
@ -674,7 +660,6 @@ export default function LearnPage() {
{/* Result Display */}
{showResult && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-semibold text-gray-700 mb-2"></div>
<div className="space-y-2">
<div>
<span className="font-semibold">{currentCard.word}</span> - {currentCard.translation}
@ -728,10 +713,6 @@ export default function LearnPage() {
<span className="text-gray-500">{currentCard.pronunciation}</span>
<AudioPlayer
text={currentCard.word}
accent="us"
speed={1.0}
showAccentSelector={false}
showSpeedControl={false}
className="flex-shrink-0"
/>
</div>
@ -739,10 +720,6 @@ export default function LearnPage() {
<div className="text-sm text-gray-600 mb-2"></div>
<AudioPlayer
text={currentCard.example}
accent="us"
speed={0.8}
showAccentSelector={true}
showSpeedControl={true}
className="flex-shrink-0"
/>
</div>

View File

@ -6,11 +6,7 @@ import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react';
export interface AudioPlayerProps {
text: string;
audioUrl?: string;
accent?: 'us' | 'uk';
speed?: number;
autoPlay?: boolean;
showAccentSelector?: boolean;
showSpeedControl?: boolean;
onPlayStart?: () => void;
onPlayEnd?: () => void;
onError?: (error: string) => void;
@ -27,11 +23,7 @@ export interface TTSResponse {
export default function AudioPlayer({
text,
audioUrl: providedAudioUrl,
accent = 'us',
speed = 1.0,
autoPlay = false,
showAccentSelector = true,
showSpeedControl = true,
onPlayStart,
onPlayEnd,
onError,
@ -39,18 +31,13 @@ export default function AudioPlayer({
}: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentAccent, setCurrentAccent] = useState<'us' | 'uk'>(accent);
const [currentSpeed, setCurrentSpeed] = useState(speed);
const [audioUrl, setAudioUrl] = useState<string | null>(providedAudioUrl || null);
const [showSettings, setShowSettings] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// 生成音頻
const generateAudio = async (textToSpeak: string, accent: 'us' | 'uk', speed: number) => {
const generateAudio = async (textToSpeak: string) => {
try {
setIsLoading(true);
setError(null);
@ -63,8 +50,8 @@ export default function AudioPlayer({
},
body: JSON.stringify({
text: textToSpeak,
accent: accent,
speed: speed,
accent: 'us',
speed: 1.0,
voice: ''
})
});
@ -103,7 +90,7 @@ export default function AudioPlayer({
// 如果沒有音頻 URL先生成
if (!urlToPlay) {
urlToPlay = await generateAudio(text, currentAccent, currentSpeed);
urlToPlay = await generateAudio(text);
if (!urlToPlay) return;
}
@ -111,8 +98,6 @@ export default function AudioPlayer({
if (!audio) return;
audio.src = urlToPlay;
audio.playbackRate = currentSpeed;
audio.volume = isMuted ? 0 : volume;
await audio.play();
setIsPlaying(true);
@ -134,7 +119,8 @@ export default function AudioPlayer({
};
// 切換播放/暫停
const togglePlayPause = () => {
const togglePlayPause = (e?: React.MouseEvent) => {
e?.stopPropagation(); // 阻止事件冒泡
if (isPlaying) {
pauseAudio();
} else {
@ -155,58 +141,10 @@ export default function AudioPlayer({
onError?.(errorMessage);
};
// 切換口音
const handleAccentChange = async (newAccent: 'us' | 'uk') => {
if (newAccent === currentAccent) return;
setCurrentAccent(newAccent);
setAudioUrl(null); // 清除現有音頻,強制重新生成
// 如果正在播放,停止並重新生成
if (isPlaying) {
pauseAudio();
await generateAudio(text, newAccent, currentSpeed);
}
};
// 切換速度
const handleSpeedChange = async (newSpeed: number) => {
if (newSpeed === currentSpeed) return;
setCurrentSpeed(newSpeed);
// 如果音頻正在播放,直接調整播放速度
const audio = audioRef.current;
if (audio && isPlaying) {
audio.playbackRate = newSpeed;
} else {
// 否則清除音頻,重新生成
setAudioUrl(null);
}
};
// 音量控制
const handleVolumeChange = (newVolume: number) => {
setVolume(newVolume);
const audio = audioRef.current;
if (audio) {
audio.volume = isMuted ? 0 : newVolume;
}
};
const toggleMute = () => {
const newMuted = !isMuted;
setIsMuted(newMuted);
const audio = audioRef.current;
if (audio) {
audio.volume = newMuted ? 0 : volume;
}
};
// 自動播放
useEffect(() => {
if (autoPlay && text && !audioUrl) {
generateAudio(text, currentAccent, currentSpeed);
generateAudio(text);
}
}, [autoPlay, text]);
@ -242,75 +180,6 @@ export default function AudioPlayer({
)}
</button>
{/* 口音選擇器 */}
{showAccentSelector && (
<div className="flex gap-1">
<button
onClick={() => handleAccentChange('us')}
className={`
px-2 py-1 text-xs rounded transition-colors
${currentAccent === 'us'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
US
</button>
<button
onClick={() => handleAccentChange('uk')}
className={`
px-2 py-1 text-xs rounded transition-colors
${currentAccent === 'uk'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
UK
</button>
</div>
)}
{/* 速度控制 */}
{showSpeedControl && (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-600">Speed:</span>
<select
value={currentSpeed}
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
className="text-xs border border-gray-300 rounded px-1 py-0.5"
>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1.0}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2.0}>2x</option>
</select>
</div>
)}
{/* 音量控制 */}
<div className="flex items-center gap-1">
<button
onClick={toggleMute}
className="p-1 text-gray-600 hover:text-gray-800"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.1}
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-16 h-1"
/>
</div>
{/* 錯誤顯示 */}
{error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">

View File

@ -168,10 +168,6 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
<div className="flex-shrink-0">
<AudioPlayer
text={formData.english}
accent="us"
speed={1.0}
showAccentSelector={true}
showSpeedControl={false}
className="w-auto"
/>
</div>