boot 失败卡片整卡移除:静默自愈轮询替代,白屏仅留一行恢复提示(方案 B)

This commit is contained in:
OpenClaw Agent
2026-09-11 16:56:13 +08:00
parent ef2febbadb
commit 6fca5055d2
+36 -137
View File
@@ -1098,18 +1098,10 @@
</div> </div>
<div id="boot-fail" hidden class="max-w-md mx-auto min-h-screen bg-slate-50 flex items-center justify-center p-8"> <!-- boot 自愈提示(方案 B):Vue 起不来时 v-cloak 白屏,中央只留一行小灰字,
<div class="card p-6 text-center w-full"> 不是卡片、无按钮;自愈成功或页面正常启动时整层移除。 -->
<p id="boot-title" class="text-sm font-bold text-slate-800">页面没能加载完成</p> <div id="boot-heal" style="display:none;position:fixed;inset:0;z-index:9999;background:#f8fafc;align-items:center;justify-content:center;">
<p id="boot-desc" class="text-xs text-slate-500 mt-2 leading-relaxed"> <p style="font-size:11px;color:#94a3b8;margin:0;">正在恢复连接…</p>
部分资源加载失败,界面暂时打不开。<br />
你的打卡数据存在这台设备本机,跟这次失败无关,一条都不会丢。<br />
一直失败的话,换个网络(关掉代理/热点)或停用浏览器插件再试。
</p>
<button id="boot-retry" type="button" class="mt-4 w-full py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md active:scale-95 transition disabled:opacity-40">重试加载</button>
<p id="boot-detail" class="text-[11px] text-slate-400 mt-3 leading-relaxed break-all"></p>
<button id="boot-copy" type="button" hidden style="margin-top:10px;font-size:11px;color:#94a3b8;background:none;border:none;text-decoration:underline;cursor:pointer;">复制诊断信息</button>
</div>
</div> </div>
<!-- 薪酬核算纯模块:与 Node 测试共用同一份。 <!-- 薪酬核算纯模块:与 Node 测试共用同一份。
@@ -1146,62 +1138,25 @@ const BOOT_DEPS = [
]; ];
const bootMissing = BOOT_DEPS.filter((d) => typeof globalThis[d.name] === 'undefined'); const bootMissing = BOOT_DEPS.filter((d) => typeof globalThis[d.name] === 'undefined');
if (bootMissing.length) { if (bootMissing.length) {
const card = document.getElementById('boot-fail'); const healLayer = document.getElementById('boot-heal');
const title = document.getElementById('boot-title');
const desc = document.getElementById('boot-desc');
const btn = document.getElementById('boot-retry');
const detail = document.getElementById('boot-detail');
const copyBtn = document.getElementById('boot-copy');
// 技术细节统一进这里:console.warn 留排查痕迹,bootDiag 供「复制诊断信息」整段带走。 // 技术细节全部走 console.warn,页面上永远只有一行「正在恢复连接…」。
// 卡片上不再出现 HTTP 状态码、字节数、重试次数这类内容——那是给开发者看的。
const bootDiag = [];
const bootLog = (msg) => { const bootLog = (msg) => {
bootDiag.push('[' + new Date().toISOString() + '] ' + msg); console.warn('[boot-guard] [' + new Date().toISOString() + '] ' + msg);
console.warn('[boot-guard] ' + msg);
}; };
document.getElementById('app').remove(); document.getElementById('app').remove();
healLayer.style.display = 'flex';
// 自动重试的次数上限必须跨 reload 记账。 // reload 记账:取回成功后必须 reload 才能把 Vue 跑起来,不记次数会闪屏循环。
// 原因:取回成功后只能靠 location.reload() 才能真正把 Vue 跑起来,而 reload 会让 // 每会话最多 2 次 reload;额度用尽后不再 reload,转纯后台轮询(成功后下一次
// 这段代码重新执行。若不记次数,一旦遇到「能取回但仍起不来」的情况就成了无限刷新循环 —— // 自然刷新/重开页面即恢复),避免服务端真坏时无限闪屏。
// 那比一张错误卡片糟糕得多,用户连「导出备份」都点不到,数据看得见摸不着。 const KEY = 'wht_boot_reload';
// const MAX_RELOAD = 2;
// 用 sessionStorage 而不是 localStorage:关掉标签页就归零。
// 上周的失败不该让今天这次连一次自动重试都不给。
const KEY = 'wht_boot_retry';
const MAX_AUTO = 2;
const spent = Number(sessionStorage.getItem(KEY) || 0); const spent = Number(sessionStorage.getItem(KEY) || 0);
const autoLeft = Math.max(0, MAX_AUTO - spent);
let round = 0;
bootLog('启动依赖缺失: ' + bootMissing.map((d) => d.name + ' (' + d.src + ', probe=' + d.probe + ')').join(', ')); bootLog('启动依赖缺失: ' + bootMissing.map((d) => d.name + ' (' + d.src + ', probe=' + d.probe + ')').join(', '));
// 卡片文案全部去技术化:不出现文件名、状态码、字节数、重试次数。
// 完整技术信息只走 console 与「复制诊断信息」出口。
const RETRY_TEXT = '正在恢复连接…';
const FAIL_DESC = '部分资源加载失败,界面暂时打不开。<br />'
+ '你的打卡数据存在这台设备本机,跟这次失败无关,一条都不会丢。<br />'
+ '一直失败的话,换个网络(关掉代理/热点)或停用浏览器插件再试。';
function showRetrying() {
title.textContent = '正在加载…';
desc.innerHTML = RETRY_TEXT + '<br />你的打卡数据存在本机,一条都不会丢。';
btn.hidden = true;
copyBtn.hidden = true;
}
function showFailure() {
title.textContent = '页面没能加载完成';
desc.innerHTML = FAIL_DESC;
btn.hidden = false;
btn.disabled = false;
btn.textContent = '再试一次';
detail.textContent = '';
copyBtn.hidden = false;
}
/** 把缺的依赖逐个取一遍并验完整性,成功返回总字节数。 */ /** 把缺的依赖逐个取一遍并验完整性,成功返回总字节数。 */
async function refetch() { async function refetch() {
let total = 0; let total = 0;
@@ -1219,91 +1174,35 @@ if (bootMissing.length) {
return total; return total;
} }
async function tryLoad(auto) { // 静默自愈:退避轮询 2s→4s→8s→…封顶 30s;取到完整文件后:
if (auto) { // - reload 额度未用完 → 记账并 reload,用户只感觉「闪了一下」
detail.textContent = RETRY_TEXT; // - 额度已用尽 → 不再 reload,继续留在自愈层;服务端修复后用户手动刷新即恢复
} else { let delay = 2000;
btn.disabled = true; async function healLoop() {
btn.textContent = RETRY_TEXT;
detail.textContent = '';
}
try { try {
const n = await refetch(); const n = await refetch();
// 只在确实要 reload 时记账:这个计数器要挡的就是刷新循环。 if (spent < MAX_RELOAD) {
sessionStorage.setItem(KEY, String(spent + 1)); sessionStorage.setItem(KEY, String(spent + 1));
bootLog('重取成功,共 ' + n + ' 字节,即将 reload'); bootLog('重取成功,共 ' + n + ' 字节,reload 恹复(第 ' + (spent + 1) + '/' + MAX_RELOAD + ' 次)');
detail.textContent = '已恢复,正在重新载入…'; location.reload();
location.reload(); } else {
} catch (e) { bootLog('重取成功但 reload 额度已用尽(' + MAX_RELOAD + ' 次),等待用户手动刷新');
const msg = (e && e.message) || String(e);
bootLog('重取失败: ' + msg);
if (auto && ++round < autoLeft) {
// 退避:链路刚断时立刻重试大概率还是断的,给它一点恢复时间。
detail.textContent = '连接不太稳定,稍后自动重试…';
setTimeout(() => tryLoad(true), 800 * round);
return;
} }
bootLog('最终失败:本轮共尝试 ' + (round + 1) + ' 次,转人工重试'); return;
showFailure(); } catch (e) {
bootLog('重取失败: ' + ((e && e.message) || String(e)) + ',' + delay + 'ms 后重试');
} }
setTimeout(healLoop, delay);
delay = Math.min(delay * 2, 30000);
} }
healLoop();
btn.addEventListener('click', () => tryLoad(false)); throw new Error('启动依赖缺失(' + bootMissing.map((d) => d.name).join(', ') + '),已切到静默自愈流程');
}
// 「复制诊断信息」:技术细节的唯一出口。平时隐藏,失败态才出现, // 启动依赖缺失时的静默自愈层:Vue 正常加载后立即移除,避免残留遮罩。
// 点一下把完整诊断(环境 + 全部 boot 日志)复制进剪贴板。 // 正常路径(Vue 已就位)这行不执行;自愈路径会 throw,也到不了这里。
function diagText() { if (document.getElementById('boot-heal')) {
return [ document.getElementById('boot-heal').remove();
'工时打卡 启动诊断',
'时间: ' + new Date().toISOString(),
'页面: ' + location.href,
'UA: ' + navigator.userAgent,
'自动重试: 已用 ' + spent + '/' + MAX_AUTO + ',本轮共尝试 ' + round + ' 次',
'缺失依赖: ' + bootMissing.map((d) => d.name + ' (' + d.src + ', probe=' + d.probe + ')').join(', '),
'---',
bootDiag.join('\n') || '(无更多日志)'
].join('\n');
}
function legacyCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
copyBtn.addEventListener('click', async () => {
const text = diagText();
let ok = false;
try {
if (navigator.clipboard) { await navigator.clipboard.writeText(text); ok = true; }
} catch (e) { ok = false; }
if (!ok) ok = legacyCopy(text);
if (ok) {
copyBtn.textContent = '✅ 已复制';
setTimeout(() => { copyBtn.textContent = '复制诊断信息'; }, 1500);
} else {
// 非 HTTPS 环境剪贴板 API 常不可用,兜底让用户手动复制。
window.prompt('自动复制不可用,请手动复制:', text);
}
});
if (autoLeft > 0) {
showRetrying();
card.hidden = false;
tryLoad(true);
} else {
// 自动重试额度已在本次会话用尽,说明不是偶发丢包,交给用户决定。
bootLog('自动重试额度已用尽(上限 ' + MAX_AUTO + ' 次),转人工重试');
showFailure();
card.hidden = false;
}
throw new Error('启动依赖缺失(' + bootMissing.map((d) => d.name).join(', ') + '),已切到 boot-fail 流程');
} }
const { createApp, ref, reactive, computed, watch, onMounted } = Vue; const { createApp, ref, reactive, computed, watch, onMounted } = Vue;