|
|
@@ -0,0 +1,271 @@
|
|
|
+import React, { useState, useEffect, useRef } from 'react';
|
|
|
+import { useQuery } from '@tanstack/react-query';
|
|
|
+import { publicVocabularyClient } from '@/client/api';
|
|
|
+import { InferResponseType } from 'hono/client';
|
|
|
+import { Play, RefreshCw } from 'lucide-react';
|
|
|
+import { Button } from '@/client/components/ui/button';
|
|
|
+import { Card } from '@/client/components/ui/card';
|
|
|
+
|
|
|
+// 定义API响应类型
|
|
|
+type VocabularyListResponse = InferResponseType<typeof publicVocabularyClient.$get, 200>;
|
|
|
+type VocabularyItem = VocabularyListResponse['data'][0];
|
|
|
+
|
|
|
+const VocabularyPracticePage: React.FC = () => {
|
|
|
+ // 状态管理
|
|
|
+ const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
|
|
+ const [userInput, setUserInput] = useState('');
|
|
|
+ const [showTranslation, setShowTranslation] = useState(false);
|
|
|
+ const [inputError, setInputError] = useState(false);
|
|
|
+ const [vocabularyList, setVocabularyList] = useState<VocabularyItem[]>([]);
|
|
|
+ const [isLoading, setIsLoading] = useState(true);
|
|
|
+ const [isPlaying, setIsPlaying] = useState(false);
|
|
|
+
|
|
|
+ const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
+
|
|
|
+ // 获取单词列表数据
|
|
|
+ useEffect(() => {
|
|
|
+ const fetchVocabulary = async () => {
|
|
|
+ try {
|
|
|
+ setIsLoading(true);
|
|
|
+ const response = await publicVocabularyClient.$get({
|
|
|
+ query: {
|
|
|
+ page: 1,
|
|
|
+ pageSize: 100,
|
|
|
+ filters: JSON.stringify({ isDisabled: 0 })
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.status === 200) {
|
|
|
+ const data = await response.json();
|
|
|
+ setVocabularyList(data.data);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to fetch vocabulary list:', error);
|
|
|
+ } finally {
|
|
|
+ setIsLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ fetchVocabulary();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理输入变化
|
|
|
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ const value = e.target.value;
|
|
|
+ setUserInput(value);
|
|
|
+ setInputError(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理键盘按键
|
|
|
+ const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
|
+ if (!vocabularyList.length) return;
|
|
|
+
|
|
|
+ // 按下回车键检查答案
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ checkAnswer();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按下空格键播放发音
|
|
|
+ if (e.key === ' ' && e.target === document.body) {
|
|
|
+ e.preventDefault();
|
|
|
+ playPronunciation();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 检查答案
|
|
|
+ const checkAnswer = () => {
|
|
|
+ if (!vocabularyList.length) return;
|
|
|
+
|
|
|
+ const currentWord = vocabularyList[currentWordIndex].word;
|
|
|
+
|
|
|
+ if (userInput.trim().toLowerCase() === currentWord.toLowerCase()) {
|
|
|
+ // 答案正确,移动到下一个单词
|
|
|
+ setUserInput('');
|
|
|
+ setShowTranslation(false);
|
|
|
+ setCurrentWordIndex((prev) => (prev + 1) % vocabularyList.length);
|
|
|
+ } else {
|
|
|
+ // 答案错误
|
|
|
+ setInputError(true);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 播放发音
|
|
|
+ const playPronunciation = () => {
|
|
|
+ if (!vocabularyList.length) return;
|
|
|
+
|
|
|
+ const currentWord = vocabularyList[currentWordIndex];
|
|
|
+
|
|
|
+ if (currentWord.pronunciationFile?.fullUrl) {
|
|
|
+ if (audioRef.current) {
|
|
|
+ audioRef.current.pause();
|
|
|
+ }
|
|
|
+
|
|
|
+ audioRef.current = new Audio(currentWord.pronunciationFile.fullUrl);
|
|
|
+ audioRef.current.play()
|
|
|
+ .then(() => setIsPlaying(true))
|
|
|
+ .catch(error => console.error('Failed to play pronunciation:', error));
|
|
|
+
|
|
|
+ // 监听音频播放结束
|
|
|
+ audioRef.current.onended = () => setIsPlaying(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取下一个单词
|
|
|
+ const nextWord = () => {
|
|
|
+ setUserInput('');
|
|
|
+ setShowTranslation(false);
|
|
|
+ setCurrentWordIndex((prev) => (prev + 1) % vocabularyList.length);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取随机单词
|
|
|
+ const getRandomWord = () => {
|
|
|
+ if (!vocabularyList.length) return;
|
|
|
+ const randomIndex = Math.floor(Math.random() * vocabularyList.length);
|
|
|
+ setCurrentWordIndex(randomIndex);
|
|
|
+ setUserInput('');
|
|
|
+ setShowTranslation(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 监听键盘事件
|
|
|
+ useEffect(() => {
|
|
|
+ window.addEventListener('keydown', handleKeyDown);
|
|
|
+ return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
+ }, [currentWordIndex, userInput]);
|
|
|
+
|
|
|
+ // 获取单词数据
|
|
|
+ const { isLoading: isLoadingWords } = useQuery({
|
|
|
+ queryKey: ['vocabularyList'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await publicVocabularyClient.$get({
|
|
|
+ query: {
|
|
|
+ page: 1,
|
|
|
+ pageSize: 100,
|
|
|
+ filters: JSON.stringify({ isDisabled: 0 })
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error('Failed to fetch vocabulary');
|
|
|
+ }
|
|
|
+
|
|
|
+ return response.json() as Promise<VocabularyListResponse>;
|
|
|
+ },
|
|
|
+ onSuccess: (data) => {
|
|
|
+ setVocabularyList(data.data);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (isLoading || isLoadingWords || !vocabularyList.length) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
|
|
|
+ <p className="mt-4 text-blue-600">加载单词数据中...</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentWord = vocabularyList[currentWordIndex];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
|
|
+ <Card className="w-full max-w-2xl p-6 shadow-lg">
|
|
|
+ <div className="flex justify-between items-center mb-8">
|
|
|
+ <h1 className="text-2xl font-bold text-blue-600">单词练习</h1>
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <Button
|
|
|
+ onClick={getRandomWord}
|
|
|
+ size="sm"
|
|
|
+ variant="secondary"
|
|
|
+ className="flex items-center gap-1"
|
|
|
+ >
|
|
|
+ <RefreshCw size={16} />
|
|
|
+ 随机
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 翻译显示 */}
|
|
|
+ <div className="mb-6 text-center">
|
|
|
+ <p className="text-gray-500 text-lg">中文翻译</p>
|
|
|
+ <p className="text-2xl font-semibold text-gray-800 mt-1">
|
|
|
+ {currentWord.meaning || "无翻译数据"}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 单词显示 */}
|
|
|
+ <div className="mb-8 text-center">
|
|
|
+ <div className="flex items-center justify-center gap-3 mb-2">
|
|
|
+ <p className="text-4xl font-bold text-gray-800">
|
|
|
+ {currentWord.word}
|
|
|
+ </p>
|
|
|
+ <Button
|
|
|
+ onClick={playPronunciation}
|
|
|
+ size="icon"
|
|
|
+ variant="ghost"
|
|
|
+ className="rounded-full"
|
|
|
+ >
|
|
|
+ <Play size={20} className={isPlaying ? "text-blue-500" : "text-gray-500"} />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {currentWord.pronunciation && (
|
|
|
+ <p className="text-gray-500 italic">/{currentWord.pronunciation}/</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 输入区域 */}
|
|
|
+ <div className="mb-8">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={userInput}
|
|
|
+ onChange={handleInputChange}
|
|
|
+ onKeyDown={(e) => e.key === 'Enter' && checkAnswer()}
|
|
|
+ className={`w-full px-4 py-3 text-xl border rounded-lg focus:outline-none focus:ring-2 ${
|
|
|
+ inputError
|
|
|
+ ? 'border-red-500 focus:ring-red-500'
|
|
|
+ : 'border-gray-300 focus:ring-blue-500'
|
|
|
+ }`}
|
|
|
+ placeholder="请输入单词..."
|
|
|
+ autoFocus
|
|
|
+ />
|
|
|
+ {inputError && (
|
|
|
+ <p className="text-red-500 text-sm mt-2 text-center">
|
|
|
+ 输入错误,请重试
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 按钮区域 */}
|
|
|
+ <div className="flex justify-center gap-4">
|
|
|
+ <Button
|
|
|
+ onClick={nextWord}
|
|
|
+ variant="secondary"
|
|
|
+ >
|
|
|
+ 下一个
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ onClick={checkAnswer}
|
|
|
+ >
|
|
|
+ 提交
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 例句显示 */}
|
|
|
+ {currentWord.example && (
|
|
|
+ <div className="mt-8 text-center text-gray-600 text-sm italic">
|
|
|
+ <p>例句: "{currentWord.example}"</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* 进度指示器 */}
|
|
|
+ <div className="mt-6 text-gray-500 text-sm">
|
|
|
+ 单词 {currentWordIndex + 1}/{vocabularyList.length}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default VocabularyPracticePage;
|