├── public ├── back.jpg ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── scenes │ │ └── page.tsx │ ├── settings │ │ ├── page.module.css │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── squid-game │ │ ├── [levelId] │ │ │ ├── page.module.css │ │ │ └── page.tsx │ │ ├── page.module.css │ │ └── page.tsx │ ├── about │ │ ├── page.module.css │ │ └── page.tsx │ ├── pronunciation │ │ ├── page.module.css │ │ └── page.tsx │ ├── api │ │ └── tts │ │ │ └── route.ts │ ├── page.tsx │ └── page.module.css ├── components │ ├── games │ │ ├── TugOfTongues.module.css │ │ ├── FinalSpeechBattle.module.css │ │ ├── GlassBridgeGrammar.module.css │ │ ├── MarblesOfMeaning.module.css │ │ ├── RedLightGreenLight.module.css │ │ ├── TugOfTongues.tsx │ │ ├── MarblesOfMeaning.tsx │ │ ├── FinalSpeechBattle.tsx │ │ ├── GlassBridgeGrammar.tsx │ │ ├── RedLightGreenLight.tsx │ │ ├── WordDalgona.module.css │ │ └── WordDalgona.tsx │ ├── Logo.module.css │ ├── MarkdownRenderer.tsx │ ├── ChunkList.jsx │ ├── ChunkList.module.css │ ├── Navbar.jsx │ ├── Logo.jsx │ ├── GitHubIcon.tsx │ ├── ChunkList.tsx │ ├── GameLevel.tsx │ ├── MarkdownRenderer.module.css │ ├── GameLevel.module.css │ ├── ChunkCard.module.css │ ├── SettingsForm.module.css │ ├── Navbar.module.css │ ├── ChunkCard.jsx │ ├── Navbar.tsx │ ├── SceneList.module.css │ ├── ChunkCard.tsx │ ├── YouGlishModal.module.css │ ├── YouGlishModal.tsx │ ├── SettingsForm.tsx │ └── SceneList.tsx ├── data │ ├── chunks.js │ ├── gameLevels.ts │ ├── chunks.json │ └── scenes.ts ├── services │ ├── chunkService.ts │ └── aiService.ts ├── index.css ├── App.jsx └── utils │ ├── settingsHelper.ts │ └── speechUtils.ts ├── next.config.js ├── next.config.ts ├── postcss.config.mjs ├── ecosystem.config.js ├── eslint.config.mjs ├── tailwind.config.ts ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /public/back.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwangjie/english-chunks/HEAD/public/back.jpg -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwangjie/english-chunks/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/components/games/TugOfTongues.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /src/components/games/FinalSpeechBattle.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /src/components/games/GlassBridgeGrammar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /src/components/games/MarblesOfMeaning.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /src/components/games/RedLightGreenLight.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // 移除之前的 webpack 配置,因为新包不需要这些 polyfills 4 | } 5 | 6 | module.exports = nextConfig -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /src/app/scenes/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import SceneList from '@/components/SceneList'; 4 | 5 | export default function ChunksPage() { 6 | return ; 7 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/components/Logo.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | } 6 | 7 | .logoText { 8 | font-size: 1.5rem; 9 | font-weight: bold; 10 | color: #2563EB; 11 | } -------------------------------------------------------------------------------- /src/app/settings/page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | 7 | .title { 8 | text-align: center; 9 | color: #1a202c; 10 | margin-bottom: 2rem; 11 | font-size: 2rem; 12 | } -------------------------------------------------------------------------------- /src/data/chunks.js: -------------------------------------------------------------------------------- 1 | export const chunks = [ 2 | { 3 | "chunk": "Yuval, good to see you.", 4 | "pronunciation": "/ˈjuːvəl ɡʊd tuː siː juː/", 5 | "chinese_meaning": "尤瓦尔,很高兴见到你。", 6 | "suitable_scenes": ["greeting someone", "meeting a friend"] 7 | }, 8 | // ... 其他数据 9 | ]; -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'english-chunks', 5 | script: 'node_modules/next/dist/bin/next', 6 | args: 'start', 7 | instances: '1', 8 | exec_mode: 'cluster', 9 | env: { 10 | PORT: 3000, 11 | NODE_ENV: 'production', 12 | }, 13 | }, 14 | ], 15 | }; -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import SettingsForm from '@/components/SettingsForm'; 5 | import styles from './page.module.css'; 6 | 7 | export default function SettingsPage() { 8 | return ( 9 |
10 |

设置

