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')