docs: 添加西班牙语日记设计文档与实现计划
+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
|
||||
- 用户认证(可选)
|
||||
- 多设备同步(可选)
|
||||
|
||||
Reference in New Issue
Block a user