11 | 12 |
13 | ); 14 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /src/services/chunkService.ts: -------------------------------------------------------------------------------- 1 | import chunksData from '../data/chunks.json'; 2 | 3 | export interface Chunk { 4 | chunk: string; 5 | pronunciation: string; 6 | chinese_meaning: string; 7 | suitable_scenes: string[]; 8 | } 9 | 10 | export const getChunks = async (): Promise => { 11 | // In the future, this could be replaced with an API call 12 | // return await fetch('/api/chunks').then(res => res.json()); 13 | return (await import('../data/chunks.json')).chunks; 14 | }; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 11 | background: #F9FAFB; 12 | color: #1F2937; 13 | line-height: 1.5; 14 | } 15 | 16 | .app { 17 | min-height: 100vh; 18 | } -------------------------------------------------------------------------------- /src/components/games/TugOfTongues.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './TugOfTongues.module.css'; 3 | 4 | interface TugOfTonguesProps { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const TugOfTongues: React.FC = ({ onComplete, currentStars }) => { 10 | return ( 11 |
12 |

拔河游戏

13 |

Coming soon...

14 |
15 | ); 16 | }; 17 | 18 | export default TugOfTongues; -------------------------------------------------------------------------------- /src/components/MarkdownRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import styles from './MarkdownRenderer.module.css'; 4 | 5 | interface MarkdownRendererProps { 6 | content: string; 7 | } 8 | 9 | const MarkdownRenderer: React.FC = ({ content }) => { 10 | return ( 11 |
12 | {content} 13 |
14 | ); 15 | }; 16 | 17 | export default MarkdownRenderer; -------------------------------------------------------------------------------- /src/components/games/MarblesOfMeaning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './MarblesOfMeaning.module.css'; 3 | 4 | interface MarblesOfMeaningProps { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const MarblesOfMeaning: React.FC = ({ onComplete, currentStars }) => { 10 | return ( 11 |
12 |

弹珠游戏

13 |

Coming soon...

14 |
15 | ); 16 | }; 17 | 18 | export default MarblesOfMeaning; -------------------------------------------------------------------------------- /src/components/games/FinalSpeechBattle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './FinalSpeechBattle.module.css'; 3 | 4 | interface FinalSpeechBattleProps { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const FinalSpeechBattle: React.FC = ({ onComplete, currentStars }) => { 10 | return ( 11 |
12 |

最终演讲对决

13 |

Coming soon...

14 |
15 | ); 16 | }; 17 | 18 | export default FinalSpeechBattle; -------------------------------------------------------------------------------- /src/components/games/GlassBridgeGrammar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './GlassBridgeGrammar.module.css'; 3 | 4 | interface GlassBridgeGrammarProps { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const GlassBridgeGrammar: React.FC = ({ onComplete, currentStars }) => { 10 | return ( 11 |
12 |

玻璃桥游戏

13 |

Coming soon...

14 |
15 | ); 16 | }; 17 | 18 | export default GlassBridgeGrammar; -------------------------------------------------------------------------------- /src/components/games/RedLightGreenLight.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './RedLightGreenLight.module.css'; 3 | 4 | interface RedLightGreenLightProps { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const RedLightGreenLight: React.FC = ({ onComplete, currentStars }) => { 10 | return ( 11 |
12 |

红绿灯游戏

13 |

Coming soon...

14 |
15 | ); 16 | }; 17 | 18 | export default RedLightGreenLight; -------------------------------------------------------------------------------- /src/components/ChunkList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ChunkCard from './ChunkCard'; 3 | import { chunks } from '../data/chunks'; 4 | import styles from './ChunkList.module.css'; 5 | 6 | const ChunkList = () => { 7 | return ( 8 |
9 |
10 | {chunks.map((chunk, index) => ( 11 | 12 | ))} 13 |
14 |
15 | ); 16 | }; 17 | 18 | export default ChunkList; -------------------------------------------------------------------------------- /src/components/ChunkList.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 2rem; 3 | max-width: 1200px; 4 | margin: 0 auto; 5 | } 6 | 7 | .title { 8 | text-align: center; 9 | margin-bottom: 2rem; 10 | color: #333; 11 | } 12 | 13 | .grid { 14 | display: grid; 15 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 16 | gap: 1.5rem; 17 | } 18 | 19 | @media (max-width: 640px) { 20 | .container { 21 | padding: 1rem; 22 | } 23 | 24 | .grid { 25 | grid-template-columns: 1fr; 26 | gap: 1rem; 27 | } 28 | } -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | * { 24 | margin: 0; 25 | padding: 0; 26 | box-sizing: border-box; 27 | } 28 | 29 | body { 30 | background: #F9FAFB; 31 | color: #1F2937; 32 | line-height: 1.5; 33 | } 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import Navbar from "@/components/Navbar"; 4 | import "./globals.css"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "英语块 - 轻松学习地道英语表达", 10 | description: "通过学习英语表达块,提升你的英语口语水平", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 3 | import Navbar from './components/Navbar'; 4 | import Home from './pages/Home'; 5 | import ChunkList from './components/ChunkList'; 6 | 7 | function App() { 8 | return ( 9 | 10 |
11 | 12 | 13 | } /> 14 | } /> 15 | 16 |
17 |
18 | ); 19 | } 20 | 21 | export default App; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/utils/settingsHelper.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation'; 2 | 3 | interface AISettings { 4 | provider: 'openai' | 'gemini'; 5 | apiKey: string; 6 | apiUrl: string; 7 | modelName: string; 8 | } 9 | 10 | export const checkAndRedirectAPISettings = ( 11 | aiSettings: AISettings | undefined, 12 | router: ReturnType 13 | ): boolean => { 14 | if (!aiSettings || 15 | !aiSettings.apiKey || 16 | !aiSettings.apiUrl || 17 | !aiSettings.modelName) { 18 | router.push('/settings'); 19 | alert('请先配置API设置(API Key、API URL和模型名称)'); 20 | return false; 21 | } 22 | return true; 23 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Logo from './Logo'; 4 | import styles from './Navbar.module.css'; 5 | 6 | const Navbar = () => { 7 | return ( 8 | 20 | ); 21 | }; 22 | 23 | export default Navbar; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "english-chunks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "microsoft-cognitiveservices-speech-sdk": "^1.34.0", 13 | "next": "^14.1.0", 14 | "react": "^18", 15 | "react-dom": "^18", 16 | "react-markdown": "^9.0.1" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "20.17.10", 20 | "@types/react": "18.3.18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10.0.1", 23 | "eslint": "^8", 24 | "eslint-config-next": "14.1.0", 25 | "postcss": "^8", 26 | "tailwindcss": "^3.3.0", 27 | "typescript": "5.7.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Logo.module.css'; 3 | 4 | const Logo = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 英语块 14 |
15 | ); 16 | }; 17 | 18 | export default Logo; -------------------------------------------------------------------------------- /src/components/GitHubIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function GitHubIcon() { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /src/app/squid-game/[levelId]/page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 2rem; 3 | max-width: 1200px; 4 | margin: 0 auto; 5 | min-height: 100vh; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | .title { 12 | color: #ff0062; 13 | font-size: 2.5rem; 14 | margin-bottom: 2rem; 15 | text-align: center; 16 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 17 | } 18 | 19 | .gameContainer { 20 | width: 100%; 21 | flex: 1; 22 | background: white; 23 | border-radius: 12px; 24 | padding: 2rem; 25 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 26 | margin-bottom: 2rem; 27 | } 28 | 29 | .backButton { 30 | padding: 1rem 2rem; 31 | background: #ff0062; 32 | color: white; 33 | border: none; 34 | border-radius: 8px; 35 | font-size: 1.1rem; 36 | cursor: pointer; 37 | transition: all 0.3s ease; 38 | } 39 | 40 | .backButton:hover { 41 | background: #d4004f; 42 | transform: translateY(-2px); 43 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 44 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ChunkList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import ChunkCard from './ChunkCard'; 5 | import { getChunks } from '@/services/chunkService'; 6 | import type { Chunk } from '@/services/chunkService'; 7 | import styles from './ChunkList.module.css'; 8 | 9 | const ChunkList = () => { 10 | const [chunks, setChunks] = useState([]); 11 | 12 | useEffect(() => { 13 | const loadChunks = async () => { 14 | const data = await getChunks(); 15 | setChunks(data); 16 | }; 17 | 18 | loadChunks(); 19 | }, []); 20 | 21 | return ( 22 |
23 |

English Chunks Practice

24 |
25 | {chunks.map((chunk, index) => ( 26 | 27 | ))} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default ChunkList; -------------------------------------------------------------------------------- /src/components/GameLevel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './GameLevel.module.css'; 3 | import { GameLevel as GameLevelType } from '@/data/gameLevels'; 4 | 5 | interface GameLevelProps { 6 | level: GameLevelType; 7 | isLocked: boolean; 8 | onClick: () => void; 9 | earnedStars: number; 10 | } 11 | 12 | const GameLevel: React.FC = ({ level, isLocked, onClick, earnedStars }) => { 13 | return ( 14 |
18 |
19 |

{level.name}

20 |

{level.description}

21 |
22 |
需要 {level.minStars} ⭐
23 | {earnedStars > 0 && ( 24 |
25 | 已获得 {earnedStars} ⭐ 26 |
27 | )} 28 |
29 | {isLocked &&
🔒
} 30 |
31 | ); 32 | }; 33 | 34 | export default GameLevel; -------------------------------------------------------------------------------- /src/app/squid-game/page.module.css: -------------------------------------------------------------------------------- 1 | .pageContainer { 2 | position: relative; 3 | min-height: 100vh; 4 | width: 100%; 5 | overflow: hidden; 6 | } 7 | 8 | .backgroundImage { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 300px; 14 | background-image: linear-gradient(to bottom, 15 | rgba(0, 0, 0, 0.6) 0%, 16 | rgba(0, 0, 0, 0.4) 50%, 17 | rgba(255, 255, 255, 1) 100% 18 | ), url('/back.jpg'); 19 | background-size: cover; 20 | background-position: center; 21 | background-repeat: no-repeat; 22 | z-index: 1; 23 | } 24 | 25 | .content { 26 | position: relative; 27 | z-index: 2; 28 | padding: 2rem; 29 | max-width: 1200px; 30 | margin: 0 auto; 31 | padding-top: 120px; 32 | } 33 | 34 | .totalStars { 35 | text-align: center; 36 | font-size: 1.5rem; 37 | color: white; 38 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); 39 | margin-bottom: 2rem; 40 | font-weight: bold; 41 | } 42 | 43 | .levelGrid { 44 | display: grid; 45 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 46 | gap: 2rem; 47 | padding: 1rem; 48 | animation: fadeIn 0.5s ease-in-out; 49 | } 50 | 51 | @keyframes fadeIn { 52 | from { 53 | opacity: 0; 54 | transform: translateY(20px); 55 | } 56 | to { 57 | opacity: 1; 58 | transform: translateY(0); 59 | } 60 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/MarkdownRenderer.module.css: -------------------------------------------------------------------------------- 1 | .markdown { 2 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 3 | font-size: 0.875rem; 4 | line-height: 1.7; 5 | color: #1f2937; 6 | } 7 | 8 | .markdown h1, 9 | .markdown h2, 10 | .markdown h3, 11 | .markdown h4, 12 | .markdown h5, 13 | .markdown h6 { 14 | margin-top: 1.5em; 15 | margin-bottom: 0.5em; 16 | font-weight: 600; 17 | color: #111827; 18 | } 19 | 20 | .markdown p { 21 | margin: 1em 0; 22 | } 23 | 24 | .markdown code { 25 | background-color: #f3f4f6; 26 | padding: 0.2em 0.4em; 27 | border-radius: 3px; 28 | font-size: 0.9em; 29 | } 30 | 31 | .markdown pre { 32 | background-color: #f3f4f6; 33 | padding: 1em; 34 | border-radius: 6px; 35 | overflow-x: auto; 36 | } 37 | 38 | .markdown pre code { 39 | background-color: transparent; 40 | padding: 0; 41 | border-radius: 0; 42 | } 43 | 44 | .markdown blockquote { 45 | border-left: 4px solid #e5e7eb; 46 | margin: 1em 0; 47 | padding-left: 1em; 48 | color: #4b5563; 49 | } 50 | 51 | .markdown ul, 52 | .markdown ol { 53 | margin: 1em 0; 54 | padding-left: 2em; 55 | } 56 | 57 | .markdown li { 58 | margin: 0.5em 0; 59 | } 60 | 61 | .markdown a { 62 | color: #2563eb; 63 | text-decoration: none; 64 | } 65 | 66 | .markdown a:hover { 67 | text-decoration: underline; 68 | } -------------------------------------------------------------------------------- /src/components/GameLevel.module.css: -------------------------------------------------------------------------------- 1 | .levelCard { 2 | background: white; 3 | border-radius: 12px; 4 | padding: 1.5rem; 5 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 6 | transition: all 0.3s ease; 7 | cursor: pointer; 8 | position: relative; 9 | overflow: hidden; 10 | } 11 | 12 | .levelCard:hover:not(.locked) { 13 | transform: translateY(-5px); 14 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); 15 | } 16 | 17 | .iconContainer { 18 | width: 60px; 19 | height: 60px; 20 | margin: 0 auto 1rem; 21 | } 22 | 23 | .iconContainer svg { 24 | width: 100%; 25 | height: 100%; 26 | color: #ff0062; 27 | } 28 | 29 | .levelName { 30 | font-size: 1.5rem; 31 | color: #333; 32 | margin-bottom: 0.5rem; 33 | text-align: center; 34 | } 35 | 36 | .description { 37 | color: #666; 38 | font-size: 0.9rem; 39 | text-align: center; 40 | margin-bottom: 1rem; 41 | } 42 | 43 | .stars { 44 | color: #ffd700; 45 | text-align: center; 46 | font-weight: bold; 47 | display: flex; 48 | flex-direction: column; 49 | gap: 0.5rem; 50 | } 51 | 52 | .earnedStars { 53 | color: #28a745; 54 | font-size: 0.9rem; 55 | } 56 | 57 | .locked { 58 | opacity: 0.7; 59 | cursor: not-allowed; 60 | } 61 | 62 | .lockOverlay { 63 | position: absolute; 64 | top: 50%; 65 | left: 50%; 66 | transform: translate(-50%, -50%); 67 | font-size: 2rem; 68 | background: rgba(0, 0, 0, 0.5); 69 | width: 100%; 70 | height: 100%; 71 | display: flex; 72 | align-items: center; 73 | justify-content: center; 74 | } -------------------------------------------------------------------------------- /src/components/ChunkCard.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | background: white; 3 | border-radius: 8px; 4 | padding: 1.5rem; 5 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 6 | transition: transform 0.2s ease; 7 | } 8 | 9 | .card:hover { 10 | transform: translateY(-2px); 11 | } 12 | 13 | .cardContent { 14 | cursor: pointer; 15 | } 16 | 17 | .chunk { 18 | font-size: 1.25rem; 19 | margin: 0 0 0.5rem 0; 20 | color: #2c3e50; 21 | } 22 | 23 | .pronunciation { 24 | color: #7f8c8d; 25 | font-family: monospace; 26 | margin: 0.5rem 0; 27 | } 28 | 29 | .meaning { 30 | color: #34495e; 31 | margin: 0.5rem 0; 32 | } 33 | 34 | .scenes { 35 | display: flex; 36 | flex-wrap: wrap; 37 | gap: 0.5rem; 38 | margin-top: 1rem; 39 | } 40 | 41 | .scene { 42 | background: #e0f2f1; 43 | padding: 0.25rem 0.75rem; 44 | border-radius: 16px; 45 | font-size: 0.875rem; 46 | color: #00796b; 47 | } 48 | 49 | .cardActions { 50 | display: flex; 51 | justify-content: flex-end; 52 | gap: 0.75rem; 53 | margin-top: 1rem; 54 | padding-top: 1rem; 55 | border-top: 1px solid #e5e7eb; 56 | } 57 | 58 | .actionButton { 59 | background: none; 60 | border: none; 61 | padding: 0.5rem; 62 | cursor: pointer; 63 | color: #6b7280; 64 | border-radius: 9999px; 65 | transition: all 0.2s ease; 66 | display: flex; 67 | align-items: center; 68 | justify-content: center; 69 | } 70 | 71 | .actionButton:hover { 72 | color: #2563eb; 73 | background-color: #f3f4f6; 74 | } -------------------------------------------------------------------------------- /src/app/squid-game/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState, useEffect } from 'react'; 3 | import styles from './page.module.css'; 4 | import GameLevel from '@/components/GameLevel'; 5 | import { levels, GameLevel as GameLevelType } from '@/data/gameLevels'; 6 | import { useRouter } from 'next/navigation'; 7 | 8 | interface GameProgress { 9 | currentLevel: number; 10 | stars: number; 11 | levelStars: { [key: number]: number }; 12 | } 13 | 14 | export default function SquidGamePage() { 15 | const router = useRouter(); 16 | const [progress, setProgress] = useState(() => { 17 | if (typeof window !== 'undefined') { 18 | const saved = localStorage.getItem('squidGameProgress'); 19 | return saved ? JSON.parse(saved) : { 20 | currentLevel: 1, 21 | stars: 0, 22 | levelStars: {} 23 | }; 24 | } 25 | return { 26 | currentLevel: 1, 27 | stars: 0, 28 | levelStars: {} 29 | }; 30 | }); 31 | 32 | useEffect(() => { 33 | localStorage.setItem('squidGameProgress', JSON.stringify(progress)); 34 | }, [progress]); 35 | 36 | const handleLevelClick = (level: GameLevelType, index: number) => { 37 | if (index <= progress.currentLevel && progress.stars >= level.minStars) { 38 | router.push(`/squid-game/${level.id}`); 39 | } 40 | }; 41 | 42 | return ( 43 |
44 |
45 |
46 |
总星星数: {progress.stars} ⭐
47 |
48 | {levels.map((level, index) => ( 49 | progress.currentLevel || progress.stars < level.minStars} 53 | onClick={() => handleLevelClick(level, index)} 54 | earnedStars={progress.levelStars[level.id] || 0} 55 | /> 56 | ))} 57 |
58 |
59 |
60 | ); 61 | } -------------------------------------------------------------------------------- /src/app/about/page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | color: #1f2937; 6 | } 7 | 8 | .title { 9 | text-align: center; 10 | color: #1a202c; 11 | margin-bottom: 3rem; 12 | font-size: 2.5rem; 13 | font-weight: 700; 14 | } 15 | 16 | .section { 17 | margin-bottom: 3rem; 18 | } 19 | 20 | .section:last-child { 21 | margin-bottom: 0; 22 | } 23 | 24 | .section h2 { 25 | color: #2d3748; 26 | margin-bottom: 1.5rem; 27 | font-size: 1.5rem; 28 | font-weight: 600; 29 | border-bottom: 2px solid #e2e8f0; 30 | padding-bottom: 0.5rem; 31 | } 32 | 33 | .section p { 34 | color: #4a5568; 35 | line-height: 1.7; 36 | margin-bottom: 1rem; 37 | font-size: 1rem; 38 | } 39 | 40 | .list { 41 | list-style: none; 42 | padding: 0; 43 | display: grid; 44 | gap: 1.5rem; 45 | } 46 | 47 | .list li { 48 | background: white; 49 | border: 1px solid #e5e7eb; 50 | border-radius: 8px; 51 | padding: 1.25rem; 52 | transition: all 0.2s ease; 53 | } 54 | 55 | .list li:hover { 56 | transform: translateY(-2px); 57 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 58 | } 59 | 60 | .list li strong { 61 | display: block; 62 | color: #2563eb; 63 | font-size: 1.125rem; 64 | margin-bottom: 0.5rem; 65 | } 66 | 67 | .list li p { 68 | color: #4b5563; 69 | margin: 0; 70 | font-size: 0.875rem; 71 | line-height: 1.5; 72 | } 73 | 74 | @media (max-width: 640px) { 75 | .container { 76 | padding: 1.5rem; 77 | } 78 | 79 | .title { 80 | font-size: 2rem; 81 | margin-bottom: 2rem; 82 | } 83 | 84 | .section { 85 | margin-bottom: 2rem; 86 | } 87 | 88 | .section h2 { 89 | font-size: 1.25rem; 90 | margin-bottom: 1rem; 91 | } 92 | 93 | .list { 94 | gap: 1rem; 95 | } 96 | 97 | .list li { 98 | padding: 1rem; 99 | } 100 | } -------------------------------------------------------------------------------- /src/data/gameLevels.ts: -------------------------------------------------------------------------------- 1 | export interface GameLevel { 2 | id: number; 3 | name: string; 4 | description: string; 5 | icon: string; 6 | minStars: number; 7 | } 8 | 9 | export const levels: GameLevel[] = [ 10 | { 11 | id: 1, 12 | name: "Word Dalgona", 13 | description: "在限定时间内刻出正确的单词拼写", 14 | icon: ` 15 | 16 | `, 17 | minStars: 0 18 | }, 19 | { 20 | id: 2, 21 | name: "Tug of Tongues", 22 | description: "通过正确发音来拔河", 23 | icon: ` 24 | 25 | `, 26 | minStars: 2 27 | }, 28 | { 29 | id: 3, 30 | name: "Marbles of Meaning", 31 | description: "匹配正确的词义对", 32 | icon: ` 33 | 34 | 35 | `, 36 | minStars: 4 37 | }, 38 | { 39 | id: 4, 40 | name: "Glass Bridge Grammar", 41 | description: "选择正确的语法选项过桥", 42 | icon: ` 43 | 44 | `, 45 | minStars: 6 46 | }, 47 | { 48 | id: 5, 49 | name: "Red Light Green Light Listening", 50 | description: "听力红绿灯挑战", 51 | icon: ` 52 | 53 | 54 | `, 55 | minStars: 8 56 | }, 57 | { 58 | id: 6, 59 | name: "Final Speech Battle", 60 | description: "终极演讲对决", 61 | icon: ` 62 | 63 | `, 64 | minStars: 10 65 | } 66 | ]; -------------------------------------------------------------------------------- /src/components/SettingsForm.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | background: white; 3 | border-radius: 8px; 4 | padding: 2rem; 5 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 6 | } 7 | 8 | .section { 9 | margin-bottom: 2rem; 10 | padding-bottom: 2rem; 11 | border-bottom: 1px solid #e5e7eb; 12 | } 13 | 14 | .section:last-child { 15 | border-bottom: none; 16 | margin-bottom: 1rem; 17 | padding-bottom: 0; 18 | } 19 | 20 | .section h2 { 21 | color: #1a202c; 22 | margin-bottom: 1rem; 23 | font-size: 1.25rem; 24 | } 25 | 26 | .field { 27 | margin-bottom: 1rem; 28 | } 29 | 30 | .field label { 31 | display: block; 32 | margin-bottom: 0.5rem; 33 | color: #4a5568; 34 | font-size: 0.875rem; 35 | } 36 | 37 | .field input[type="text"], 38 | .field input[type="password"], 39 | .field select { 40 | width: 100%; 41 | padding: 0.5rem; 42 | border: 1px solid #e2e8f0; 43 | border-radius: 4px; 44 | font-size: 1rem; 45 | color: #2d3748; 46 | transition: border-color 0.2s; 47 | } 48 | 49 | .field input[type="text"]:focus, 50 | .field input[type="password"]:focus, 51 | .field select:focus { 52 | outline: none; 53 | border-color: #3182ce; 54 | box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1); 55 | } 56 | 57 | .field input[type="range"] { 58 | width: 100%; 59 | margin: 0.5rem 0; 60 | } 61 | 62 | .actions { 63 | display: flex; 64 | gap: 1rem; 65 | margin-top: 2rem; 66 | } 67 | 68 | .saveButton, 69 | .exportButton { 70 | padding: 0.5rem 1rem; 71 | border: none; 72 | border-radius: 4px; 73 | font-size: 1rem; 74 | cursor: pointer; 75 | transition: all 0.2s; 76 | } 77 | 78 | .saveButton { 79 | background-color: #3182ce; 80 | color: white; 81 | } 82 | 83 | .saveButton:hover { 84 | background-color: #2c5282; 85 | } 86 | 87 | .exportButton { 88 | background-color: #48bb78; 89 | color: white; 90 | } 91 | 92 | .exportButton:hover { 93 | background-color: #2f855a; 94 | } 95 | 96 | .message { 97 | margin-top: 1rem; 98 | padding: 0.5rem; 99 | border-radius: 4px; 100 | text-align: center; 101 | background-color: #48bb78; 102 | color: white; 103 | animation: fadeOut 3s forwards; 104 | } 105 | 106 | @keyframes fadeOut { 107 | 0% { opacity: 1; } 108 | 70% { opacity: 1; } 109 | 100% { opacity: 0; } 110 | } -------------------------------------------------------------------------------- /src/components/Navbar.module.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | background: white; 3 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); 4 | position: sticky; 5 | top: 0; 6 | z-index: 100; 7 | } 8 | 9 | .container { 10 | max-width: 1200px; 11 | margin: 0 auto; 12 | padding: 1rem 2rem; 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | } 17 | 18 | .logoLink { 19 | text-decoration: none; 20 | } 21 | 22 | .menu { 23 | display: flex; 24 | gap: 2rem; 25 | } 26 | 27 | .menuItem { 28 | text-decoration: none; 29 | color: #4B5563; 30 | font-weight: 500; 31 | padding: 0.5rem 1rem; 32 | border-radius: 6px; 33 | transition: all 0.2s ease; 34 | } 35 | 36 | .menuItem:hover { 37 | background: #F3F4F6; 38 | color: #2563EB; 39 | } 40 | 41 | .menuButton { 42 | display: none; 43 | flex-direction: column; 44 | justify-content: space-between; 45 | width: 24px; 46 | height: 20px; 47 | background: transparent; 48 | border: none; 49 | cursor: pointer; 50 | padding: 0; 51 | z-index: 10; 52 | } 53 | 54 | .menuButton span { 55 | width: 100%; 56 | height: 2px; 57 | background-color: #4B5563; 58 | transition: all 0.3s ease; 59 | } 60 | 61 | .menuButton.active span:first-child { 62 | transform: rotate(45deg) translate(6px, 6px); 63 | } 64 | 65 | .menuButton.active span:nth-child(2) { 66 | opacity: 0; 67 | } 68 | 69 | .menuButton.active span:last-child { 70 | transform: rotate(-45deg) translate(6px, -6px); 71 | } 72 | 73 | @media (max-width: 640px) { 74 | .container { 75 | padding: 0.75rem 1rem; 76 | } 77 | 78 | .menuButton { 79 | display: flex; 80 | margin-left: 1rem; 81 | } 82 | 83 | .menu { 84 | position: fixed; 85 | top: 0; 86 | right: -100%; 87 | width: 70%; 88 | max-width: 300px; 89 | height: 100vh; 90 | background: white; 91 | flex-direction: column; 92 | gap: 0; 93 | padding: 5rem 1.5rem 2rem; 94 | box-shadow: -2px 0 4px rgba(0, 0, 0, 0.1); 95 | transition: right 0.3s ease; 96 | } 97 | 98 | .menu.menuOpen { 99 | right: 0; 100 | } 101 | 102 | .menuItem { 103 | padding: 1rem; 104 | border-radius: 0; 105 | border-bottom: 1px solid #E5E7EB; 106 | } 107 | 108 | .menuItem:last-child { 109 | border-bottom: none; 110 | } 111 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # English Chunks - 轻松学习地道英语表达 2 | 3 | 访问地址:https://english.nobb.asia/ 4 | 5 | 一键部署:[![Vercel Deployment](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fiwangjie%2Fenglish-chunks&project-name=english-chunks&repository-name=english-chunks) 6 | 7 | 您的 ![0E97B714](https://github.com/user-attachments/assets/2d01f855-86f8-491c-8c56-61d85eee093b) 是我更新的动力!!!!! 8 | 9 | 10 | ## 简介 11 | 12 | **English Chunks** 是一个帮助用户学习地道英语表达的应用。它利用 AI 技术从 YouTube 视频中提取出常用的英语表达(chunks),并通过反复播放(磨耳)的方式帮助用户自然地习得这些表达,从而提升口语能力。 13 | 14 | **访问地址:** [英语块 - 轻松学习地道英语表达](https://your-vercel-app-url.vercel.app) _(请将此链接替换为你实际的 Vercel 应用地址)_ 15 | 16 | **开源地址:** [iwangjie/english-chunks](https://github.com/iwangjie/english-chunks) 17 | 18 | 19 | 1735998297579 20 | 21 | 22 | ![image](https://github.com/user-attachments/assets/a0636f35-cd21-41c6-86e4-d70cf5088b52) 23 | 24 | 1735374122201 25 | 26 | 27 | ![image](https://github.com/user-attachments/assets/a2268b24-6720-4c9a-b0ee-da7ca875eaad) 28 | 29 | ![image](https://github.com/user-attachments/assets/0b825ca4-8688-46f7-9208-d1f45e3b7c22) 30 | 31 | 32 | ## 特点 33 | 34 | * **AI 驱动的英语块提取:** 利用 AI 技术自动识别并提取 YouTube 视频中的常用英语表达。 35 | * **磨耳式练习:** 通过重复播放选定的英语块,帮助用户自然地习得发音和用法。 36 | * **YouTube 视频源:** 以丰富的 YouTube 视频作为学习材料,确保学习内容的地道性和实用性。 37 | * **100% Cursor 编写,含人量 0%:** 本项目所有代码均由 [Cursor](https://cursor.sh/) 编写,整个开发过程没有人工编写任何代码,包括项目搭建、代码编写、调试等。 38 | 39 | ## 现状和未来 40 | 41 | 本项目目前处于**测试版本**,存在一些 **bug**(尤其是移动端适配问题),但基本功能可用。 42 | 43 | **思路:** 通过 AI 的能力来提取英语块,并结合 YouTube 视频进行磨耳式练习,从而达到口语上的进步。这个思路还有很大的发挥空间,**欢迎大家提出宝贵意见**。我会继续完善此项目,并探索更多有趣的学习方式。 44 | 45 | **_我们正在探索更多利用 AI 辅助英语学习的可能性,敬请期待!_** 46 | 47 | ## 快速开始 48 | 49 | 1. **访问应用:** 点击 [英语块 - 轻松学习地道英语表达](https://english.nobb.asia/) 开始使用。 _(请将此链接替换为你实际的 Vercel 应用地址)_ 50 | 2. **探索源码:** 查看 [iwangjie/english-chunks](https://github.com/iwangjie/english-chunks) 了解项目的详细信息。 51 | 3. **一键部署:** 通过上方的 Vercel 部署按钮,您可以轻松地将该项目部署到您的 Vercel 账户。 52 | 53 | ## 免责声明 54 | 55 | 本项目旨在探索 AI 在英语学习中的应用,学习效果因人而异。由于是测试版本,可能存在不稳定的情况,请谅解。 56 | 57 | ## 贡献 58 | 59 | 欢迎任何形式的贡献,包括但不限于: 60 | 61 | * **提出问题:** 通过 [Issues](https://github.com/iwangjie/english-chunks/issues) 提交 bug 报告或功能建议。 62 | * **代码贡献:** 提交 Pull Request 改进代码或添加新功能。 63 | * **思路探讨:** 分享您对英语学习和 AI 应用的想法。 64 | 65 | ## 致谢 66 | 67 | 感谢 [Cursor](https://cursor.sh/) 提供的强大 AI 编程工具,使这个项目成为可能。 68 | -------------------------------------------------------------------------------- /src/app/pronunciation/page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1200px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | 7 | .title { 8 | font-size: 2rem; 9 | font-weight: 600; 10 | margin-bottom: 1rem; 11 | color: #1f2937; 12 | } 13 | 14 | .description { 15 | color: #6b7280; 16 | margin-bottom: 2rem; 17 | } 18 | 19 | .sentenceList { 20 | display: flex; 21 | flex-direction: column; 22 | gap: 1.5rem; 23 | } 24 | 25 | .sentenceCard { 26 | background: white; 27 | border: 1px solid #e5e7eb; 28 | border-radius: 8px; 29 | padding: 1.5rem; 30 | transition: all 0.2s ease; 31 | } 32 | 33 | .sentenceCard:hover { 34 | border-color: #2563eb; 35 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 36 | } 37 | 38 | .sentenceHeader { 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: flex-start; 42 | gap: 1rem; 43 | margin-bottom: 1rem; 44 | } 45 | 46 | .sentenceText { 47 | font-size: 1.25rem; 48 | font-weight: 500; 49 | color: #1f2937; 50 | flex: 1; 51 | } 52 | 53 | .emphasisLink { 54 | color: #2563eb; 55 | font-weight: 600; 56 | text-decoration: none; 57 | border-bottom: 2px solid #2563eb; 58 | transition: all 0.2s ease; 59 | } 60 | 61 | .emphasisLink:hover { 62 | background-color: #f0f9ff; 63 | } 64 | 65 | .controls { 66 | display: flex; 67 | gap: 0.5rem; 68 | flex-shrink: 0; 69 | } 70 | 71 | .translation { 72 | color: #6b7280; 73 | margin-bottom: 0.5rem; 74 | } 75 | 76 | .focus { 77 | color: #dc2626; 78 | font-size: 0.875rem; 79 | margin-bottom: 1rem; 80 | } 81 | 82 | .context, .formality { 83 | color: #4b5563; 84 | font-size: 0.875rem; 85 | margin-bottom: 0.5rem; 86 | } 87 | 88 | .label { 89 | color: #6b7280; 90 | margin-right: 0.5rem; 91 | } 92 | 93 | .playButton, .recordButton { 94 | padding: 0.5rem 1rem; 95 | border: none; 96 | border-radius: 4px; 97 | cursor: pointer; 98 | font-size: 0.875rem; 99 | transition: all 0.2s ease; 100 | white-space: nowrap; 101 | } 102 | 103 | .playButton { 104 | background: #2563eb; 105 | color: white; 106 | } 107 | 108 | .playButton:hover { 109 | background: #1d4ed8; 110 | } 111 | 112 | .recordButton { 113 | background: #059669; 114 | color: white; 115 | } 116 | 117 | .recordButton:hover { 118 | background: #047857; 119 | } 120 | 121 | .recordButton.recording { 122 | background: #dc2626; 123 | animation: pulse 2s infinite; 124 | } 125 | 126 | .recordButton.recording:hover { 127 | background: #b91c1c; 128 | } 129 | 130 | .audioPlayback { 131 | margin-top: 1rem; 132 | padding-top: 1rem; 133 | border-top: 1px solid #e5e7eb; 134 | } 135 | 136 | .audioPlayback audio { 137 | width: 100%; 138 | height: 36px; 139 | } 140 | 141 | @keyframes pulse { 142 | 0% { 143 | box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); 144 | } 145 | 70% { 146 | box-shadow: 0 0 0 10px rgba(220, 38, 38, 0); 147 | } 148 | 100% { 149 | box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); 150 | } 151 | } -------------------------------------------------------------------------------- /src/app/squid-game/[levelId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useRouter } from 'next/navigation'; 4 | import { levels, GameLevel } from '@/data/gameLevels'; 5 | import WordDalgona from '@/components/games/WordDalgona'; 6 | import TugOfTongues from '@/components/games/TugOfTongues'; 7 | import MarblesOfMeaning from '@/components/games/MarblesOfMeaning'; 8 | import GlassBridgeGrammar from '@/components/games/GlassBridgeGrammar'; 9 | import RedLightGreenLight from '@/components/games/RedLightGreenLight'; 10 | import FinalSpeechBattle from '@/components/games/FinalSpeechBattle'; 11 | import styles from './page.module.css'; 12 | 13 | interface GameProgress { 14 | currentLevel: number; 15 | stars: number; 16 | levelStars: { [key: number]: number }; 17 | } 18 | 19 | export default function LevelPage({ params }: { params: { levelId: string } }) { 20 | const router = useRouter(); 21 | const levelId = parseInt(params.levelId); 22 | const level = levels.find(l => l.id === levelId); 23 | 24 | const [progress, setProgress] = useState(() => { 25 | if (typeof window !== 'undefined') { 26 | const saved = localStorage.getItem('squidGameProgress'); 27 | return saved ? JSON.parse(saved) : { 28 | currentLevel: 1, 29 | stars: 0, 30 | levelStars: {} 31 | }; 32 | } 33 | return { 34 | currentLevel: 1, 35 | stars: 0, 36 | levelStars: {} 37 | }; 38 | }); 39 | 40 | useEffect(() => { 41 | if (!level) { 42 | router.push('/squid-game'); 43 | return; 44 | } 45 | 46 | const index = levels.findIndex(l => l.id === levelId); 47 | if (index > progress.currentLevel || progress.stars < level.minStars) { 48 | router.push('/squid-game'); 49 | } 50 | }, [level, levelId, progress.currentLevel, progress.stars, router]); 51 | 52 | const handleGameComplete = (earnedStars: number) => { 53 | const newProgress = { 54 | ...progress, 55 | stars: progress.stars + earnedStars - (progress.levelStars[levelId] || 0), 56 | levelStars: { 57 | ...progress.levelStars, 58 | [levelId]: earnedStars 59 | } 60 | }; 61 | 62 | if (earnedStars === 3 && levelId === progress.currentLevel) { 63 | newProgress.currentLevel = Math.min(levels.length - 1, progress.currentLevel + 1); 64 | } 65 | 66 | setProgress(newProgress); 67 | localStorage.setItem('squidGameProgress', JSON.stringify(newProgress)); 68 | }; 69 | 70 | if (!level) return null; 71 | 72 | const GameComponent = { 73 | 1: WordDalgona, 74 | 2: TugOfTongues, 75 | 3: MarblesOfMeaning, 76 | 4: GlassBridgeGrammar, 77 | 5: RedLightGreenLight, 78 | 6: FinalSpeechBattle 79 | }[level.id]; 80 | 81 | return ( 82 |
83 |

