From df1c2b92ef1c6e9216d79093f3f9e4dec5bc5cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Thu, 2 Oct 2025 15:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E6=87=89=E7=94=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=8C=89=E9=88=95=E7=B5=B1=E4=B8=80=E7=82=BA=E8=97=8D?= =?UTF-8?q?=E5=BA=95=E6=BC=B8=E5=B1=A4=E8=A8=AD=E8=A8=88=20+=20=E6=9E=B6?= =?UTF-8?q?=E6=A7=8B=E7=B0=A1=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 組件統一: • 創建 BluePlayButton 統一組件 - 支援 sm/md/lg 三種尺寸 • 替換 10 個組件中的播放按鈕為統一的藍底漸層設計 • 移除 AudioPlayer 中間層抽象,直接使用 BluePlayButton 清理優化: • 刪除未使用的 TTSButton 和 AudioPlayer 組件 • 簡化組件架構,每個組件內建 TTS 播放邏輯 • 統一 speechSynthesis API 使用方式 視覺統一: • 藍底漸層 + 綠色播放中狀態 + 波紋動畫 • 響應式尺寸適配不同使用場景 • 完整的播放/暫停/禁用狀態設計 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../flashcards/FlashcardContentBlocks.tsx | 40 ++------ .../flashcards/FlashcardDetailHeader.tsx | 43 ++------- .../components/flashcards/FlashcardForm.tsx | 31 ++++++- .../components/generate/IdiomDetailModal.tsx | 36 +++++--- frontend/components/media/AudioPlayer.tsx | 91 ------------------- .../review/review-tests/FlipMemoryTest.tsx | 63 ++++++++++++- .../review-tests/SentenceListeningTest.tsx | 30 +++++- .../review-tests/VocabListeningTest.tsx | 30 +++++- .../review/shared/AnswerActions.tsx | 32 +++++-- .../review/shared/TestResultDisplay.tsx | 49 +++++++++- frontend/components/shared/BluePlayButton.tsx | 76 ++++++++++++++++ frontend/components/shared/TTSButton.tsx | 62 ------------- frontend/components/word/WordPopup.tsx | 36 +++++--- 13 files changed, 356 insertions(+), 263 deletions(-) delete mode 100644 frontend/components/media/AudioPlayer.tsx create mode 100644 frontend/components/shared/BluePlayButton.tsx delete mode 100644 frontend/components/shared/TTSButton.tsx diff --git a/frontend/components/flashcards/FlashcardContentBlocks.tsx b/frontend/components/flashcards/FlashcardContentBlocks.tsx index 0f3144e..9283842 100644 --- a/frontend/components/flashcards/FlashcardContentBlocks.tsx +++ b/frontend/components/flashcards/FlashcardContentBlocks.tsx @@ -1,6 +1,7 @@ import React from 'react' import type { Flashcard } from '@/lib/services/flashcards' import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils' +import { BluePlayButton } from '@/components/shared/BluePlayButton' interface FlashcardContentBlocksProps { flashcard: Flashcard @@ -142,40 +143,15 @@ export const FlashcardContentBlocks: React.FC = ({ "{flashcard.example}"

- + />

