diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index d0dbb556..21726329 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -1137,6 +1137,42 @@ test('the source section shows what is in effect, and saving posts the one addre assert.ok(h.toasts.some(t => t.type === 'success'), 'a saved source must be confirmed'); }); +test('a finished check must not leave the source form frozen (the button stays clickable)', async () => { + // 线上实测过的形态:打开页面 → 自动检查在忙期里重绘 → 检查结束后不再重绘, + // 「保存更新源」带着 disabled + data-state-disabled 留在 DOM 上——之后点击永远没有反应, + // 服务端连请求都收不到。这里把用户的整条动作链原样跑一遍。 + const h = await harness({ status: statusOf({ installed: true }), source: configuredSource }); + + h.fireDeferred(); // 打开页面 1.2 秒后的那次自动检查 + assert.match(h.html(), /update-source-save-btn" disabled/, + 'the form must visibly lock while a check is in flight'); + await h.flush(); + assert.match(h.html(), /已是最新|落后/, 'precondition: the automatic check finished'); + assert.doesNotMatch(h.html(), /update-source-save-btn" disabled/, + 'a finished check must re-render the form with the button enabled again'); + + // 用户的下一步:清空地址 → 点保存 → 请求必须真的发出去 + h.runHandler("updateSourceRepoChanged('')"); + await h.fire('saveUpdateSource'); + await h.flush(); + const save = h.calls.find(c => c.url === '/api/system/update/source'); + assert.ok(save, 'saving right after a check must reach the server'); + assert.deepEqual(JSON.parse(save.body), { repo: '' }); + assert.doesNotMatch(h.html(), /update-source-save-btn" disabled/, + 'after saving the button must be ready for the next action, not frozen'); +}); + +test('a second save is not swallowed by a stale busy flag', async () => { + const h = await harness({ status: statusOf({ installed: true }), source: configuredSource }); + h.runHandler("updateSourceRepoChanged('https://gitlab.com/mirror/csai.git')"); + await h.fire('saveUpdateSource'); + await h.flush(); + await h.fire('saveUpdateSource'); + await h.flush(); + const saves = h.calls.filter(c => c.url === '/api/system/update/source'); + assert.equal(saves.length, 2, 'the second save must also reach the server'); +}); + test('clearing the address is the way back to the official repository', async () => { const h = await harness({ status: statusOf({ installed: true }), source: configuredSource }); h.runHandler('updateSourceRepoChanged("")'); diff --git a/web/static/js/update.js b/web/static/js/update.js index f94687e0..cf6c1ca5 100644 --- a/web/static/js/update.js +++ b/web/static/js/update.js @@ -34,6 +34,7 @@ let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧 let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰 let updateSourceDraft = null; // 编辑中的更新源仓库地址;null=还贴着服务端保存的值 let updateSourceError = ''; // 最近一次保存/接入失败的原因,画在区块里 +let updateActionError = ''; // 最近一次动作(检查/更新/回滚/重启)失败的原因,画在控制台顶部 let updateAdoptPlan = null; // 预览回来的接入计划;确认后清掉 function updateT(key, opts) { @@ -172,25 +173,30 @@ function notify(message, type) { } } +// 动作期的重绘必须头尾对称。忙时要画"占用中"(按钮带 disabled),闲时要再画回来—— +// 这个页面的检查流程(包括打开页面后的自动检查)会在忙期里重绘,若结束时不再重绘, +// 那份 disabled 就留在 DOM 上把按钮永久冻死:实测打开页面自动检查一次后,「保存更新源」 +// 带 disabled + data-state-disabled 留在页面上,再点永远没有反应,连请求都发不出去。 +// 横幅改成状态(updateActionError)由 renderUpdateConsole 渲染,退出重绘就不会把它擦掉; +// 重启守侧接管画面时(updateRestartPending)一律不重绘,别把自恢复视图盖回控制台。 async function withUpdateBusy(fn, label) { if (updateBusy) return; updateBusy = true; + updateActionError = ''; + if (!updateRestartPending) renderUpdateConsole(); try { await fn(); } catch (err) { const reason = err && err.message ? err.message : String(err); - const text = updateT(UPDATE_FAILURE_KEYS[label] || 'loadFailed', { reason: reason }); - const el = document.getElementById('update-console'); - if (el && isUpdateConsoleActive()) { - // 先按当前状态重绘再挂横幅:请求炸在半路上时,画面还停在"正在检查远端…"这种 - // 已经不再成立的状态上,光加一条红字是盖不住它的。 - renderUpdateConsole(); - el.insertAdjacentHTML('afterbegin', '