更新至 v0.7.0_20251126 版本

This commit is contained in:
2025-11-26 23:52:23 +08:00
parent 66fa85e474
commit bd64cdd8de
8 changed files with 265 additions and 56 deletions

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { Language, ListeningLesson, ListeningLessonRecord, ReadingDifficulty, ChatMessage, Role, MessageType } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw, Copy, Check } from 'lucide-react';
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw, Copy, Check, PenTool, Sparkles } from 'lucide-react';
import { translations } from '../utils/localization';
import ChatBubble from '../components/ChatBubble';
@@ -61,6 +62,8 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
const [isTTSLoading, setIsTTSLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [audioCache, setAudioCache] = useState<string | null>(null);
const [playingVocabWord, setPlayingVocabWord] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
@@ -70,10 +73,32 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
const [isChatLoading, setIsChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Selection State
const [selectedText, setSelectedText] = useState<string | null>(null);
const scriptRef = useRef<HTMLDivElement>(null);
// Cleanup audio when leaving lesson
useEffect(() => {
return () => stopAudio();
}, [lesson]);
// Handle Selection
useEffect(() => {
const handleSelectionChange = () => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed && scriptRef.current && scriptRef.current.contains(selection.anchorNode)) {
const text = selection.toString().trim();
if (text.length > 0) {
setSelectedText(text);
return;
}
}
setSelectedText(null);
};
document.addEventListener('selectionchange', handleSelectionChange);
return () => document.removeEventListener('selectionchange', handleSelectionChange);
}, [lesson, showScript]);
const stopAudio = () => {
if (audioSourceRef.current) {
@@ -81,9 +106,10 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
audioSourceRef.current = null;
}
setIsPlaying(false);
setPlayingVocabWord(null);
};
const playAudioData = async (base64Data: string) => {
const playAudioData = async (base64Data: string, onEnded?: () => void) => {
stopAudio();
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
@@ -95,7 +121,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setIsPlaying(false);
source.onended = onEnded || (() => setIsPlaying(false));
source.start();
audioSourceRef.current = source;
};
@@ -108,7 +134,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
if (audioCache) {
setIsPlaying(true);
await playAudioData(audioCache);
await playAudioData(audioCache, () => setIsPlaying(false));
return;
}
@@ -124,7 +150,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
setAudioCache(audioBase64);
setIsPlaying(true);
await playAudioData(audioBase64);
await playAudioData(audioBase64, () => setIsPlaying(false));
} catch (e) {
console.error("TTS Playback failed", e);
setIsPlaying(false);
@@ -157,6 +183,25 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
}
};
const playVocab = async (word: string) => {
if (playingVocabWord === word) {
stopAudio();
return;
}
setPlayingVocabWord(word);
try {
const audioBase64 = await geminiService.generateSpeech(word);
if (audioBase64) {
await playAudioData(audioBase64, () => setPlayingVocabWord(null));
} else {
setPlayingVocabWord(null);
}
} catch (e) {
setPlayingVocabWord(null);
}
};
const generateLesson = async () => {
if (!topic.trim()) return;
setIsGenerating(true);
@@ -239,11 +284,21 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
}
};
const handleAskTutor = async () => {
if (!chatInput.trim() || !lesson) return;
const handleAskTutor = async (customQuestion?: string) => {
const question = customQuestion || chatInput;
if (!question.trim() || !lesson) return;
const question = chatInput;
setChatInput('');
setMobileTab('tutor');
if (!customQuestion) {
setChatInput('');
} else {
if (window.getSelection) {
window.getSelection()?.removeAllRanges();
}
setSelectedText(null);
}
setIsChatLoading(true);
// Add User Message
@@ -433,9 +488,9 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
{lesson && (
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
{/* Left: Content */}
<div className={`flex-1 flex-col h-full overflow-hidden bg-white relative z-10 ${mobileTab === 'content' ? 'flex' : 'hidden lg:flex'}`}>
<div className={`flex-1 flex-col h-full overflow-hidden bg-white relative z-10 ${mobileTab === 'text' ? 'flex' : 'hidden lg:flex'}`}>
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white/80 backdrop-blur z-10">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => { setLesson(null); setCurrentRecordId(null); }} className="text-slate-400 hover:text-slate-600 p-2 hover:scale-110 transition-transform">
<ChevronLeft size={24} />
</button>
@@ -475,7 +530,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
</div>
<div className="flex-1 overflow-y-auto p-6 md:p-10 bg-slate-50/30">
<div className="max-w-3xl mx-auto space-y-8">
<div className="max-w-3xl mx-auto space-y-8 pb-24">
{/* Audio Player Section - Modern Card Design */}
<div className="bg-white p-6 md:p-8 rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-slate-100 flex flex-col items-center justify-center animate-scale-in relative overflow-hidden">
@@ -622,7 +677,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
{/* Script Reveal */}
{showScript && (
<div className="animate-fade-in-up">
<div className="animate-fade-in-up" ref={scriptRef}>
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6 relative">
<div className="flex items-center justify-between mb-4">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{t.scriptTitle}</h4>
@@ -642,22 +697,68 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
</div>
{/* Vocabulary List */}
<div className="bg-sky-50/50 rounded-2xl p-6 border border-sky-100/50">
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm mb-6">
<h4 className="text-sm font-bold text-sky-800 mb-4 flex items-center gap-2">
<List size={18} /> {translations[language].reading.vocabTitle}
<List size={18} /> {t.vocabTitle}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lesson.vocabulary?.map((v, i) => (
<div key={i} className="bg-white p-3 rounded-xl shadow-sm border border-sky-100 hover:shadow-md transition-shadow relative">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-lg font-bold text-slate-800">{v.word}</span>
<span className="text-sm text-slate-500">({v.reading})</span>
<div
key={i}
className="bg-sky-50 p-3 rounded-xl border border-sky-100 flex flex-col group relative animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-sky-100/50"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'both' }}
>
<div className="flex justify-between items-start mb-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
<span className="text-lg font-bold text-slate-800">{v.word}</span>
{v.reading && <span className="text-sm text-slate-500">({v.reading})</span>}
</div>
<button
onClick={() => playVocab(v.word)}
className={`p-1.5 rounded-full transition-colors flex-shrink-0 ml-2 ${playingVocabWord === v.word ? 'bg-pink-100 text-pink-500' : 'text-sky-300 hover:bg-sky-100 hover:text-sky-600'}`}
>
{playingVocabWord === v.word ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
</button>
</div>
<p className="text-sm text-sky-700 font-medium">{v.meaning}</p>
</div>
))}
</div>
</div>
{/* Grammar Section */}
{lesson.grammarPoints && lesson.grammarPoints.length > 0 && (
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
<h4 className="text-sm font-bold text-sky-800 mb-4 flex items-center gap-2">
<PenTool size={18} /> {t.grammarHeader}
</h4>
<div className="space-y-4">
{lesson.grammarPoints.map((g, i) => (
<div
key={i}
className="bg-sky-50/50 p-4 rounded-xl border border-sky-100 animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-sky-100"
style={{ animationDelay: `${i * 100}ms`, animationFillMode: 'both' }}
>
<h5 className="font-bold text-sky-900 mb-1">{g.point}</h5>
<p className="text-sm text-sky-700 leading-relaxed">{g.explanation}</p>
</div>
))}
</div>
</div>
)}
{/* Floating Ask Button */}
{selectedText && (
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
<button
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
>
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
</button>
</div>
)}
</div>
)}
</div>
@@ -694,7 +795,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
/>
<button
onClick={handleAskTutor}
onClick={() => handleAskTutor()}
disabled={!chatInput.trim() || isChatLoading}
className="p-2 bg-sky-500 text-white rounded-full hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed transform active:scale-95 transition-transform"
>
@@ -707,7 +808,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
)}
</div>
{/* Sidebar History (Desktop) */}
{/* Sidebar History (Desktop - Collapsible) */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
@@ -728,4 +829,4 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
);
};
export default ListeningView;
export default ListeningView;