diff --git a/frontend/components/flashcards/FlashcardDetailHeader.tsx b/frontend/components/flashcards/FlashcardDetailHeader.tsx index f8f5cbd..eec0b5f 100644 --- a/frontend/components/flashcards/FlashcardDetailHeader.tsx +++ b/frontend/components/flashcards/FlashcardDetailHeader.tsx @@ -1,6 +1,7 @@ import React from 'react' import type { Flashcard } from '@/lib/services/flashcards' import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils' +import { BluePlayButton } from '@/components/shared/BluePlayButton' interface FlashcardDetailHeaderProps { flashcard: Flashcard @@ -25,42 +26,16 @@ export const FlashcardDetailHeader: React.FC = ({ {flashcard.pronunciation} - {/* TTS播放按鈕 - 藍底漸層設計 */} - + /> diff --git a/frontend/components/flashcards/FlashcardForm.tsx b/frontend/components/flashcards/FlashcardForm.tsx index 242e5e5..a36febd 100644 --- a/frontend/components/flashcards/FlashcardForm.tsx +++ b/frontend/components/flashcards/FlashcardForm.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { flashcardsService, type CreateFlashcardRequest, type Flashcard } from '@/lib/services/flashcards' -import AudioPlayer from '@/components/media/AudioPlayer' +import { BluePlayButton } from '@/components/shared/BluePlayButton' interface FlashcardFormProps { cardSets?: any[] // 保持相容性 @@ -13,6 +13,27 @@ interface FlashcardFormProps { } export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel }: FlashcardFormProps) { + const [isPlayingWord, setIsPlayingWord] = useState(false) + + // TTS 播放邏輯 + const handleToggleWordTTS = (text: string, lang?: string) => { + if (isPlayingWord) { + speechSynthesis.cancel() + setIsPlayingWord(false) + return + } + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' + utterance.rate = 0.8 + + utterance.onstart = () => setIsPlayingWord(true) + utterance.onend = () => setIsPlayingWord(false) + utterance.onerror = () => setIsPlayingWord(false) + + speechSynthesis.speak(utterance) + } + const [formData, setFormData] = useState({ word: initialData?.word || '', translation: initialData?.translation || '', @@ -128,7 +149,13 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel placeholder="例如: /wɜːrd/" /> {formData.pronunciation && ( - + )} diff --git a/frontend/components/generate/IdiomDetailModal.tsx b/frontend/components/generate/IdiomDetailModal.tsx index c89d2c8..29f28fc 100644 --- a/frontend/components/generate/IdiomDetailModal.tsx +++ b/frontend/components/generate/IdiomDetailModal.tsx @@ -1,7 +1,7 @@ -import React from 'react' -import { Play } from 'lucide-react' +import React, { useState } from 'react' import { Modal } from '@/components/ui/Modal' import { ContentBlock } from '@/components/shared/ContentBlock' +import { BluePlayButton } from '@/components/shared/BluePlayButton' interface IdiomAnalysis { idiom: string @@ -34,16 +34,29 @@ export const IdiomDetailModal: React.FC = ({ onSaveIdiom, className = '' }) => { + const [isPlaying, setIsPlaying] = useState(false) + if (!idiomPopup) { return null } const { analysis } = idiomPopup - const handlePlayPronunciation = () => { - const utterance = new SpeechSynthesisUtterance(analysis.idiom) - utterance.lang = 'en-US' + const handlePlayPronunciation = (text: string, lang?: string) => { + if (isPlaying) { + speechSynthesis.cancel() + setIsPlaying(false) + return + } + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' utterance.rate = 0.8 + + utterance.onstart = () => setIsPlaying(true) + utterance.onend = () => setIsPlaying(false) + utterance.onerror = () => setIsPlaying(false) + speechSynthesis.speak(utterance) } @@ -65,13 +78,14 @@ export const IdiomDetailModal: React.FC = ({

{analysis.pronunciation} - + />
diff --git a/frontend/components/media/AudioPlayer.tsx b/frontend/components/media/AudioPlayer.tsx deleted file mode 100644 index 4981f19..0000000 --- a/frontend/components/media/AudioPlayer.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useState, useRef } from 'react' -import { Play, Pause, Volume2 } from 'lucide-react' - -interface AudioPlayerProps { - text: string - className?: string - autoPlay?: boolean - voice?: 'us' | 'uk' - speed?: number -} - -export default function AudioPlayer({ - text, - className = '', - autoPlay = false, - voice = 'us', - speed = 1.0 -}: AudioPlayerProps) { - const [isPlaying, setIsPlaying] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const audioRef = useRef(null) - - const handlePlay = async () => { - if (!text.trim()) return - - try { - setIsLoading(true) - - // 簡單的TTS模擬 - 實際應該調用TTS API - const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = voice === 'us' ? 'en-US' : 'en-GB' - utterance.rate = speed - - utterance.onstart = () => { - setIsPlaying(true) - setIsLoading(false) - } - - utterance.onend = () => { - setIsPlaying(false) - } - - utterance.onerror = () => { - setIsPlaying(false) - setIsLoading(false) - } - - window.speechSynthesis.speak(utterance) - - } catch (error) { - console.error('TTS Error:', error) - setIsLoading(false) - setIsPlaying(false) - } - } - - const handleStop = () => { - window.speechSynthesis.cancel() - setIsPlaying(false) - } - - return ( - - ) -} \ No newline at end of file diff --git a/frontend/components/review/review-tests/FlipMemoryTest.tsx b/frontend/components/review/review-tests/FlipMemoryTest.tsx index a95ce6e..716aec9 100644 --- a/frontend/components/review/review-tests/FlipMemoryTest.tsx +++ b/frontend/components/review/review-tests/FlipMemoryTest.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, memo, useCallback } from 'react' -import AudioPlayer from '@/components/media/AudioPlayer' +import { BluePlayButton } from '@/components/shared/BluePlayButton' import { ErrorReportButton, TestHeader, @@ -16,6 +16,51 @@ const FlipMemoryTestComponent: React.FC = ({ disabled = false }) => { const [isFlipped, setIsFlipped] = useState(false) + const [isPlayingWord, setIsPlayingWord] = useState(false) + const [isPlayingExample, setIsPlayingExample] = useState(false) + + // TTS 播放邏輯 + const handleToggleWordTTS = useCallback((text: string, lang?: string) => { + if (isPlayingWord) { + speechSynthesis.cancel() + setIsPlayingWord(false) + return + } + + setIsPlayingExample(false) + speechSynthesis.cancel() + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' + utterance.rate = 0.8 + + utterance.onstart = () => setIsPlayingWord(true) + utterance.onend = () => setIsPlayingWord(false) + utterance.onerror = () => setIsPlayingWord(false) + + speechSynthesis.speak(utterance) + }, [isPlayingWord]) + + const handleToggleExampleTTS = useCallback((text: string, lang?: string) => { + if (isPlayingExample) { + speechSynthesis.cancel() + setIsPlayingExample(false) + return + } + + setIsPlayingWord(false) + speechSynthesis.cancel() + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' + utterance.rate = 0.8 + + utterance.onstart = () => setIsPlayingExample(true) + utterance.onend = () => setIsPlayingExample(false) + utterance.onerror = () => setIsPlayingExample(false) + + speechSynthesis.speak(utterance) + }, [isPlayingExample]) const [selectedConfidence, setSelectedConfidence] = useState(null) const [cardHeight, setCardHeight] = useState(400) const frontRef = useRef(null) @@ -111,7 +156,13 @@ const FlipMemoryTestComponent: React.FC = ({ {cardData.pronunciation} )}
e.stopPropagation()}> - +
@@ -148,7 +199,13 @@ const FlipMemoryTestComponent: React.FC = ({

{cardData.example}

e.stopPropagation()}> - +

{cardData.exampleTranslation}

diff --git a/frontend/components/review/review-tests/SentenceListeningTest.tsx b/frontend/components/review/review-tests/SentenceListeningTest.tsx index 530b929..41a052f 100644 --- a/frontend/components/review/review-tests/SentenceListeningTest.tsx +++ b/frontend/components/review/review-tests/SentenceListeningTest.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, memo } from 'react' -import AudioPlayer from '@/components/media/AudioPlayer' +import { BluePlayButton } from '@/components/shared/BluePlayButton' import { TestResultDisplay, ListeningTestContainer, @@ -23,6 +23,7 @@ const SentenceListeningTestComponent: React.FC = ({ }) => { const [selectedAnswer, setSelectedAnswer] = useState(null) const [showResult, setShowResult] = useState(false) + const [isPlayingExample, setIsPlayingExample] = useState(false) // 判斷是否已答題(選擇了答案) const hasAnswered = selectedAnswer !== null @@ -36,10 +37,35 @@ const SentenceListeningTestComponent: React.FC = ({ const isCorrect = useMemo(() => selectedAnswer === cardData.example, [selectedAnswer, cardData.example]) + // TTS 播放邏輯 + const handleToggleTTS = useCallback((text: string, lang?: string) => { + if (isPlayingExample) { + speechSynthesis.cancel() + setIsPlayingExample(false) + return + } + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' + utterance.rate = 0.8 + + utterance.onstart = () => setIsPlayingExample(true) + utterance.onend = () => setIsPlayingExample(false) + utterance.onerror = () => setIsPlayingExample(false) + + speechSynthesis.speak(utterance) + }, [isPlayingExample]) + // 音頻播放區域 const audioArea = (
- +
) diff --git a/frontend/components/review/review-tests/VocabListeningTest.tsx b/frontend/components/review/review-tests/VocabListeningTest.tsx index fab6bfe..b5d7ddd 100644 --- a/frontend/components/review/review-tests/VocabListeningTest.tsx +++ b/frontend/components/review/review-tests/VocabListeningTest.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, memo } from 'react' -import AudioPlayer from '@/components/media/AudioPlayer' +import { BluePlayButton } from '@/components/shared/BluePlayButton' import { TestResultDisplay, ListeningTestContainer, @@ -18,6 +18,7 @@ const VocabListeningTestComponent: React.FC = ({ }) => { const [selectedAnswer, setSelectedAnswer] = useState(null) const [showResult, setShowResult] = useState(false) + const [isPlayingWord, setIsPlayingWord] = useState(false) // 判斷是否已答題(選擇了答案) const hasAnswered = selectedAnswer !== null @@ -31,13 +32,38 @@ const VocabListeningTestComponent: React.FC = ({ const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word]) + // TTS 播放邏輯 + const handleToggleTTS = useCallback((text: string, lang?: string) => { + if (isPlayingWord) { + speechSynthesis.cancel() + setIsPlayingWord(false) + return + } + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' + utterance.rate = 0.8 + + utterance.onstart = () => setIsPlayingWord(true) + utterance.onend = () => setIsPlayingWord(false) + utterance.onerror = () => setIsPlayingWord(false) + + speechSynthesis.speak(utterance) + }, [isPlayingWord]) + // 音頻播放區域 const audioArea = (

發音

{cardData.pronunciation && {cardData.pronunciation}} - +
) diff --git a/frontend/components/review/shared/AnswerActions.tsx b/frontend/components/review/shared/AnswerActions.tsx index b0a135b..5fbbdb6 100644 --- a/frontend/components/review/shared/AnswerActions.tsx +++ b/frontend/components/review/shared/AnswerActions.tsx @@ -1,4 +1,5 @@ -import React, { memo } from 'react' +import React, { memo, useState } from 'react' +import { BluePlayButton } from '@/components/shared/BluePlayButton' /** * 答題動作元件集合 @@ -244,6 +245,18 @@ export const RecordingControl: React.FC = memo(({ onSubmit, disabled = false }) => { + const [isPlayingRecording, setIsPlayingRecording] = useState(false) + + const handlePlaybackToggle = () => { + if (isPlayingRecording) { + setIsPlayingRecording(false) + } else { + setIsPlayingRecording(true) + onPlayback() + // 模擬播放結束 + setTimeout(() => setIsPlayingRecording(false), 3000) + } + } return (
{/* 錄音按鈕 */} @@ -273,13 +286,16 @@ export const RecordingControl: React.FC = memo(({ {/* 控制按鈕 */} {hasRecording && !isRecording && (
- +
+ + 播放錄音 +

{example} - + handleToggleTTS(text, 'example', lang)} + size="sm" + title="播放例句" + />

{exampleTranslation} diff --git a/frontend/components/shared/BluePlayButton.tsx b/frontend/components/shared/BluePlayButton.tsx new file mode 100644 index 0000000..575d0b8 --- /dev/null +++ b/frontend/components/shared/BluePlayButton.tsx @@ -0,0 +1,76 @@ +import React from 'react' + +interface BluePlayButtonProps { + text?: string + lang?: string + isPlaying: boolean + onToggle: (text: string, lang?: string) => void + disabled?: boolean + className?: string + size?: 'sm' | 'md' | 'lg' + title?: string +} + +export const BluePlayButton: React.FC = ({ + text, + lang = 'en-US', + isPlaying, + onToggle, + disabled = false, + className = '', + size = 'md', + title +}) => { + const sizeClasses = { + sm: 'w-8 h-8', + md: 'w-10 h-10', + lg: 'w-12 h-12' + } + + const iconSizes = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6' + } + + const handleClick = () => { + onToggle(text || '', lang) + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/components/shared/TTSButton.tsx b/frontend/components/shared/TTSButton.tsx deleted file mode 100644 index 529dd2b..0000000 --- a/frontend/components/shared/TTSButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' - -interface TTSButtonProps { - text: string - lang?: string - isPlaying: boolean - onToggle: (text: string, lang?: string) => void - className?: string - size?: 'sm' | 'md' | 'lg' -} - -export const TTSButton: React.FC = ({ - text, - lang = 'en-US', - isPlaying, - onToggle, - className = '', - size = 'md' -}) => { - const sizeClasses = { - sm: 'w-6 h-6 text-xs', - md: 'w-8 h-8 text-sm', - lg: 'w-10 h-10 text-base' - } - - const baseClasses = ` - ${sizeClasses[size]} - rounded-full - border-2 - transition-all - duration-200 - flex - items-center - justify-center - cursor-pointer - hover:scale-110 - active:scale-95 - ` - - const stateClasses = isPlaying - ? 'bg-blue-500 border-blue-500 text-white animate-pulse' - : 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600' - - const handleClick = () => { - onToggle(text, lang) - } - - return ( - - ) -} \ No newline at end of file diff --git a/frontend/components/word/WordPopup.tsx b/frontend/components/word/WordPopup.tsx index dcf56d1..b848059 100644 --- a/frontend/components/word/WordPopup.tsx +++ b/frontend/components/word/WordPopup.tsx @@ -1,8 +1,8 @@ -import React from 'react' -import { Play } from 'lucide-react' +import React, { useState } from 'react' import { Modal } from '@/components/ui/Modal' import { ContentBlock } from '@/components/shared/ContentBlock' import { getCEFRColor } from '@/lib/utils/flashcardUtils' +import { BluePlayButton } from '@/components/shared/BluePlayButton' import { useWordAnalysis } from './hooks/useWordAnalysis' import type { WordAnalysis } from './types' @@ -24,6 +24,7 @@ export const WordPopup: React.FC = ({ isSaving = false }) => { const { getWordProperty } = useWordAnalysis() + const [isPlaying, setIsPlaying] = useState(false) if (!selectedWord || !analysis?.[selectedWord]) { return null @@ -31,11 +32,21 @@ export const WordPopup: React.FC = ({ const wordAnalysis = analysis[selectedWord] - const handlePlayPronunciation = () => { - const word = getWordProperty(wordAnalysis, 'word') || selectedWord - const utterance = new SpeechSynthesisUtterance(word) - utterance.lang = 'en-US' + const handlePlayPronunciation = (text: string, lang?: string) => { + if (isPlaying) { + speechSynthesis.cancel() + setIsPlaying(false) + return + } + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = lang || 'en-US' utterance.rate = 0.8 + + utterance.onstart = () => setIsPlaying(true) + utterance.onend = () => setIsPlaying(false) + utterance.onerror = () => setIsPlaying(false) + speechSynthesis.speak(utterance) } @@ -64,13 +75,14 @@ export const WordPopup: React.FC = ({ {getWordProperty(wordAnalysis, 'pronunciation')} - + />