- {buttons.map(({ rating, label, sublabel, color, shadow }) => (
+ {BUTTONS.map(({ rating, label, sublabel, shortcut, color, shadow }) => (
diff --git a/src/components/TtsSettingsModal.tsx b/src/components/TtsSettingsModal.tsx
new file mode 100644
index 0000000..bfa6952
--- /dev/null
+++ b/src/components/TtsSettingsModal.tsx
@@ -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 (
+
+
+
+
+
发音设置
+
优先使用法语语音(fr-FR / fr)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setTtsSettings({ rate: Number(e.target.value) })}
+ className="w-full"
+ />
+
{tts.rate.toFixed(2)}
+
+
+
+
setTtsSettings({ pitch: Number(e.target.value) })}
+ className="w-full"
+ />
+
{tts.pitch.toFixed(2)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/db/database.ts b/src/db/database.ts
index 193289e..3f3e502 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -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);
diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts
index a985615..37ccccb 100644
--- a/src/stores/appStore.ts
+++ b/src/stores/appStore.ts
@@ -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
;
setWords: (words: Word[]) => void;
addWords: (words: Word[]) => Promise;
+ deleteWord: (id: string) => Promise;
flipCard: () => void;
rateWord: (rating: DifficultyRating) => Promise;
nextWord: () => void;
getCurrentWord: () => Word | null;
getDueWords: () => Word[];
+ getFilteredWords: () => Word[];
exportData: () => Promise;
importData: (data: any) => Promise;
speak: (text: string) => void;
+ setCategory: (category: string | 'all') => void;
+ setSearchQuery: (query: string) => void;
+ resetProgress: () => Promise;
+ getCategoryStats: () => CategoryStats[];
+
+ loadSettings: () => Promise;
+ setTtsSettings: (patch: Partial) => Promise;
+ refreshVoices: () => void;
}
+const DAILY_GOAL = 20;
+
export const useAppStore = create((set, get) => ({
words: [],
progress: new Map(),
@@ -35,35 +53,66 @@ export const useAppStore = create((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();
-
- // Load words from DB
+ await get().loadSettings();
+ get().refreshVoices();
+
const wordsFromDB = await db.words.toArray();
-
- // Load progress from DB
const progressFromDB = await db.progress.toArray();
const progressMap = new Map();
- 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((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((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((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((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,
+ };
+ });
+ },
}));
diff --git a/src/types/settings.ts b/src/types/settings.ts
new file mode 100644
index 0000000..2643711
--- /dev/null
+++ b/src/types/settings.ts
@@ -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,
+};
diff --git a/src/types/vocabulary.ts b/src/types/vocabulary.ts
index ea17c37..d993929 100644
--- a/src/types/vocabulary.ts
+++ b/src/types/vocabulary.ts
@@ -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;
+}
diff --git a/src/utils/srs.ts b/src/utils/srs.ts
index 65ccb39..2d1eee6 100644
--- a/src/utils/srs.ts
+++ b/src/utils/srs.ts
@@ -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;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7910041..5b6bb8b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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
+ }
})