2026-06-08 18:01:22 +08:00

922 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ── 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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();
});