{level.name}

84 |
85 | {GameComponent && ( 86 | 90 | )} 91 |
92 | 98 |
99 | ); 100 | } -------------------------------------------------------------------------------- /src/components/games/WordDalgona.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | padding: 2rem; 8 | } 9 | 10 | .startScreen, .endScreen { 11 | text-align: center; 12 | background: rgba(255, 255, 255, 0.9); 13 | padding: 2rem; 14 | border-radius: 1rem; 15 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 16 | } 17 | 18 | .startScreen h2, .endScreen h2 { 19 | font-size: 2rem; 20 | color: #ff0062; 21 | margin-bottom: 1rem; 22 | } 23 | 24 | .startScreen p, .endScreen p { 25 | color: #666; 26 | margin-bottom: 1rem; 27 | } 28 | 29 | .difficultyButtons { 30 | display: flex; 31 | gap: 1rem; 32 | justify-content: center; 33 | margin-top: 1.5rem; 34 | } 35 | 36 | .difficultyButtons button, .endScreen button { 37 | padding: 0.8rem 1.5rem; 38 | background: #ff0062; 39 | color: white; 40 | border: none; 41 | border-radius: 8px; 42 | font-size: 1rem; 43 | cursor: pointer; 44 | transition: all 0.3s ease; 45 | } 46 | 47 | .difficultyButtons button:hover, .endScreen button:hover { 48 | background: #d4004f; 49 | transform: translateY(-2px); 50 | } 51 | 52 | .gameScreen { 53 | width: 100%; 54 | max-width: 600px; 55 | text-align: center; 56 | } 57 | 58 | .gameInfo { 59 | display: flex; 60 | justify-content: space-between; 61 | margin-bottom: 2rem; 62 | font-size: 1.2rem; 63 | color: #666; 64 | background: rgba(255, 255, 255, 0.9); 65 | padding: 1rem; 66 | border-radius: 0.5rem; 67 | } 68 | 69 | .dalgona { 70 | position: relative; 71 | width: 300px; 72 | height: 300px; 73 | margin: 0 auto; 74 | background: #f4d03f; 75 | border-radius: 50%; 76 | box-shadow: 77 | inset 0 2px 10px rgba(0, 0, 0, 0.2), 78 | 0 4px 10px rgba(0, 0, 0, 0.1); 79 | } 80 | 81 | .cracksOverlay { 82 | position: absolute; 83 | top: 0; 84 | left: 0; 85 | width: 100%; 86 | height: 100%; 87 | display: grid; 88 | grid-template-columns: repeat(10, 1fr); 89 | border-radius: 50%; 90 | overflow: hidden; 91 | } 92 | 93 | .crackRow { 94 | display: contents; 95 | } 96 | 97 | .crackCell { 98 | width: 100%; 99 | height: 30px; 100 | transition: all 0.3s ease; 101 | } 102 | 103 | .crackCell.cracked { 104 | background: rgba(0, 0, 0, 0.1); 105 | clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); 106 | } 107 | 108 | .word { 109 | position: absolute; 110 | top: 50%; 111 | left: 50%; 112 | transform: translate(-50%, -50%); 113 | display: flex; 114 | gap: 0.5rem; 115 | z-index: 1; 116 | } 117 | 118 | .letter { 119 | width: 40px; 120 | height: 40px; 121 | background: #e4c43f; 122 | border: 2px solid #d4b43f; 123 | border-radius: 8px; 124 | display: flex; 125 | align-items: center; 126 | justify-content: center; 127 | font-size: 1.5rem; 128 | font-weight: bold; 129 | color: #8b7355; 130 | cursor: pointer; 131 | transition: all 0.3s ease; 132 | } 133 | 134 | .letter:hover:not(.revealed) { 135 | transform: scale(1.1); 136 | background: #f4d43f; 137 | } 138 | 139 | .letter.revealed { 140 | background: #8b7355; 141 | color: #f4d03f; 142 | cursor: default; 143 | animation: revealLetter 0.5s ease-out; 144 | } 145 | 146 | @keyframes revealLetter { 147 | 0% { 148 | transform: scale(1); 149 | } 150 | 50% { 151 | transform: scale(1.2); 152 | } 153 | 100% { 154 | transform: scale(1); 155 | } 156 | } -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import styles from './page.module.css'; 4 | 5 | export default function AboutPage() { 6 | return ( 7 |
8 |

关于英语块

9 | 10 |
11 |

什么是英语块?

12 |

13 | 英语块是一个创新的英语学习工具,专注于帮助学习者掌握地道的英语表达方式。 14 | 不同于传统的单词或句子学习,我们将实用的英语表达切分成易于理解和记忆的"块", 15 | 让你能够更自然地组织语言,提升口语表达能力。 16 |

17 |
18 | 19 |
20 |

核心功能

21 |
    22 |
  • 23 | 智能提取 24 |

    从任意英语文本中自动提取适合初学者的英语表达块,包含发音、含义和使用场景。

    25 |
  • 26 |
  • 27 | 场景化学习 28 |

    每个英语块都标注了适用的场景,帮助你在正确的语境中使用这些表达。

    29 |
  • 30 |
  • 31 | 发音指导 32 |

    集成了 YouGlish 视频学习系统,让你能看到母语者如何在实际对话中使用这些表达。

    33 |
  • 34 |
  • 35 | 播放控制 36 |

    支持视频速度调节、重播和跳转功能,方便反复练习和深入学习。

    37 |
  • 38 |
39 |
40 | 41 |
42 |

学习建议

43 |
    44 |
  • 45 | 循序渐进 46 |

    从简单的日常对话场景开始,逐步过渡到更复杂的表达方式。

    47 |
  • 48 |
  • 49 | 情境记忆 50 |

    注意每个英语块的使用场景,在相似场景中尝试运用。

    51 |
  • 52 |
  • 53 | 反复练习 54 |

    使用 YouGlish 观看不同母语者的发音和用法,帮助加深理解。

    55 |
  • 56 |
  • 57 | 实践应用 58 |

    将学到的英语块融入日常对话,通过实践来巩固记忆。

    59 |
  • 60 |
61 |
62 | 63 |
64 |

技术特点

65 |
    66 |
  • 67 | AI 驱动 68 |

    使用先进的 AI 技术智能分析和提取有价值的英语表达块。

    69 |
  • 70 |
  • 71 | 实时处理 72 |

    支持实时文本分析和英语块提取,快速获取学习材料。

    73 |
  • 74 |
  • 75 | 视频集成 76 |

    无缝集成 YouGlish 视频学习系统,提供丰富的真实语言环境。

    77 |
  • 78 |
  • 79 | 响应式设计 80 |

    完美适配各种设备,随时随地学习英语。

    81 |
  • 82 |
83 |
84 | 85 |
86 |

未来规划

87 |

88 | 我们将持续优化和扩展英语块的功能,计划添加更多学习工具和练习模式, 89 | 打造更完整的英语学习生态系统。欢迎提供反馈和建议,帮助我们做得更好! 90 |

91 |
92 |
93 | ); 94 | } -------------------------------------------------------------------------------- /src/app/api/tts/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import * as sdk from 'microsoft-cognitiveservices-speech-sdk'; 3 | 4 | export async function POST(request: Request) { 5 | try { 6 | const body = await request.json(); 7 | 8 | if (!body.text) { 9 | return NextResponse.json( 10 | { message: 'Text is required' }, 11 | { status: 400 } 12 | ); 13 | } 14 | 15 | // 创建音频配置 16 | const audioConfig = sdk.AudioConfig.fromDefaultSpeakerOutput(); 17 | 18 | // 创建语音配置 19 | const speechConfig = sdk.SpeechConfig.fromEndpoint( 20 | new URL('wss://eastus.api.cognitive.microsoft.com/sts/v1.0/issuetoken'), 21 | '' // 不需要密钥,因为我们使用的是免费服务 22 | ); 23 | 24 | // 设置语音参数 25 | speechConfig.speechSynthesisVoiceName = "en-US-JennyNeural"; 26 | 27 | // 创建语音合成器 28 | const synthesizer = new sdk.SpeechSynthesizer(speechConfig, audioConfig); 29 | 30 | // 构建 SSML 31 | const ssml = ` 32 | 34 | 35 | 36 | ${body.text} 37 | 38 | 39 | 40 | `.trim(); 41 | 42 | // 合成音频 43 | const result = await new Promise((resolve, reject) => { 44 | synthesizer.speakSsmlAsync( 45 | ssml, 46 | result => { 47 | synthesizer.close(); 48 | resolve(result); 49 | }, 50 | error => { 51 | synthesizer.close(); 52 | reject(error); 53 | } 54 | ); 55 | }); 56 | 57 | // 将 ArrayBuffer 转换为 Uint8Array 以获取长度 58 | const audioData = new Uint8Array(result.audioData); 59 | 60 | // 检查结果 61 | if (audioData.length === 0) { 62 | throw new Error('No audio data generated'); 63 | } 64 | 65 | // 返回音频数据 66 | return new NextResponse(audioData, { 67 | headers: { 68 | 'Content-Type': 'audio/wav', 69 | 'Content-Length': audioData.length.toString(), 70 | 'Access-Control-Allow-Origin': '*', 71 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 72 | 'Access-Control-Allow-Headers': 'Content-Type', 73 | }, 74 | }); 75 | } catch (error) { 76 | console.error('TTS Error:', error); 77 | return NextResponse.json( 78 | { 79 | message: 'Error generating speech', 80 | error: error instanceof Error ? error.message : String(error) 81 | }, 82 | { 83 | status: 500, 84 | headers: { 85 | 'Access-Control-Allow-Origin': '*', 86 | } 87 | } 88 | ); 89 | } 90 | } 91 | 92 | export async function OPTIONS(request: Request) { 93 | return new NextResponse(null, { 94 | headers: { 95 | 'Access-Control-Allow-Origin': '*', 96 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 97 | 'Access-Control-Allow-Headers': 'Content-Type', 98 | }, 99 | }); 100 | } -------------------------------------------------------------------------------- /src/data/chunks.json: -------------------------------------------------------------------------------- 1 | { 2 | "chunks": [ 3 | { 4 | "chunk": "Yuval, good to see you.", 5 | "pronunciation": "/ˈjuːvəl ɡʊd tuː siː juː/", 6 | "chinese_meaning": "尤瓦尔,很高兴见到你。", 7 | "suitable_scenes": ["greeting someone", "meeting a friend"] 8 | }, 9 | { 10 | "chunk": "Jonah, thank you.", 11 | "pronunciation": "/ˈdʒoʊnə θæŋk juː/", 12 | "chinese_meaning": "乔纳,谢谢你。", 13 | "suitable_scenes": ["responding to a greeting", "showing gratitude"] 14 | }, 15 | { 16 | "chunk": "old friend", 17 | "pronunciation": "/oʊld frend/", 18 | "chinese_meaning": "老朋友", 19 | "suitable_scenes": ["describing a friend", "casual conversation"] 20 | }, 21 | { 22 | "chunk": "new addition", 23 | "pronunciation": "/njuː əˈdɪʃən/", 24 | "chinese_meaning": "新的加入", 25 | "suitable_scenes": ["announcing new member", "introducing new people"] 26 | }, 27 | { 28 | "chunk": "10th anniversary", 29 | "pronunciation": "/tɛnθ ˌænɪˈvɜrsəri/", 30 | "chinese_meaning": "十周年纪念", 31 | "suitable_scenes": ["celebrating an event", "talking about a milestone"] 32 | }, 33 | { 34 | "chunk": "Of course.", 35 | "pronunciation": "/əv kɔːrs/", 36 | "chinese_meaning": "当然。", 37 | "suitable_scenes": ["agreeing with someone", "responding to a suggestion"] 38 | }, 39 | { 40 | "chunk": "actually, yeah.", 41 | "pronunciation": "/ˈæktʃuəli jæ/", 42 | "chinese_meaning": "实际上,是的。", 43 | "suitable_scenes": ["agreeing to something", "confirming a suggestion"] 44 | }, 45 | { 46 | "chunk": "why don't you tell people", 47 | "pronunciation": "/waɪ doʊnt juː tɛl ˈpiːpl/", 48 | "chinese_meaning": "你为什么不告诉人们", 49 | "suitable_scenes": ["asking for information", "starting a presentation"] 50 | }, 51 | { 52 | "chunk": "what it is", 53 | "pronunciation": "/wɑt ɪt ɪz/", 54 | "chinese_meaning": "它是什么", 55 | "suitable_scenes": ["asking for definition", "asking for explanation"] 56 | }, 57 | { 58 | "chunk": "how you see it", 59 | "pronunciation": "/haʊ juː siː ɪt/", 60 | "chinese_meaning": "你是如何看待它的", 61 | "suitable_scenes": ["asking for someone's perspective", "asking for someone's opinion"] 62 | }, 63 | { 64 | "chunk": "thank you", 65 | "pronunciation": "/θæŋk juː/", 66 | "chinese_meaning": "谢谢你", 67 | "suitable_scenes": ["showing gratitude", "acknowledging a gesture"] 68 | }, 69 | { 70 | "chunk": "long-form essays", 71 | "pronunciation": "/lɔŋ fɔrm ˈɛˌseɪz/", 72 | "chinese_meaning": "长篇论文", 73 | "suitable_scenes": ["discussing written work", "talking about academic material"] 74 | }, 75 | { 76 | "chunk": "political philosophy", 77 | "pronunciation": "/pəˈlɪtɪkəl fɪˈlɑsəfi/", 78 | "chinese_meaning": "政治哲学", 79 | "suitable_scenes": ["discussing a subject", "learning about philosophy"] 80 | }, 81 | { 82 | "chunk": "the challenges that", 83 | "pronunciation": "/ðə ˈtʃælənʤɪz ðæt/", 84 | "chinese_meaning": "那些挑战", 85 | "suitable_scenes": ["talking about problems", "presenting difficulties"] 86 | }, 87 | { 88 | "chunk": "as a country", 89 | "pronunciation": "/æz ə ˈkʌntri/", 90 | "chinese_meaning": "作为一个国家", 91 | "suitable_scenes": ["talking about nations", "referring to the country"] 92 | }, 93 | { 94 | "chunk": "think out loud", 95 | "pronunciation": "/θɪŋk aʊt laʊd/", 96 | "chinese_meaning": "大声思考", 97 | "suitable_scenes": ["brainstorming ideas", "thinking openly"] 98 | } 99 | ] 100 | } -------------------------------------------------------------------------------- /src/utils/speechUtils.ts: -------------------------------------------------------------------------------- 1 | interface SpeechSettings { 2 | voice: string; 3 | speed: number; 4 | } 5 | 6 | interface OpenAISpeechSettings { 7 | apiUrl: string; 8 | apiKey: string; 9 | } 10 | 11 | const isBrowser = typeof window !== 'undefined'; 12 | 13 | export class SpeechUtils { 14 | private static async getSettings(): Promise { 15 | const defaultSettings: SpeechSettings = { 16 | voice: 'en-US-JennyNeural', 17 | speed: 1.0 18 | }; 19 | 20 | if (!isBrowser) return defaultSettings; 21 | 22 | const savedSettings = localStorage.getItem('userSettings'); 23 | if (savedSettings) { 24 | try { 25 | const settings = JSON.parse(savedSettings); 26 | return { 27 | voice: settings.voice || defaultSettings.voice, 28 | speed: settings.speed || defaultSettings.speed 29 | }; 30 | } catch (error) { 31 | console.error('Error parsing settings:', error); 32 | } 33 | } 34 | return defaultSettings; 35 | } 36 | 37 | private static async getOpenAISpeechSettings(): Promise { 38 | if (!isBrowser) return null; 39 | 40 | const savedSettings = localStorage.getItem('openAISpeechSettings'); 41 | if (savedSettings) { 42 | try { 43 | return JSON.parse(savedSettings); 44 | } catch (error) { 45 | console.error('Error parsing OpenAI speech settings:', error); 46 | } 47 | } 48 | return null; 49 | } 50 | 51 | private static async playBrowserTTS(text: string): Promise { 52 | if (!isBrowser) return; 53 | 54 | const settings = await this.getSettings(); 55 | const utterance = new SpeechSynthesisUtterance(text.replace(/\*\*/g, '')); 56 | utterance.lang = 'en-US'; 57 | utterance.rate = settings.speed; 58 | utterance.pitch = 1; 59 | utterance.volume = 1; 60 | utterance.voice = window.speechSynthesis.getVoices().find(v => v.name === settings.voice) || null; 61 | window.speechSynthesis.speak(utterance); 62 | } 63 | 64 | private static async playOpenAITTS(text: string): Promise { 65 | if (!isBrowser) return; 66 | 67 | const settings = await this.getOpenAISpeechSettings(); 68 | if (!settings) { 69 | throw new Error('OpenAI speech settings not configured'); 70 | } 71 | 72 | try { 73 | const response = await fetch(settings.apiUrl, { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json', 77 | 'Authorization': `Bearer ${settings.apiKey}` 78 | }, 79 | body: JSON.stringify({ 80 | model: 'tts-1', 81 | input: text.replace(/\*\*/g, ''), 82 | voice: 'alloy' 83 | }) 84 | }); 85 | 86 | if (!response.ok) { 87 | throw new Error('Failed to get audio from OpenAI'); 88 | } 89 | 90 | const audioBlob = await response.blob(); 91 | const audioUrl = URL.createObjectURL(audioBlob); 92 | const audio = new Audio(audioUrl); 93 | await audio.play(); 94 | } catch (error) { 95 | console.error('Error playing OpenAI TTS:', error); 96 | throw error; 97 | } 98 | } 99 | 100 | public static async playTTS(text: string): Promise { 101 | if (!isBrowser) return; 102 | 103 | try { 104 | const openAISettings = await this.getOpenAISpeechSettings(); 105 | if (openAISettings?.apiUrl && openAISettings?.apiKey) { 106 | await this.playOpenAITTS(text); 107 | } else { 108 | await this.playBrowserTTS(text); 109 | } 110 | } catch (error) { 111 | console.error('Error playing TTS:', error); 112 | // 如果 OpenAI TTS 失败,回退到浏览器 TTS 113 | await this.playBrowserTTS(text); 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /src/components/ChunkCard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './ChunkCard.module.css'; 3 | 4 | const ChunkCard = ({ chunk }) => { 5 | const playAudio = async () => { 6 | try { 7 | const response = await fetch('/api/tts', { 8 | method: 'POST', 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | }, 12 | body: JSON.stringify({ text: chunk.chunk }), 13 | }); 14 | 15 | const audioBlob = await response.blob(); 16 | const audioUrl = URL.createObjectURL(audioBlob); 17 | const audio = new Audio(audioUrl); 18 | audio.play(); 19 | } catch (error) { 20 | console.error('Error playing audio:', error); 21 | } 22 | }; 23 | 24 | const getYouglishUrl = (text) => { 25 | return `https://youglish.com/pronounce/${encodeURIComponent(text)}/english`; 26 | }; 27 | 28 | return ( 29 |
30 |
31 |

{chunk.chunk}

32 |

{chunk.pronunciation}

33 |

{chunk.chinese_meaning}

34 |
35 | {chunk.suitable_scenes.map((scene, index) => ( 36 | 37 | {scene} 38 | 39 | ))} 40 |
41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 54 | 61 | 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default ChunkCard; -------------------------------------------------------------------------------- /src/data/scenes.ts: -------------------------------------------------------------------------------- 1 | export interface Scene { 2 | id: string; 3 | title: string; 4 | description: string; 5 | icon: string; 6 | isCustom?: boolean; 7 | } 8 | 9 | export const scenes: Scene[] = [ 10 | { 11 | id: 'daily-greetings', 12 | title: '日常问候', 13 | description: '学习日常生活中的问候语和寒暄', 14 | icon: `` 15 | }, 16 | { 17 | id: 'restaurant', 18 | title: '餐厅用餐', 19 | description: '掌握在餐厅点餐和用餐的对话', 20 | icon: `` 21 | }, 22 | { 23 | id: 'asking-directions', 24 | title: '问路指路', 25 | description: '学习如何询问和指引方向', 26 | icon: `` 27 | }, 28 | { 29 | id: 'shopping', 30 | title: '购物交易', 31 | description: '购物时的讨价还价和交易对话', 32 | icon: `` 33 | }, 34 | { 35 | id: 'job-interview', 36 | title: '工作面试', 37 | description: '掌握面试中的专业对话', 38 | icon: `` 39 | }, 40 | { 41 | id: 'casual-chat', 42 | title: '闲聊话题', 43 | description: '日常生活话题的轻松对话', 44 | icon: `` 45 | }, 46 | { 47 | id: 'presentation', 48 | title: '演讲报告', 49 | description: '商务演讲和报告的表达方式', 50 | icon: `` 51 | }, 52 | { 53 | id: 'travel', 54 | title: '旅行交通', 55 | description: '旅行中的各种对话场景', 56 | icon: `` 57 | }, 58 | { 59 | id: 'custom', 60 | title: '自定义场景', 61 | description: '输入你想要练习的具体场景', 62 | icon: ``, 63 | isCustom: true 64 | } 65 | ]; -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import Link from 'next/link'; 5 | import { usePathname } from 'next/navigation'; 6 | import Logo from './Logo'; 7 | import styles from './Navbar.module.css'; 8 | 9 | const GitHubIcon = () => ( 10 | 11 | 12 | 13 | ); 14 | 15 | export default function Navbar() { 16 | const [isMenuOpen, setIsMenuOpen] = useState(false); 17 | const pathname = usePathname(); 18 | 19 | const toggleMenu = () => { 20 | setIsMenuOpen(!isMenuOpen); 21 | }; 22 | 23 | return ( 24 | 88 | ); 89 | } -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import Link from 'next/link'; 5 | import styles from './page.module.css'; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 |
12 |

13 | 英语学习助手 14 |
AI驱动的英语学习体验
15 |

16 |

17 | 通过场景对话和发音练习提升你的英语水平 18 |

19 |
20 |
21 | 22 | 23 | 24 |
25 |
🎯
26 |
🗣️
27 |
🎓
28 |
🔊
29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | 38 | 39 |
40 |

场景练习

41 |

选择或自定义场景,生成地道的英语对话。支持:

42 |
    43 |
  • 日常对话场景
  • 44 |
  • 职场沟通场景
  • 45 |
  • 自定义场景内容
  • 46 |
  • 智能抽取英语短语
  • 47 |
48 | 49 | 开始练习 50 | 51 |
52 | 53 |
54 |
55 | 56 | 57 | 58 |
59 |

发音纠错

60 |

练习常用英语表达的发音。包含:

61 |
    62 |
  • 常见口语缩读
  • 63 |
  • 标准发音示例
  • 64 |
  • 录音对比功能
  • 65 |
  • 详细发音要点
  • 66 |
67 | 68 | 练习发音 69 | 70 |
71 |
72 | 73 |
74 |
75 | 76 | 77 | 78 |
79 |

个性化设置

80 |

根据你的需求自定义学习体验:

81 |
    82 |
  • 选择 AI 模型(OpenAI/Gemini)
  • 83 |
  • 调整英语难度级别
  • 84 |
  • 设置发音语音和速度
  • 85 |
  • 配置 OpenAI 语音服务
  • 86 |
87 | 88 | 调整设置 89 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/SceneList.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1200px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | 7 | .grid { 8 | display: grid; 9 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 10 | gap: 1.5rem; 11 | } 12 | 13 | .sceneCard { 14 | background: white; 15 | border-radius: 12px; 16 | padding: 1.5rem; 17 | cursor: pointer; 18 | transition: all 0.2s ease; 19 | border: 1px solid #e5e7eb; 20 | } 21 | 22 | .sceneCard:hover { 23 | transform: translateY(-2px); 24 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 25 | border-color: #2563eb; 26 | } 27 | 28 | .icon { 29 | margin-bottom: 1rem; 30 | color: #2563eb; 31 | } 32 | 33 | .sceneCard h3 { 34 | font-size: 1.25rem; 35 | font-weight: 600; 36 | margin-bottom: 0.5rem; 37 | color: #1f2937; 38 | } 39 | 40 | .sceneCard p { 41 | color: #6b7280; 42 | font-size: 0.875rem; 43 | line-height: 1.5; 44 | } 45 | 46 | .dialogueContainer { 47 | padding: 1rem 0; 48 | } 49 | 50 | .backButton { 51 | background: #2563eb; 52 | color: white; 53 | border: none; 54 | padding: 0.75rem 1.5rem; 55 | border-radius: 6px; 56 | font-weight: 500; 57 | cursor: pointer; 58 | margin-bottom: 2rem; 59 | transition: background 0.2s ease; 60 | } 61 | 62 | .backButton:hover { 63 | background: #1d4ed8; 64 | } 65 | 66 | .loading { 67 | text-align: center; 68 | padding: 2rem; 69 | color: #6b7280; 70 | } 71 | 72 | .dialoguePreview { 73 | margin-top: 1.5rem; 74 | padding: 1.5rem; 75 | background: #f8fafc; 76 | border-radius: 8px; 77 | border: 1px solid #e2e8f0; 78 | max-height: 400px; 79 | overflow-y: auto; 80 | text-align: left; 81 | } 82 | 83 | .dialoguePreview pre { 84 | margin: 0; 85 | white-space: pre-wrap; 86 | word-wrap: break-word; 87 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 88 | font-size: 0.875rem; 89 | line-height: 1.7; 90 | color: #1f2937; 91 | } 92 | 93 | .error { 94 | background: #fee2e2; 95 | color: #dc2626; 96 | padding: 1rem; 97 | border-radius: 6px; 98 | margin-bottom: 1rem; 99 | } 100 | 101 | .chunksGrid { 102 | display: grid; 103 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 104 | gap: 1.5rem; 105 | margin-top: 2rem; 106 | } 107 | 108 | .progressContainer { 109 | width: 100%; 110 | height: 4px; 111 | background: #e5e7eb; 112 | border-radius: 2px; 113 | overflow: hidden; 114 | margin: 1rem 0; 115 | } 116 | 117 | .progressBar { 118 | height: 100%; 119 | background: #2563eb; 120 | transition: width 0.3s ease; 121 | } 122 | 123 | .customSceneInput { 124 | margin-top: 1rem; 125 | width: 100%; 126 | padding: 0.75rem; 127 | border: 1px solid #e5e7eb; 128 | border-radius: 6px; 129 | font-size: 1rem; 130 | transition: border-color 0.2s ease; 131 | } 132 | 133 | .customSceneInput:focus { 134 | outline: none; 135 | border-color: #2563eb; 136 | box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); 137 | } 138 | 139 | .customSceneButton { 140 | margin-top: 1rem; 141 | background: #2563eb; 142 | color: white; 143 | border: none; 144 | padding: 0.75rem 1.5rem; 145 | border-radius: 6px; 146 | font-weight: 500; 147 | cursor: pointer; 148 | transition: background 0.2s ease; 149 | } 150 | 151 | .customSceneButton:hover { 152 | background: #1d4ed8; 153 | } 154 | 155 | .customSceneButton:disabled { 156 | background: #9ca3af; 157 | cursor: not-allowed; 158 | } 159 | 160 | .dialogueCollapse { 161 | margin-bottom: 2rem; 162 | border: 1px solid #e5e7eb; 163 | border-radius: 8px; 164 | overflow: hidden; 165 | } 166 | 167 | .dialogueHeader { 168 | padding: 1rem; 169 | background: #f8fafc; 170 | cursor: pointer; 171 | display: flex; 172 | justify-content: space-between; 173 | align-items: center; 174 | border-bottom: 1px solid #e5e7eb; 175 | } 176 | 177 | .dialogueHeader:hover { 178 | background: #f1f5f9; 179 | } 180 | 181 | .dialogueContent { 182 | padding: 1rem; 183 | background: white; 184 | max-height: 0; 185 | overflow: hidden; 186 | transition: max-height 0.3s ease; 187 | } 188 | 189 | .dialogueContent.expanded { 190 | max-height: 500px; 191 | overflow-y: auto; 192 | } 193 | 194 | .sceneInputContainer { 195 | margin: 20px 0; 196 | display: flex; 197 | gap: 10px; 198 | width: 100%; 199 | } 200 | 201 | .sceneInput { 202 | flex: 1; 203 | padding: 10px 15px; 204 | border: 1px solid #ddd; 205 | border-radius: 4px; 206 | font-size: 16px; 207 | transition: border-color 0.3s ease; 208 | } 209 | 210 | .sceneInput:focus { 211 | outline: none; 212 | border-color: #0070f3; 213 | } 214 | 215 | .generateButton { 216 | padding: 10px 20px; 217 | background-color: #0070f3; 218 | color: white; 219 | border: none; 220 | border-radius: 4px; 221 | cursor: pointer; 222 | font-size: 16px; 223 | transition: background-color 0.3s ease; 224 | } 225 | 226 | .generateButton:hover:not(:disabled) { 227 | background-color: #0051b3; 228 | } 229 | 230 | .generateButton:disabled { 231 | background-color: #ccc; 232 | cursor: not-allowed; 233 | } -------------------------------------------------------------------------------- /src/components/ChunkCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styles from './ChunkCard.module.css'; 3 | import type { Chunk } from '@/services/chunkService'; 4 | import MarkdownRenderer from './MarkdownRenderer'; 5 | import YouGlishModal from './YouGlishModal'; 6 | 7 | interface ChunkProps { 8 | chunk: Chunk; 9 | } 10 | 11 | const ChunkCard: React.FC = ({ chunk }) => { 12 | const [isYouGlishOpen, setIsYouGlishOpen] = useState(false); 13 | 14 | // Ensure chunk has all required properties with default values 15 | const safeChunk = { 16 | chunk: chunk.chunk || '', 17 | pronunciation: chunk.pronunciation || '', 18 | chinese_meaning: chunk.chinese_meaning || '', 19 | suitable_scenes: Array.isArray(chunk.suitable_scenes) ? chunk.suitable_scenes : [], 20 | }; 21 | 22 | const playAudio = () => { 23 | // 从localStorage获取设置 24 | const savedSettings = localStorage.getItem('userSettings'); 25 | let voice = 'en-US-JennyNeural'; 26 | let speed = 1.0; 27 | 28 | if (savedSettings) { 29 | try { 30 | const settings = JSON.parse(savedSettings); 31 | voice = settings.voice || voice; 32 | speed = settings.speed || speed; 33 | } catch (error) { 34 | console.error('Error parsing settings:', error); 35 | } 36 | } 37 | 38 | const utterance = new SpeechSynthesisUtterance(safeChunk.chunk); 39 | utterance.lang = 'en-US'; 40 | utterance.rate = speed; 41 | utterance.pitch = 1; 42 | utterance.volume = 1; 43 | utterance.voice = window.speechSynthesis.getVoices().find(v => v.name === voice) || null; 44 | window.speechSynthesis.speak(utterance); 45 | }; 46 | 47 | return ( 48 | <> 49 |
50 |
51 |

{safeChunk.chunk}

52 |

{safeChunk.pronunciation}

53 |

{safeChunk.chinese_meaning}

54 | {safeChunk.suitable_scenes.length > 0 && ( 55 |
56 | {safeChunk.suitable_scenes.map((scene, index) => ( 57 | 58 | {scene} 59 | 60 | ))} 61 |
62 | )} 63 |
64 |
65 | 77 | 82 | 89 | 98 |
99 |
100 | setIsYouGlishOpen(false)} 103 | query={safeChunk.chunk} 104 | /> 105 | 106 | ); 107 | }; 108 | 109 | export default ChunkCard; -------------------------------------------------------------------------------- /src/components/games/WordDalgona.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import styles from './WordDalgona.module.css'; 3 | 4 | interface Props { 5 | onComplete: (stars: number) => void; 6 | currentStars: number; 7 | } 8 | 9 | const DIFFICULTY_SETTINGS = { 10 | 1: { timeLimit: 60, wordLength: 5, mistakes: 3 }, 11 | 2: { timeLimit: 45, wordLength: 7, mistakes: 2 }, 12 | 3: { timeLimit: 30, wordLength: 9, mistakes: 1 } 13 | }; 14 | 15 | const WORD_LISTS = { 16 | 1: ['HELLO', 'WORLD', 'APPLE', 'SMILE', 'HAPPY', 'LEARN'], 17 | 2: ['PROGRAM', 'ENGLISH', 'STUDENT', 'TEACHER', 'READING'], 18 | 3: ['EDUCATION', 'CHALLENGE', 'KNOWLEDGE', 'ADVENTURE', 'EXCELLENT'] 19 | }; 20 | 21 | const WordDalgona: React.FC = ({ onComplete, currentStars }) => { 22 | const [gameState, setGameState] = useState<'start' | 'playing' | 'end'>('start'); 23 | const [difficulty, setDifficulty] = useState(1); 24 | const [currentWord, setCurrentWord] = useState(''); 25 | const [revealedLetters, setRevealedLetters] = useState([]); 26 | const [mistakes, setMistakes] = useState(0); 27 | const [timeLeft, setTimeLeft] = useState(60); 28 | const [score, setScore] = useState(0); 29 | const [crackedPattern, setCrackedPattern] = useState([]); 30 | 31 | const initializeGame = (diff: number) => { 32 | const words = WORD_LISTS[diff as keyof typeof WORD_LISTS]; 33 | const randomWord = words[Math.floor(Math.random() * words.length)]; 34 | setCurrentWord(randomWord); 35 | setRevealedLetters(new Array(randomWord.length).fill(false)); 36 | setMistakes(0); 37 | setTimeLeft(DIFFICULTY_SETTINGS[diff as keyof typeof DIFFICULTY_SETTINGS].timeLimit); 38 | setScore(0); 39 | setDifficulty(diff); 40 | setGameState('playing'); 41 | 42 | // 创建达尔戈纳饼干的裂纹模式 43 | const pattern = Array(10).fill(null).map(() => 44 | Array(10).fill(false) 45 | ); 46 | setCrackedPattern(pattern); 47 | }; 48 | 49 | const handleLetterClick = (index: number) => { 50 | if (revealedLetters[index] || gameState !== 'playing') return; 51 | 52 | const newRevealedLetters = [...revealedLetters]; 53 | newRevealedLetters[index] = true; 54 | setRevealedLetters(newRevealedLetters); 55 | 56 | // 添加裂纹效果 57 | const newPattern = crackedPattern.map(row => [...row]); 58 | const centerX = Math.floor(Math.random() * 10); 59 | const centerY = Math.floor(Math.random() * 10); 60 | for (let i = -1; i <= 1; i++) { 61 | for (let j = -1; j <= 1; j++) { 62 | const x = centerX + i; 63 | const y = centerY + j; 64 | if (x >= 0 && x < 10 && y >= 0 && y < 10) { 65 | newPattern[x][y] = true; 66 | } 67 | } 68 | } 69 | setCrackedPattern(newPattern); 70 | 71 | // 检查是否完成单词 72 | const isWordComplete = newRevealedLetters.every(letter => letter); 73 | if (isWordComplete) { 74 | setScore(prev => prev + difficulty); 75 | if (score + 1 >= 5 && difficulty < 3) { 76 | setDifficulty(prev => prev + 1); 77 | initializeGame(difficulty + 1); 78 | } else { 79 | initializeGame(difficulty); 80 | } 81 | } 82 | }; 83 | 84 | useEffect(() => { 85 | if (gameState === 'playing' && timeLeft > 0) { 86 | const timer = setInterval(() => { 87 | setTimeLeft(prev => prev - 1); 88 | }, 1000); 89 | return () => clearInterval(timer); 90 | } else if (timeLeft === 0 && gameState === 'playing') { 91 | endGame(); 92 | } 93 | }, [timeLeft, gameState]); 94 | 95 | const endGame = () => { 96 | setGameState('end'); 97 | let stars = 1; 98 | if (score >= 10) stars = 2; 99 | if (score >= 15) stars = 3; 100 | onComplete(stars); 101 | }; 102 | 103 | return ( 104 |
105 | {gameState === 'start' && ( 106 |
107 |

单词达尔戈纳

108 |

小心翼翼地"刻出"正确的单词,不要弄碎糖饼!

109 |

当前最高星星数: {currentStars}⭐

110 |
111 | 112 | 113 | 114 |
115 |
116 | )} 117 | 118 | {gameState === 'playing' && ( 119 |
120 |
121 |
时间: {timeLeft}s
122 |
得分: {score}
123 |
难度: {difficulty}
124 |
125 | 126 |
127 |
128 | {crackedPattern.map((row, i) => ( 129 |
130 | {row.map((cracked, j) => ( 131 |
135 | ))} 136 |
137 | ))} 138 |
139 |
140 | {currentWord.split('').map((letter, index) => ( 141 |
handleLetterClick(index)} 145 | > 146 | {revealedLetters[index] ? letter : '?'} 147 |
148 | ))} 149 |
150 |
151 |
152 | )} 153 | 154 | {gameState === 'end' && ( 155 |
156 |

游戏结束!

157 |

最终得分: {score}

158 | 159 |
160 | )} 161 |
162 | ); 163 | }; 164 | 165 | export default WordDalgona; -------------------------------------------------------------------------------- /src/components/YouGlishModal.module.css: -------------------------------------------------------------------------------- 1 | .modalOverlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.75); 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | z-index: 1000; 12 | } 13 | 14 | .modalContent { 15 | background: white; 16 | border-radius: 12px; 17 | padding: 1.25rem; 18 | position: relative; 19 | width: 98%; 20 | max-width: 1400px; 21 | height: 98vh; 22 | display: flex; 23 | flex-direction: column; 24 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 25 | overflow: hidden; 26 | } 27 | 28 | .closeButton { 29 | position: absolute; 30 | top: 1.25rem; 31 | right: 1.25rem; 32 | background: none; 33 | border: none; 34 | color: #666; 35 | cursor: pointer; 36 | padding: 0.25rem; 37 | z-index: 2; 38 | border-radius: 50%; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | transition: all 0.2s ease; 43 | } 44 | 45 | .closeButton:hover { 46 | background-color: rgba(0, 0, 0, 0.1); 47 | color: #333; 48 | } 49 | 50 | .header { 51 | margin-bottom: 1rem; 52 | padding-right: 2.5rem; 53 | flex-shrink: 0; 54 | display: flex; 55 | align-items: center; 56 | justify-content: space-between; 57 | min-height: 28px; 58 | position: relative; 59 | z-index: 1; 60 | } 61 | 62 | .header h3 { 63 | margin: 0; 64 | font-size: 1rem; 65 | font-weight: 600; 66 | color: #1f2937; 67 | white-space: nowrap; 68 | overflow: hidden; 69 | text-overflow: ellipsis; 70 | } 71 | 72 | .trackInfo { 73 | font-size: 0.875rem; 74 | color: #6b7280; 75 | margin-left: 1rem; 76 | } 77 | 78 | .widgetContainer { 79 | position: relative; 80 | flex: 1; 81 | min-height: 0; 82 | background: #f8f9fa; 83 | border-radius: 8px; 84 | overflow: visible; 85 | padding: 0.5rem; 86 | border: 1px solid #e5e7eb; 87 | isolation: isolate; 88 | } 89 | 90 | .widgetWrapper { 91 | position: absolute; 92 | top: 0.5rem; 93 | left: 0.5rem; 94 | right: 0.5rem; 95 | bottom: 0.5rem; 96 | border-radius: 6px; 97 | overflow: hidden; 98 | z-index: 1; 99 | } 100 | 101 | .widgetContainer > div { 102 | position: absolute !important; 103 | top: 0 !important; 104 | left: 0 !important; 105 | right: 0 !important; 106 | bottom: 0 !important; 107 | height: 100% !important; 108 | width: 100% !important; 109 | border-radius: 6px; 110 | overflow: hidden; 111 | } 112 | 113 | .controlsWrapper { 114 | position: absolute; 115 | top: 0; 116 | left: 0; 117 | right: 0; 118 | bottom: 0; 119 | pointer-events: none; 120 | z-index: 100; 121 | } 122 | 123 | .controls { 124 | position: absolute; 125 | left: 50%; 126 | bottom: 2rem; 127 | transform: translateX(-50%); 128 | background: rgba(0, 0, 0, 0.75); 129 | backdrop-filter: blur(8px); 130 | border: 1px solid rgba(255, 255, 255, 0.1); 131 | border-radius: 8px; 132 | padding: 0.5rem 0.75rem; 133 | display: flex; 134 | align-items: center; 135 | gap: 0.75rem; 136 | flex-shrink: 0; 137 | min-height: 32px; 138 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 139 | pointer-events: auto; 140 | transition: all 0.2s ease; 141 | } 142 | 143 | .widgetContainer:not(:hover) .controls { 144 | opacity: 0.4; 145 | transform: translate(-50%, 1rem); 146 | } 147 | 148 | .speedControls { 149 | display: flex; 150 | align-items: center; 151 | gap: 0.25rem; 152 | flex-wrap: nowrap; 153 | flex-shrink: 0; 154 | position: relative; 155 | } 156 | 157 | .speedControls span { 158 | color: rgba(255, 255, 255, 0.9); 159 | font-size: 0.75rem; 160 | } 161 | 162 | .speedButton { 163 | background: rgba(255, 255, 255, 0.1); 164 | border: 1px solid rgba(255, 255, 255, 0.2); 165 | padding: 0.1rem 0.25rem; 166 | border-radius: 3px; 167 | font-size: 0.75rem; 168 | color: rgba(255, 255, 255, 0.9); 169 | cursor: pointer; 170 | transition: all 0.2s ease; 171 | min-width: 32px; 172 | } 173 | 174 | .speedButton:hover { 175 | background: rgba(255, 255, 255, 0.2); 176 | border-color: rgba(255, 255, 255, 0.3); 177 | } 178 | 179 | .speedButton.active { 180 | background: rgba(255, 255, 255, 0.9); 181 | color: rgba(0, 0, 0, 0.9); 182 | border-color: rgba(255, 255, 255, 0.9); 183 | } 184 | 185 | .navigationControls { 186 | display: flex; 187 | gap: 0.5rem; 188 | flex-wrap: nowrap; 189 | flex: 1; 190 | justify-content: flex-end; 191 | position: relative; 192 | } 193 | 194 | .navButton { 195 | display: flex; 196 | align-items: center; 197 | justify-content: center; 198 | gap: 0.25rem; 199 | background: rgba(255, 255, 255, 0.1); 200 | border: 1px solid rgba(255, 255, 255, 0.2); 201 | padding: 0.25rem 0.5rem; 202 | border-radius: 4px; 203 | color: rgba(255, 255, 255, 0.9); 204 | font-size: 0.75rem; 205 | cursor: pointer; 206 | transition: all 0.2s ease; 207 | min-width: 70px; 208 | flex-shrink: 0; 209 | } 210 | 211 | .navButton:hover { 212 | background: rgba(255, 255, 255, 0.2); 213 | border-color: rgba(255, 255, 255, 0.3); 214 | } 215 | 216 | .navButton svg { 217 | width: 14px; 218 | height: 14px; 219 | transition: all 0.2s ease; 220 | flex-shrink: 0; 221 | stroke: rgba(255, 255, 255, 0.9); 222 | } 223 | 224 | .navButton:hover svg { 225 | stroke: rgba(255, 255, 255, 1); 226 | } 227 | 228 | @media (max-width: 640px) { 229 | .modalContent { 230 | width: 100%; 231 | height: 100vh; 232 | padding: 1rem; 233 | border-radius: 0; 234 | } 235 | 236 | .header { 237 | margin-bottom: 0.75rem; 238 | } 239 | 240 | .header h3 { 241 | font-size: 0.875rem; 242 | } 243 | 244 | .trackInfo { 245 | font-size: 0.75rem; 246 | } 247 | 248 | .controls { 249 | bottom: 1rem; 250 | padding: 0.375rem 0.5rem; 251 | gap: 0.5rem; 252 | } 253 | 254 | .speedButton { 255 | min-width: 28px; 256 | font-size: 0.7rem; 257 | } 258 | 259 | .navButton { 260 | min-width: 60px; 261 | padding: 0.2rem 0.375rem; 262 | font-size: 0.7rem; 263 | } 264 | 265 | .navButton svg { 266 | width: 12px; 267 | height: 12px; 268 | } 269 | } -------------------------------------------------------------------------------- /src/components/YouGlishModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import styles from './YouGlishModal.module.css'; 3 | 4 | declare global { 5 | interface Window { 6 | YG: any; 7 | onYouglishAPIReady: () => void; 8 | } 9 | } 10 | 11 | interface YouGlishModalProps { 12 | isOpen: boolean; 13 | onClose: () => void; 14 | query: string; 15 | } 16 | 17 | const YouGlishModal: React.FC = ({ isOpen, onClose, query }) => { 18 | const widgetRef = useRef(null); 19 | const widgetInstanceRef = useRef(null); 20 | const [currentSpeed, setCurrentSpeed] = useState(1); 21 | const [totalResults, setTotalResults] = useState(0); 22 | const [currentTrack, setCurrentTrack] = useState(0); 23 | 24 | useEffect(() => { 25 | if (!isOpen) return; 26 | 27 | window.onYouglishAPIReady = () => { 28 | if (widgetRef.current) { 29 | initializeWidget(); 30 | } 31 | }; 32 | 33 | if (window.YG) { 34 | initializeWidget(); 35 | return; 36 | } 37 | 38 | const script = document.createElement('script'); 39 | script.src = 'https://youglish.com/public/emb/widget.js'; 40 | script.async = true; 41 | 42 | document.body.appendChild(script); 43 | 44 | return () => { 45 | if (script.parentNode) { 46 | script.parentNode.removeChild(script); 47 | } 48 | if (widgetInstanceRef.current) { 49 | try { 50 | widgetInstanceRef.current.close(); 51 | } catch (e) { 52 | console.error('Error closing widget:', e); 53 | } 54 | widgetInstanceRef.current = null; 55 | } 56 | }; 57 | }, [isOpen, query]); 58 | 59 | const initializeWidget = () => { 60 | if (!widgetRef.current || !query) return; 61 | 62 | try { 63 | if (widgetInstanceRef.current) { 64 | widgetInstanceRef.current.close(); 65 | widgetInstanceRef.current = null; 66 | } 67 | 68 | widgetInstanceRef.current = new window.YG.Widget(widgetRef.current.id, { 69 | width: Math.floor(widgetRef.current.clientWidth), 70 | height: Math.floor(widgetRef.current.clientHeight), 71 | components: 1847, 72 | events: { 73 | 'onFetchDone': (event: any) => { 74 | setTotalResults(event.totalResult); 75 | if (event.totalResult === 0) { 76 | console.log("No results found"); 77 | } 78 | }, 79 | 'onVideoChange': (event: any) => { 80 | setCurrentTrack(event.trackNumber); 81 | }, 82 | 'onError': (event: any) => { 83 | console.error("YouGlish error:", event); 84 | } 85 | } 86 | }); 87 | 88 | setTimeout(() => { 89 | if (widgetInstanceRef.current) { 90 | widgetInstanceRef.current.fetch(query, "english"); 91 | } 92 | }, 0); 93 | } catch (error) { 94 | console.error('Error initializing widget:', error); 95 | } 96 | }; 97 | 98 | const handleSpeedChange = (speed: number) => { 99 | if (widgetInstanceRef.current) { 100 | widgetInstanceRef.current.setSpeed(speed); 101 | setCurrentSpeed(speed); 102 | } 103 | }; 104 | 105 | const handlePrevious = () => { 106 | if (widgetInstanceRef.current) { 107 | widgetInstanceRef.current.previous(); 108 | } 109 | }; 110 | 111 | const handleNext = () => { 112 | if (widgetInstanceRef.current) { 113 | widgetInstanceRef.current.next(); 114 | } 115 | }; 116 | 117 | const handleReplay = () => { 118 | if (widgetInstanceRef.current) { 119 | widgetInstanceRef.current.replay(); 120 | } 121 | }; 122 | 123 | if (!isOpen) return null; 124 | 125 | return ( 126 |
127 |
e.stopPropagation()}> 128 | 134 | 135 |
136 |

