/* ── 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 = `
`; const textarea = titleEl.querySelector('.inline-edit-input'); const confirmBtn = titleEl.querySelector('.btn-edit-confirm'); textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); // 保存编辑草稿 textarea.addEventListener('input', () => { const val = textarea.value; if (val && val !== originalTitle) { localStorage.setItem(editDraftKey, val); } else { localStorage.removeItem(editDraftKey); } }); const autoResize = () => { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; }; textarea.addEventListener('input', autoResize); autoResize(); confirmBtn.addEventListener('mousedown', (e) => { e.preventDefault(); this.commitEdit(textarea); }); row.dataset.originalTitle = originalTitle; } async commitEdit(inputEl) { const id = this.editingId; if (!id) return; const title = inputEl.value.trim(); this.editingId = null; localStorage.removeItem(this.editDraftPrefix + id); if (!title || title === inputEl.closest('.todo-row')?.dataset.originalTitle) { this.fetchAndRender(); return; } try { await this.api(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ title }), }); await this.fetchAndRender(); } catch (e) { alert('编辑失败: ' + e.message); this.fetchAndRender(); } } cancelEdit() { const id = this.editingId; if (id) localStorage.removeItem(this.editDraftPrefix + id); this.editingId = null; this.fetchAndRender(); } /* ── 子任务创建 ────────────────────────────────── */ showSubInput(parentId) { this.hideSubInput(); this.addingChildFor = parentId; const level = this.getLevel(parentId); if (level >= 2) return; const insertAfter = this.findLastDescendantRow(parentId); const row = document.createElement('li'); row.className = `inline-create level-${level + 1}`; row.dataset.parentId = parentId; const draftKey = this.subDraftPrefix + parentId; const draft = localStorage.getItem(draftKey) || ''; row.innerHTML = ` `; if (insertAfter) { insertAfter.after(row); } else { const parentEl = this.$todoList.querySelector(`.todo-row-wrapper[data-id="${parentId}"]`); if (parentEl) parentEl.after(row); else this.$todoList.appendChild(row); } const input = row.querySelector('.inline-create-input'); input.focus(); // 保存草稿 input.addEventListener('input', () => { const val = input.value; if (val) localStorage.setItem(draftKey, val); else localStorage.removeItem(draftKey); }); row.dataset.draftKey = draftKey; } hideSubInput() { const existing = this.$todoList.querySelector('.inline-create'); if (existing) { const dk = existing.dataset.draftKey; if (dk) localStorage.removeItem(dk); existing.remove(); } this.addingChildFor = null; } async createSubTodo(parentId, inputEl) { const title = inputEl.value.trim(); if (!title) return; try { await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title, parentId }), }); // 清除草稿 const dk = this.subDraftPrefix + parentId; localStorage.removeItem(dk); await this.fetchAndRender(); } catch (e) { alert('创建失败: ' + e.message); } } /* ── 切换完成状态 ──────────────────────────────── */ async toggleComplete(id, row) { const todo = this.todos.find(t => t.id === id); if (!todo) return; const newStatus = todo.status === 'completed' ? 'active' : 'completed'; try { await this.api(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }), }); await this.fetchAndRender(); } catch (e) { alert('操作失败: ' + e.message); } } /* ── 弹出菜单 ──────────────────────────────────── */ toggleMenu(id, row) { this.closeMenu(); if (this.openMenuId === id) { this.openMenuId = null; return; } this.openMenuId = id; const todo = this.todos.find(t => t.id === id); if (!todo) return; const level = this.getLevel(id); const canAddSub = level < 2 && todo.status === 'active'; const wrapper = row.closest('.todo-row-wrapper'); wrapper.querySelector('.popup-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'popup-menu show'; menu.innerHTML = this.buildMenuHtml(todo, canAddSub); wrapper.appendChild(menu); menu.addEventListener('click', (e) => { const action = e.target.closest('[data-action]')?.dataset.action; if (!action) return; this.handleMenuAction(action, id); }); } buildMenuHtml(todo, canAddSub) { const items = []; if (todo.status === 'active') { items.push(``); items.push(``); } else { items.push(``); } if (canAddSub) { items.push(``); items.push(``); } items.push(``); items.push(``); return items.join(''); } closeMenu() { document.querySelectorAll('.popup-menu').forEach(m => m.remove()); this.openMenuId = null; } async handleMenuAction(action, id) { this.closeMenu(); try { switch (action) { case 'complete': await this.api(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'completed' }), }); break; case 'abandon': await this.api(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'abandoned' }), }); break; case 'restore': await this.api(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'active' }), }); break; case 'addSub': this.showSubInput(id); return; case 'delete': if (!confirm('确定要删除该任务吗?子任务也会一并删除。')) return; await this.api(`/api/todos/${id}`, { method: 'DELETE' }); break; } await this.fetchAndRender(); } catch (e) { alert('操作失败: ' + e.message); } } /* ── 视图切换 ──────────────────────────────────── */ switchView(view) { this.view = view; this.hideSubInput(); this.closeMenu(); this.closeSwipe(); document.querySelectorAll('.filter-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); }); this.render(); } /* ── 渲染 ──────────────────────────────────────── */ render() { this.$todoList.innerHTML = ''; this.hideSubInput(); this.closeMenu(); this.closeSwipe(); // 找出根任务并按状态筛选 let roots = this.todos.filter(t => !t.parentId && t.status === this.view); // 已完成和已废弃按更新时间倒序 if (this.view !== 'active') { roots.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); } if (roots.length === 0) { this.$emptyState.classList.remove('hidden'); const labels = { active: '暂无进行中的任务', completed: '暂无已完成的任务', abandoned: '暂无已废弃的任务' }; this.$emptyState.querySelector('p').textContent = labels[this.view] || '暂无任务'; return; } this.$emptyState.classList.add('hidden'); for (const root of roots) { this.renderNode(root, 0, this.$todoList); } this.updateTaskCount(); } // 递归渲染节点及其所有子孙(不限状态,跟随父级归属) renderNode(todo, level, container) { const wrapper = this.createRowElement(todo, level); container.appendChild(wrapper); // 渲染所有子节点,不限状态 const children = this.todos.filter(t => t.parentId === todo.id); for (const child of children) { this.renderNode(child, level + 1, container); } } /* 创建行 DOM(含滑动包装层) */ createRowElement(todo, level, isHistory = false) { const wrapper = document.createElement('li'); wrapper.className = 'todo-row-wrapper'; wrapper.dataset.id = todo.id; const checked = todo.status === 'completed' ? 'checked' : ''; const disabled = isHistory ? 'disabled' : ''; const statusLabel = todo.status === 'completed' ? '已完成' : todo.status === 'abandoned' ? '已废弃' : ''; const badgeClass = todo.status === 'completed' ? 'completed' : 'abandoned'; const timeStr = this.formatTime(todo.createdAt); // 滑动右侧标签:active → 完成, 其他 → 恢复 const rightSwipeLabel = isHistory ? '恢复' : '完成'; // 右侧操作按钮 let bgRightActions; if (isHistory) { bgRightActions = ` 编辑 删除`; } else { bgRightActions = ` 编辑 废弃 删除`; } wrapper.innerHTML = `
✓ ${rightSwipeLabel}
${bgRightActions}
${this.escapeHtml(todo.title)} ${!isHistory ? ` ` : ''} ${statusLabel ? `${statusLabel}` : ''} ${timeStr}
`; return wrapper; } /* ── 工具方法 ──────────────────────────────────── */ getLevel(id) { let depth = 0; let current = this.todos.find(t => t.id === id); while (current && current.parentId) { depth++; current = this.todos.find(t => t.id === current.parentId); } return depth; } findLastDescendantRow(parentId) { const descendantIds = this.collectDescendantIds(parentId); for (let i = descendantIds.length - 1; i >= 0; i--) { const el = this.$todoList.querySelector(`.todo-row-wrapper[data-id="${descendantIds[i]}"]`); if (el) return el; } return null; } collectDescendantIds(parentId) { const ids = []; const children = this.todos.filter(t => t.parentId === parentId); for (const child of children) { ids.push(child.id); ids.push(...this.collectDescendantIds(child.id)); } return ids; } formatTime(isoStr) { const d = new Date(isoStr); const now = new Date(); const diff = now - d; if (diff < 60_000) return '刚刚'; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`; if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } escapeHtml(str) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return str.replace(/[&<>"']/g, c => map[c]); } updateTaskCount() { const count = this.todos.filter(t => t.status === 'active').length; this.$taskCount.textContent = count ? `${count} 个进行中` : ''; } } /* ── 启动 ─────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { new TodoApp(); });