diff --git a/public/todo-public.html b/public/todo-public.html index 0777c6e..231e4ca 100644 --- a/public/todo-public.html +++ b/public/todo-public.html @@ -465,6 +465,30 @@ try { } @keyframes spin { to { transform: rotate(360deg); } } + /* ===== 操作反馈 loading ===== */ + /* 按钮内小号转圈 */ + .spinner.sm { + width: 14px; height: 14px; margin: 0 7px -2px 0; + border-width: 2px; display: inline-block; + border-color: currentColor; border-top-color: transparent; + } + .btn.busy { opacity: .78; } + /* 卡片勾选转圈:请求期间勾选框外套一圈 */ + .check.busy { position: relative; } + .check.busy::after { + content: ''; position: absolute; inset: -4px; + border: 2px solid var(--indigo); border-top-color: transparent; + border-radius: 50%; animation: spin .7s linear infinite; + } + /* 卡片删除中:半透明 + 右侧转圈 */ + .task.busy { opacity: .55; pointer-events: none; } + .task.busy::after { + content: ''; position: absolute; right: 16px; top: 50%; margin-top: -9px; + width: 18px; height: 18px; + border: 2px solid var(--indigo); border-top-color: transparent; + border-radius: 50%; animation: spin .7s linear infinite; + } + /* ===== 弹窗 ===== */ .mask { position: fixed; inset: 0; z-index: 50; background: rgba(15, 23, 42, .42); @@ -1121,6 +1145,21 @@ try { } /* ===== 请求 ===== */ + /* 按钮进入 loading 态:转圈 + 禁用,防重复提交;返回恢复函数。 + 已处于 loading 时返回 null,调用方据此拦截本次点击 */ + function btnLoading(btn, text) { + if (!btn || btn.disabled) return null + var oldHtml = btn.innerHTML + btn.disabled = true + btn.classList.add('busy') + btn.innerHTML = '' + esc(text || '处理中') + return function () { + btn.disabled = false + btn.classList.remove('busy') + btn.innerHTML = oldHtml + } + } + function post(path, data) { var body = new URLSearchParams() Object.keys(data || {}).forEach(function (k) { @@ -1277,6 +1316,9 @@ try { function addTodo() { var title = (elQuick.value || '').trim() if (!title) { toast('先写点什么吧'); elQuick.focus(); return } + // 添加按钮转圈,等添加请求与列表刷新完成后关闭动画 + var restore = btnLoading($('addBtn'), '添加中') + if (!restore) return var data = { title: title, priority: getSel('quickPriority'), @@ -1288,17 +1330,25 @@ try { setDateSel($('quickDue'), '') toast('已添加') return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '添加失败', true) }) + }).catch(function (e) { + toast(e.message || '添加失败', true) + }).then(function () { restore() }) } - function toggleStatus(id, cur) { + function toggleStatus(id, cur, el) { + if (el && el.classList.contains('busy')) return + if (el) el.classList.add('busy') post('/updateStatus', { id: id, status: cur === 1 ? 0 : 1 }).then(function (res) { if (!res || res.code !== 200) throw new Error((res && res.message) || '操作失败') return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '操作失败', true) }) + }).catch(function (e) { + toast(e.message || '操作失败', true) + }).then(function () { + if (el) el.classList.remove('busy') + }) } - function removeTodo(id, title) { + function removeTodo(id, title, cardEl) { confirmDialog({ title: '删除这条待办?', text: '删除后不再显示,且无法恢复。', @@ -1306,11 +1356,16 @@ try { okText: '删除' }).then(function (ok) { if (!ok) return + // 确认后卡片进入删除中状态,等请求响应后由列表刷新移除 + if (cardEl) cardEl.classList.add('busy') return post('/remove', { id: id }).then(function (res) { if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败') toast('已删除') return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '删除失败', true) }) + }).catch(function (e) { + if (cardEl) cardEl.classList.remove('busy') + toast(e.message || '删除失败', true) + }) }) } @@ -1376,6 +1431,9 @@ try { $('mSave').addEventListener('click', function () { var title = (mTitle.value || '').trim() if (!title) { toast('标题不能为空'); mTitle.focus(); return } + // 保存按钮转圈,等请求响应成功后关闭弹窗与动画;失败恢复按钮可重试 + var restore = btnLoading(this, '保存中') + if (!restore) return var remarkHtml = sanitizeHtml($('mRemark').innerHTML) if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = '' var data = { @@ -1398,12 +1456,18 @@ try { document.removeEventListener('keydown', onKey) toast(isNew ? '已添加' : '已保存') return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '保存失败', true) }) + }).catch(function (e) { + restore() + toast(e.message || '保存失败', true) + }) }) } /* ===== 导入导出 ===== */ function exportJson() { + // 导出按钮转圈,等数据拉取完成后再触发下载 + var restore = btnLoading($('exportBtn'), '导出中') + if (!restore) return post('/list', { sort: 'created' }).then(function (res) { var list = (res && res.data) || [] if (!list.length) { toast('没有可导出的数据'); return } @@ -1416,7 +1480,9 @@ try { document.body.removeChild(a) setTimeout(function () { URL.revokeObjectURL(a.href) }, 1000) toast('已导出 ' + list.length + ' 条') - }).catch(function (e) { toast(e.message || '导出失败', true) }) + }).catch(function (e) { + toast(e.message || '导出失败', true) + }).then(function () { restore() }) } function clearDone() { @@ -1428,11 +1494,16 @@ try { okText: '清除' }).then(function (ok) { if (!ok) return + // 确认后按钮转圈,等清理请求与列表刷新完成后关闭动画 + var restore = btnLoading($('clearDoneBtn'), '清理中') + if (!restore) return return post('/removeDone', {}).then(function (res) { if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败') toast('已清除 ' + n + ' 条') return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '清理失败', true) }) + }).catch(function (e) { + toast(e.message || '清理失败', true) + }).then(function () { restore() }) }) } @@ -1525,9 +1596,9 @@ try { var act = actEl.dataset.act var item = state.items.filter(function (i) { return String(i.id) === String(id) })[0] if (!item) return - if (act === 'toggle') toggleStatus(item.id, item.status) + if (act === 'toggle') toggleStatus(item.id, item.status, actEl) else if (act === 'edit') openEdit(item) - else if (act === 'del') removeTodo(item.id, item.title) + else if (act === 'del') removeTodo(item.id, item.title, taskEl) else if (act === 'peek') { if (e.target.closest('a')) return // 点展开内容里的链接时不折叠 actEl.classList.toggle('open')