1
西班牙语日记 实现计划
guoqingshan edited this page 2026-07-31 15:21:50 +08:00

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
<!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
*, *::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
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
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
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
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>:

<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):

    // 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.