diff --git a/index.html b/index.html index 2c28abb..3edcecc 100644 --- a/index.html +++ b/index.html @@ -1,22 +1,28 @@ - + - + + + + + + + - French Vocabulary + + 法语词汇学习
+ - diff --git a/public/manifest.json b/public/manifest.json index 7d3c505..fc6c03f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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" }] } ] } diff --git a/src/App.tsx b/src/App.tsx index b10788c..c1fa4a3 100644 --- a/src/App.tsx +++ b/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() { French Vocabulary
+
); } diff --git a/src/components/FlashCard.tsx b/src/components/FlashCard.tsx index 963643a..c8f6489 100644 --- a/src/components/FlashCard.tsx +++ b/src/components/FlashCard.tsx @@ -16,80 +16,136 @@ 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 ( -
-
{/* Front */} -
-
+
{word.category} + {word.difficulty && ( + + {word.difficulty} + + )}
- - {/* Speak Button */} + - -
-

{word.french}

+ +
+

{word.french}

+ {word.partOfSpeech && ( +
{word.partOfSpeech}
+ )} {word.pronunciation && ( - /{word.pronunciation}/ +
/{word.pronunciation}/
+ )} + {word.chinese && ( +
{word.chinese}
+ )} + {word.tags && word.tags.length > 0 && ( +
+ {word.tags.slice(0, 4).map(tag => ( + + #{tag} + + ))} +
)}
- +
- 点击翻转 + 点击翻转查看释义与例句
{/* Back */} -
-
-

{word.english}

- +
+
+
Meaning
+

{word.english}

+ {word.chinese && ( +

{word.chinese}

+ )} +
+ {word.example && ( -
-

"{word.example}"

+
+
+
+
Exemple
+

"{word.example}"

+ {word.examplePronunciation && ( +

/{word.examplePronunciation}/

+ )} +
+ +
{word.exampleTranslation && ( -

{word.exampleTranslation}

+

{word.exampleTranslation}

)}
)} + + {word.notes && ( +
+
Notes
+

{word.notes}

+
+ )}
diff --git a/src/components/ImportModal.tsx b/src/components/ImportModal.tsx index c0972ae..5489f8e 100644 --- a/src/components/ImportModal.tsx +++ b/src/components/ImportModal.tsx @@ -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, 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); - onClose(); - return; + 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); - onClose(); - return; + const words = json.words.map((item: Partial, 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}`, - french: parts[0] || '', - english: parts[1] || '', - pronunciation: parts[2] || '', - category: parts[3] || 'General', - difficulty: 'beginner', - }; - }).filter(w => w.french && w.english); + // fallthrough to CSV + } - if (words.length > 0) { - onImport(words); - onClose(); - } else { - setError('无法解析文件格式'); - } + 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 = { + 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', + example: parts[4] || '', + exampleTranslation: parts[5] || '', + }, + index + ); + }) + .filter(Boolean) as Word[]; + + if (words.length > 0) { + onImport(words); + onClose(); + } else { + setError('无法解析文件格式,请检查 french 和 english 字段是否存在'); } }; @@ -74,9 +149,12 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) { return (
-
-

导入词库

- +
+

导入词库

+

+ 支持更完整的法语词条字段:单词、英文、中文、音标、词性、例句、例句翻译、分类、难度。 +

+