Browse Source

✨ feat(vocabulary): 添加单词练习功能

- 创建公共词汇API路由,允许匿名访问词汇数据
- 实现单词练习页面,支持通过中文翻译默写单词
- 添加键盘快捷键支持:回车检查答案,空格播放发音
- 实现随机切换单词功能,提升练习体验
- 添加发音播放功能,支持音频播放状态显示

✨ feat(api): 添加公共词汇接口

- 创建public/vocabularies路由,提供只读访问权限
- 支持分页、筛选和搜索功能
- 关联查询发音文件信息

♻️ refactor(routes): 更新首页路由指向单词练习页面
yourname 3 months ago
parent
commit
a2ac85c9a2

+ 5 - 1
src/client/api.ts

@@ -2,7 +2,7 @@ import axios, { isAxiosError } from 'axios';
 import { hc } from 'hono/client'
 import type {
   AuthRoutes, UserRoutes, RoleRoutes,
-  FileRoutes, VocabularyRoutes
+  FileRoutes, VocabularyRoutes, PublicVocabularyRoutes
 } from '@/server/api';
 
 // 创建 axios 适配器
@@ -79,3 +79,7 @@ export const fileClient = hc<FileRoutes>('/', {
 export const vocabularyClient = hc<VocabularyRoutes>('/', {
   fetch: axiosFetch,
 }).api.v1.vocabularies;
+
+export const publicVocabularyClient = hc<PublicVocabularyRoutes>('/', {
+  fetch: axiosFetch,
+}).api.public.vocabularies;

+ 271 - 0
src/client/home/pages/VocabularyPracticePage.tsx

@@ -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;

+ 2 - 2
src/client/home/routes.tsx

@@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
 import { ProtectedRoute } from './components/ProtectedRoute';
 import { ErrorPage } from './components/ErrorPage';
 import { NotFoundPage } from './components/NotFoundPage';
-import HomePage from './pages/HomePage';
+import VocabularyPracticePage from './pages/VocabularyPracticePage';
 import { MainLayout } from './layouts/MainLayout';
 import LoginPage from './pages/LoginPage';
 import RegisterPage from './pages/RegisterPage';
@@ -12,7 +12,7 @@ import MemberPage from './pages/MemberPage';
 export const router = createBrowserRouter([
   {
     path: '/',
-    element: <HomePage />
+    element: <VocabularyPracticePage />
   },
   {
     path: '/login',

+ 3 - 0
src/server/api.ts

@@ -6,6 +6,7 @@ import authRoute from './api/auth/index'
 import rolesRoute from './api/roles/index'
 import fileRoutes from './api/files/index'
 import vocabularyRoutes from './api/vocabularies/index'
+import publicVocabularyRoutes from './api/public/vocabularies/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -105,12 +106,14 @@ const authRoutes = api.route('/api/v1/auth', authRoute)
 const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 const fileApiRoutes = api.route('/api/v1/files', fileRoutes)
 const vocabRoutes = api.route('/api/v1/vocabularies', vocabularyRoutes)
+const publicVocabRoutes = api.route('/api/public/vocabularies', publicVocabularyRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type RoleRoutes = typeof roleRoutes
 export type FileRoutes = typeof fileApiRoutes
 export type VocabularyRoutes = typeof vocabRoutes
+export type PublicVocabularyRoutes = typeof publicVocabRoutes
 
 app.route('/', api)
 export default app

+ 21 - 0
src/server/api/public/vocabularies/index.ts

@@ -0,0 +1,21 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { VocabularyEntity } from '@/server/modules/vocabulary/vocabulary.entity';
+import { VocabularySchema } from '@/server/modules/vocabulary/vocabulary.schema';
+
+// 创建公共只读路由 - 只包含GET方法
+const publicVocabularyRoutes = createCrudRoutes({
+  entity: VocabularyEntity,
+  // 只读模式 - 仅生成GET路由
+  readOnly: true,
+  // 响应schema
+  getSchema: VocabularySchema,
+  listSchema: VocabularySchema,
+  // 搜索字段
+  searchFields: ['word', 'meaning'],
+  // 关联查询发音文件
+  relations: ['pronunciationFile'],
+  // 公共路由不需要认证中间件
+  middleware: []
+});
+
+export default publicVocabularyRoutes;