/* ── Todo App ──────────────────────────────────────── */ class TodoApp { constructor() { this.todos = []; this.view = 'active'; // 'active' | 'completed' | 'abandoned' this.openMenuId = null; // 当前打开的菜单 ID this.addingChildFor = null; // 正在添加子任务的父任务 ID this.editingId = null; // 正在编辑的任务 ID this.draftKey = 'todo-draft'; // localStorage 主输入草稿 this.subDraftPrefix = 'todo-sub-draft-'; // 子任务草稿前缀 this.editDraftPrefix = 'todo-edit-draft-'; // 编辑草稿前缀 // 滑动状态 this.swipeState = { wrapper: null, startX: 0, startY: 0, deltaX: 0, locked: false }; this.openWrapper = null; // 当前左滑展开的 wrapper // 自动保存 this.autoSaveEnabled = false; this.autoSaveSec = 10; // 默认 10 秒 this.autoSaveTimer = null; this.cacheDom(); this.bindEvents(); this.restoreDraft(); this.init(); } async init() { await this.fetchSettings(); // 从服务端恢复自动保存状态 await this.fetchAndRender(); } /* ── DOM 缓存 ──────────────────────────────────── */ cacheDom() { this.$todoList = document.getElementById('todoList'); this.$emptyState = document.getElementById('emptyState'); this.$taskCount = document.getElementById('taskCount'); this.$createInput = document.getElementById('createInput'); this.$btnConfirmCreate = document.getElementById('btnConfirmCreate'); this.$autoSaveCheck = document.getElementById('autoSaveCheck'); this.$autoSaveSeconds = document.getElementById('autoSaveSeconds'); } /* ── 事件绑定 ──────────────────────────────────── */ bindEvents() { // 主任务创建 this.$btnConfirmCreate.addEventListener('click', () => this.createMainTodo()); this.$createInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.createMainTodo(); if (e.key === 'Escape') { this.$createInput.value = ''; this.saveDraft(); } }); this.$createInput.addEventListener('input', () => this.saveDraft()); // 事件委托 this.$todoList.addEventListener('click', (e) => { // 滑动背景按钮 const swipeAct = e.target.closest('.swipe-act'); if (swipeAct) { e.stopPropagation(); const wrapper = e.target.closest('.todo-row-wrapper'); if (wrapper) this.handleSwipeAction(swipeAct.dataset.swipeAction, wrapper.dataset.id); return; } const row = e.target.closest('.todo-row'); if (!row) return; const id = row.dataset.id; if (e.target.closest('.checkbox')) { this.toggleComplete(id, row); } else if (e.target.closest('.btn-menu')) { e.stopPropagation(); this.toggleMenu(id, row); } else if (e.target.closest('.btn-add-sub')) { e.stopPropagation(); this.showSubInput(id); } else if (e.target.closest('.btn-edit')) { e.stopPropagation(); this.startEdit(id, row); } }); this.$todoList.addEventListener('keydown', (e) => { if (e.target.classList.contains('inline-create-input')) { const wrap = e.target.closest('.inline-create'); const parentId = wrap.dataset.parentId; if (e.key === 'Enter') this.createSubTodo(parentId, e.target); if (e.key === 'Escape') this.hideSubInput(); } if (e.target.classList.contains('inline-edit-input')) { if (e.key === 'Escape') this.cancelEdit(); } }); // 双击标题 → 进入编辑 this.$todoList.addEventListener('dblclick', (e) => { const row = e.target.closest('.todo-row'); if (!row) return; // 排除 checkbox、按钮等 if (e.target.closest('.checkbox, .btn-icon, button')) return; const id = row.dataset.id; const todo = this.todos.find(t => t.id === id); if (todo && todo.status === 'active') this.startEdit(id, row); }); // 右键 → 打开"更多"菜单 this.$todoList.addEventListener('contextmenu', (e) => { const row = e.target.closest('.todo-row'); if (!row) return; e.preventDefault(); const id = row.dataset.id; this.toggleMenu(id, row); }); // 全局点击:关闭菜单 + 关闭滑动(排除菜单按钮和菜单本身) document.addEventListener('click', (e) => { if (!e.target.closest('.btn-menu') && !e.target.closest('.popup-menu')) { this.closeMenu(); } if (!e.target.closest('.todo-row-wrapper')) this.closeSwipe(); }); // 页面关闭前:自动保存未提交的新建任务(编辑不自动保存) window.addEventListener('beforeunload', () => this.autoSaveDrafts()); // 任何输入框键入时,若自动保存已开启则重置倒计时 document.addEventListener('input', (e) => { if (e.target.matches('.create-input, .inline-create-input, .inline-edit-input')) { this.scheduleAutoSave(); } }); // 触摸事件委托(滑动) this.$todoList.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false }); this.$todoList.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false }); this.$todoList.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: false }); // 底部筛选 document.querySelectorAll('.filter-tab').forEach(btn => { btn.addEventListener('click', () => this.switchView(btn.dataset.view)); }); // 自动保存开关 this.$autoSaveCheck.addEventListener('change', () => { this.autoSaveEnabled = this.$autoSaveCheck.checked; this.saveSetting('autoSaveEnabled', this.autoSaveEnabled); if (!this.autoSaveEnabled) { clearTimeout(this.autoSaveTimer); this.autoSaveTimer = null; } }); // 点击秒数切换间隔 this.$autoSaveSeconds.addEventListener('click', () => { const options = [5, 10, 30, 60]; const current = this.autoSaveSec; const idx = options.indexOf(current); const next = options[(idx + 1) % options.length]; this.autoSaveSec = next; this.$autoSaveSeconds.textContent = next + 's'; this.saveSetting('autoSaveSec', this.autoSaveSec); }); } /* ── 滑动触摸处理 ──────────────────────────────── */ onTouchStart(e) { const wrapper = e.target.closest('.todo-row-wrapper'); if (!wrapper) { this.closeSwipe(); return; } // 如果用户在编辑/输入状态,不处理滑动 if (e.target.closest('input, textarea')) return; if (e.target.closest('.popup-menu')) return; const touch = e.touches[0]; this.swipeState = { wrapper, startX: touch.clientX, startY: touch.clientY, deltaX: 0, locked: false, }; } onTouchMove(e) { const { wrapper, startX, startY, locked } = this.swipeState; if (!wrapper) return; const touch = e.touches[0]; const dx = touch.clientX - startX; const dy = touch.clientY - startY; if (!locked) { if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 8) { this.swipeState.locked = true; } else { return; // 垂直滚动,不处理 } } // 限制滑动范围:最多展开 200px const clamped = Math.max(-200, Math.min(200, dx)); this.swipeState.deltaX = clamped; const swipeEl = wrapper.querySelector('.todo-row-swipe'); if (swipeEl) { swipeEl.style.transition = 'none'; swipeEl.style.transform = `translateX(${clamped}px)`; } e.preventDefault(); } onTouchEnd(e) { const { wrapper, deltaX } = this.swipeState; this.swipeState = { wrapper: null, startX: 0, startY: 0, deltaX: 0, locked: false }; if (!wrapper) return; const swipeEl = wrapper.querySelector('.todo-row-swipe'); const SWIPE_THRESHOLD = 60; if (deltaX > SWIPE_THRESHOLD) { // 右滑:触发完成/恢复 const id = wrapper.dataset.id; const todo = this.todos.find(t => t.id === id); if (!todo) { this.snapSwipe(swipeEl, 0); return; } if (todo.status === 'active') { this.handleMenuAction('complete', id); } else { this.handleMenuAction('restore', id); } if (swipeEl) this.snapSwipe(swipeEl, 0); } else if (deltaX < -SWIPE_THRESHOLD) { // 左滑:展开操作按钮 this.closeSwipe(); const rightBg = wrapper.querySelector('.swipe-bg-right'); const btnWidth = rightBg ? rightBg.offsetWidth : 140; this.openWrapper = wrapper; wrapper.classList.add('swiped-open'); if (swipeEl) this.snapSwipe(swipeEl, -btnWidth); } else { // 滑动不足,回弹 this.snapSwipe(swipeEl, 0); } } snapSwipe(el, targetX) { if (!el) return; el.style.transition = 'transform 0.25s cubic-bezier(0.2, 0, 0, 1)'; el.style.transform = targetX === 0 ? '' : `translateX(${targetX}px)`; } closeSwipe() { if (this.openWrapper) { this.openWrapper.classList.remove('swiped-open'); const swipeEl = this.openWrapper.querySelector('.todo-row-swipe'); this.snapSwipe(swipeEl, 0); this.openWrapper = null; } } async handleSwipeAction(action, id) { this.closeSwipe(); switch (action) { case 'edit': { const wrapper = document.querySelector(`.todo-row-wrapper[data-id="${id}"]`); const row = wrapper?.querySelector('.todo-row'); if (row) this.startEdit(id, row); return; } case 'abandon': await this.handleMenuAction('abandon', id); break; case 'delete': await this.handleMenuAction('delete', id); break; } } /* ── API 调用 ──────────────────────────────────── */ async api(path, options = {}) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `请求失败 (${res.status})`); } return res.json(); } async fetchAndRender() { try { this.todos = await this.api('/api/todos'); this.render(); } catch (e) { console.error('加载数据失败:', e); } } /* ── 主任务创建 ────────────────────────────────── */ async createMainTodo() { const title = this.$createInput.value.trim(); if (!title) return; try { await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title }), }); this.$createInput.value = ''; this.saveDraft(); this.$createInput.focus(); await this.fetchAndRender(); } catch (e) { alert('创建失败: ' + e.message); } } /* ── 草稿保存 / 恢复 ──────────────────────────── */ saveDraft() { const val = this.$createInput.value; if (val) { localStorage.setItem(this.draftKey, val); } else { localStorage.removeItem(this.draftKey); } } restoreDraft() { const draft = localStorage.getItem(this.draftKey); if (draft) { this.$createInput.value = draft; } } /* 页面关闭时:自动落库未提交的新建任务(编辑不自动保存) */ autoSaveDrafts() { const beacon = (body) => { const blob = new Blob([JSON.stringify(body)], { type: 'application/json' }); navigator.sendBeacon('/api/todos', blob); }; // 主任务新建输入框 const mainTitle = this.$createInput.value.trim(); if (mainTitle) { beacon({ title: mainTitle }); } // 子/孙任务新建输入框 const subInput = document.querySelector('.inline-create-input'); if (subInput) { const subTitle = subInput.value.trim(); if (subTitle) { const parentId = subInput.closest('.inline-create')?.dataset.parentId; beacon({ title: subTitle, parentId: parentId || null }); } } // 编辑中的已有任务不做自动保存(草稿已在 localStorage 中) } /* ── 服务端设置存取 ────────────────────────────── */ async fetchSettings() { try { const s = await this.api('/api/settings'); this.autoSaveEnabled = s.autoSaveEnabled || false; this.autoSaveSec = s.autoSaveSec || 10; } catch { /* 使用默认值 */ } // 恢复 UI this.$autoSaveCheck.checked = this.autoSaveEnabled; this.$autoSaveSeconds.textContent = this.autoSaveSec + 's'; } async saveSetting(key, value) { try { await this.api('/api/settings', { method: 'PUT', body: JSON.stringify({ [key]: value }), }); } catch { /* 静默失败 */ } } /* 自动保存调度:任何输入框键入时重置倒计时,N 秒无操作后自动落库 */ scheduleAutoSave() { if (!this.autoSaveEnabled) return; clearTimeout(this.autoSaveTimer); this.autoSaveTimer = setTimeout(() => this.performAutoSave(), this.autoSaveSec * 1000); } async performAutoSave() { // 1. 主任务新建输入框 const mainTitle = this.$createInput.value.trim(); if (mainTitle) { try { await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title: mainTitle }) }); this.$createInput.value = ''; this.saveDraft(); await this.fetchAndRender(); this.$createInput.focus(); return; } catch { /* 静默失败 */ } } // 2. 子/孙任务新建输入框 const subInput = document.querySelector('.inline-create-input'); if (subInput) { const subTitle = subInput.value.trim(); if (subTitle) { const parentId = subInput.closest('.inline-create')?.dataset.parentId; try { await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title: subTitle, parentId: parentId || null }) }); await this.fetchAndRender(); return; } catch { /* 静默失败 */ } } } // 3. 行内编辑输入框(静默保存,不退出编辑状态) const editInput = document.querySelector('.inline-edit-input'); if (editInput && this.editingId) { const newTitle = editInput.value.trim(); const row = editInput.closest('.todo-row'); const original = row?.dataset.originalTitle; if (newTitle && newTitle !== original) { try { await this.api(`/api/todos/${this.editingId}`, { method: 'PATCH', body: JSON.stringify({ title: newTitle }), }); // 更新内存中的数据,避免重渲染 const todo = this.todos.find(t => t.id === this.editingId); if (todo) todo.title = newTitle; // 更新基准标题,后续自动保存只比较最新已保存版本 row.dataset.originalTitle = newTitle; localStorage.removeItem(this.editDraftPrefix + this.editingId); // 不退出编辑,不重渲染 — 用户手动点 ✓ 才退出 } catch { /* 静默失败 */ } } } } /* ── 行内编辑 ──────────────────────────────────── */ startEdit(id, row) { // 清理上一个编辑态(不重渲染,避免破坏当前 DOM 引用) if (this.editingId && this.editingId !== id) { const prevRow = document.querySelector(`.todo-row[data-id="${this.editingId}"]`); if (prevRow) { const prevTodo = this.todos.find(t => t.id === this.editingId); if (prevTodo) { const prevTitle = prevRow.querySelector('.todo-title'); if (prevTitle) prevTitle.textContent = prevTodo.title; } prevRow.querySelector('.btn-edit-confirm')?.remove(); } localStorage.removeItem(this.editDraftPrefix + this.editingId); this.editingId = null; } const todo = this.todos.find(t => t.id === id); if (!todo) return; this.editingId = id; const titleEl = row.querySelector('.todo-title'); const originalTitle = todo.title; const editDraftKey = this.editDraftPrefix + id; // 恢复草稿:有编辑草稿则用它,否则用原标题 const draft = localStorage.getItem(editDraftKey); const displayTitle = draft !== null ? draft : originalTitle; titleEl.innerHTML = `