boot 失败卡片整卡移除:静默自愈轮询替代,白屏仅留一行恢复提示(方案 B)
This commit is contained in:
+34
-135
@@ -1098,18 +1098,10 @@
|
||||
|
||||
</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">
|
||||
<div class="card p-6 text-center w-full">
|
||||
<p id="boot-title" class="text-sm font-bold text-slate-800">页面没能加载完成</p>
|
||||
<p id="boot-desc" class="text-xs text-slate-500 mt-2 leading-relaxed">
|
||||
部分资源加载失败,界面暂时打不开。<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>
|
||||
<!-- boot 自愈提示(方案 B):Vue 起不来时 v-cloak 白屏,中央只留一行小灰字,
|
||||
不是卡片、无按钮;自愈成功或页面正常启动时整层移除。 -->
|
||||
<div id="boot-heal" style="display:none;position:fixed;inset:0;z-index:9999;background:#f8fafc;align-items:center;justify-content:center;">
|
||||
<p style="font-size:11px;color:#94a3b8;margin:0;">正在恢复连接…</p>
|
||||
</div>
|
||||
|
||||
<!-- 薪酬核算纯模块:与 Node 测试共用同一份。
|
||||
@@ -1146,62 +1138,25 @@ const BOOT_DEPS = [
|
||||
];
|
||||
const bootMissing = BOOT_DEPS.filter((d) => typeof globalThis[d.name] === 'undefined');
|
||||
if (bootMissing.length) {
|
||||
const card = document.getElementById('boot-fail');
|
||||
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');
|
||||
const healLayer = document.getElementById('boot-heal');
|
||||
|
||||
// 技术细节统一进这里:console.warn 留排查痕迹,bootDiag 供「复制诊断信息」整段带走。
|
||||
// 卡片上不再出现 HTTP 状态码、字节数、重试次数这类内容——那是给开发者看的。
|
||||
const bootDiag = [];
|
||||
// 技术细节全部走 console.warn,页面上永远只有一行「正在恢复连接…」。
|
||||
const bootLog = (msg) => {
|
||||
bootDiag.push('[' + new Date().toISOString() + '] ' + msg);
|
||||
console.warn('[boot-guard] ' + msg);
|
||||
console.warn('[boot-guard] [' + new Date().toISOString() + '] ' + msg);
|
||||
};
|
||||
|
||||
document.getElementById('app').remove();
|
||||
healLayer.style.display = 'flex';
|
||||
|
||||
// 自动重试的次数上限必须跨 reload 记账。
|
||||
// 原因:取回成功后只能靠 location.reload() 才能真正把 Vue 跑起来,而 reload 会让
|
||||
// 这段代码重新执行。若不记次数,一旦遇到「能取回但仍起不来」的情况就成了无限刷新循环 ——
|
||||
// 那比一张错误卡片糟糕得多,用户连「导出备份」都点不到,数据看得见摸不着。
|
||||
//
|
||||
// 用 sessionStorage 而不是 localStorage:关掉标签页就归零。
|
||||
// 上周的失败不该让今天这次连一次自动重试都不给。
|
||||
const KEY = 'wht_boot_retry';
|
||||
const MAX_AUTO = 2;
|
||||
// reload 记账:取回成功后必须 reload 才能把 Vue 跑起来,不记次数会闪屏循环。
|
||||
// 每会话最多 2 次 reload;额度用尽后不再 reload,转纯后台轮询(成功后下一次
|
||||
// 自然刷新/重开页面即恢复),避免服务端真坏时无限闪屏。
|
||||
const KEY = 'wht_boot_reload';
|
||||
const MAX_RELOAD = 2;
|
||||
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(', '));
|
||||
|
||||
// 卡片文案全部去技术化:不出现文件名、状态码、字节数、重试次数。
|
||||
// 完整技术信息只走 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() {
|
||||
let total = 0;
|
||||
@@ -1219,91 +1174,35 @@ if (bootMissing.length) {
|
||||
return total;
|
||||
}
|
||||
|
||||
async function tryLoad(auto) {
|
||||
if (auto) {
|
||||
detail.textContent = RETRY_TEXT;
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.textContent = RETRY_TEXT;
|
||||
detail.textContent = '';
|
||||
}
|
||||
// 静默自愈:退避轮询 2s→4s→8s→…封顶 30s;取到完整文件后:
|
||||
// - reload 额度未用完 → 记账并 reload,用户只感觉「闪了一下」
|
||||
// - 额度已用尽 → 不再 reload,继续留在自愈层;服务端修复后用户手动刷新即恢复
|
||||
let delay = 2000;
|
||||
async function healLoop() {
|
||||
try {
|
||||
const n = await refetch();
|
||||
// 只在确实要 reload 时记账:这个计数器要挡的就是刷新循环。
|
||||
if (spent < MAX_RELOAD) {
|
||||
sessionStorage.setItem(KEY, String(spent + 1));
|
||||
bootLog('重取成功,共 ' + n + ' 字节,即将 reload');
|
||||
detail.textContent = '已恢复,正在重新载入…';
|
||||
bootLog('重取成功,共 ' + n + ' 字节,reload 恹复(第 ' + (spent + 1) + '/' + MAX_RELOAD + ' 次)');
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || String(e);
|
||||
bootLog('重取失败: ' + msg);
|
||||
if (auto && ++round < autoLeft) {
|
||||
// 退避:链路刚断时立刻重试大概率还是断的,给它一点恢复时间。
|
||||
detail.textContent = '连接不太稳定,稍后自动重试…';
|
||||
setTimeout(() => tryLoad(true), 800 * round);
|
||||
} else {
|
||||
bootLog('重取成功但 reload 额度已用尽(' + MAX_RELOAD + ' 次),等待用户手动刷新');
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
bootLog('重取失败: ' + ((e && e.message) || String(e)) + ',' + delay + 'ms 后重试');
|
||||
}
|
||||
bootLog('最终失败:本轮共尝试 ' + (round + 1) + ' 次,转人工重试');
|
||||
showFailure();
|
||||
}
|
||||
setTimeout(healLoop, delay);
|
||||
delay = Math.min(delay * 2, 30000);
|
||||
}
|
||||
healLoop();
|
||||
|
||||
btn.addEventListener('click', () => tryLoad(false));
|
||||
|
||||
// 「复制诊断信息」:技术细节的唯一出口。平时隐藏,失败态才出现,
|
||||
// 点一下把完整诊断(环境 + 全部 boot 日志)复制进剪贴板。
|
||||
function diagText() {
|
||||
return [
|
||||
'工时打卡 启动诊断',
|
||||
'时间: ' + 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 流程');
|
||||
throw new Error('启动依赖缺失(' + bootMissing.map((d) => d.name).join(', ') + '),已切到静默自愈流程');
|
||||
}
|
||||
// 启动依赖缺失时的静默自愈层:Vue 正常加载后立即移除,避免残留遮罩。
|
||||
// 正常路径(Vue 已就位)这行不执行;自愈路径会 throw,也到不了这里。
|
||||
if (document.getElementById('boot-heal')) {
|
||||
document.getElementById('boot-heal').remove();
|
||||
}
|
||||
const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user