Update: TTS settings modal and settings types
This commit is contained in:
26
index.html
26
index.html
@@ -1,22 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="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" />
|
||||
<title>French Vocabulary</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<title>法语词汇学习</title>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(console.error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
{
|
||||
"name": "French Vocabulary",
|
||||
"short_name": "FrenchVocab",
|
||||
"description": "Master French with spaced repetition",
|
||||
"name": "法语词汇学习",
|
||||
"short_name": "法语词汇",
|
||||
"description": "使用间隔重复系统高效学习法语单词",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color":="#ffffff",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3b82f6",
|
||||
"lang": "zh-CN",
|
||||
"dir": "ltr",
|
||||
"categories": ["education", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -3,10 +3,12 @@ import { FlashCard } from './components/FlashCard';
|
||||
import { RatingButtons } from './components/RatingButtons';
|
||||
import { ProgressBar } from './components/ProgressBar';
|
||||
import { ImportModal } from './components/ImportModal';
|
||||
import { TtsSettingsModal } from './components/TtsSettingsModal';
|
||||
import { useAppStore } from './stores/appStore';
|
||||
|
||||
function App() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showTtsSettings, setShowTtsSettings] = useState(false);
|
||||
// Export functionality handled by direct download
|
||||
const {
|
||||
init,
|
||||
@@ -108,6 +110,15 @@ function App() {
|
||||
<span className="text-gray-600 font-medium">French Vocabulary</span>
|
||||
</div>
|
||||
<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
|
||||
onClick={() => setShowImport(true)}
|
||||
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
|
||||
@@ -199,6 +210,11 @@ function App() {
|
||||
onClose={() => setShowImport(false)}
|
||||
onImport={addWords}
|
||||
/>
|
||||
|
||||
<TtsSettingsModal
|
||||
isOpen={showTtsSettings}
|
||||
onClose={() => setShowTtsSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,19 +16,26 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
onFlip?.(newState);
|
||||
};
|
||||
|
||||
const handleSpeak = (e: React.MouseEvent) => {
|
||||
const handleSpeakWord = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSpeak?.(word.french);
|
||||
onSpeak?.(word.ttsText || word.french);
|
||||
};
|
||||
|
||||
const handleSpeakExample = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (word.example) {
|
||||
onSpeak?.(word.example);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-96 cursor-pointer group"
|
||||
className="relative w-full min-h-[32rem] cursor-pointer group"
|
||||
style={{ perspective: '1000px' }}
|
||||
onClick={handleFlip}
|
||||
>
|
||||
<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={{
|
||||
transformStyle: 'preserve-3d',
|
||||
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"
|
||||
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">
|
||||
{word.category}
|
||||
</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>
|
||||
|
||||
{/* Speak 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"
|
||||
title="播放发音"
|
||||
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 className="text-center">
|
||||
<h2 className="text-6xl font-bold mb-4 drop-shadow-lg">{word.french}</h2>
|
||||
<div className="text-center max-w-xl">
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<span className="text-sm font-medium">点击翻转</span>
|
||||
<span className="text-sm font-medium">点击翻转查看释义与例句</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,16 +105,46 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h3 className="text-4xl font-bold drop-shadow-lg">{word.english}</h3>
|
||||
<div className="w-full max-w-2xl text-center space-y-5">
|
||||
<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 && (
|
||||
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 max-w-md">
|
||||
<p className="text-lg italic mb-3 leading-relaxed">"{word.example}"</p>
|
||||
{word.exampleTranslation && (
|
||||
<p className="text-sm opacity-80 border-t border-white/20 pt-3">{word.exampleTranslation}</p>
|
||||
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-left">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Exemple</div>
|
||||
<p className="text-lg italic leading-relaxed">"{word.example}"</p>
|
||||
{word.examplePronunciation && (
|
||||
<p className="mt-2 text-sm text-white/75">/{word.examplePronunciation}/</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import type { Word } from '../types/vocabulary';
|
||||
|
||||
interface ImportModalProps {
|
||||
isOpen: boolean;
|
||||
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) {
|
||||
const [content, setContent] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -27,40 +58,84 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
};
|
||||
|
||||
const parseContent = (text: string) => {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Try JSON first
|
||||
const json = JSON.parse(text);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
}
|
||||
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();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, try CSV
|
||||
const lines = text.trim().split('\n');
|
||||
const words = lines.map((line, index) => {
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
return {
|
||||
id: `imported-${Date.now()}-${index}`,
|
||||
// fallthrough to CSV
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.trim()
|
||||
.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] || '',
|
||||
english: parts[1] || '',
|
||||
pronunciation: parts[2] || '',
|
||||
category: parts[3] || 'General',
|
||||
difficulty: 'beginner',
|
||||
};
|
||||
}).filter(w => w.french && w.english);
|
||||
example: parts[4] || '',
|
||||
exampleTranslation: parts[5] || '',
|
||||
},
|
||||
index
|
||||
);
|
||||
})
|
||||
.filter(Boolean) as Word[];
|
||||
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
} else {
|
||||
setError('无法解析文件格式');
|
||||
}
|
||||
setError('无法解析文件格式,请检查 french 和 english 字段是否存在');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,8 +149,11 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
|
||||
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">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">导入词库</h2>
|
||||
<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-2">导入词库</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
支持更完整的法语词条字段:单词、英文、中文、音标、词性、例句、例句翻译、分类、难度。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -103,18 +181,16 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`CSV 格式:
|
||||
french,english,pronunciation,category
|
||||
Bonjour,Hello,bɔ̃ʒuʁ,Greetings
|
||||
placeholder={`CSV 表头示例:
|
||||
french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty
|
||||
bonjour,hello,你好,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,大家好。,beginner
|
||||
|
||||
JSON 格式:
|
||||
[{"french": "Bonjour", "english": "Hello"}]`}
|
||||
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"
|
||||
JSON 示例:
|
||||
[{"french":"bonjour","english":"hello","chinese":"你好","pronunciation":"bɔ̃ʒuʁ","partOfSpeech":"interjection","example":"Bonjour, comment ça va ?","exampleTranslation":"你好,最近怎么样?"}]`}
|
||||
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 && (
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { DifficultyRating } from '../types/vocabulary';
|
||||
|
||||
interface RatingButtonsProps {
|
||||
@@ -5,50 +6,90 @@ interface RatingButtonsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RatingButtons({ onRate, disabled }: RatingButtonsProps) {
|
||||
const buttons = [
|
||||
interface ButtonConfig {
|
||||
rating: DifficultyRating;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
shortcut: string;
|
||||
color: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
const BUTTONS: ButtonConfig[] = [
|
||||
{
|
||||
rating: 'again' as DifficultyRating,
|
||||
rating: 'again',
|
||||
label: '重来',
|
||||
sublabel: '< 1 min',
|
||||
sublabel: '< 1 分钟',
|
||||
shortcut: '1',
|
||||
color: 'from-rose-400 to-red-500',
|
||||
shadow: 'shadow-rose-200'
|
||||
},
|
||||
{
|
||||
rating: 'hard' as DifficultyRating,
|
||||
rating: 'hard',
|
||||
label: '困难',
|
||||
sublabel: '~ 2 days',
|
||||
sublabel: '~ 2 天',
|
||||
shortcut: '2',
|
||||
color: 'from-orange-400 to-amber-500',
|
||||
shadow: 'shadow-orange-200'
|
||||
},
|
||||
{
|
||||
rating: 'good' as DifficultyRating,
|
||||
rating: 'good',
|
||||
label: '良好',
|
||||
sublabel: '~ 4 days',
|
||||
sublabel: '~ 4 天',
|
||||
shortcut: '3',
|
||||
color: 'from-blue-400 to-indigo-500',
|
||||
shadow: 'shadow-blue-200'
|
||||
},
|
||||
{
|
||||
rating: 'easy' as DifficultyRating,
|
||||
rating: 'easy',
|
||||
label: '简单',
|
||||
sublabel: '~ 7 days',
|
||||
sublabel: '~ 7 天',
|
||||
shortcut: '4',
|
||||
color: 'from-emerald-400 to-teal-500',
|
||||
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 (
|
||||
<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
|
||||
key={rating}
|
||||
onClick={() => onRate(rating)}
|
||||
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`}
|
||||
aria-label={`${label} (${shortcut})`}
|
||||
>
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<span className="text-lg font-bold">{label}</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 className="absolute inset-0 bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
|
||||
134
src/components/TtsSettingsModal.tsx
Normal file
134
src/components/TtsSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -32,29 +32,517 @@ export const db = new FrenchVocabDB();
|
||||
export async function initDatabase() {
|
||||
const count = await db.words.count();
|
||||
if (count === 0) {
|
||||
// 添加示例单词
|
||||
const now = new Date();
|
||||
const defaultWords: WordEntry[] = [
|
||||
{
|
||||
id: '1',
|
||||
french: 'Bonjour',
|
||||
pronunciation: 'bɔ̃ʒuʁ',
|
||||
ttsText: 'bonjour',
|
||||
english: 'Hello',
|
||||
example: 'Bonjour, comment allez-vous?',
|
||||
exampleTranslation: 'Hello, how are you?',
|
||||
chinese: '你好;早安',
|
||||
partOfSpeech: 'interjection',
|
||||
example: 'Bonjour, comment allez-vous ?',
|
||||
exampleTranslation: '你好,你最近怎么样?',
|
||||
category: 'Greetings',
|
||||
difficulty: 'beginner',
|
||||
addedAt: new Date(),
|
||||
tags: ['daily', 'greeting'],
|
||||
addedAt: now,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
french: 'Merci',
|
||||
pronunciation: 'mɛʁsi',
|
||||
ttsText: 'merci',
|
||||
english: 'Thank you',
|
||||
chinese: '谢谢',
|
||||
partOfSpeech: 'interjection',
|
||||
example: 'Merci beaucoup pour votre aide.',
|
||||
exampleTranslation: 'Thank you very much for your help.',
|
||||
exampleTranslation: '非常感谢你的帮助。',
|
||||
category: 'Greetings',
|
||||
difficulty: 'beginner',
|
||||
addedAt: new Date(),
|
||||
tags: ['daily', 'polite'],
|
||||
addedAt: now,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
french: 'S’il vous plaît',
|
||||
pronunciation: 'sil vu plɛ',
|
||||
ttsText: 's’il vous plaît',
|
||||
english: 'Please',
|
||||
chinese: '请',
|
||||
partOfSpeech: 'expression',
|
||||
example: 'Un café, s’il 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 n’ai 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 d’eau.',
|
||||
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: 'J’apprends 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 aujourd’hui.',
|
||||
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: 'J’ai un rendez-vous chez le médecin à dix heures.',
|
||||
exampleTranslation: '我十点要去看医生。',
|
||||
category: 'Daily Life',
|
||||
difficulty: 'intermediate',
|
||||
tags: ['schedule'],
|
||||
addedAt: now,
|
||||
},
|
||||
{
|
||||
id: '29',
|
||||
french: 'Aujourd’hui',
|
||||
pronunciation: 'o.ʒuʁ.dɥi',
|
||||
ttsText: 'aujourd’hui',
|
||||
english: 'Today',
|
||||
chinese: '今天',
|
||||
partOfSpeech: 'adverb',
|
||||
example: 'Aujourd’hui, 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);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Word, StudyProgress, StudyStats, DifficultyRating } from '../types/vocabulary';
|
||||
import { calculateNextReview } from '../utils/srs';
|
||||
import type { Word, StudyProgress, StudyStats, DifficultyRating, CategoryStats } from '../types/vocabulary';
|
||||
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';
|
||||
|
||||
interface AppState {
|
||||
@@ -10,21 +12,37 @@ interface AppState {
|
||||
currentWordIndex: number;
|
||||
isFlipped: boolean;
|
||||
isLoading: boolean;
|
||||
selectedCategory: string | 'all';
|
||||
searchQuery: string;
|
||||
tts: TtsSettings;
|
||||
availableVoices: { name: string; lang: string; voiceURI: string }[];
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>;
|
||||
setWords: (words: Word[]) => void;
|
||||
addWords: (words: Word[]) => Promise<void>;
|
||||
deleteWord: (id: string) => Promise<void>;
|
||||
flipCard: () => void;
|
||||
rateWord: (rating: DifficultyRating) => Promise<void>;
|
||||
nextWord: () => void;
|
||||
getCurrentWord: () => Word | null;
|
||||
getDueWords: () => Word[];
|
||||
getFilteredWords: () => Word[];
|
||||
exportData: () => Promise<any>;
|
||||
importData: (data: any) => Promise<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) => ({
|
||||
words: [],
|
||||
progress: new Map(),
|
||||
@@ -35,35 +53,66 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
streakDays: 0,
|
||||
todayStudied: 0,
|
||||
todayNewWords: 0,
|
||||
weeklyGoal: DAILY_GOAL * 7,
|
||||
weeklyProgress: 0,
|
||||
},
|
||||
currentWordIndex: 0,
|
||||
isFlipped: false,
|
||||
isLoading: true,
|
||||
selectedCategory: 'all',
|
||||
searchQuery: '',
|
||||
tts: DEFAULT_TTS_SETTINGS,
|
||||
availableVoices: [],
|
||||
|
||||
init: async () => {
|
||||
await initDatabase();
|
||||
await get().loadSettings();
|
||||
get().refreshVoices();
|
||||
|
||||
// Load words from DB
|
||||
const wordsFromDB = await db.words.toArray();
|
||||
|
||||
// Load progress from DB
|
||||
const progressFromDB = await db.progress.toArray();
|
||||
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 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({
|
||||
words: wordsFromDB,
|
||||
progress: progressMap,
|
||||
stats: statsFromDB || {
|
||||
stats: {
|
||||
totalWords: wordsFromDB.length,
|
||||
masteredWords: 0,
|
||||
studyingWords: 0,
|
||||
streakDays: 0,
|
||||
todayStudied: 0,
|
||||
masteredWords: progressMap.size > 0 ?
|
||||
Array.from(progressMap.values()).filter(p => (p.mastery || 0) >= 80).length : 0,
|
||||
studyingWords: wordsFromDB.length,
|
||||
streakDays,
|
||||
todayStudied,
|
||||
todayNewWords: 0,
|
||||
longestStreak: statsFromDB?.longestStreak || 0,
|
||||
lastStudyDate: new Date(),
|
||||
weeklyGoal: DAILY_GOAL * 7,
|
||||
weeklyProgress: todayStudied,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -75,13 +124,36 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
}),
|
||||
|
||||
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);
|
||||
|
||||
const allWords = await db.words.toArray();
|
||||
set({
|
||||
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
|
||||
);
|
||||
|
||||
// Save to IndexedDB
|
||||
const mastery = calculateMastery(newProgress);
|
||||
newProgress.mastery = mastery;
|
||||
|
||||
await db.progress.put(newProgress);
|
||||
|
||||
const newProgressMap = new Map(state.progress);
|
||||
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 = {
|
||||
...state.stats,
|
||||
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' });
|
||||
@@ -154,12 +243,49 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
getDueWords: () => {
|
||||
const state = get();
|
||||
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);
|
||||
if (!progress) return true;
|
||||
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 () => {
|
||||
@@ -171,12 +297,117 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
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) => {
|
||||
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);
|
||||
utterance.lang = 'fr-FR';
|
||||
utterance.rate = 0.8;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
utterance.rate = tts.rate;
|
||||
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
15
src/types/settings.ts
Normal 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,
|
||||
};
|
||||
@@ -2,11 +2,20 @@ export interface Word {
|
||||
id: string;
|
||||
french: string;
|
||||
pronunciation?: string;
|
||||
ttsText?: string;
|
||||
english: string;
|
||||
chinese?: string;
|
||||
partOfSpeech?: string;
|
||||
example?: string;
|
||||
exampleTranslation?: string;
|
||||
examplePronunciation?: string;
|
||||
notes?: string;
|
||||
audioUrl?: string;
|
||||
category: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||
tags?: string[];
|
||||
addedAt?: Date;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
export interface StudyProgress {
|
||||
@@ -16,6 +25,7 @@ export interface StudyProgress {
|
||||
easeFactor: number;
|
||||
nextReviewDate: Date;
|
||||
lastStudiedDate: Date;
|
||||
mastery?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface StudyStats {
|
||||
@@ -25,6 +35,24 @@ export interface StudyStats {
|
||||
streakDays: number;
|
||||
todayStudied: number;
|
||||
todayNewWords: number;
|
||||
longestStreak?: number;
|
||||
lastStudyDate?: Date;
|
||||
weeklyGoal?: number;
|
||||
weeklyProgress?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
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(
|
||||
progress: StudyProgress,
|
||||
rating: DifficultyRating
|
||||
rating: DifficultyRating,
|
||||
config: SRSConfig = DEFAULT_CONFIG
|
||||
): StudyProgress {
|
||||
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) {
|
||||
case 'again':
|
||||
// 完全重置,但保留一些学习历史
|
||||
newProgress.interval = 1;
|
||||
newProgress.repetitions = 0;
|
||||
newProgress.easeFactor = Math.max(1.3, progress.easeFactor - 0.2);
|
||||
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.2);
|
||||
break;
|
||||
|
||||
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.easeFactor = Math.max(1.3, progress.easeFactor - 0.15);
|
||||
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.15);
|
||||
break;
|
||||
|
||||
case 'good':
|
||||
// 良好:标准 SM-2 算法
|
||||
if (progress.repetitions === 0) {
|
||||
newProgress.interval = 1;
|
||||
} else if (progress.repetitions === 1) {
|
||||
@@ -27,7 +62,9 @@ export function calculateNextReview(
|
||||
}
|
||||
newProgress.repetitions += 1;
|
||||
break;
|
||||
|
||||
case 'easy':
|
||||
// 简单:间隔增长更快
|
||||
if (progress.repetitions === 0) {
|
||||
newProgress.interval = 4;
|
||||
} else {
|
||||
@@ -38,9 +75,52 @@ export function calculateNextReview(
|
||||
break;
|
||||
}
|
||||
|
||||
// 限制最大间隔
|
||||
newProgress.interval = Math.min(newProgress.interval, maxInterval);
|
||||
|
||||
// 确保 easeFactor 在合理范围内
|
||||
newProgress.easeFactor = Math.max(minEase, Math.min(newProgress.easeFactor, 3.0));
|
||||
|
||||
// 计算下次复习日期
|
||||
const now = new Date();
|
||||
newProgress.nextReviewDate = new Date(now.getTime() + newProgress.interval * 24 * 60 * 60 * 1000);
|
||||
newProgress.lastStudiedDate = now;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,26 +7,76 @@ export default defineConfig({
|
||||
react(),
|
||||
VitePWA({
|
||||
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: {
|
||||
name: 'French Vocabulary',
|
||||
short_name: 'FrenchVocab',
|
||||
description: 'Master French with spaced repetition',
|
||||
name: '法语词汇学习',
|
||||
short_name: '法语词汇',
|
||||
description: '使用间隔重复系统高效学习法语单词',
|
||||
theme_color: '#3b82f6',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user