优化(公开看板): 添加/保存/勾选/删除/导出/清理全部加loading,防重复提交
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 18s

This commit is contained in:
张家训
2026-10-23 10:58:12 +08:00
parent 10660209f2
commit 8a45a5dfff
+81 -10
View File
@@ -465,6 +465,30 @@ try {
} }
@keyframes spin { to { transform: rotate(360deg); } } @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 { .mask {
position: fixed; inset: 0; z-index: 50; background: rgba(15, 23, 42, .42); 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 = '<span class="spinner sm"></span>' + esc(text || '处理中')
return function () {
btn.disabled = false
btn.classList.remove('busy')
btn.innerHTML = oldHtml
}
}
function post(path, data) { function post(path, data) {
var body = new URLSearchParams() var body = new URLSearchParams()
Object.keys(data || {}).forEach(function (k) { Object.keys(data || {}).forEach(function (k) {
@@ -1277,6 +1316,9 @@ try {
function addTodo() { function addTodo() {
var title = (elQuick.value || '').trim() var title = (elQuick.value || '').trim()
if (!title) { toast('先写点什么吧'); elQuick.focus(); return } if (!title) { toast('先写点什么吧'); elQuick.focus(); return }
// 添加按钮转圈,等添加请求与列表刷新完成后关闭动画
var restore = btnLoading($('addBtn'), '添加中')
if (!restore) return
var data = { var data = {
title: title, title: title,
priority: getSel('quickPriority'), priority: getSel('quickPriority'),
@@ -1288,17 +1330,25 @@ try {
setDateSel($('quickDue'), '') setDateSel($('quickDue'), '')
toast('已添加') toast('已添加')
return fetchList(true).then(fetchStats) 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) { post('/updateStatus', { id: id, status: cur === 1 ? 0 : 1 }).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '操作失败') if (!res || res.code !== 200) throw new Error((res && res.message) || '操作失败')
return fetchList(true).then(fetchStats) 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({ confirmDialog({
title: '删除这条待办?', title: '删除这条待办?',
text: '删除后不再显示,且无法恢复。', text: '删除后不再显示,且无法恢复。',
@@ -1306,11 +1356,16 @@ try {
okText: '删除' okText: '删除'
}).then(function (ok) { }).then(function (ok) {
if (!ok) return if (!ok) return
// 确认后卡片进入删除中状态,等请求响应后由列表刷新移除
if (cardEl) cardEl.classList.add('busy')
return post('/remove', { id: id }).then(function (res) { return post('/remove', { id: id }).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败') if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败')
toast('已删除') toast('已删除')
return fetchList(true).then(fetchStats) 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 () { $('mSave').addEventListener('click', function () {
var title = (mTitle.value || '').trim() var title = (mTitle.value || '').trim()
if (!title) { toast('标题不能为空'); mTitle.focus(); return } if (!title) { toast('标题不能为空'); mTitle.focus(); return }
// 保存按钮转圈,等请求响应成功后关闭弹窗与动画;失败恢复按钮可重试
var restore = btnLoading(this, '保存中')
if (!restore) return
var remarkHtml = sanitizeHtml($('mRemark').innerHTML) var remarkHtml = sanitizeHtml($('mRemark').innerHTML)
if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = '' if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = ''
var data = { var data = {
@@ -1398,12 +1456,18 @@ try {
document.removeEventListener('keydown', onKey) document.removeEventListener('keydown', onKey)
toast(isNew ? '已添加' : '已保存') toast(isNew ? '已添加' : '已保存')
return fetchList(true).then(fetchStats) return fetchList(true).then(fetchStats)
}).catch(function (e) { toast(e.message || '保存失败', true) }) }).catch(function (e) {
restore()
toast(e.message || '保存失败', true)
})
}) })
} }
/* ===== 导入导出 ===== */ /* ===== 导入导出 ===== */
function exportJson() { function exportJson() {
// 导出按钮转圈,等数据拉取完成后再触发下载
var restore = btnLoading($('exportBtn'), '导出中')
if (!restore) return
post('/list', { sort: 'created' }).then(function (res) { post('/list', { sort: 'created' }).then(function (res) {
var list = (res && res.data) || [] var list = (res && res.data) || []
if (!list.length) { toast('没有可导出的数据'); return } if (!list.length) { toast('没有可导出的数据'); return }
@@ -1416,7 +1480,9 @@ try {
document.body.removeChild(a) document.body.removeChild(a)
setTimeout(function () { URL.revokeObjectURL(a.href) }, 1000) setTimeout(function () { URL.revokeObjectURL(a.href) }, 1000)
toast('已导出 ' + list.length + ' 条') toast('已导出 ' + list.length + ' 条')
}).catch(function (e) { toast(e.message || '导出失败', true) }) }).catch(function (e) {
toast(e.message || '导出失败', true)
}).then(function () { restore() })
} }
function clearDone() { function clearDone() {
@@ -1428,11 +1494,16 @@ try {
okText: '清除' okText: '清除'
}).then(function (ok) { }).then(function (ok) {
if (!ok) return if (!ok) return
// 确认后按钮转圈,等清理请求与列表刷新完成后关闭动画
var restore = btnLoading($('clearDoneBtn'), '清理中')
if (!restore) return
return post('/removeDone', {}).then(function (res) { return post('/removeDone', {}).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败') if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败')
toast('已清除 ' + n + ' 条') toast('已清除 ' + n + ' 条')
return fetchList(true).then(fetchStats) 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 act = actEl.dataset.act
var item = state.items.filter(function (i) { return String(i.id) === String(id) })[0] var item = state.items.filter(function (i) { return String(i.id) === String(id) })[0]
if (!item) return 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 === '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') { else if (act === 'peek') {
if (e.target.closest('a')) return // 点展开内容里的链接时不折叠 if (e.target.closest('a')) return // 点展开内容里的链接时不折叠
actEl.classList.toggle('open') actEl.classList.toggle('open')