ux: 優化學習模式的用戶體驗設計
- 簡化語音播放器,移除口音切換和音量控制 - 修正選擇題邏輯:根據英文定義選擇詞彙 - 修復播放按鈕事件冒泡問題,避免誤觸翻卡 - 優化翻卡背面設計,使用灰色區塊和自適應高度 - 統一語音播放為美式發音,提供一致體驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0bfb08a97b
commit
da85bb8f42
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue