docs: 添加西班牙语日记设计文档与实现计划

2026-07-31 15:21:50 +08:00
parent 09ce70b8b3
commit 19f7a82d16
2 changed files with 964 additions and 0 deletions
+821
@@ -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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mi Diario — 西班牙语日记</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<aside id="panel-list">
<div class="panel-header">
<h2>📋 日记</h2>
<button id="btn-new" title="新建日记">+</button>
</div>
<ul id="entry-list"></ul>
</aside>
<main id="panel-editor">
<div class="panel-header">
<input type="date" id="entry-date">
<button id="btn-save">💾 保存</button>
</div>
<textarea id="entry-text" placeholder="用西班牙语写下今天的日记..." spellcheck="false"></textarea>
<div id="entry-hint" class="hint-text">输入西语后点击保存,右侧自动显示翻译</div>
</main>
<aside id="panel-translate">
<div class="panel-header">
<h2>🌐 翻译</h2>
</div>
<div class="translate-section">
<div class="translate-label">🇬🇧 English</div>
<div id="translate-en" class="translate-text">-</div>
<button class="btn-speech" data-lang="en">🔊 朗读</button>
</div>
<div class="translate-section">
<div class="translate-label">🇨🇳 中文</div>
<div id="translate-cn" class="translate-text">-</div>
<button class="btn-speech" data-lang="zh">🔊 朗读</button>
</div>
</aside>
</div>
<script src="db.js"></script>
<script src="translate.js"></script>
<script src="speech.js"></script>
<script src="app.js"></script>
</body>
</html>
```
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 `<li class="${active}" data-id="${e.id}">
<div class="entry-date">${e.date}</div>
<div class="entry-preview">${escHtml(preview)}</div>
<div class="entry-actions">
<button class="btn-delete" data-id="${e.id}">🗑 删除</button>
</div>
</li>`;
}).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 `</body>`:
```html
<div id="modal-overlay">
<div id="modal-box">
<p>确定删除这条日记吗?此操作不可撤销。</p>
<div class="modal-actions">
<button class="btn-cancel">取消</button>
<button class="btn-confirm">删除</button>
</div>
</div>
</div>
```
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.*
+143
@@ -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
- 用户认证(可选)
- 多设备同步(可选)