YouGlish - "{query}"

137 |
138 | {totalResults > 0 && ( 139 | Video {currentTrack} of {totalResults} 140 | )} 141 |
142 |
143 | 144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | Speed: 152 | {[0.5, 0.75, 1, 1.25, 1.5].map(speed => ( 153 | 160 | ))} 161 |
162 |
163 | 169 | 176 | 182 |
183 |
184 |
185 |
186 |
187 |
188 | ); 189 | }; 190 | 191 | export default YouGlishModal; -------------------------------------------------------------------------------- /src/services/aiService.ts: -------------------------------------------------------------------------------- 1 | import type { Chunk } from './chunkService'; 2 | 3 | interface AIConfig { 4 | provider: 'openai' | 'gemini'; 5 | apiKey: string; 6 | apiUrl: string; 7 | modelName: string; 8 | englishLevel: string; 9 | } 10 | 11 | interface SceneResponse { 12 | dialogue: string; 13 | chunks: Chunk[]; 14 | } 15 | 16 | const getEnglishLevelDescription = (level: string): string => { 17 | switch (level) { 18 | case 'kindergarten': 19 | return 'beginner level (age 3-6), using very simple vocabulary and basic expressions'; 20 | case 'elementary': 21 | return 'elementary level (age 7-12), using simple vocabulary and common daily expressions'; 22 | case 'junior': 23 | return 'intermediate level (age 13-15), using moderate vocabulary and some idioms'; 24 | case 'university': 25 | return 'advanced level (university student), using rich vocabulary and natural expressions'; 26 | case 'postdoc': 27 | return 'professional level (post-doctoral), using sophisticated vocabulary and professional expressions'; 28 | default: 29 | return 'intermediate level, using moderate vocabulary and common expressions'; 30 | } 31 | }; 32 | 33 | const SYSTEM_PROMPT = (englishLevel: string) => `You are an English learning assistant. Your task is to: 34 | 1. Generate a natural dialogue based on the given scene 35 | 2. Extract useful English chunks from the dialogue 36 | 3. Return both in a structured format 37 | 38 | Target English Level: ${getEnglishLevelDescription(englishLevel)} 39 | 40 | Requirements: 41 | - The dialogue should be realistic and include 3-4 speakers 42 | - Each chunk should be a useful phrase or expression (not complete sentences) 43 | - Each chunk must include pronunciation, Chinese meaning, and suitable scenes 44 | - Format all speakers in the dialogue as "Speaker: Content" 45 | - Adjust language difficulty according to the target English level 46 | - For lower levels (kindergarten/elementary), focus on basic daily expressions 47 | - For higher levels (university/postdoc), include more sophisticated expressions and idioms 48 | 49 | Response Format: 50 | { 51 | "dialogue": "string (the generated dialogue with speaker names)", 52 | "chunks": [ 53 | { 54 | "chunk": "useful English phrase or expression", 55 | "pronunciation": "IPA phonetic symbols", 56 | "chinese_meaning": "中文含义", 57 | "suitable_scenes": ["场景1", "场景2"] 58 | } 59 | ] 60 | }`; 61 | 62 | const createRequestBody = (config: AIConfig, scene: string, stream = true) => { 63 | const prompt = `Generate an English learning dialogue and chunks for the following scene: ${scene}`; 64 | 65 | if (config.provider === 'openai') { 66 | return { 67 | model: config.modelName, 68 | messages: [ 69 | { role: 'system', content: SYSTEM_PROMPT(config.englishLevel) }, 70 | { role: 'user', content: prompt } 71 | ], 72 | stream 73 | }; 74 | } else { 75 | return { 76 | contents: [ 77 | { 78 | parts: [ 79 | { text: SYSTEM_PROMPT(config.englishLevel) + "\n\nUser: " + prompt } 80 | ] 81 | } 82 | ] 83 | }; 84 | } 85 | }; 86 | 87 | const handleStreamResponse = async ( 88 | response: Response, 89 | config: AIConfig, 90 | onProgress: (text: string) => void 91 | ): Promise => { 92 | const reader = response.body?.getReader(); 93 | const decoder = new TextDecoder(); 94 | let fullText = ''; 95 | 96 | if (!reader) { 97 | throw new Error('Failed to read response'); 98 | } 99 | 100 | while (true) { 101 | const { done, value } = await reader.read(); 102 | if (done) break; 103 | 104 | const chunk = decoder.decode(value); 105 | const lines = chunk.split('\n'); 106 | 107 | for (const line of lines) { 108 | if (line.startsWith('data: ')) { 109 | const data = line.slice(6); 110 | if (data === '[DONE]') continue; 111 | 112 | try { 113 | const parsed = JSON.parse(data); 114 | let content = ''; 115 | if (config.provider === 'openai' && parsed.choices?.[0]?.delta?.content) { 116 | content = parsed.choices[0].delta.content; 117 | } else if (config.provider === 'gemini' && parsed.candidates?.[0]?.content?.parts?.[0]?.text) { 118 | content = parsed.candidates[0].content.parts[0].text; 119 | } 120 | fullText += content; 121 | onProgress(fullText); 122 | } catch (e) { 123 | console.error('Error parsing streaming response:', e); 124 | } 125 | } 126 | } 127 | } 128 | 129 | return fullText; 130 | }; 131 | 132 | export const generateSceneContent = async ( 133 | scene: string, 134 | config: AIConfig, 135 | onProgress: (dialogue: string) => void 136 | ): Promise => { 137 | try { 138 | let endpoint; 139 | let headers; 140 | let body; 141 | 142 | if (config.provider === 'openai') { 143 | endpoint = `${config.apiUrl}/v1/chat/completions`; 144 | headers = { 145 | 'Content-Type': 'application/json', 146 | 'Authorization': `Bearer ${config.apiKey}`, 147 | }; 148 | body = createRequestBody(config, scene, false); 149 | } else { 150 | endpoint = `${config.apiUrl}/v1beta/models/${config.modelName}:generateContent?key=${config.apiKey}`; 151 | headers = { 152 | 'Content-Type': 'application/json' 153 | }; 154 | body = createRequestBody(config, scene, false); 155 | } 156 | 157 | const response = await fetch(endpoint, { 158 | method: 'POST', 159 | headers, 160 | body: JSON.stringify(body) 161 | }); 162 | 163 | if (!response.ok) { 164 | throw new Error('Failed to generate scene content'); 165 | } 166 | 167 | const data = await response.json(); 168 | let content = ''; 169 | 170 | if (config.provider === 'openai') { 171 | content = data.choices[0]?.message?.content; 172 | } else { 173 | content = data.candidates[0]?.content?.parts[0]?.text; 174 | } 175 | 176 | if (!content) { 177 | throw new Error('No content in response'); 178 | } 179 | 180 | try { 181 | // 清理响应内容,确保它是有效的JSON 182 | const cleanContent = content 183 | .replace(/^```json\s*/m, '') 184 | .replace(/\s*```\s*$/m, '') 185 | .trim(); 186 | 187 | const result = JSON.parse(cleanContent) as SceneResponse; 188 | 189 | // 验证响应格式 190 | if (!result.dialogue || !Array.isArray(result.chunks)) { 191 | throw new Error('Invalid response format'); 192 | } 193 | 194 | // 格式化对话为Markdown 195 | const formattedDialogue = result.dialogue 196 | .split('\n') 197 | .map(line => { 198 | if (line.includes(':')) { 199 | const [speaker, content] = line.split(':').map(part => part.trim()); 200 | return `**${speaker}**: ${content}`; 201 | } 202 | return line; 203 | }) 204 | .join('\n\n'); 205 | 206 | onProgress(formattedDialogue); 207 | 208 | return { 209 | dialogue: formattedDialogue, 210 | chunks: result.chunks.map(chunk => ({ 211 | chunk: chunk.chunk || '', 212 | pronunciation: chunk.pronunciation || '', 213 | chinese_meaning: chunk.chinese_meaning || '', 214 | suitable_scenes: Array.isArray(chunk.suitable_scenes) ? chunk.suitable_scenes : [], 215 | })) 216 | }; 217 | } catch (e) { 218 | console.error('Error parsing response:', e); 219 | console.error('Content was:', content); 220 | throw new Error('Failed to parse scene content'); 221 | } 222 | } catch (error) { 223 | console.error('Error generating scene content:', error); 224 | throw error; 225 | } 226 | }; -------------------------------------------------------------------------------- /src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1200px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | 7 | .hero { 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | margin-bottom: 4rem; 12 | min-height: 500px; 13 | position: relative; 14 | } 15 | 16 | .heroContent { 17 | flex: 1; 18 | max-width: 600px; 19 | z-index: 1; 20 | } 21 | 22 | .heroGraphic { 23 | position: relative; 24 | width: 400px; 25 | height: 400px; 26 | } 27 | 28 | .blob { 29 | position: absolute; 30 | width: 100%; 31 | height: 100%; 32 | animation: blobAnimation 8s ease-in-out infinite; 33 | opacity: 0.1; 34 | z-index: 0; 35 | } 36 | 37 | .iconGrid { 38 | position: absolute; 39 | width: 100%; 40 | height: 100%; 41 | display: grid; 42 | grid-template-columns: repeat(2, 1fr); 43 | gap: 2rem; 44 | padding: 2rem; 45 | z-index: 1; 46 | } 47 | 48 | .icon { 49 | font-size: 2.5rem; 50 | background: white; 51 | width: 60px; 52 | height: 60px; 53 | border-radius: 12px; 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 58 | backdrop-filter: blur(8px); 59 | -webkit-backdrop-filter: blur(8px); 60 | border: 1px solid rgba(255, 255, 255, 0.3); 61 | } 62 | 63 | .iconFloat1 { animation: float 6s ease-in-out infinite; } 64 | .iconFloat2 { animation: float 6s ease-in-out infinite 1.5s; } 65 | .iconFloat3 { animation: float 6s ease-in-out infinite 3s; } 66 | .iconFloat4 { animation: float 6s ease-in-out infinite 4.5s; } 67 | 68 | .title { 69 | font-size: 3.5rem; 70 | font-weight: 800; 71 | line-height: 1.2; 72 | margin-bottom: 1rem; 73 | } 74 | 75 | .gradientText { 76 | background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); 77 | -webkit-background-clip: text; 78 | -webkit-text-fill-color: transparent; 79 | animation: gradientFlow 8s ease infinite; 80 | background-size: 200% 200%; 81 | } 82 | 83 | .subtitle { 84 | font-size: 1.5rem; 85 | color: #6b7280; 86 | margin-top: 0.5rem; 87 | } 88 | 89 | .description { 90 | font-size: 1.25rem; 91 | color: #6b7280; 92 | margin-bottom: 2rem; 93 | line-height: 1.6; 94 | } 95 | 96 | .features { 97 | display: grid; 98 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 99 | gap: 2rem; 100 | margin-bottom: 3rem; 101 | } 102 | 103 | .feature { 104 | background: white; 105 | border: 1px solid #e5e7eb; 106 | border-radius: 16px; 107 | padding: 2rem; 108 | transition: all 0.3s ease; 109 | position: relative; 110 | overflow: hidden; 111 | } 112 | 113 | .feature:hover { 114 | border-color: #2563eb; 115 | box-shadow: 0 8px 16px rgba(37, 99, 235, 0.1); 116 | transform: translateY(-4px); 117 | } 118 | 119 | .featureIcon { 120 | width: 48px; 121 | height: 48px; 122 | margin-bottom: 1.5rem; 123 | border-radius: 12px; 124 | background: rgba(37, 99, 235, 0.1); 125 | display: flex; 126 | align-items: center; 127 | justify-content: center; 128 | } 129 | 130 | .featureSvg { 131 | width: 24px; 132 | height: 24px; 133 | fill: #2563eb; 134 | } 135 | 136 | .feature h2 { 137 | font-size: 1.5rem; 138 | font-weight: 700; 139 | color: #1f2937; 140 | margin-bottom: 1rem; 141 | } 142 | 143 | .feature p { 144 | color: #6b7280; 145 | margin-bottom: 1rem; 146 | line-height: 1.6; 147 | } 148 | 149 | .feature ul { 150 | list-style-type: none; 151 | padding: 0; 152 | margin-bottom: 1.5rem; 153 | } 154 | 155 | .feature li { 156 | color: #4b5563; 157 | margin-bottom: 0.75rem; 158 | padding-left: 1.5rem; 159 | position: relative; 160 | line-height: 1.4; 161 | } 162 | 163 | .feature li::before { 164 | content: "•"; 165 | color: #2563eb; 166 | position: absolute; 167 | left: 0; 168 | font-weight: bold; 169 | } 170 | 171 | .settings { 172 | background: white; 173 | border: 1px solid #e5e7eb; 174 | border-radius: 16px; 175 | padding: 2rem; 176 | position: relative; 177 | overflow: hidden; 178 | } 179 | 180 | .settings::before { 181 | content: ''; 182 | position: absolute; 183 | top: 0; 184 | right: 0; 185 | width: 200px; 186 | height: 200px; 187 | background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%); 188 | border-radius: 0 0 0 100%; 189 | z-index: 0; 190 | } 191 | 192 | .settingsIcon { 193 | width: 48px; 194 | height: 48px; 195 | margin-bottom: 1.5rem; 196 | border-radius: 12px; 197 | background: rgba(37, 99, 235, 0.1); 198 | display: flex; 199 | align-items: center; 200 | justify-content: center; 201 | } 202 | 203 | .settings h2 { 204 | font-size: 1.5rem; 205 | font-weight: 700; 206 | color: #1f2937; 207 | margin-bottom: 1rem; 208 | position: relative; 209 | z-index: 1; 210 | } 211 | 212 | .settings p { 213 | color: #6b7280; 214 | margin-bottom: 1rem; 215 | position: relative; 216 | z-index: 1; 217 | } 218 | 219 | .settings ul { 220 | list-style-type: none; 221 | padding: 0; 222 | margin-bottom: 1.5rem; 223 | columns: 2; 224 | position: relative; 225 | z-index: 1; 226 | } 227 | 228 | .settings li { 229 | color: #4b5563; 230 | margin-bottom: 0.75rem; 231 | padding-left: 1.5rem; 232 | position: relative; 233 | break-inside: avoid; 234 | line-height: 1.4; 235 | } 236 | 237 | .settings li::before { 238 | content: "•"; 239 | color: #2563eb; 240 | position: absolute; 241 | left: 0; 242 | font-weight: bold; 243 | } 244 | 245 | .button { 246 | display: inline-flex; 247 | align-items: center; 248 | background: #2563eb; 249 | color: white; 250 | padding: 0.75rem 1.5rem; 251 | border-radius: 8px; 252 | text-decoration: none; 253 | font-weight: 500; 254 | transition: all 0.3s ease; 255 | position: relative; 256 | overflow: hidden; 257 | } 258 | 259 | .button:hover { 260 | background: #1d4ed8; 261 | transform: translateY(-2px); 262 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2); 263 | } 264 | 265 | @keyframes blobAnimation { 266 | 0%, 100% { transform: translate(100, 100) scale(1); } 267 | 50% { transform: translate(100, 100) scale(1.1); } 268 | } 269 | 270 | @keyframes float { 271 | 0%, 100% { transform: translateY(0); } 272 | 50% { transform: translateY(-20px); } 273 | } 274 | 275 | @keyframes gradientFlow { 276 | 0% { background-position: 0% 50%; } 277 | 50% { background-position: 100% 50%; } 278 | 100% { background-position: 0% 50%; } 279 | } 280 | 281 | @media (max-width: 768px) { 282 | .container { 283 | padding: 1rem; 284 | } 285 | 286 | .hero { 287 | flex-direction: column; 288 | text-align: center; 289 | min-height: auto; 290 | padding: 2rem 0; 291 | margin-bottom: 2rem; 292 | } 293 | 294 | .heroContent { 295 | margin-bottom: 3rem; 296 | padding: 0 1rem; 297 | } 298 | 299 | .heroGraphic { 300 | width: 280px; 301 | height: 280px; 302 | margin: 0 auto; 303 | transform: translateY(-20px); 304 | } 305 | 306 | .blob { 307 | opacity: 0.05; 308 | transform: scale(0.9); 309 | } 310 | 311 | .iconGrid { 312 | gap: 1.25rem; 313 | padding: 1.25rem; 314 | } 315 | 316 | .icon { 317 | width: 48px; 318 | height: 48px; 319 | font-size: 1.75rem; 320 | border-radius: 10px; 321 | } 322 | 323 | .title { 324 | font-size: 2.25rem; 325 | } 326 | 327 | .subtitle { 328 | font-size: 1.125rem; 329 | margin-top: 0.25rem; 330 | } 331 | 332 | .description { 333 | font-size: 1rem; 334 | margin-bottom: 1.5rem; 335 | padding: 0 0.5rem; 336 | } 337 | 338 | .features { 339 | gap: 1.5rem; 340 | margin-bottom: 2rem; 341 | padding: 0 0.5rem; 342 | } 343 | 344 | .feature { 345 | padding: 1.5rem; 346 | } 347 | 348 | .feature h2 { 349 | font-size: 1.25rem; 350 | } 351 | 352 | .feature p { 353 | font-size: 0.875rem; 354 | } 355 | 356 | .feature ul { 357 | margin-bottom: 1.25rem; 358 | } 359 | 360 | .feature li { 361 | font-size: 0.875rem; 362 | margin-bottom: 0.5rem; 363 | } 364 | 365 | .settings { 366 | padding: 1.5rem; 367 | margin: 0 0.5rem; 368 | } 369 | 370 | .settings h2 { 371 | font-size: 1.25rem; 372 | } 373 | 374 | .settings p { 375 | font-size: 0.875rem; 376 | } 377 | 378 | .settings ul { 379 | columns: 1; 380 | margin-bottom: 1.25rem; 381 | } 382 | 383 | .settings li { 384 | font-size: 0.875rem; 385 | margin-bottom: 0.5rem; 386 | } 387 | 388 | .button { 389 | width: 100%; 390 | justify-content: center; 391 | padding: 0.875rem 1rem; 392 | font-size: 0.875rem; 393 | } 394 | 395 | .featureIcon, .settingsIcon { 396 | width: 40px; 397 | height: 40px; 398 | margin-bottom: 1rem; 399 | } 400 | 401 | .featureSvg { 402 | width: 20px; 403 | height: 20px; 404 | } 405 | 406 | @keyframes float { 407 | 0%, 100% { transform: translateY(0); } 408 | 50% { transform: translateY(-12px); } 409 | } 410 | } -------------------------------------------------------------------------------- /src/components/SettingsForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import styles from './SettingsForm.module.css'; 5 | 6 | // 定义设置的类型 7 | interface Settings { 8 | ai: { 9 | provider: 'openai' | 'gemini'; 10 | apiKey: string; 11 | apiUrl: string; 12 | modelName: string; 13 | }; 14 | englishLevel: string; 15 | voice: string; 16 | speed: number; 17 | } 18 | 19 | interface OpenAISpeechSettings { 20 | apiUrl: string; 21 | apiKey: string; 22 | } 23 | 24 | // 默认设置 25 | const defaultSettings: Settings = { 26 | ai: { 27 | provider: 'openai', 28 | apiKey: '', 29 | apiUrl: 'https://api-proxy.me/openai', 30 | modelName: 'gpt-4o', 31 | }, 32 | englishLevel: 'elementary', 33 | voice: 'en-US-JennyNeural', 34 | speed: 1.0, 35 | }; 36 | 37 | // 英语等级选项 38 | const englishLevels = [ 39 | { value: 'kindergarten', label: '英语幼儿园' }, 40 | { value: 'elementary', label: '英语小学生' }, 41 | { value: 'junior', label: '英语初中生' }, 42 | { value: 'university', label: '英语大学生' }, 43 | { value: 'postdoc', label: '英语博士后' }, 44 | ]; 45 | 46 | // Edge TTS 音色列表 47 | const voices = [ 48 | 'en-US-JennyNeural', 49 | 'en-US-GuyNeural', 50 | 'en-GB-SoniaNeural', 51 | 'en-GB-RyanNeural', 52 | 'en-AU-NatashaNeural', 53 | 'en-AU-WilliamNeural', 54 | 'en-CA-ClaraNeural', 55 | 'en-CA-LiamNeural', 56 | ]; 57 | 58 | const SettingsForm = () => { 59 | const [settings, setSettings] = useState(defaultSettings); 60 | const [message, setMessage] = useState(''); 61 | const [openAISpeechSettings, setOpenAISpeechSettings] = useState({ 62 | apiUrl: '', 63 | apiKey: '' 64 | }); 65 | 66 | // 加载设置 67 | useEffect(() => { 68 | if (typeof window === 'undefined') return; 69 | 70 | const savedSettings = localStorage.getItem('userSettings'); 71 | if (savedSettings) { 72 | try { 73 | const parsed = JSON.parse(savedSettings); 74 | setSettings({ 75 | ...defaultSettings, 76 | ...parsed, 77 | ai: { 78 | ...defaultSettings.ai, 79 | ...(parsed.ai || {}), 80 | } 81 | }); 82 | } catch (error) { 83 | console.error('Error loading settings:', error); 84 | setSettings(defaultSettings); 85 | } 86 | } 87 | 88 | const savedOpenAISpeechSettings = localStorage.getItem('openAISpeechSettings'); 89 | if (savedOpenAISpeechSettings) { 90 | try { 91 | setOpenAISpeechSettings(JSON.parse(savedOpenAISpeechSettings)); 92 | } catch (error) { 93 | console.error('Error parsing OpenAI speech settings:', error); 94 | } 95 | } 96 | }, []); 97 | 98 | // 保存设置 99 | const saveSettings = () => { 100 | try { 101 | localStorage.setItem('userSettings', JSON.stringify(settings)); 102 | setMessage('设置已保存'); 103 | setTimeout(() => setMessage(''), 3000); 104 | } catch (error) { 105 | console.error('Error saving settings:', error); 106 | setMessage('保存设置失败'); 107 | } 108 | }; 109 | 110 | // 导出用户数据 111 | const exportData = () => { 112 | try { 113 | const data = { 114 | settings, 115 | // 这里可以添加其他需要导出的用户数据 116 | }; 117 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 118 | const url = URL.createObjectURL(blob); 119 | const a = document.createElement('a'); 120 | a.href = url; 121 | a.download = 'user-data.json'; 122 | document.body.appendChild(a); 123 | a.click(); 124 | document.body.removeChild(a); 125 | URL.revokeObjectURL(url); 126 | } catch (error) { 127 | console.error('Error exporting data:', error); 128 | setMessage('导出数据失败'); 129 | } 130 | }; 131 | 132 | const handleOpenAISpeechSettingsChange = (e: React.ChangeEvent) => { 133 | const { name, value } = e.target; 134 | setOpenAISpeechSettings(prev => { 135 | const newSettings = { 136 | ...prev, 137 | [name]: value 138 | }; 139 | localStorage.setItem('openAISpeechSettings', JSON.stringify(newSettings)); 140 | return newSettings; 141 | }); 142 | }; 143 | 144 | return ( 145 |
146 |
147 |

AI 设置

148 |
149 | 150 | 161 |
162 |
163 | 164 | setSettings({ 169 | ...settings, 170 | ai: { ...settings.ai, apiKey: e.target.value } 171 | })} 172 | placeholder={`输入你的 ${settings.ai.provider === 'openai' ? 'OpenAI' : 'Gemini'} API Key`} 173 | /> 174 |
175 |
176 | 177 | setSettings({ 182 | ...settings, 183 | ai: { ...settings.ai, apiUrl: e.target.value } 184 | })} 185 | placeholder={`输入 ${settings.ai.provider === 'openai' ? 'OpenAI' : 'Gemini'} API 的URL`} 186 | /> 187 |
188 |
189 | 190 | setSettings({ 195 | ...settings, 196 | ai: { ...settings.ai, modelName: e.target.value } 197 | })} 198 | placeholder="输入模型名称,如 gpt-3.5-turbo" 199 | /> 200 |
201 |
202 | 203 |
204 |

