922 lines
32 KiB
JavaScript
922 lines
32 KiB
JavaScript
/* ── 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 = `
|
||
<div class="inline-edit-wrap">
|
||
<textarea class="inline-edit-input" rows="1"
|
||
maxlength="200">${this.escapeHtml(displayTitle)}</textarea>
|
||
<button class="btn-icon btn-confirm btn-edit-confirm" title="确认">
|
||
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
|
||
<path d="M3 9l4 4 8-8" stroke="currentColor" stroke-width="2"
|
||
stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>`;
|
||
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 = `
|
||
<input type="text" class="inline-create-input"
|
||
placeholder="${level === 0 ? '添加子任务…' : '添加孙任务…'}"
|
||
maxlength="200" value="${this.escapeHtml(draft)}">
|
||
<button class="btn-icon btn-confirm">
|
||
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
|
||
<path d="M3 9l4 4 8-8" stroke="currentColor" stroke-width="2"
|
||
stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
`;
|
||
|
||
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(`<button class="popup-menu-item" data-action="complete">
|
||
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M2 7l3 3 6-6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
标记完成
|
||
</button>`);
|
||
items.push(`<button class="popup-menu-item" data-action="abandon">
|
||
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M5 5l4 4M9 5l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||
标记废弃
|
||
</button>`);
|
||
} else {
|
||
items.push(`<button class="popup-menu-item" data-action="restore">
|
||
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 8l3 3 5-6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
恢复
|
||
</button>`);
|
||
}
|
||
if (canAddSub) {
|
||
items.push(`<div class="popup-divider"></div>`);
|
||
items.push(`<button class="popup-menu-item" data-action="addSub">
|
||
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M7 2v10M2 7h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||
添加子任务
|
||
</button>`);
|
||
}
|
||
items.push(`<div class="popup-divider"></div>`);
|
||
items.push(`<button class="popup-menu-item danger" data-action="delete">
|
||
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 4h8M5 4V3a1 1 0 011-1h2a1 1 0 011 1v1M6 6v4M8 6v4" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linecap="round"/><path d="M4 4l.8 7.2a1 1 0 001 .8h2.4a1 1 0 001-.8L10 4" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
|
||
删除
|
||
</button>`);
|
||
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 = `
|
||
<span class="swipe-act swipe-act-edit" data-swipe-action="edit">编辑</span>
|
||
<span class="swipe-act swipe-act-delete" data-swipe-action="delete">删除</span>`;
|
||
} else {
|
||
bgRightActions = `
|
||
<span class="swipe-act swipe-act-edit" data-swipe-action="edit">编辑</span>
|
||
<span class="swipe-act swipe-act-abandon" data-swipe-action="abandon">废弃</span>
|
||
<span class="swipe-act swipe-act-delete" data-swipe-action="delete">删除</span>`;
|
||
}
|
||
|
||
wrapper.innerHTML = `
|
||
<div class="swipe-bg">
|
||
<div class="swipe-bg-left">
|
||
<span>✓ ${rightSwipeLabel}</span>
|
||
</div>
|
||
<div class="swipe-bg-right">${bgRightActions}</div>
|
||
</div>
|
||
<div class="todo-row-swipe">
|
||
<div class="todo-row ${todo.status} level-${Math.min(level, 2)}" data-id="${todo.id}">
|
||
<input type="checkbox" class="checkbox" ${checked} ${disabled}>
|
||
<span class="todo-title">${this.escapeHtml(todo.title)}</span>
|
||
${!isHistory ? `
|
||
<button class="btn-icon btn-add-sub" title="添加子任务">
|
||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||
<path d="M7 2v10M2 7h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||
</svg>
|
||
</button>
|
||
<button class="btn-icon btn-edit" title="编辑">
|
||
<svg width="13" height="13" viewBox="0 0 13 13">
|
||
<path d="M9.5 1.5l2 2-7.5 7.5L1 11.5l.5-2.5z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
<button class="btn-icon btn-menu" title="更多操作">
|
||
<svg width="15" height="15" viewBox="0 0 15 15">
|
||
<circle cx="3.5" cy="7.5" r="1.2" fill="currentColor"/>
|
||
<circle cx="7.5" cy="7.5" r="1.2" fill="currentColor"/>
|
||
<circle cx="11.5" cy="7.5" r="1.2" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
${statusLabel ? `<span class="status-badge ${badgeClass}">${statusLabel}</span>` : ''}
|
||
<span class="todo-time">${timeStr}</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
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();
|
||
});
|