Update: TTS settings modal and settings types

This commit is contained in:
likingcode
2026-03-18 15:18:47 +08:00
parent 71232cf489
commit 4ea96243c2
13 changed files with 1393 additions and 149 deletions

View File

@@ -1,22 +1,28 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#3b82f6" /> <meta name="theme-color" content="#3b82f6" />
<meta name="description" content="使用间隔重复系统高效学习法语单词" />
<meta name="keywords" content="法语学习单词记忆SRS" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="法语词汇" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>French Vocabulary</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<title>法语词汇学习</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<noscript>
<div style="padding: 20px; text-align: center;">
<h2>需要启用 JavaScript</h2>
<p>请启用 JavaScript 以使用法语词汇学习应用</p>
</div>
</noscript>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(console.error);
});
}
</script>
</body> </body>
</html> </html>

View File

@@ -1,21 +1,44 @@
{ {
"name": "French Vocabulary", "name": "法语词汇学习",
"short_name": "FrenchVocab", "short_name": "法语词汇",
"description": "Master French with spaced repetition", "description": "使用间隔重复系统高效学习法语单词",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color":="#ffffff", "orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#3b82f6", "theme_color": "#3b82f6",
"lang": "zh-CN",
"dir": "ltr",
"categories": ["education", "productivity"],
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png",
"purpose": "any maskable"
}, },
{ {
"src": "/icon-512.png", "src": "/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "开始学习",
"short_name": "学习",
"description": "开始今日学习",
"url": "/?action=study",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "导入词库",
"short_name": "导入",
"description": "导入新的单词",
"url": "/?action=import",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
} }
] ]
} }

View File