英语等级

205 |
206 | 219 |
220 |
221 | 222 |
223 |

语音设置

224 |
225 | 226 | 240 |
241 |
242 | 243 | setSettings({ 251 | ...settings, 252 | speed: parseFloat(e.target.value) 253 | })} 254 | /> 255 |
256 |
257 | 258 |
259 |

OpenAI 语音配置(可选)

260 |
261 | 262 | 270 |
271 |
272 | 273 | 281 |
282 |
283 | 284 |
285 | 288 | 291 |
292 | 293 | {message &&
{message}
} 294 |
295 | ); 296 | }; 297 | 298 | export default SettingsForm; -------------------------------------------------------------------------------- /src/components/SceneList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useRouter } from 'next/navigation'; 3 | import { scenes } from '@/data/scenes'; 4 | import { generateSceneContent } from '@/services/aiService'; 5 | import { checkAndRedirectAPISettings } from '@/utils/settingsHelper'; 6 | import ChunkCard from './ChunkCard'; 7 | import MarkdownRenderer from './MarkdownRenderer'; 8 | import styles from './SceneList.module.css'; 9 | 10 | const SceneList = () => { 11 | const router = useRouter(); 12 | const [selectedScene, setSelectedScene] = useState(null); 13 | const [chunks, setChunks] = useState([]); 14 | const [loading, setLoading] = useState(false); 15 | const [error, setError] = useState(null); 16 | const [dialogue, setDialogue] = useState(''); 17 | const [processingStep, setProcessingStep] = useState<'idle' | 'generating'>('idle'); 18 | const [progress, setProgress] = useState(0); 19 | const [customSceneInput, setCustomSceneInput] = useState(''); 20 | const [additionalContext, setAdditionalContext] = useState(''); 21 | const [isDialogueExpanded, setIsDialogueExpanded] = useState(false); 22 | 23 | const handleSceneClick = async (sceneId: string) => { 24 | setSelectedScene(sceneId); 25 | setAdditionalContext(''); // 清空附加信息 26 | if (sceneId === 'custom') { 27 | return; 28 | } 29 | 30 | const savedSettings = localStorage.getItem('userSettings'); 31 | if (!savedSettings) { 32 | router.push('/settings'); 33 | alert('请先配置API设置'); 34 | return; 35 | } 36 | 37 | const settings = JSON.parse(savedSettings); 38 | if (!checkAndRedirectAPISettings(settings.ai, router)) { 39 | return; 40 | } 41 | }; 42 | 43 | const handleSceneSubmit = async () => { 44 | if (selectedScene === 'custom' && !customSceneInput.trim()) return; 45 | 46 | const savedSettings = localStorage.getItem('userSettings'); 47 | if (!savedSettings) { 48 | router.push('/settings'); 49 | alert('请先配置API设置'); 50 | return; 51 | } 52 | 53 | const settings = JSON.parse(savedSettings); 54 | if (!checkAndRedirectAPISettings(settings.ai, router)) { 55 | return; 56 | } 57 | 58 | await generateSceneDialogue(selectedScene!, selectedScene === 'custom' ? customSceneInput : undefined); 59 | }; 60 | 61 | const generateSceneDialogue = async (sceneId: string, customPrompt?: string) => { 62 | setLoading(true); 63 | setError(null); 64 | setDialogue(''); 65 | setChunks([]); 66 | setProgress(0); 67 | setProcessingStep('generating'); 68 | 69 | try { 70 | const savedSettings = localStorage.getItem('userSettings'); 71 | if (!savedSettings) { 72 | throw new Error('请先在设置中配置 API 信息'); 73 | } 74 | 75 | const settings = JSON.parse(savedSettings); 76 | const config = { 77 | provider: settings.ai.provider, 78 | apiKey: settings.ai.apiKey, 79 | apiUrl: settings.ai.apiUrl, 80 | modelName: settings.ai.modelName, 81 | englishLevel: settings.englishLevel 82 | }; 83 | 84 | const scene = scenes.find(s => s.id === sceneId); 85 | if (!scene && !customPrompt) return; 86 | 87 | // 构建完整的场景描述 88 | let sceneDescription = customPrompt || scene!.title; 89 | if (additionalContext && sceneId !== 'custom') { 90 | sceneDescription += ` (Additional context: ${additionalContext})`; 91 | } 92 | 93 | const result = await generateSceneContent( 94 | sceneDescription, 95 | config, 96 | (dialogueText) => { 97 | setDialogue(dialogueText); 98 | setProgress(Math.min(90, (dialogueText.length / 500) * 90)); 99 | } 100 | ); 101 | 102 | setDialogue(result.dialogue); 103 | setChunks(result.chunks); 104 | setProgress(100); 105 | setProcessingStep('idle'); 106 | setIsDialogueExpanded(false); 107 | 108 | } catch (err) { 109 | console.error('Error generating scene content:', err); 110 | setError(err instanceof Error ? err.message : '生成内容时出错'); 111 | setSelectedScene(null); 112 | setProcessingStep('idle'); 113 | } finally { 114 | setLoading(false); 115 | } 116 | }; 117 | 118 | const getLoadingMessage = () => { 119 | return processingStep === 'generating' ? '正在生成内容...' : ''; 120 | }; 121 | 122 | const getSceneInputPlaceholder = (sceneId: string) => { 123 | const scene = scenes.find(s => s.id === sceneId); 124 | if (!scene) return ''; 125 | 126 | switch (sceneId) { 127 | case 'custom': 128 | return '请输入你想练习的具体场景,例如:在咖啡店点一杯拿铁'; 129 | case 'chat': 130 | return '可以补充具体对象,例如:和同事、和老婆、和朋友等'; 131 | case 'interview': 132 | return '可以补充具体职位,例如:Java程序员、产品经理、设计师等'; 133 | default: 134 | return `可以补充具体场景细节,丰富对话内容`; 135 | } 136 | }; 137 | 138 | return ( 139 |
140 | {!selectedScene ? ( 141 |
142 | {scenes.map((scene) => ( 143 |
handleSceneClick(scene.id)} 147 | > 148 |
149 |

{scene.title}

150 |

{scene.description}

151 |
152 | ))} 153 |
154 | ) : ( 155 |
156 | 170 | 171 |
172 | {selectedScene === 'custom' ? ( 173 | setCustomSceneInput(e.target.value)} 178 | placeholder={getSceneInputPlaceholder('custom')} 179 | /> 180 | ) : ( 181 | setAdditionalContext(e.target.value)} 186 | placeholder={getSceneInputPlaceholder(selectedScene)} 187 | /> 188 | )} 189 | 196 |
197 | 198 | {processingStep !== 'idle' && ( 199 |
200 | {getLoadingMessage()} 201 |
202 |
206 |
207 | {processingStep === 'generating' && dialogue && ( 208 |
209 | 210 |
211 | )} 212 |
213 | )} 214 | 215 | {error &&
{error}
} 216 | 217 | {dialogue && ( 218 | <> 219 |
220 |
setIsDialogueExpanded(!isDialogueExpanded)} 223 | > 224 | 原始对话{isDialogueExpanded ? ' (点击收起)' : ' (点击展开)'} 225 | 240 | 241 | 242 |
243 |
244 | 245 |
246 |
247 | {chunks.length > 0 && ( 248 |
249 | {chunks.map((chunk, index) => ( 250 | 251 | ))} 252 |
253 | )} 254 | 255 | )} 256 |
257 | )} 258 |
259 | ); 260 | }; 261 | 262 | export default SceneList; -------------------------------------------------------------------------------- /src/app/pronunciation/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState, useRef } from 'react'; 4 | import styles from './page.module.css'; 5 | import { SpeechUtils } from '@/utils/speechUtils'; 6 | 7 | const commonSentences = [ 8 | { 9 | text: "I'd like a **cup of coffee**, please.", 10 | translation: "我想要一杯咖啡,谢谢。", 11 | focus: "注意 'd like 的连读和 coffee 的重音", 12 | context: "在咖啡店点餐", 13 | formality: "通用", 14 | emphasis: ["cup of coffee"] 15 | }, 16 | { 17 | text: "**Could you** repeat that, please?", 18 | translation: "请您再说一遍好吗?", 19 | focus: "注意 Could you 的弱读,通常发音类似 'Cud ya'", 20 | context: "听不清对方说话时", 21 | formality: "礼貌正式", 22 | emphasis: ["Could you"] 23 | }, 24 | { 25 | text: "**What do you do** for a living?", 26 | translation: "你是做什么工作的?", 27 | focus: "注意 What do you 的连读,通常发音类似 'Whaddya'", 28 | context: "初次见面社交场合", 29 | formality: "通用", 30 | emphasis: ["What do you do"] 31 | }, 32 | { 33 | text: "**Nice to meet** you!", 34 | translation: "很高兴见到你!", 35 | focus: "注意 Nice to 的连读,meet 的重音", 36 | context: "初次见面", 37 | formality: "通用", 38 | emphasis: ["Nice to meet"] 39 | }, 40 | { 41 | text: "I'm **gonna** go to the **movies**.", 42 | translation: "我要去看电影。", 43 | focus: "注意 gonna 是 going to 的口语形式,movies 的重音在第一个音节", 44 | context: "日常对话", 45 | formality: "非正式", 46 | emphasis: ["gonna", "movies"] 47 | }, 48 | { 49 | text: "**Would you mind** if I opened the window?", 50 | translation: "介意我开一下窗户吗?", 51 | focus: "注意 Would you 的连读,mind 的语调上扬", 52 | context: "请求许可", 53 | formality: "正式礼貌", 54 | emphasis: ["Would you mind"] 55 | }, 56 | { 57 | text: "**What's up** with you?", 58 | translation: "你最近怎么样?", 59 | focus: "注意 What's up 的连读,通常发音类似 'Wassup'", 60 | context: "朋友间打招呼", 61 | formality: "非常非正式", 62 | emphasis: ["What's up"] 63 | }, 64 | { 65 | text: "I **should've** done it earlier.", 66 | translation: "我早该做这件事的。", 67 | focus: "注意 should've 的缩读,不要发成 should of", 68 | context: "表达后悔", 69 | formality: "通用", 70 | emphasis: ["should've"] 71 | }, 72 | { 73 | text: "**Lemme** think about it.", 74 | translation: "让我想想。", 75 | focus: "注意 Lemme 是 Let me 的口语形式", 76 | context: "需要时间思考时", 77 | formality: "非正式", 78 | emphasis: ["Lemme"] 79 | }, 80 | { 81 | text: "**Gimme** a minute.", 82 | translation: "给我一分钟。", 83 | focus: "注意 Gimme 是 Give me 的口语形式", 84 | context: "需要一点时间时", 85 | formality: "非正式", 86 | emphasis: ["Gimme"] 87 | }, 88 | { 89 | text: "I **dunno** what to do.", 90 | translation: "我不知道该怎么办。", 91 | focus: "注意 dunno 是 don't know 的口语形式", 92 | context: "表达困惑", 93 | formality: "非正式", 94 | emphasis: ["dunno"] 95 | }, 96 | { 97 | text: "**Wanna** grab some lunch?", 98 | translation: "想去吃午饭吗?", 99 | focus: "注意 Wanna 是 want to 的口语形式", 100 | context: "邀请用餐", 101 | formality: "非正式", 102 | emphasis: ["Wanna"] 103 | }, 104 | { 105 | text: "**Gotta** run, catch you later!", 106 | translation: "我得走了,回头见!", 107 | focus: "注意 Gotta 是 got to 的口语形式", 108 | context: "匆忙离开", 109 | formality: "非正式", 110 | emphasis: ["Gotta"] 111 | }, 112 | { 113 | text: "**How've** you been?", 114 | translation: "你最近怎么样?", 115 | focus: "注意 How've 的缩读,是 How have 的缩写", 116 | context: "问候", 117 | formality: "通用", 118 | emphasis: ["How've"] 119 | }, 120 | { 121 | text: "**D'you** know what I mean?", 122 | translation: "你明白我的意思吗?", 123 | focus: "注意 D'you 是 Do you 的口语缩读", 124 | context: "确认理解", 125 | formality: "非正式", 126 | emphasis: ["D'you"] 127 | }, 128 | { 129 | text: "I **could've** sworn I put it here.", 130 | translation: "我发誓我把它放在这里了。", 131 | focus: "注意 could've 的缩读,是 could have 的缩写", 132 | context: "表达确信", 133 | formality: "通用", 134 | emphasis: ["could've"] 135 | }, 136 | { 137 | text: "**What're** you up to?", 138 | translation: "你在忙什么呢?", 139 | focus: "注意 What're 是 What are 的缩读", 140 | context: "询问近况", 141 | formality: "非正式", 142 | emphasis: ["What're"] 143 | }, 144 | { 145 | text: "**Where've** you been?", 146 | translation: "你去哪儿了?", 147 | focus: "注意 Where've 是 Where have 的缩读", 148 | context: "询问去向", 149 | formality: "通用", 150 | emphasis: ["Where've"] 151 | }, 152 | { 153 | text: "**Ain't** that the truth!", 154 | translation: "可不是嘛!", 155 | focus: "注意 Ain't 是 isn't/aren't 的���正式用法", 156 | context: "表示赞同", 157 | formality: "非常非正式", 158 | emphasis: ["Ain't"] 159 | }, 160 | { 161 | text: "**Y'all** ready?", 162 | translation: "你们都准备好了吗?", 163 | focus: "注意 Y'all 是 you all 的南方口语", 164 | context: "询问准备情况", 165 | formality: "非正式", 166 | emphasis: ["Y'all"] 167 | }, 168 | { 169 | text: "I'm **kinda** tired.", 170 | translation: "我有点累。", 171 | focus: "注意 kinda 是 kind of 的口语形式", 172 | context: "表达状态", 173 | formality: "非正式", 174 | emphasis: ["kinda"] 175 | }, 176 | { 177 | text: "It's **sorta** like that.", 178 | translation: "有点像那样。", 179 | focus: "注意 sorta 是 sort of 的口语形式", 180 | context: "做比较", 181 | formality: "非正式", 182 | emphasis: ["sorta"] 183 | }, 184 | { 185 | text: "**Whatcha** doing?", 186 | translation: "你在做什么?", 187 | focus: "注意 Whatcha 是 What are you 的口语形式", 188 | context: "询问当前活动", 189 | formality: "非正式", 190 | emphasis: ["Whatcha"] 191 | }, 192 | { 193 | text: "**How come** you didn't tell me?", 194 | translation: "你怎么没告诉我?", 195 | focus: "注意 How come 是 Why 的口语替代", 196 | context: "询问原因", 197 | formality: "非正式", 198 | emphasis: ["How come"] 199 | }, 200 | { 201 | text: "**C'mere** for a second.", 202 | translation: "过来一下。", 203 | focus: "注意 C'mere 是 Come here 的口语缩读", 204 | context: "叫人过来", 205 | formality: "非正式", 206 | emphasis: ["C'mere"] 207 | }, 208 | { 209 | text: "**D'ya** wanna come with?", 210 | translation: "你想一起来吗?", 211 | focus: "注意 D'ya 是 Do you 的口语缩读", 212 | context: "邀请", 213 | formality: "非正式", 214 | emphasis: ["D'ya"] 215 | }, 216 | { 217 | text: "I'm **fixin' to** leave.", 218 | translation: "我准备要走了。", 219 | focus: "注意 fixin' to 是南方口语,表示 preparing to", 220 | context: "表达即将行动", 221 | formality: "非正式", 222 | emphasis: ["fixin' to"] 223 | }, 224 | { 225 | text: "**Betcha** can't do it!", 226 | translation: "我打赌你做不到!", 227 | focus: "注意 Betcha 是 I bet you 的口语缩读", 228 | context: "打赌挑战", 229 | formality: "非正式", 230 | emphasis: ["Betcha"] 231 | }, 232 | { 233 | text: "**Wouldja** mind moving?", 234 | translation: "你介意挪一下吗?", 235 | focus: "注意 Wouldja 是 Would you 的口语缩读", 236 | context: "礼貌请求", 237 | formality: "非正式", 238 | emphasis: ["Wouldja"] 239 | }, 240 | { 241 | text: "**Didja** hear about that?", 242 | translation: "你听说那件事了吗?", 243 | focus: "注意 Didja 是 Did you 的口语缩读", 244 | context: "询问消息", 245 | formality: "非正式", 246 | emphasis: ["Didja"] 247 | }, 248 | { 249 | text: "**Hafta** go now.", 250 | translation: "现在必须走了。", 251 | focus: "注意 Hafta 是 have to 的口语形式", 252 | context: "表达必要性", 253 | formality: "非正式", 254 | emphasis: ["Hafta"] 255 | }, 256 | { 257 | text: "**S'pose** we should start.", 258 | translation: "我想我们该开始了。", 259 | focus: "注意 S'pose 是 Suppose 的口语缩读", 260 | context: "提出建议", 261 | formality: "非正式", 262 | emphasis: ["S'pose"] 263 | }, 264 | { 265 | text: "**Imma** head out.", 266 | translation: "我要走了。", 267 | focus: "注意 Imma 是 I am going to 的极度口语形式", 268 | context: "表达离开意图", 269 | formality: "非常非正式", 270 | emphasis: ["Imma"] 271 | }, 272 | { 273 | text: "**How'd** you do that?", 274 | translation: "你是怎么做到的?", 275 | focus: "注意 How'd 是 How did 的缩读", 276 | context: "询问方法", 277 | formality: "通用", 278 | emphasis: ["How'd"] 279 | }, 280 | { 281 | text: "**What'd** you say?", 282 | translation: "你说什么?", 283 | focus: "注意 What'd 是 What did 的缩读", 284 | context: "请求重复", 285 | formality: "通用", 286 | emphasis: ["What'd"] 287 | }, 288 | { 289 | text: "**Where'd** you get that?", 290 | translation: "你从哪里得到的?", 291 | focus: "注意 Where'd 是 Where did 的缩读", 292 | context: "询问来源", 293 | formality: "通用", 294 | emphasis: ["Where'd"] 295 | }, 296 | { 297 | text: "**When're** we leaving?", 298 | translation: "我们什么时候走?", 299 | focus: "注意 When're 是 When are 的缩读", 300 | context: "询问时间", 301 | formality: "通用", 302 | emphasis: ["When're"] 303 | }, 304 | { 305 | text: "**Who're** you waiting for?", 306 | translation: "你在等谁?", 307 | focus: "注意 Who're 是 Who are 的缩读", 308 | context: "询问对象", 309 | formality: "通用", 310 | emphasis: ["Who're"] 311 | }, 312 | { 313 | text: "**That'll** work.", 314 | translation: "那样可以。", 315 | focus: "注意 That'll 是 That will 的缩读", 316 | context: "表示同意", 317 | formality: "通用", 318 | emphasis: ["That'll"] 319 | }, 320 | { 321 | text: "**It'll** be fine.", 322 | translation: "会没事的。", 323 | focus: "注意 It'll 是 It will 的缩读", 324 | context: "安慰", 325 | formality: "通用", 326 | emphasis: ["It'll"] 327 | }, 328 | { 329 | text: "**They'll** be here soon.", 330 | translation: "他们很快就到。", 331 | focus: "注意 They'll 是 They will 的缩读", 332 | context: "预测", 333 | formality: "通用", 334 | emphasis: ["They'll"] 335 | }, 336 | { 337 | text: "**We'll** see about that.", 338 | translation: "那就走着瞧吧。", 339 | focus: "注意 We'll 是 We will 的缩读", 340 | context: "表示怀疑", 341 | formality: "通用", 342 | emphasis: ["We'll"] 343 | }, 344 | { 345 | text: "**I'll** get back to you.", 346 | translation: "我稍后回复你。", 347 | focus: "注意 I'll 是 I will 的缩读", 348 | context: "承诺回复", 349 | formality: "通用", 350 | emphasis: ["I'll"] 351 | }, 352 | { 353 | text: "**You'll** love it!", 354 | translation: "你会喜欢的!", 355 | focus: "注意 You'll 是 You will 的缩读", 356 | context: "表达确信", 357 | formality: "通用", 358 | emphasis: ["You'll"] 359 | } 360 | ]; 361 | 362 | export default function PronunciationPage() { 363 | const [recordings, setRecordings] = useState<{[key: number]: string}>({}); 364 | const [recordingIndex, setRecordingIndex] = useState(null); 365 | const mediaRecorder = useRef(null); 366 | const audioChunks = useRef([]); 367 | 368 | const startRecording = async (index: number) => { 369 | try { 370 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 371 | mediaRecorder.current = new MediaRecorder(stream); 372 | audioChunks.current = []; 373 | 374 | mediaRecorder.current.ondataavailable = (event) => { 375 | audioChunks.current.push(event.data); 376 | }; 377 | 378 | mediaRecorder.current.onstop = () => { 379 | const audioBlob = new Blob(audioChunks.current, { type: 'audio/wav' }); 380 | const url = URL.createObjectURL(audioBlob); 381 | setRecordings(prev => ({ 382 | ...prev, 383 | [index]: url 384 | })); 385 | }; 386 | 387 | mediaRecorder.current.start(); 388 | setRecordingIndex(index); 389 | } catch (error) { 390 | console.error('Error accessing microphone:', error); 391 | alert('无法访问麦克风,请确保已授予权限。'); 392 | } 393 | }; 394 | 395 | const stopRecording = () => { 396 | if (mediaRecorder.current && recordingIndex !== null) { 397 | mediaRecorder.current.stop(); 398 | setRecordingIndex(null); 399 | mediaRecorder.current.stream.getTracks().forEach(track => track.stop()); 400 | } 401 | }; 402 | 403 | const playEdgeTTS = async (text: string) => { 404 | try { 405 | await SpeechUtils.playTTS(text); 406 | } catch (error) { 407 | console.error('Error playing TTS:', error); 408 | alert('播放语音失败,请检查语音设置。'); 409 | } 410 | }; 411 | 412 | const renderText = (text: string, emphasisWords: string[]) => { 413 | let result = text; 414 | emphasisWords.forEach(word => { 415 | const pattern = new RegExp(`\\*\\*(${word})\\*\\*`, 'g'); 416 | result = result.replace(pattern, (_, p1) => `${p1}`); 417 | }); 418 | return
; 419 | }; 420 | 421 | return ( 422 |
423 |

发音纠错练习

424 |

425 | 选择句子练习发音,点击加粗部分可以查看更多发音示例。 426 |

427 | 428 |
429 | {commonSentences.map((sentence, index) => ( 430 |
431 |
432 |
433 | {renderText(sentence.text, sentence.emphasis)} 434 |
435 |
436 | 442 | 448 |
449 |
450 |
{sentence.translation}
451 |
{sentence.focus}
452 |
453 | 使用场景: 454 | {sentence.context} 455 |
456 |
457 | 正式程度: 458 | {sentence.formality} 459 |
460 | {recordings[index] && ( 461 |
462 |
464 | )} 465 |
466 | ))} 467 |
468 |
469 | ); 470 | } --------------------------------------------------------------------------------