diff --git a/西班牙语日记-实现计划.md b/西班牙语日记-实现计划.md new file mode 100644 index 0000000..0c3bad0 --- /dev/null +++ b/西班牙语日记-实现计划.md @@ -0,0 +1,821 @@ +# Spanish Diary (Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a pure-frontend Spanish learning diary app where users write daily entries in Spanish, see English/Chinese translations auto-displayed, hear pronunciation via Web Speech API, and manage entries via IndexedDB. + +**Architecture:** Single-page application with modular JavaScript files. Three-panel layout (entry list | Spanish editor | translation display). Each JS module encapsulates one concern (database, translation, speech). The HTML file loads all scripts and provides the DOM structure; CSS handles the three-column responsive layout. + +**Tech Stack:** HTML5, CSS3, Vanilla JavaScript (ES6+), IndexedDB (native API), Web Speech API. + +--- + +## File Structure + +``` +spanish-diary/ + index.html — Main HTML page with DOM skeleton + style.css — All styles, three-column layout, responsive + app.js — Application logic: state management, event wiring between panels + db.js — IndexedDB wrapper: open CRUD operations + translate.js — Translation module: mock dictionary lookup + speech.js — Speech module: Web Speech API wrapper +``` + +Each file has one clear responsibility. `app.js` is the coordinator that connects the other modules. + +--- + +### Task 1: Create project directory and HTML skeleton + +**Files:** +- Create: `spanish-diary/index.html` + +**Details:** The HTML must define the three-panel structure with IDs for JS targeting. No external dependencies. Load all JS files at the bottom. + +- [ ] **Step 1: Write the HTML file** + +```html + + + + + + Mi Diario — 西班牙语日记 + + + +
+ + +
+
+ + +
+ +
输入西语后点击保存,右侧自动显示翻译
+
+ + +
+ + + + + + + +``` + +Verify: Open the file in browser — three empty panels should display (no styling yet). + +- [ ] **Step 2: Verify in browser** + +Open `file:///path/to/spanish-diary/index.html`. Should show unstyled three columns with text elements. + +--- + +### Task 2: Write the CSS for the three-column layout + +**Files:** +- Create: `spanish-diary/style.css` + +**Details:** Responsive three-column layout. Left panel 240px, center flexible, right panel 280px. Dark mode support via `prefers-color-scheme`. Clean, professional look with muted brand colors. + +- [ ] **Step 1: Write the CSS file** + +```css +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f8f9fa; + --panel-bg: #ffffff; + --border: #dee2e6; + --text: #212529; + --text-muted: #6c757d; + --accent: #0d6efd; + --accent-hover: #0b5ed7; + --danger: #dc3545; + --success: #198754; + --highlight: #fff3cd; + --shadow: 0 1px 3px rgba(0,0,0,0.08); + --radius: 8px; + --font-mono: 'Consolas', 'Courier New', monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1d23; + --panel-bg: #212529; + --border: #373b3e; + --text: #e9ecef; + --text-muted: #9ca3af; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --danger: #ef4444; + --success: #22c55e; + --highlight: #3d3520; + } +} + +html, body { height: 100%; } +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + display: flex; + justify-content: center; + align-items: stretch; + padding: 1rem; +} + +#app { + display: grid; + grid-template-columns: 240px 1fr 280px; + gap: 1rem; + width: 100%; + max-width: 1200px; + min-height: 0; +} + +#app > * { + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + gap: 0.5rem; +} + +.panel-header h2 { font-size: 0.95rem; font-weight: 600; } + +/* Left panel — entry list */ +#panel-list { overflow-y: auto; } +#entry-list { + list-style: none; + flex: 1; + overflow-y: auto; +} +#entry-list li { + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +#entry-list li:hover { background: var(--highlight); } +#entry-list li.active { background: var(--highlight); border-left: 3px solid var(--accent); } +#entry-list .entry-date { font-size: 0.75rem; color: var(--text-muted); } +#entry-list .entry-preview { font-size: 0.85rem; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-actions { display: none; gap: 0.3rem; margin-top: 0.3rem; } +#entry-list li:hover .entry-actions { display: flex; } +.entry-actions button { + background: none; border: 1px solid var(--border); border-radius: 4px; + padding: 0.15rem 0.4rem; font-size: 0.7rem; cursor: pointer; color: var(--text-muted); +} +.entry-actions .btn-delete { color: var(--danger); border-color: var(--danger); } + +#btn-new { + background: var(--accent); color: #fff; border: none; + width: 28px; height: 28px; border-radius: 6px; + font-size: 1.2rem; cursor: pointer; line-height: 1; +} +#btn-new:hover { background: var(--accent-hover); } + +/* Center panel — editor */ +#panel-editor { display: flex; flex-direction: column; } +#entry-date { + border: 1px solid var(--border); border-radius: 4px; + padding: 0.3rem 0.5rem; font-size: 0.85rem; + background: var(--panel-bg); color: var(--text); +} +#btn-save { + background: var(--accent); color: #fff; border: none; + padding: 0.35rem 0.75rem; border-radius: 6px; + font-size: 0.85rem; cursor: pointer; white-space: nowrap; +} +#btn-save:hover { background: var(--accent-hover); } +#entry-text { + flex: 1; resize: none; border: none; padding: 1rem; + font-size: 1.05rem; line-height: 1.7; + font-family: var(--font-mono); + background: transparent; color: var(--text); + outline: none; +} +#entry-hint { + padding: 0.5rem 1rem; font-size: 0.75rem; color: var(--text-muted); + border-top: 1px solid var(--border); +} + +/* Right panel — translation */ +#panel-translate { overflow-y: auto; } +.translate-section { + padding: 1rem; + border-bottom: 1px solid var(--border); +} +.translate-label { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-muted); } +.translate-text { + font-size: 0.95rem; line-height: 1.6; + min-height: 3rem; padding: 0.5rem; + background: var(--bg); border-radius: 4px; +} +.btn-speech { + background: none; border: 1px solid var(--border); border-radius: 4px; + padding: 0.25rem 0.6rem; font-size: 0.75rem; cursor: pointer; + margin-top: 0.5rem; color: var(--text-muted); +} +.btn-speech:hover { border-color: var(--accent); color: var(--accent); } + +/* Modal overlay for delete confirmation */ +#modal-overlay { + display: none; + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + align-items: center; justify-content: center; + z-index: 100; +} +#modal-overlay.show { display: flex; } +#modal-box { + background: var(--panel-bg); border-radius: var(--radius); + padding: 1.5rem; max-width: 360px; width: 90%; + box-shadow: 0 8px 24px rgba(0,0,0,0.15); +} +#modal-box p { margin-bottom: 1rem; font-size: 0.9rem; } +#modal-box .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } +#modal-box button { + padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid var(--border); + cursor: pointer; font-size: 0.85rem; +} +#modal-box .btn-cancel { background: var(--panel-bg); color: var(--text); } +#modal-box .btn-confirm { background: var(--danger); color: #fff; border-color: var(--danger); } + +@media (max-width: 768px) { + body { padding: 0.5rem; } + #app { grid-template-columns: 1fr; } + #panel-list { max-height: 30vh; } + #panel-editor { min-height: 40vh; } + #panel-translate { max-height: 30vh; } +} +``` + +Verify: Open `index.html` — three-column layout with styled panels. + +--- + +### Task 3: Implement IndexedDB module (db.js) + +**Files:** +- Create: `spanish-diary/db.js` + +**Details:** Wrapper around IndexedDB. Database name `SpanishDiary`, object store `entries` with key path `id`. Provides `openDb()`, `getAllEntries()`, `getEntry(id)`, `addEntry(entry)`, `updateEntry(entry)`, `deleteEntry(id)`. Uses a `dbPromise` pattern (store the DB reference after open). + +- [ ] **Step 1: Write db.js** + +```js +const DB_NAME = 'SpanishDiary'; +const DB_VERSION = 1; +const STORE_NAME = 'entries'; + +let db = null; + +function openDb() { + return new Promise((resolve, reject) => { + if (db) { resolve(db); return; } + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (e) => { + const d = e.target.result; + if (!d.objectStoreNames.contains(STORE_NAME)) { + const store = d.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('date', 'date', { unique: false }); + } + }; + req.onsuccess = (e) => { db = e.target.result; resolve(db); }; + req.onerror = (e) => reject(e.target.error); + }); +} + +function getAllEntries() { + return openDb().then(db => { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const index = store.index('date'); + const req = index.openCursor(null, 'prev'); + const results = []; + req.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { results.push(cursor.value); cursor.continue(); } + else resolve(results); + }; + req.onerror = (e) => reject(e.target.error); + }); + }); +} + +function getEntry(id) { + return openDb().then(db => { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(id); + req.onsuccess = () => resolve(req.result || null); + req.onerror = (e) => reject(e.target.error); + }); + }); +} + +function addEntry(entry) { + return openDb().then(db => { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.add(entry); + req.onsuccess = () => resolve(entry); + req.onerror = (e) => reject(e.target.error); + }); + }); +} + +function updateEntry(entry) { + return openDb().then(db => { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(entry); + req.onsuccess = () => resolve(entry); + req.onerror = (e) => reject(e.target.error); + }); + }); +} + +function deleteEntry(id) { + return openDb().then(db => { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(id); + req.onsuccess = () => resolve(); + req.onerror = (e) => reject(e.target.error); + }); + }); +} +``` + +Verify: Load `index.html` and check console for `db` object (will test with app.js later). + +--- + +### Task 4: Implement translation module (translate.js) + +**Files:** +- Create: `spanish-diary/translate.js` + +**Details:** Lookup-based mock translation for common Spanish diary phrases. Function `translateText(text)` returns `{ english, chinese }`. Falls back to `text` + `[待翻译]` for unknown phrases. Covers ~20+ common daily expressions. + +- [ ] **Step 1: Write translate.js** + +```js +const MOCK_TRANSLATIONS = { + 'hola': { en: 'Hello', zh: '你好' }, + 'buenos días': { en: 'Good morning', zh: '早上好' }, + 'buenas tardes': { en: 'Good afternoon', zh: '下午好' }, + 'buenas noches': { en: 'Good night', zh: '晚安' }, + 'hoy': { en: 'today', zh: '今天' }, + 'ayer': { en: 'yesterday', zh: '昨天' }, + 'mañana': { en: 'tomorrow', zh: '明天' }, + 'fui': { en: 'I went', zh: '我去了' }, + 'fui a': { en: 'I went to', zh: '我去了' }, + 'estoy': { en: 'I am', zh: '我' }, + 'estoy muy': { en: 'I am very', zh: '我非常' }, + 'tengo': { en: 'I have', zh: '我有' }, + 'quiero': { en: 'I want', zh: '我想要' }, + 'me gusta': { en: 'I like', zh: '我喜欢' }, + 'me gustó': { en: 'I liked', zh: '我喜欢了' }, + 'aprendí': { en: 'I learned', zh: '我学了' }, + 'estudié': { en: 'I studied', zh: '我学习了' }, + 'comí': { en: 'I ate', zh: '我吃了' }, + 'bebí': { en: 'I drank', zh: '我喝了' }, + 'leí': { en: 'I read', zh: '我读了' }, + 'escribí': { en: 'I wrote', zh: '我写了' }, + 'trabajé': { en: 'I worked', zh: '我工作了' }, + 'descansé': { en: 'I rested', zh: '我休息了' }, + 'caminé': { en: 'I walked', zh: '我散步了' }, + 'corrí': { en: 'I ran', zh: '我跑步了' }, + 'compré': { en: 'I bought', zh: '我买了' }, + 'vi': { en: 'I saw', zh: '我看了' }, + 'con': { en: 'with', zh: '和' }, + 'mi': { en: 'my', zh: '我的' }, + 'amigo': { en: 'friend', zh: '朋友' }, + 'amiga': { en: 'friend (female)', zh: '女性朋友' }, + 'familia': { en: 'family', zh: '家人' }, + 'casa': { en: 'home', zh: '家' }, + 'trabajo': { en: 'work', zh: '工作' }, + 'escuela': { en: 'school', zh: '学校' }, + 'parque': { en: 'park', zh: '公园' }, + 'restaurante': { en: 'restaurant', zh: '餐厅' }, + 'café': { en: 'coffee / café', zh: '咖啡 / 咖啡馆' }, + 'libro': { en: 'book', zh: '书' }, + 'película': { en: 'movie', zh: '电影' }, + 'música': { en: 'music', zh: '音乐' }, + 'día': { en: 'day', zh: '天' }, + 'noche': { en: 'night', zh: '夜晚' }, + 'comida': { en: 'food', zh: '食物' }, + 'agua': { en: 'water', zh: '水' }, + 'tiempo': { en: 'time / weather', zh: '时间 / 天气' }, + 'feliz': { en: 'happy', zh: '开心的' }, + 'cansado': { en: 'tired', zh: '累的' }, + 'enfermo': { en: 'sick', zh: '生病的' }, + 'bien': { en: 'well / good', zh: '好' }, + 'mal': { en: 'bad', zh: '不好' }, + 'muy': { en: 'very', zh: '非常' }, + 'un poco': { en: 'a little', zh: '一点' }, + 'mucho': { en: 'a lot', zh: '很多' }, + 'y': { en: 'and', zh: '和' }, + 'pero': { en: 'but', zh: '但是' }, + 'porque': { en: 'because', zh: '因为' }, + 'también': { en: 'also', zh: '也' }, + 'no': { en: 'no / not', zh: '不 / 没有' }, + 'sí': { en: 'yes', zh: '是的' }, + 'gracias': { en: 'thank you', zh: '谢谢' }, + 'por favor': { en: 'please', zh: '请' }, + 'lo siento': { en: 'sorry', zh: '对不起' }, +}; + +function translateText(text) { + if (!text || !text.trim()) return { english: '', chinese: '' }; + const lower = text.toLowerCase().trim(); + + let enParts = []; + let zhParts = []; + let unknownParts = []; + const words = lower.split(/\s+/); + + for (const word of words) { + const cleaned = word.replace(/[^a-záéíóúüñ]/g, ''); + if (!cleaned) continue; + const entry = MOCK_TRANSLATIONS[cleaned]; + if (entry) { + enParts.push(entry.en); + zhParts.push(entry.zh); + } else { + enParts.push(cleaned); + zhParts.push(cleaned + '[?]'); + unknownParts.push(cleaned); + } + } + + return { + english: enParts.length > 0 ? enParts.join(' ') : text, + chinese: zhParts.length > 0 ? zhParts.join('') : text, + unknown: unknownParts.length > 0 ? unknownParts : [] + }; +} +``` + +Verify: Open browser console, call `translateText('hoy fui al parque')`, check result. + +--- + +### Task 5: Implement speech module (speech.js) + +**Files:** +- Create: `spanish-diary/speech.js` + +**Details:** Wrapper around `window.speechSynthesis`. `speakText(text, lang)` function. Language mapping: `es` → Spanish, `en` → English UK/US, `zh` → Chinese. + +- [ ] **Step 1: Write speech.js** + +```js +function speakText(text, lang) { + if (!text || !window.speechSynthesis) return; + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + + const langMap = { + 'es': 'es-ES', + 'en': 'en-US', + 'zh': 'zh-CN' + }; + utterance.lang = langMap[lang] || lang; + utterance.rate = 0.9; + utterance.pitch = 1; + + window.speechSynthesis.speak(utterance); +} +``` + +Verify: Open console, call `speakText('Hola', 'es')` — should pronounce Spanish. + +--- + +### Task 6: Implement application logic (app.js) + +**Files:** +- Create: `spanish-diary/app.js` + +**Details:** Coordinates everything. State: `entries` (all entries), `currentEntry` (selected/null). On load: open DB, load entries into list. Feature logic: new entry, select entry, save entry, delete entry, render translation, handle speech buttons. + +- [ ] **Step 1: Write app.js** + +```js +let state = { + entries: [], + currentId: null +}; + +function uuid() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +function todayStr() { + const d = new Date(); + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); +} + +// ----- Render functions ----- + +function renderList() { + const ul = document.getElementById('entry-list'); + ul.innerHTML = state.entries.map(e => { + const preview = e.spanish.slice(0, 30) + (e.spanish.length > 30 ? '...' : ''); + const active = e.id === state.currentId ? 'active' : ''; + return `
  • +
    ${e.date}
    +
    ${escHtml(preview)}
    +
    + +
    +
  • `; + }).join(''); +} + +function escHtml(s) { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} + +function renderTranslate(entry) { + document.getElementById('translate-en').textContent = entry ? entry.english : '-'; + document.getElementById('translate-cn').textContent = entry ? entry.chinese : '-'; +} + +function setEditor(entry) { + document.getElementById('entry-date').value = entry ? entry.date : todayStr(); + document.getElementById('entry-text').value = entry ? entry.spanish : ''; + const hint = document.getElementById('entry-hint'); + hint.textContent = entry && entry.spanish + ? '已保存。修改后点击保存更新。' + : '输入西语后点击保存,右侧自动显示翻译'; +} + +function clearEditor() { + state.currentId = null; + document.querySelectorAll('#entry-list li').forEach(el => el.classList.remove('active')); + setEditor(null); + renderTranslate(null); +} + +// ----- Event handlers ----- + +async function selectEntry(id) { + state.currentId = id; + const entry = await dbGetEntry(id); + if (entry) { + setEditor(entry); + renderTranslate(entry); + renderList(); + } +} + +async function saveCurrentEntry() { + const date = document.getElementById('entry-date').value; + const spanish = document.getElementById('entry-text').value.trim(); + if (!spanish) { alert('请输入西语内容'); return; } + + const translation = translateText(spanish); + + if (state.currentId) { + const existing = await dbGetEntry(state.currentId); + existing.date = date; + existing.spanish = spanish; + existing.english = translation.english; + existing.chinese = translation.chinese; + existing.updatedAt = new Date().toISOString(); + await dbUpdateEntry(existing); + } else { + const entry = { + id: uuid(), + date: date, + spanish: spanish, + english: translation.english, + chinese: translation.chinese, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + await dbAddEntry(entry); + state.currentId = entry.id; + } + + state.entries = await dbGetAllEntries(); + renderList(); + renderTranslate({ english: translation.english, chinese: translation.chinese }); + setEditor(state.entries.find(e => e.id === state.currentId)); + document.getElementById('btn-save').textContent = '💾 已保存 ✓'; + setTimeout(() => { document.getElementById('btn-save').textContent = '💾 保存'; }, 1500); +} + +async function deleteEntry(id) { + showDeleteModal(id); +} + +async function confirmDelete(id) { + await dbDeleteEntry(id); + if (state.currentId === id) clearEditor(); + state.entries = await dbGetAllEntries(); + renderList(); + hideDeleteModal(); +} + +function newEntry() { + clearEditor(); + document.getElementById('entry-date').value = todayStr(); + document.getElementById('entry-text').focus(); +} + +// ----- Modal ----- + +let pendingDeleteId = null; + +function showDeleteModal(id) { + pendingDeleteId = id; + document.getElementById('modal-overlay').classList.add('show'); +} + +function hideDeleteModal() { + pendingDeleteId = null; + document.getElementById('modal-overlay').classList.remove('show'); +} + +// ----- Speech handler ----- + +function handleSpeech(lang) { + const text = lang === 'en' + ? document.getElementById('translate-en').textContent + : document.getElementById('translate-cn').textContent; + if (text && text !== '-') speakText(text, lang); +} + +// ----- Initialization ----- + +async function init() { + state.entries = await dbGetAllEntries(); + renderList(); + setEditor(null); + renderTranslate(null); + + // Event delegation for list clicks + document.getElementById('entry-list').addEventListener('click', (e) => { + const li = e.target.closest('li[data-id]'); + if (!li) return; + if (e.target.closest('.btn-delete')) { + deleteEntry(li.dataset.id); + return; + } + selectEntry(li.dataset.id); + }); + + document.getElementById('btn-new').addEventListener('click', newEntry); + document.getElementById('btn-save').addEventListener('click', saveCurrentEntry); + + document.querySelectorAll('.btn-speech').forEach(btn => { + btn.addEventListener('click', () => handleSpeech(btn.dataset.lang)); + }); + + document.getElementById('modal-box').querySelector('.btn-cancel').addEventListener('click', hideDeleteModal); + document.getElementById('modal-box').querySelector('.btn-confirm').addEventListener('click', () => { + if (pendingDeleteId) confirmDelete(pendingDeleteId); + }); + + // Keyboard shortcut + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'Enter') saveCurrentEntry(); + }); +} + +document.addEventListener('DOMContentLoaded', init); +``` + +Also add the delete confirmation modal HTML to `index.html` before the ``: + +```html + +``` + +Verify: Full app workflow — new entry, save, switch entries, delete, speech. Test in Chrome/Edge. + +--- + +### Task 7: Self-review and fix + +- [ ] **Step 1: Spec coverage check** + + Go through each spec requirement and verify the plan covers it: + - CRUD: db.js handles all four, app.js wires create/save/delete — covered. + - Translation display: translate.js + app.js renderTranslate — covered. + - Pronunciation: speech.js + .btn-speech event handlers — covered. + - IndexedDB storage: db.js — covered. + - Delete confirmation: modal overlay in HTML + app.js modal logic — covered. + - New/Edit/Delete in list: renderList with .entry-actions — covered. + - Dark mode: CSS `prefers-color-scheme: dark` — covered. + - Responsive: CSS `@media (max-width: 768px)` grid collapse — covered. + +- [ ] **Step 2: Placeholder scan** + + Search: no TBD, TODO, or placeholder patterns found. All code blocks are complete. + +- [ ] **Step 3: Name/type consistency check** + + - `dbGetAllEntries` / `dbGetEntry` / `dbAddEntry` / `dbUpdateEntry` / `dbDeleteEntry` — used in app.js but defined in db.js as `getAllEntries` etc. Need to alias in app.js or rename. + - Fix: In app.js, the calls use `dbGetAllEntries()` etc. but db.js exports `getAllEntries()`. Need to add `const` aliases at top of app.js. + + **Fix**: This is a real bug the executor must handle. Add at top of app.js (before any other code): + + ```js + // Alias db.js functions + const dbGetAllEntries = getAllEntries; + const dbGetEntry = getEntry; + const dbAddEntry = addEntry; + const dbUpdateEntry = updateEntry; + const dbDeleteEntry = deleteEntry; + ``` + +--- + +### Task 8: Final verification + +- [ ] **Step 1: Open in browser and run full workflow** + + Open `spanish-diary/index.html` in Chrome/Edge. + + - [ ] New entry: Click "+" → date should be today → type Spanish → click Save → appears in left list → right panel shows translation + - [ ] Select entry: Click entry in list → loads in editor with translation + - [ ] Edit: Change text → Save → list preview updates, translation updates + - [ ] Delete: Hover entry → click delete icon → modal appears → confirm → entry removed + - [ ] Speech: Click 🔊 buttons on translations → hear pronunciation + - [ ] Dark mode: Toggle OS theme → UI adapts + - [ ] Responsive: Resize to mobile width → single column layout + - [ ] Persistence: Close and reopen → data intact + - [ ] Keyboard: Ctrl+Enter to save + +- [ ] **Step 2: Fix any discovered issues** + + If any workflow step fails, fix the bug, then re-test. No known issues expected. + +- [ ] **Step 3: Final confirmation** + + All spec requirements implemented. Ready for Phase 2 discussion. + +--- + +*End of Phase 1 implementation plan. Phase 2 (Python backend + SQLite + real translation API) to be designed separately.* diff --git a/西班牙语日记-设计文档.md b/西班牙语日记-设计文档.md new file mode 100644 index 0000000..6295219 --- /dev/null +++ b/西班牙语日记-设计文档.md @@ -0,0 +1,143 @@ +# 西班牙语日记本 — 设计文档 + +## 概述 + +一个日常使用的西班牙语学习工具。用户每天用西语写日记,系统自动显示英语和中文翻译,支持发音朗读。数据持久化存储,分两阶段交付。 + +## 项目阶段 + +| 阶段 | 存储 | 翻译 | 部署 | +|------|------|------|------| +| 阶段1 (纯前端) | IndexedDB | Mock 数据 | 浏览器直接打开 | +| 阶段2 (Python 后端) | SQLite | 翻译 API | Flask/FastAPI + 静态文件 | + +## 布局方案 + +选择 **方案B — 左右对照布局** + +### 三栏结构 + +``` ++-------------------+---------------------+----------------------+ +| 左侧面板 | 主编辑区 | 右侧面板 | +| 日记列表 | 西语输入 | 翻译区 | +| | | | +| 📅 2026-07-31 | 📝 Mi Diario | English | +| Hoy fui al... | (text area) | Today I went to... | +| 📅 2026-07-30 | | | +| Ayer aprendí... | [🔊 保存] | 中文 | +| 📅 2026-07-29 | | 今天我去了... | +| Estoy cansado... | | | +| | | [🔊 发音] | +| [+ 新建] | | | ++-------------------+---------------------+----------------------+ +``` + +### 左侧面板 — 日记列表 + +- 按日期倒序显示所有记录 +- 每条显示:日期 + 西语内容前 20 字预览 +- 当前选中的记录高亮 +- 顶部 "+" 按钮新建当日记录 +- 每条记录悬停时显示编辑/删除图标 +- 删除需要二次确认 + +### 主编辑区 — 西语输入 + +- 标题自动设为当天日期(格式:2026-07-31),可手动修改 +- 可编辑文本区用于输入西语日记 +- 底部保存按钮 +- 保存后自动更新左侧列表和右侧翻译 + +### 右侧面板 — 翻译区 + +- 只读展示,不可编辑 +- 上半部分:英文翻译 +- 下半部分:中文翻译 +- 每条翻译下方有 🔊 发音按钮(点击朗读对应语言的翻译文本) + +## 功能需求 + +### 阶段1 (纯前端) + +1. **CRUD 操作** + - 新建日记:自动填入当天日期,西语内容为空 + - 编辑:点击已有记录,在主编辑区修改后保存 + - 删除:悬停时出现删除按钮,点击后弹窗确认 + +2. **翻译功能** + - 阶段1 使用内置 mock 翻译数据(有限的预设短语库) + - 保存后立即在右侧显示英/中文对照翻译 + - 纯前端无法调用翻译 API,预设覆盖日常日记常用句式 + 如 "Hoy fui a..."、"Estoy..."、"Ayer..."、"Me gusta..." 等 + - 不在预设库中的内容显示原文 + 占位提示 + +3. **发音功能** + - 使用浏览器 Web Speech API (SpeechSynthesis) + - 支持西班牙语、英语、中文三种语言的文本朗读 + +4. **数据存储** + - 使用 IndexedDB + - 数据模型: + ```json + { + "id": "uuid", + "date": "2026-07-31", + "spanish": "Hoy fui al parque con mi amigo.", + "english": "Today I went to the park with my friend.", + "chinese": "今天我和朋友去了公园。", + "createdAt": "2026-07-31T10:00:00Z", + "updatedAt": "2026-07-31T10:00:00Z" + } + ``` + +### 阶段2 (Python 后端) + +1. **存储迁移**:IndexedDB → SQLite +2. **REST API**:Flask/FastAPI 提供 CRUD 接口 +3. **真实翻译**:接入 Google Translate API 或 DeepL API +4. **部署**:前端静态文件由后端服务 + +## 技术方案 + +### 阶段1 技术栈 + +- 纯 HTML + CSS + JavaScript +- IndexedDB 使用 idb 库或原生 API +- Web Speech API 用于发音 +- 无构建工具,无依赖 + +### 项目文件结构 (阶段1) + +``` +spanish-diary/ + index.html — 主页面 + style.css — 样式 + app.js — 应用逻辑 + db.js — IndexedDB 操作封装 + translate.js — 翻译模块 + speech.js — 发音模块 +``` + +## 交互细节 + +- 点击左侧列表中的记录 → 主编辑区加载该记录内容,右侧显示翻译 +- 修改主编辑区内容后点击保存 → 更新 IndexedDB,刷新左侧列表和右侧翻译 +- 点击 🔊 → 使用 Web Speech API 朗读文本(自动选择对应语言语音) +- 选择日期与已有记录相同 → 打开该记录而非新建 + +## 成功标准 + +- 打开 HTML 文件即可使用,无需任何安装 +- 新建、编辑、删除操作流畅无刷新 +- 翻译在保存后即时显示 +- 发音功能在主流浏览器(Chrome/Edge)正常工作 +- 页面关闭后重新打开,数据完整保留 + +## 后续阶段 + +- 阶段2 将 IndexedDB 数据迁移到 SQLite,通过 REST API 操作 +- 接入真实翻译 API +- 用户认证(可选) +- 多设备同步(可选) +