@@ -3,10 +3,12 @@ import { FlashCard } from './components/FlashCard';
import { RatingButtons } from './components/RatingButtons'; import { RatingButtons } from './components/RatingButtons';
import { ProgressBar } from './components/ProgressBar'; import { ProgressBar } from './components/ProgressBar';
import { ImportModal } from './components/ImportModal'; import { ImportModal } from './components/ImportModal';
import { TtsSettingsModal } from './components/TtsSettingsModal';
import { useAppStore } from './stores/appStore'; import { useAppStore } from './stores/appStore';
function App() { function App() {
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [showTtsSettings, setShowTtsSettings] = useState(false);
// Export functionality handled by direct download // Export functionality handled by direct download
const { const {
init, init,
@@ -108,6 +110,15 @@ function App() {
<span className="text-gray-600 font-medium">French Vocabulary</span> <span className="text-gray-600 font-medium">French Vocabulary</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button
onClick={() => setShowTtsSettings(true)}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
title="发音设置"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
<button <button
onClick={() => setShowImport(true)} onClick={() => setShowImport(true)}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors" className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
@@ -199,6 +210,11 @@ function App() {
onClose={() => setShowImport(false)} onClose={() => setShowImport(false)}
onImport={addWords} onImport={addWords}
/> />
<TtsSettingsModal
isOpen={showTtsSettings}
onClose={() => setShowTtsSettings(false)}
/>
</div> </div>
); );
} }

View File

@@ -16,19 +16,26 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
onFlip?.(newState); onFlip?.(newState);
}; };
const handleSpeak = (e: React.MouseEvent) => { const handleSpeakWord = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onSpeak?.(word.french); onSpeak?.(word.ttsText || word.french);
};
const handleSpeakExample = (e: React.MouseEvent) => {
e.stopPropagation();
if (word.example) {
onSpeak?.(word.example);
}
}; };
return ( return (
<div <div
className="relative w-full h-96 cursor-pointer group" className="relative w-full min-h-[32rem] cursor-pointer group"
style={{ perspective: '1000px' }} style={{ perspective: '1000px' }}
onClick={handleFlip} onClick={handleFlip}
> >
<div <div
className="relative w-full h-full transition-all duration-500 ease-out" className="relative w-full min-h-[32rem] transition-all duration-500 ease-out"
style={{ style={{
transformStyle: 'preserve-3d', transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)' transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
@@ -39,27 +46,46 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
className="absolute inset-0 bg-gradient-to-br from-sky-400 via-blue-500 to-indigo-600 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 text-white border-4 border-white/30" className="absolute inset-0 bg-gradient-to-br from-sky-400 via-blue-500 to-indigo-600 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 text-white border-4 border-white/30"
style={{ backfaceVisibility: 'hidden' }} style={{ backfaceVisibility: 'hidden' }}
> >
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4 flex items-center gap-2">
<span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium"> <span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium">
{word.category} {word.category}
</span> </span>
{word.difficulty && (
<span className="bg-white/15 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium capitalize">
{word.difficulty}
</span>
)}
</div> </div>
{/* Speak Button */}
<button <button
onClick={handleSpeak} onClick={handleSpeakWord}
className="absolute top-4 left-4 p-2 bg-white/20 backdrop-blur-sm rounded-full hover:bg-white/30 transition-colors" className="absolute top-4 left-4 p-2 bg-white/20 backdrop-blur-sm rounded-full hover:bg-white/30 transition-colors"
title="播放发音" title="播放单词发音"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg> </svg>
</button> </button>
<div className="text-center"> <div className="text-center max-w-xl">
<h2 className="text-6xl font-bold mb-4 drop-shadow-lg">{word.french}</h2> <h2 className="text-5xl md:text-6xl font-bold mb-4 drop-shadow-lg break-words">{word.french}</h2>
{word.partOfSpeech && (
<div className="text-sm uppercase tracking-[0.2em] text-white/75 mb-3">{word.partOfSpeech}</div>
)}
{word.pronunciation && ( {word.pronunciation && (
<span className="text-xl opacity-90 font-light tracking-wide">/{word.pronunciation}/</span> <div className="text-xl opacity-90 font-light tracking-wide mb-3">/{word.pronunciation}/</div>
)}
{word.chinese && (
<div className="text-lg text-white/90">{word.chinese}</div>
)}
{word.tags && word.tags.length > 0 && (
<div className="mt-4 flex flex-wrap justify-center gap-2">
{word.tags.slice(0, 4).map(tag => (
<span key={tag} className="px-3 py-1 rounded-full bg-white/15 text-xs text-white/85">
#{tag}
</span>
))}
</div>
)} )}
</div> </div>
@@ -67,7 +93,7 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
<svg className="w-5 h-5 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg> </svg>
<span className="text-sm font-medium"></span> <span className="text-sm font-medium"></span>
</div> </div>
</div> </div>
@@ -79,16 +105,46 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
transform: 'rotateY(180deg)' transform: 'rotateY(180deg)'
}} }}
> >
<div className="text-center space-y-4"> <div className="w-full max-w-2xl text-center space-y-5">
<h3 className="text-4xl font-bold drop-shadow-lg">{word.english}</h3> <div>
<div className="text-sm uppercase tracking-[0.25em] text-white/70 mb-2">Meaning</div>
<h3 className="text-3xl md:text-4xl font-bold drop-shadow-lg">{word.english}</h3>
{word.chinese && (
<p className="mt-2 text-lg text-white/90">{word.chinese}</p>
)}
</div>
{word.example && ( {word.example && (
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 max-w-md"> <div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-left">
<p className="text-lg italic mb-3 leading-relaxed">&quot;{word.example}&quot;</p> <div className="flex items-start justify-between gap-3 mb-3">
{word.exampleTranslation && ( <div>
<p className="text-sm opacity-80 border-t border-white/20 pt-3">{word.exampleTranslation}</p> <div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Exemple</div>
<p className="text-lg italic leading-relaxed">&quot;{word.example}&quot;</p>
{word.examplePronunciation && (
<p className="mt-2 text-sm text-white/75">/{word.examplePronunciation}/</p>
)} )}
</div> </div>
<button
onClick={handleSpeakExample}
className="shrink-0 p-2 bg-white/15 backdrop-blur-sm rounded-full hover:bg-white/25 transition-colors"
title="播放例句发音"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
{word.exampleTranslation && (
<p className="text-sm opacity-90 border-t border-white/20 pt-3">{word.exampleTranslation}</p>
)}
</div>
)}
{word.notes && (
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-4 text-left">
<div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Notes</div>
<p className="text-sm text-white/90 leading-relaxed">{word.notes}</p>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,42 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import type { Word } from '../types/vocabulary';
interface ImportModalProps { interface ImportModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onImport: (words: any[]) => void; onImport: (words: Word[]) => void;
} }
const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
const french = input.french?.trim();
const english = input.english?.trim();
if (!french || !english) {
return null;
}
return {
id: input.id || `imported-${Date.now()}-${index}`,
french,
english,
pronunciation: input.pronunciation?.trim() || '',
ttsText: input.ttsText?.trim() || french,
chinese: input.chinese?.trim() || '',
partOfSpeech: input.partOfSpeech?.trim() || '',
example: input.example?.trim() || '',
exampleTranslation: input.exampleTranslation?.trim() || '',
examplePronunciation: input.examplePronunciation?.trim() || '',
notes: input.notes?.trim() || '',
audioUrl: input.audioUrl?.trim() || '',
category: input.category?.trim() || 'General',
difficulty: input.difficulty || 'beginner',
tags: input.tags || [],
};
};
const parseCsvLine = (line: string) =>
line.split(',').map(part => part.trim().replace(/^"|"$/g, ''));
export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) { export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -27,40 +58,84 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
}; };
const parseContent = (text: string) => { const parseContent = (text: string) => {
setError('');
try { try {
// Try JSON first
const json = JSON.parse(text); const json = JSON.parse(text);
if (Array.isArray(json)) { if (Array.isArray(json)) {
onImport(json); const words = json.map((item, index) => normalizeWord(item, index)).filter(Boolean) as Word[];
if (words.length > 0) {
onImport(words);
onClose(); onClose();
return; return;
} }
}
if (json.words && Array.isArray(json.words)) { if (json.words && Array.isArray(json.words)) {
onImport(json.words); const words = json.words.map((item: Partial<Word>, index: number) => normalizeWord(item, index)).filter(Boolean) as Word[];
if (words.length > 0) {
onImport(words);
onClose(); onClose();
return; return;
} }
}
} catch { } catch {
// Not JSON, try CSV // fallthrough to CSV
const lines = text.trim().split('\n'); }
const words = lines.map((line, index) => {
const parts = line.split(',').map(p => p.trim()); const lines = text
return { .trim()
id: `imported-${Date.now()}-${index}`, .split('\n')
.map(line => line.trim())
.filter(Boolean);
if (lines.length === 0) {
setError('没有可导入的内容');
return;
}
const firstLine = parseCsvLine(lines[0]).map(v => v.toLowerCase());
const hasHeader = firstLine.includes('french') || firstLine.includes('english');
const dataLines = hasHeader ? lines.slice(1) : lines;
const headerIndex = hasHeader ? firstLine : [];
const words = dataLines
.map((line, index) => {
const parts = parseCsvLine(line);
if (hasHeader) {
const item: Partial<Word> = {
french: parts[headerIndex.indexOf('french')] || '',
english: parts[headerIndex.indexOf('english')] || '',
pronunciation: parts[headerIndex.indexOf('pronunciation')] || '',
chinese: parts[headerIndex.indexOf('chinese')] || '',
partOfSpeech: parts[headerIndex.indexOf('partofspeech')] || '',
example: parts[headerIndex.indexOf('example')] || '',
exampleTranslation: parts[headerIndex.indexOf('exampletranslation')] || '',
category: parts[headerIndex.indexOf('category')] || 'General',
difficulty: (parts[headerIndex.indexOf('difficulty')] as Word['difficulty']) || 'beginner',
};
return normalizeWord(item, index);
}
return normalizeWord(
{
french: parts[0] || '', french: parts[0] || '',
english: parts[1] || '', english: parts[1] || '',
pronunciation: parts[2] || '', pronunciation: parts[2] || '',
category: parts[3] || 'General', category: parts[3] || 'General',
difficulty: 'beginner', example: parts[4] || '',
}; exampleTranslation: parts[5] || '',
}).filter(w => w.french && w.english); },
index
);
})
.filter(Boolean) as Word[];
if (words.length > 0) { if (words.length > 0) {
onImport(words); onImport(words);
onClose(); onClose();
} else { } else {
setError('无法解析文件格式'); setError('无法解析文件格式,请检查 french 和 english 字段是否存在');
}
} }
}; };
@@ -74,8 +149,11 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full p-6"> <div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4"></h2> <h2 className="text-2xl font-bold text-gray-800 mb-2"></h2>
<p className="text-sm text-gray-500 mb-4">
</p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -103,18 +181,16 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder={`CSV 格式: placeholder={`CSV 表头示例:
french,english,pronunciation,category french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty
Bonjour,Hello,bɔ̃ʒuʁ,Greetings bonjour,hello,你好,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,大家好。,beginner
JSON 格式: JSON 示例:
[{"french": "Bonjour", "english": "Hello"}]`} [{"french":"bonjour","english":"hello","chinese":"你好","pronunciation":"bɔ̃ʒuʁ","partOfSpeech":"interjection","example":"Bonjour, comment ça va ?","exampleTranslation":"你好,最近怎么样?"}]`}
className="w-full h-40 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm" className="w-full h-56 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/> />
{error && ( {error && <p className="text-red-500 text-sm">{error}</p>}
<p className="text-red-500 text-sm">{error}</p>
)}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { DifficultyRating } from '../types/vocabulary'; import type { DifficultyRating } from '../types/vocabulary';
interface RatingButtonsProps { interface RatingButtonsProps {
@@ -5,50 +6,90 @@ interface RatingButtonsProps {
disabled?: boolean; disabled?: boolean;
} }
export function RatingButtons({ onRate, disabled }: RatingButtonsProps) { interface ButtonConfig {
const buttons = [ rating: DifficultyRating;
label: string;
sublabel: string;
shortcut: string;
color: string;
shadow: string;
}
const BUTTONS: ButtonConfig[] = [
{ {
rating: 'again' as DifficultyRating, rating: 'again',
label: '重来', label: '重来',
sublabel: '< 1 min', sublabel: '< 1 分钟',
shortcut: '1',
color: 'from-rose-400 to-red-500', color: 'from-rose-400 to-red-500',
shadow: 'shadow-rose-200' shadow: 'shadow-rose-200'
}, },
{ {
rating: 'hard' as DifficultyRating, rating: 'hard',
label: '困难', label: '困难',
sublabel: '~ 2 days', sublabel: '~ 2 ',
shortcut: '2',
color: 'from-orange-400 to-amber-500', color: 'from-orange-400 to-amber-500',
shadow: 'shadow-orange-200' shadow: 'shadow-orange-200'
}, },
{ {
rating: 'good' as DifficultyRating, rating: 'good',
label: '良好', label: '良好',
sublabel: '~ 4 days', sublabel: '~ 4 ',
shortcut: '3',
color: 'from-blue-400 to-indigo-500', color: 'from-blue-400 to-indigo-500',
shadow: 'shadow-blue-200' shadow: 'shadow-blue-200'
}, },
{ {
rating: 'easy' as DifficultyRating, rating: 'easy',
label: '简单', label: '简单',
sublabel: '~ 7 days', sublabel: '~ 7 ',
shortcut: '4',
color: 'from-emerald-400 to-teal-500', color: 'from-emerald-400 to-teal-500',
shadow: 'shadow-emerald-200' shadow: 'shadow-emerald-200'
}, },
]; ];
export function RatingButtons({ onRate, disabled }: RatingButtonsProps) {
// 键盘快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (disabled) return;
const keyMap: Record<string, DifficultyRating> = {
'1': 'again',
'2': 'hard',
'3': 'good',
'4': 'easy',
'Enter': 'good',
' ': 'good',
};
const rating = keyMap[e.key];
if (rating) {
e.preventDefault();
onRate(rating);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onRate, disabled]);
return ( return (
<div className="grid grid-cols-4 gap-3 mt-8"> <div className="grid grid-cols-4 gap-3 mt-8">
{buttons.map(({ rating, label, sublabel, color, shadow }) => ( {BUTTONS.map(({ rating, label, sublabel, shortcut, color, shadow }) => (
<button <button
key={rating} key={rating}
onClick={() => onRate(rating)} onClick={() => onRate(rating)}
disabled={disabled} disabled={disabled}
className={`group relative overflow-hidden bg-gradient-to-b ${color} ${shadow} shadow-lg hover:shadow-xl text-white rounded-2xl py-4 px-2 transition-all duration-200 hover:-translate-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0`} className={`group relative overflow-hidden bg-gradient-to-b ${color} ${shadow} shadow-lg hover:shadow-xl text-white rounded-2xl py-4 px-2 transition-all duration-200 hover:-translate-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0`}
aria-label={`${label} (${shortcut})`}
> >
<div className="relative z-10 flex flex-col items-center"> <div className="relative z-10 flex flex-col items-center">
<span className="text-lg font-bold">{label}</span> <span className="text-lg font-bold">{label}</span>
<span className="text-xs opacity-80 mt-1">{sublabel}</span> <span className="text-xs opacity-80 mt-1">{sublabel}</span>
<span className="absolute top-1 right-2 text-xs opacity-50 font-mono">{shortcut}</span>
</div> </div>
<div className="absolute inset-0 bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-0 bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity" />
</button> </button>

View File

@@ -0,0 +1,134 @@
import { useEffect, useMemo } from 'react';
import { useAppStore } from '../stores/appStore';
interface TtsSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function TtsSettingsModal({ isOpen, onClose }: TtsSettingsModalProps) {
const { tts, availableVoices, setTtsSettings, refreshVoices } = useAppStore();
useEffect(() => {
if (!isOpen) return;
refreshVoices();
}, [isOpen, refreshVoices]);
const frenchVoices = useMemo(
() => availableVoices.filter(v => v.lang?.toLowerCase().startsWith('fr')),
[availableVoices]
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full p-6">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-800"></h2>
<p className="text-sm text-gray-500 mt-1">使fr-FR / fr</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl hover:bg-gray-100 text-gray-500"
aria-label="关闭"
>
</button>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">Voice</label>
<select
value={tts.voiceURI || ''}
onChange={(e) => setTtsSettings({ voiceURI: e.target.value || undefined })}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> fr-FR</option>
{frenchVoices.length > 0 ? (
frenchVoices.map(v => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} {v.lang}
</option>
))
) : (
<option value="" disabled>
</option>
)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-2"></label>
<input
type="range"
min={0.5}
max={1.2}
step={0.05}
value={tts.rate}
onChange={(e) => setTtsSettings({ rate: Number(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-gray-500 mt-1">{tts.rate.toFixed(2)}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-2"></label>
<input
type="range"
min={0.8}
max={1.2}
step={0.05}
value={tts.pitch}
onChange={(e) => setTtsSettings({ pitch: Number(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-gray-500 mt-1">{tts.pitch.toFixed(2)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={tts.autoSpeakWord}
onChange={(e) => setTtsSettings({ autoSpeakWord: e.target.checked })}
/>
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={tts.autoSpeakExample}
onChange={(e) => setTtsSettings({ autoSpeakExample: e.target.checked })}
/>
</label>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => window.speechSynthesis && window.speechSynthesis.cancel()}
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={() => {
// Quick test
const text = 'Bonjour. Je parle français.';
useAppStore.getState().speak(text);
}}
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:shadow-lg transition-all"
>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -32,29 +32,517 @@ export const db = new FrenchVocabDB();
export async function initDatabase() { export async function initDatabase() {
const count = await db.words.count(); const count = await db.words.count();
if (count === 0) { if (count === 0) {
// 添加示例单词 const now = new Date();
const defaultWords: WordEntry[] = [ const defaultWords: WordEntry[] = [
{ {
id: '1', id: '1',
french: 'Bonjour', french: 'Bonjour',
pronunciation: 'bɔ̃ʒuʁ', pronunciation: 'bɔ̃ʒuʁ',
ttsText: 'bonjour',
english: 'Hello', english: 'Hello',
chinese: '你好;早安',
partOfSpeech: 'interjection',
example: 'Bonjour, comment allez-vous ?', example: 'Bonjour, comment allez-vous ?',
exampleTranslation: 'Hello, how are you?', exampleTranslation: '你好,你最近怎么样?',
category: 'Greetings', category: 'Greetings',
difficulty: 'beginner', difficulty: 'beginner',
addedAt: new Date(), tags: ['daily', 'greeting'],
addedAt: now,
}, },
{ {
id: '2', id: '2',
french: 'Merci', french: 'Merci',
pronunciation: 'mɛʁsi', pronunciation: 'mɛʁsi',
ttsText: 'merci',
english: 'Thank you', english: 'Thank you',
chinese: '谢谢',
partOfSpeech: 'interjection',
example: 'Merci beaucoup pour votre aide.', example: 'Merci beaucoup pour votre aide.',
exampleTranslation: 'Thank you very much for your help.', exampleTranslation: '非常感谢你的帮助。',
category: 'Greetings', category: 'Greetings',
difficulty: 'beginner', difficulty: 'beginner',
addedAt: new Date(), tags: ['daily', 'polite'],
addedAt: now,
},
{
id: '3',
french: 'Sil vous plaît',
pronunciation: 'sil vu plɛ',
ttsText: 'sil vous plaît',
english: 'Please',
chinese: '请',
partOfSpeech: 'expression',
example: 'Un café, sil vous plaît.',
exampleTranslation: '请给我一杯咖啡。',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily', 'polite'],
addedAt: now,
},
{
id: '4',
french: 'Salut',
pronunciation: 'sa.ly',
ttsText: 'salut',
english: 'Hi / bye (informal)',
chinese: '嗨;再见(非正式)',
partOfSpeech: 'interjection',
example: 'Salut ! Ça va ?',
exampleTranslation: '嗨!你好吗?',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '5',
french: 'Au revoir',
pronunciation: 'o ʁə.vwaʁ',
ttsText: 'au revoir',
english: 'Goodbye',
chinese: '再见',
partOfSpeech: 'expression',
example: 'Au revoir, à demain !',
exampleTranslation: '再见,明天见!',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '6',
french: 'Excusez-moi',
pronunciation: 'ɛk.sky.ze mwa',
ttsText: 'excusez-moi',
english: 'Excuse me',
chinese: '打扰一下;对不起(礼貌)',
partOfSpeech: 'expression',
example: 'Excusez-moi, où est la station de métro ?',
exampleTranslation: '打扰一下,地铁站在哪里?',
category: 'Greetings',
difficulty: 'beginner',
tags: ['polite'],
addedAt: now,
},
{
id: '7',
french: 'Pardon',
pronunciation: 'paʁ.dɔ̃',
ttsText: 'pardon',
english: 'Sorry / pardon',
chinese: '抱歉;劳驾',
partOfSpeech: 'interjection',
example: 'Pardon, je nai pas compris.',
exampleTranslation: '抱歉,我没听懂。',
category: 'Greetings',
difficulty: 'beginner',
tags: ['polite'],
addedAt: now,
},
{
id: '8',
french: 'Oui',
pronunciation: 'wi',
ttsText: 'oui',
english: 'Yes',
chinese: '是;对',
partOfSpeech: 'adverb',
example: 'Oui, bien sûr.',
exampleTranslation: '是的,当然。',
category: 'Basics',
difficulty: 'beginner',
tags: ['essential'],
addedAt: now,
},
{
id: '9',
french: 'Non',
pronunciation: 'nɔ̃',
ttsText: 'non',
english: 'No',
chinese: '不',
partOfSpeech: 'adverb',
example: 'Non, merci.',
exampleTranslation: '不,谢谢。',
category: 'Basics',
difficulty: 'beginner',
tags: ['essential'],
addedAt: now,
},
{
id: '10',
french: 'Pomme',
pronunciation: 'pɔm',
ttsText: 'pomme',
english: 'Apple',
chinese: '苹果',
partOfSpeech: 'noun',
example: 'Je mange une pomme après le déjeuner.',
exampleTranslation: '我午饭后吃一个苹果。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'daily'],
addedAt: now,
},
{
id: '11',
french: 'Eau',
pronunciation: 'o',
ttsText: 'eau',
english: 'Water',
chinese: '水',
partOfSpeech: 'noun',
example: 'Je voudrais un verre deau.',
exampleTranslation: '我想要一杯水。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'essential'],
addedAt: now,
},
{
id: '12',
french: 'Pain',
pronunciation: 'pɛ̃',
ttsText: 'pain',
english: 'Bread',
chinese: '面包',
partOfSpeech: 'noun',
example: 'Je prends du pain avec du fromage.',
exampleTranslation: '我吃面包配奶酪。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '13',
french: 'Fromage',
pronunciation: 'fʁɔ.maʒ',
ttsText: 'fromage',
english: 'Cheese',
chinese: '奶酪',
partOfSpeech: 'noun',
example: 'Ce fromage est délicieux.',
exampleTranslation: '这个奶酪很好吃。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '14',
french: 'Café',
pronunciation: 'ka.fe',
ttsText: 'café',
english: 'Coffee',
chinese: '咖啡',
partOfSpeech: 'noun',
example: 'Je bois un café le matin.',
exampleTranslation: '我早上喝咖啡。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'daily'],
addedAt: now,
},
{
id: '15',
french: 'Thé',
pronunciation: 'te',
ttsText: 'thé',
english: 'Tea',
chinese: '茶',
partOfSpeech: 'noun',
example: 'Je préfère le thé sans sucre.',
exampleTranslation: '我更喜欢不加糖的茶。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '16',
french: 'Maison',
pronunciation: 'mɛ.zɔ̃',
ttsText: 'maison',
english: 'House',
chinese: '房子;家',
partOfSpeech: 'noun',
example: 'Ma maison est près de la gare.',
exampleTranslation: '我家在火车站附近。',
category: 'Places',
difficulty: 'beginner',
tags: ['home'],
addedAt: now,
},
{
id: '17',
french: 'École',
pronunciation: 'e.kɔl',
ttsText: 'école',
english: 'School',
chinese: '学校',
partOfSpeech: 'noun',
example: 'Les enfants vont à lécole.',
exampleTranslation: '孩子们去上学。',
category: 'Places',
difficulty: 'beginner',
tags: ['place'],
addedAt: now,
},
{
id: '18',
french: 'Gare',
pronunciation: 'ɡaʁ',
ttsText: 'gare',
english: 'Train station',
chinese: '火车站',
partOfSpeech: 'noun',
example: 'La gare est à cinq minutes.',
exampleTranslation: '火车站走五分钟就到。',
category: 'Places',
difficulty: 'beginner',
tags: ['travel'],
addedAt: now,
},
{
id: '19',
french: 'Étudier',
pronunciation: 'e.ty.dje',
ttsText: 'étudier',
english: 'To study',
chinese: '学习',
partOfSpeech: 'verb',
example: 'Jétudie le français tous les soirs.',
exampleTranslation: '我每天晚上学法语。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb', 'daily'],
addedAt: now,
},
{
id: '20',
french: 'Apprendre',
pronunciation: 'a.pʁɑ̃dʁ',
ttsText: 'apprendre',
english: 'To learn',
chinese: '学习;学会',
partOfSpeech: 'verb',
example: 'Japprends de nouveaux mots chaque jour.',
exampleTranslation: '我每天学习新单词。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb', 'study'],
addedAt: now,
},
{
id: '21',
french: 'Comprendre',
pronunciation: 'kɔ̃.pʁɑ̃dʁ',
ttsText: 'comprendre',
english: 'To understand',
chinese: '理解',
partOfSpeech: 'verb',
example: 'Je comprends cette phrase maintenant.',
exampleTranslation: '我现在理解这个句子了。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb', 'study'],
addedAt: now,
},
{
id: '22',
french: 'Parler',
pronunciation: 'paʁ.le',
ttsText: 'parler',
english: 'To speak',
chinese: '说(语言)',
partOfSpeech: 'verb',
example: 'Je parle un peu français.',
exampleTranslation: '我会说一点法语。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb'],
addedAt: now,
},
{
id: '23',
french: 'Écrire',
pronunciation: 'e.kʁiʁ',
ttsText: 'écrire',
english: 'To write',
chinese: '写',
partOfSpeech: 'verb',
example: 'Jécris un message à mon ami.',
exampleTranslation: '我给朋友写一条消息。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb'],
addedAt: now,
},
{
id: '24',
french: 'Lire',
pronunciation: 'liʁ',
ttsText: 'lire',
english: 'To read',
chinese: '读',
partOfSpeech: 'verb',
example: 'Je lis un livre intéressant.',
exampleTranslation: '我在读一本有趣的书。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb'],
addedAt: now,
},
{
id: '25',
french: 'Heureux',
pronunciation: 'ø.ʁø',
ttsText: 'heureux',
english: 'Happy',
chinese: '开心的;幸福的',
partOfSpeech: 'adjective',
example: 'Je suis heureux de te voir.',
exampleTranslation: '见到你我很开心。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['emotion'],
addedAt: now,
},
{
id: '26',
french: 'Triste',
pronunciation: 'tʁist',
ttsText: 'triste',
english: 'Sad',
chinese: '难过的',
partOfSpeech: 'adjective',
example: 'Il est triste aujourdhui.',
exampleTranslation: '他今天很难过。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['emotion'],
addedAt: now,
},
{
id: '27',
french: 'Fatigué',
pronunciation: 'fa.ti.ɡe',
ttsText: 'fatigué',
english: 'Tired',
chinese: '累的',
partOfSpeech: 'adjective',
example: 'Je suis fatigué après le travail.',
exampleTranslation: '我下班后很累。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '28',
french: 'Rendez-vous',
pronunciation: 'ʁɑ̃.de.vu',
ttsText: 'rendez-vous',
english: 'Appointment / meeting',
chinese: '约会;预约;会面',
partOfSpeech: 'noun',
example: 'Jai un rendez-vous chez le médecin à dix heures.',
exampleTranslation: '我十点要去看医生。',
category: 'Daily Life',
difficulty: 'intermediate',
tags: ['schedule'],
addedAt: now,
},
{
id: '29',
french: 'Aujourdhui',
pronunciation: 'o.ʒuʁ.dɥi',
ttsText: 'aujourdhui',
english: 'Today',
chinese: '今天',
partOfSpeech: 'adverb',
example: 'Aujourdhui, il fait beau.',
exampleTranslation: '今天,天气很好。',
category: 'Daily Life',
difficulty: 'beginner',
tags: ['time'],
addedAt: now,
},
{
id: '30',
french: 'Demain',
pronunciation: 'də.mɛ̃',
ttsText: 'demain',
english: 'Tomorrow',
chinese: '明天',
partOfSpeech: 'adverb',
example: 'On se voit demain.',
exampleTranslation: '我们明天见。',
category: 'Daily Life',
difficulty: 'beginner',
tags: ['time'],
addedAt: now,
},
{
id: '31',
french: 'Voyager',
pronunciation: 'vwa.ja.ʒe',
ttsText: 'voyager',
english: 'To travel',
chinese: '旅行',
partOfSpeech: 'verb',
example: 'Nous aimons voyager en train en France.',
exampleTranslation: '我们喜欢在法国坐火车旅行。',
category: 'Travel',
difficulty: 'intermediate',
tags: ['travel', 'verb'],
addedAt: now,
},
{
id: '32',
french: 'Billet',
pronunciation: 'bi.jɛ',
ttsText: 'billet',
english: 'Ticket',
chinese: '票(车票/门票)',
partOfSpeech: 'noun',
example: 'Je voudrais un billet pour Lyon.',
exampleTranslation: '我想要一张去里昂的票。',
category: 'Travel',
difficulty: 'intermediate',
tags: ['travel'],
addedAt: now,
},
{
id: '33',
french: 'Rapide',
pronunciation: 'ʁa.pid',
ttsText: 'rapide',
english: 'Fast',
chinese: '快的',
partOfSpeech: 'adjective',
example: 'Ce train est très rapide.',
exampleTranslation: '这趟火车很快。',
category: 'Travel',
difficulty: 'beginner',
tags: ['travel'],
addedAt: now,
},
{
id: '34',
french: 'Rapidement',
pronunciation: 'ʁa.pid.mɑ̃',
ttsText: 'rapidement',
english: 'Quickly',
chinese: '迅速地',
partOfSpeech: 'adverb',
example: 'Elle parle rapidement mais clairement.',
exampleTranslation: '她说得很快,但很清楚。',
category: 'Daily Life',
difficulty: 'intermediate',
tags: ['adverb'],
addedAt: now,
}, },
]; ];
await db.words.bulkAdd(defaultWords); await db.words.bulkAdd(defaultWords);

View File

@@ -1,6 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Word, StudyProgress, StudyStats, DifficultyRating } from '../types/vocabulary'; import type { Word, StudyProgress, StudyStats, DifficultyRating, CategoryStats } from '../types/vocabulary';
import { calculateNextReview } from '../utils/srs'; import type { TtsSettings } from '../types/settings';
import { DEFAULT_TTS_SETTINGS } from '../types/settings';
import { calculateNextReview, calculateMastery } from '../utils/srs';
import { db, initDatabase, exportData, importData } from '../db/database'; import { db, initDatabase, exportData, importData } from '../db/database';
interface AppState { interface AppState {
@@ -10,21 +12,37 @@ interface AppState {
currentWordIndex: number; currentWordIndex: number;
isFlipped: boolean; isFlipped: boolean;
isLoading: boolean; isLoading: boolean;
selectedCategory: string | 'all';
searchQuery: string;
tts: TtsSettings;
availableVoices: { name: string; lang: string; voiceURI: string }[];
// Actions // Actions
init: () => Promise<void>; init: () => Promise<void>;
setWords: (words: Word[]) => void; setWords: (words: Word[]) => void;
addWords: (words: Word[]) => Promise<void>; addWords: (words: Word[]) => Promise<void>;
deleteWord: (id: string) => Promise<void>;
flipCard: () => void; flipCard: () => void;
rateWord: (rating: DifficultyRating) => Promise<void>; rateWord: (rating: DifficultyRating) => Promise<void>;
nextWord: () => void; nextWord: () => void;
getCurrentWord: () => Word | null; getCurrentWord: () => Word | null;
getDueWords: () => Word[]; getDueWords: () => Word[];
getFilteredWords: () => Word[];
exportData: () => Promise<any>; exportData: () => Promise<any>;
importData: (data: any) => Promise<void>; importData: (data: any) => Promise<void>;
speak: (text: string) => void; speak: (text: string) => void;
setCategory: (category: string | 'all') => void;
setSearchQuery: (query: string) => void;
resetProgress: () => Promise<void>;
getCategoryStats: () => CategoryStats[];
loadSettings: () => Promise<void>;
setTtsSettings: (patch: Partial<TtsSettings>) => Promise<void>;
refreshVoices: () => void;
} }
const DAILY_GOAL = 20;
export const useAppStore = create<AppState>((set, get) => ({ export const useAppStore = create<AppState>((set, get) => ({
words: [], words: [],
progress: new Map(), progress: new Map(),
@@ -35,35 +53,66 @@ export const useAppStore = create<AppState>((set, get) => ({
streakDays: 0, streakDays: 0,
todayStudied: 0, todayStudied: 0,
todayNewWords: 0, todayNewWords: 0,
weeklyGoal: DAILY_GOAL * 7,
weeklyProgress: 0,
}, },
currentWordIndex: 0, currentWordIndex: 0,
isFlipped: false, isFlipped: false,
isLoading: true, isLoading: true,
selectedCategory: 'all',
searchQuery: '',
tts: DEFAULT_TTS_SETTINGS,
availableVoices: [],
init: async () => { init: async () => {
await initDatabase(); await initDatabase();
await get().loadSettings();
get().refreshVoices();
// Load words from DB
const wordsFromDB = await db.words.toArray(); const wordsFromDB = await db.words.toArray();
// Load progress from DB
const progressFromDB = await db.progress.toArray(); const progressFromDB = await db.progress.toArray();
const progressMap = new Map<string, StudyProgress>(); const progressMap = new Map<string, StudyProgress>();
progressFromDB.forEach(p => progressMap.set(p.wordId, p));
// Load stats from DB progressFromDB.forEach(p => {
const mastery = calculateMastery(p);
progressMap.set(p.wordId, { ...p, mastery });
});
const statsFromDB = await db.stats.get('main'); const statsFromDB = await db.stats.get('main');
const today = new Date().toDateString();
const lastStudyDate = statsFromDB?.lastStudyDate
? new Date(statsFromDB.lastStudyDate).toDateString()
: null;
let streakDays = statsFromDB?.streakDays || 0;
if (statsFromDB?.lastStudyDate && lastStudyDate) {
const lastDate = new Date(statsFromDB.lastStudyDate);
const diffDays = Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays > 1) {
streakDays = 0; // 断签了
} else if (diffDays === 1 && today !== lastStudyDate) {
streakDays += 1;
}
}
// 如果是新的一天,重置今日计数
const todayStudied = today === lastStudyDate ? (statsFromDB?.todayStudied || 0) : 0;
set({ set({
words: wordsFromDB, words: wordsFromDB,
progress: progressMap, progress: progressMap,
stats: statsFromDB || { stats: {
totalWords: wordsFromDB.length, totalWords: wordsFromDB.length,
masteredWords: 0, masteredWords: progressMap.size > 0 ?
studyingWords: 0, Array.from(progressMap.values()).filter(p => (p.mastery || 0) >= 80).length : 0,
streakDays: 0, studyingWords: wordsFromDB.length,
todayStudied: 0, streakDays,
todayStudied,
todayNewWords: 0, todayNewWords: 0,
longestStreak: statsFromDB?.longestStreak || 0,
lastStudyDate: new Date(),
weeklyGoal: DAILY_GOAL * 7,
weeklyProgress: todayStudied,
}, },
isLoading: false, isLoading: false,
}); });
@@ -75,13 +124,36 @@ export const useAppStore = create<AppState>((set, get) => ({
}), }),
addWords: async (newWords) => { addWords: async (newWords) => {
const wordsWithDate = newWords.map(w => ({ ...w, addedAt: new Date() })); const wordsWithDate = newWords.map(w => ({
...w,
addedAt: new Date(),
lastModified: new Date()
}));
await db.words.bulkAdd(wordsWithDate); await db.words.bulkAdd(wordsWithDate);
const allWords = await db.words.toArray(); const allWords = await db.words.toArray();
set({ set({
words: allWords, words: allWords,
stats: { ...get().stats, totalWords: allWords.length } stats: {
...get().stats,
totalWords: allWords.length,
todayNewWords: get().stats.todayNewWords + newWords.length
}
});
},
deleteWord: async (id) => {
await db.words.delete(id);
await db.progress.delete(id);
const newWords = get().words.filter(w => w.id !== id);
const newProgress = new Map(get().progress);
newProgress.delete(id);
set({
words: newWords,
progress: newProgress,
stats: { ...get().stats, totalWords: newWords.length }
}); });
}, },
@@ -105,15 +177,32 @@ export const useAppStore = create<AppState>((set, get) => ({
rating rating
); );
// Save to IndexedDB const mastery = calculateMastery(newProgress);
newProgress.mastery = mastery;
await db.progress.put(newProgress); await db.progress.put(newProgress);
const newProgressMap = new Map(state.progress); const newProgressMap = new Map(state.progress);
newProgressMap.set(currentWord.id, newProgress); newProgressMap.set(currentWord.id, newProgress);
const today = new Date().toDateString();
const lastStudyDate = state.stats.lastStudyDate
? new Date(state.stats.lastStudyDate).toDateString()
: null;
let streakDays = state.stats.streakDays;
if (today !== lastStudyDate) {
streakDays += 1;
}
const newStats = { const newStats = {
...state.stats, ...state.stats,
todayStudied: state.stats.todayStudied + 1, todayStudied: state.stats.todayStudied + 1,
weeklyProgress: (state.stats.weeklyProgress || 0) + 1,
streakDays,
longestStreak: Math.max(streakDays, state.stats.longestStreak || 0),
lastStudyDate: new Date(),
masteredWords: Array.from(newProgressMap.values()).filter(p => (p.mastery || 0) >= 80).length,
}; };
await db.stats.put({ ...newStats, id: 'main' }); await db.stats.put({ ...newStats, id: 'main' });
@@ -154,12 +243,49 @@ export const useAppStore = create<AppState>((set, get) => ({
getDueWords: () => { getDueWords: () => {
const state = get(); const state = get();
const now = new Date(); const now = new Date();
const { selectedCategory, searchQuery } = state;
return state.words.filter((word) => { let dueWords = state.words.filter((word) => {
const progress = state.progress.get(word.id); const progress = state.progress.get(word.id);
if (!progress) return true; if (!progress) return true;
return new Date(progress.nextReviewDate) <= now; return new Date(progress.nextReviewDate) <= now;
}); });
// 按分类过滤
if (selectedCategory !== 'all') {
dueWords = dueWords.filter(w => w.category === selectedCategory);
}
// 按搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
dueWords = dueWords.filter(w =>
w.french.toLowerCase().includes(query) ||
w.english.toLowerCase().includes(query) ||
w.category.toLowerCase().includes(query)
);
}
return dueWords;
},
getFilteredWords: () => {
const state = get();
let filtered = state.words;
if (state.selectedCategory !== 'all') {
filtered = filtered.filter(w => w.category === state.selectedCategory);
}
if (state.searchQuery) {
const query = state.searchQuery.toLowerCase();
filtered = filtered.filter(w =>
w.french.toLowerCase().includes(query) ||
w.english.toLowerCase().includes(query)
);
}
return filtered;
}, },
exportData: async () => { exportData: async () => {
@@ -171,12 +297,117 @@ export const useAppStore = create<AppState>((set, get) => ({
await get().init(); await get().init();
}, },
resetProgress: async () => {
await db.progress.clear();
set({ progress: new Map(), isFlipped: false, currentWordIndex: 0 });
},
setCategory: (category) => set({ selectedCategory: category }),
setSearchQuery: (query) => set({ searchQuery: query }),
speak: (text: string) => { speak: (text: string) => {
if ('speechSynthesis' in window) { if (!text?.trim()) return;
if (!('speechSynthesis' in window)) return;
const synth = window.speechSynthesis;
const { tts } = get();
const pickFrenchVoice = () => {
const voices = synth.getVoices();
if (!voices || voices.length === 0) return null;
// If user selected a voice, prefer it.
if (tts.voiceURI) {
const chosen = voices.find(v => v.voiceURI === tts.voiceURI);
if (chosen) return chosen;
}
return (
voices.find(v => v.lang?.toLowerCase() === 'fr-fr') ||
voices.find(v => v.lang?.toLowerCase().startsWith('fr')) ||
null
);
};
const speakNow = () => {
const utterance = new SpeechSynthesisUtterance(text); const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'fr-FR'; utterance.lang = 'fr-FR';
utterance.rate = 0.8; utterance.rate = tts.rate;
window.speechSynthesis.speak(utterance); utterance.pitch = tts.pitch;
const voice = pickFrenchVoice();
if (voice) utterance.voice = voice;
synth.cancel();
synth.speak(utterance);
};
if (synth.getVoices().length === 0) {
const onVoicesChanged = () => {
synth.removeEventListener('voiceschanged', onVoicesChanged);
speakNow();
};
synth.addEventListener('voiceschanged', onVoicesChanged);
synth.getVoices();
window.setTimeout(() => {
try {
synth.removeEventListener('voiceschanged', onVoicesChanged);
} catch {
// ignore
}
speakNow();
}, 250);
return;
}
speakNow();
},
loadSettings: async () => {
const ttsFromDb = await db.settings.get('tts');
if (ttsFromDb?.value) {
set({ tts: { ...DEFAULT_TTS_SETTINGS, ...ttsFromDb.value } });
} }
}, },
setTtsSettings: async (patch) => {
const next = { ...get().tts, ...patch };
set({ tts: next });
await db.settings.put({ key: 'tts', value: next });
},
refreshVoices: () => {
if (!('speechSynthesis' in window)) return;
const voices = window.speechSynthesis.getVoices();
set({
availableVoices: voices.map(v => ({ name: v.name, lang: v.lang, voiceURI: v.voiceURI }))
});
},
getCategoryStats: () => {
const state = get();
const categories = [...new Set(state.words.map(w => w.category))];
return categories.map(category => {
const categoryWords = state.words.filter(w => w.category === category);
const masteredCount = categoryWords.filter(w => {
const progress = state.progress.get(w.id);
return progress && (progress.mastery || 0) >= 80;
}).length;
const dueCount = categoryWords.filter(w => {
const progress = state.progress.get(w.id);
if (!progress) return true;
return new Date(progress.nextReviewDate) <= new Date();
}).length;
return {
category,
total: categoryWords.length,
mastered: masteredCount,
due: dueCount,
};
});
},
})); }));

15
src/types/settings.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface TtsSettings {
rate: number; // 0.1 - 2.0
pitch: number; // 0 - 2
voiceURI?: string; // exact voice identifier
autoSpeakWord: boolean;
autoSpeakExample: boolean;
}
export const DEFAULT_TTS_SETTINGS: TtsSettings = {
rate: 0.85,
pitch: 1.0,
voiceURI: undefined,
autoSpeakWord: false,
autoSpeakExample: false,
};

View File

@@ -2,11 +2,20 @@ export interface Word {
id: string; id: string;
french: string; french: string;
pronunciation?: string; pronunciation?: string;
ttsText?: string;
english: string; english: string;
chinese?: string;
partOfSpeech?: string;
example?: string; example?: string;
exampleTranslation?: string; exampleTranslation?: string;
examplePronunciation?: string;
notes?: string;
audioUrl?: string;
category: string; category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced'; difficulty: 'beginner' | 'intermediate' | 'advanced';
tags?: string[];
addedAt?: Date;
lastModified?: Date;
} }
export interface StudyProgress { export interface StudyProgress {
@@ -16,6 +25,7 @@ export interface StudyProgress {
easeFactor: number; easeFactor: number;
nextReviewDate: Date; nextReviewDate: Date;
lastStudiedDate: Date; lastStudiedDate: Date;
mastery?: number; // 0-100
} }
export interface StudyStats { export interface StudyStats {
@@ -25,6 +35,24 @@ export interface StudyStats {
streakDays: number; streakDays: number;
todayStudied: number; todayStudied: number;
todayNewWords: number; todayNewWords: number;
longestStreak?: number;
lastStudyDate?: Date;
weeklyGoal?: number;
weeklyProgress?: number;
} }
export type DifficultyRating = 'again' | 'hard' | 'good' | 'easy'; export type DifficultyRating = 'again' | 'hard' | 'good' | 'easy';
export interface DailyStats {
date: string;
studied: number;
newWords: number;
accuracy: number;
}
export interface CategoryStats {
category: string;
total: number;
mastered: number;
due: number;
}

View File

@@ -1,23 +1,58 @@
import type { StudyProgress, DifficultyRating } from '../types/vocabulary'; import type { StudyProgress, DifficultyRating } from '../types/vocabulary';
interface SRSConfig {
initialEase: number;
minEase: number;
maxInterval: number;
learningStepInterval: number;
}
const DEFAULT_CONFIG: SRSConfig = {
initialEase: 2.5,
minEase: 1.3,
maxInterval: 365, // 最大间隔 365 天
learningStepInterval: 10, // 学习步骤间隔(分钟)
};
/**
* 改进的 SM-2 算法实现
* 基于 Anki 和 SuperMemo 的研究
*/
export function calculateNextReview( export function calculateNextReview(
progress: StudyProgress, progress: StudyProgress,
rating: DifficultyRating rating: DifficultyRating,
config: SRSConfig = DEFAULT_CONFIG
): StudyProgress { ): StudyProgress {
const newProgress = { ...progress }; const newProgress = { ...progress };
const { minEase, maxInterval } = config;
// 如果是新单词repetitions === 0使用学习步骤
if (progress.repetitions === 0 && rating === 'again') {
// 标记为"再次",保持在第一步
newProgress.interval = 0;
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.2);
newProgress.nextReviewDate = new Date(Date.now() + DEFAULT_CONFIG.learningStepInterval * 60 * 1000);
newProgress.lastStudiedDate = new Date();
return newProgress;
}
switch (rating) { switch (rating) {
case 'again': case 'again':
// 完全重置,但保留一些学习历史
newProgress.interval = 1; newProgress.interval = 1;
newProgress.repetitions = 0; newProgress.repetitions = 0;
newProgress.easeFactor = Math.max(1.3, progress.easeFactor - 0.2); newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.2);
break; break;
case 'hard': case 'hard':
newProgress.interval = Math.round(progress.interval * 1.2); // 困难:间隔增长较慢
newProgress.interval = Math.max(1, Math.round(progress.interval * 1.2));
newProgress.repetitions += 1; newProgress.repetitions += 1;
newProgress.easeFactor = Math.max(1.3, progress.easeFactor - 0.15); newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.15);
break; break;
case 'good': case 'good':
// 良好:标准 SM-2 算法
if (progress.repetitions === 0) { if (progress.repetitions === 0) {
newProgress.interval = 1; newProgress.interval = 1;
} else if (progress.repetitions === 1) { } else if (progress.repetitions === 1) {
@@ -27,7 +62,9 @@ export function calculateNextReview(
} }
newProgress.repetitions += 1; newProgress.repetitions += 1;
break; break;
case 'easy': case 'easy':
// 简单:间隔增长更快
if (progress.repetitions === 0) { if (progress.repetitions === 0) {
newProgress.interval = 4; newProgress.interval = 4;
} else { } else {
@@ -38,9 +75,52 @@ export function calculateNextReview(
break; break;
} }
// 限制最大间隔
newProgress.interval = Math.min(newProgress.interval, maxInterval);
// 确保 easeFactor 在合理范围内
newProgress.easeFactor = Math.max(minEase, Math.min(newProgress.easeFactor, 3.0));
// 计算下次复习日期
const now = new Date(); const now = new Date();
newProgress.nextReviewDate = new Date(now.getTime() + newProgress.interval * 24 * 60 * 60 * 1000); newProgress.nextReviewDate = new Date(now.getTime() + newProgress.interval * 24 * 60 * 60 * 1000);
newProgress.lastStudiedDate = now; newProgress.lastStudiedDate = now;
return newProgress; return newProgress;
} }
/**
* 计算单词的掌握程度 (0-100%)
*/
export function calculateMastery(progress: StudyProgress): number {
if (progress.repetitions === 0) return 0;
const baseScore = Math.min(progress.repetitions * 20, 80);
const intervalBonus = Math.min(progress.interval / 30 * 20, 20);
return Math.min(100, Math.round(baseScore + intervalBonus));
}
/**
* 获取复习优先级分数(越高越优先)
*/
export function getReviewPriority(progress: StudyProgress): number {
const now = new Date();
const nextReview = new Date(progress.nextReviewDate);
const overdue = now.getTime() - nextReview.getTime();
// 逾期的单词优先级更高
if (overdue > 0) {
return 1000 + Math.min(overdue / (1000 * 60 * 60), 1000); // 每小时增加 1 点优先级
}
// 即将到期的单词
const timeUntilDue = nextReview.getTime() - now.getTime();
const hoursUntilDue = timeUntilDue / (1000 * 60 * 60);
if (hoursUntilDue < 24) {
return 500 + (24 - hoursUntilDue) * 20;
}
return hoursUntilDue;
}

View File

@@ -7,26 +7,76 @@ export default defineConfig({
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
manifest: { manifest: {
name: 'French Vocabulary', name: '法语词汇学习',
short_name: 'FrenchVocab', short_name: '法语词汇',
description: 'Master French with spaced repetition', description: '使用间隔重复系统高效学习法语单词',
theme_color: '#3b82f6', theme_color: '#3b82f6',
background_color: '#ffffff', background_color: '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [ icons: [
{ {
src: '/icon-192.png', src: '/icon-192.png',
sizes: '192x192', sizes: '192x192',
type: 'image/png' type: 'image/png',
purpose: 'any maskable'
}, },
{ {
src: '/icon-512.png', src: '/icon-512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/png' type: 'image/png',
purpose: 'any maskable'
} }
] ],
categories: ['education', 'productivity'],
lang: 'zh-CN',
dir: 'ltr'
} }
}) })
], ],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
db: ['dexie'],
state: ['zustand']
}
}
},
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
server: {
port: 3000,
host: true
}
}) })