Files
admin a06885295c feat(品牌标记): 统一换用「AI 芯片」标记,浮层改抠底发光版
- 新增 web/mark-glow-192.png(抠底透明版,浮层专用)及 favicon.ico/.png/-180/-192
- portal/login/admin/register 四页 head 由 /favicon.svg 改为 ico+png+180 三行
- portal.html 顶栏品牌位 /favicon.svg -> /favicon-192.png
- 浮层 assets/inject/recovery.js:三辐条 hub 改为抠底标记(光环 + 虚线环,
  青色 #38d6d0 / 紫 #7c58ff),标记 96->132px;发光由 box-shadow 改为
  filter:drop-shadow(box-shadow 沿元素矩形绘制,会在抠底图外画出一圈方形光晕)
- 顺带清掉旧品牌蓝 #4d7cfe / rgba(77,124,254) -> #7c58ff

标记真源 = 用户 2026-09-20 提供的六边形芯片图标。旧三辐条 favicon.svg 至此无引用。
2026-09-21 17:22:03 +08:00

602 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─────────────────────────────────────────────────────────────────────────────
* 状态机(单一来源,档案 81 · R1-②)—— 本文件是「回到页面」链路**唯一**的策略实现。
*
* 事件(触发面) 判定 动作
* ─────────────────────────────────────────────────────────────────────────────
* ① 切回页面 / 首次进入 探针(平台 /api/dsh/status + 实例 GET /) 坏 → 恢复;好 → 撤提示条
* (visibilitychange / pageshow) 提示条「正在检查工作区连接…」
* ② 页面自己发出的请求 401 传输层已透明重放(档案 51);仍失败 → 恢复
* 或 404 not_running
* ③ 心跳(**页面可见**时每 25 s) soft 模式:**连续两次**失败才动作 恢复(滞后 ≤ 50 s)
* ④ EventSource / WebSocket 断开 soft 模式:同上 恢复(比心跳更早)
* ⑤ 有界保护 窗口 10 min 内恢复 > 3 次 停手:明确失败态 + 手动「重试」
* ─────────────────────────────────────────────────────────────────────────────
* 恢复动作:覆盖层「工作区正在恢复…」→ POST /api/dsh/enter → 拿到新 token 的直达地址
* → **原地 replace(保留 path/hash)**;拿不到 url → 原地 reload。
* 冷却/告警:平台侧 orchestrator 负责崩溃熔断与告警(档案 78);本文件只管"页面这一侧"。
* ⚠️ 改这个文件**不需要**再动 proxy.ts(档案 81 R1-① 已把脚本外置),但改完必须跑
* `npm run verify`(会做 node --check + 内联回退检查)。
* ──────────────────────────────────────────────────────────────────────────── */
(function () {
if (window.__dshRecover) return;
window.__dshRecover = 1;
// 一层覆盖层,两种用途:
// pending —— 请求长时间未返回(服务端正在拉起实例)→ 显示"正在启动…",请求结束后撤掉
// expired —— 401(实例被回收重建,launch token 已轮换)→ 显示后整页 reload
// expired 是终态,不可被 pending 覆盖、也不主动撤除(随后就 reload 了)。
var state = 'none';
var box = null;
var ticker = null;
function ensureCss() {
if (document.getElementById('__dshRecoverCss')) return;
var st = document.createElement('style');
st.id = '__dshRecoverCss';
// 档案 77 视觉升级:整套样式一次性注入(含 prefers-reduced-motion 回退)。
// 2026-09-21 二次改造:浮层标记换成**用户提供的标记本体**(六边形芯片 + 节点网络 + 青紫辉光)。
// 上一版是按 web/favicon.svg 复刻的三辐条 hub —— 那是错源:该 svg 已不是站点图标,
// 页面 head 早已改引 /favicon.png。现在浮层直接引用**与站点图标同一份**静态资源
// (/favicon-192.png),从根上消除"浮层图标 ≠ 站点图标"。
// 动画职责改由标记外围承担:虚线环缓转 + 青紫光晕呼吸 + 标记脉冲;
// 进入 = 缩放淡入;失败 = .__dsh-fail(标记转灰 + 环/晕转 --danger #e5484d)。
// ⛔ 不再出现旧 DeepSeek 品牌蓝 #4d7cfe(紫色统一用新标记的 #7c58ff)。
// ⚠️ 本文件是注入脚本的模板字面量 —— 内容不得含反引号 / 美元花括号;
// 故 CSS 里的字体名一律用单引号('Segoe UI'),JS 字符串用双引号。
st.textContent = [
"@keyframes __dshr-in{0%{transform:scale(.8)}60%{transform:scale(1.05)}100%{transform:scale(1)}}",
"@keyframes __dshr-spin{to{transform:rotate(360deg)}}",
"@keyframes __dshr-halo{0%,100%{opacity:.42;transform:scale(.94)}50%{opacity:.85;transform:scale(1.08)}}",
"@keyframes __dshr-scan{0%{transform:translateX(-115%)}100%{transform:translateX(275%)}}",
"@keyframes __dshr-sheen{0%{background-position:190% 0}100%{background-position:-90% 0}}",
".__dsh-ov{",
"position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;",
"background:radial-gradient(880px 460px at 50% 26%, rgba(56,214,208,.12), transparent 62%),radial-gradient(720px 420px at 50% 86%, rgba(124,88,255,.12), transparent 64%),rgba(8,15,28,.92);",
"}",
".__dsh-card{",
"position:relative;display:flex;flex-direction:column;align-items:center;gap:13px;",
"padding:26px 34px 22px;border-radius:16px;",
"background:linear-gradient(180deg, rgba(18,31,52,.88), rgba(10,18,33,.92));",
"box-shadow:0 1px 0 rgba(255,255,255,.05) inset,0 0 0 1px rgba(56,214,208,.20),0 18px 48px rgba(0,0,0,.55);",
"-webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px);",
"}",
".__dsh-hub{position:relative;width:132px;height:132px;display:flex;align-items:center;justify-content:center}",
".__dsh-hub-halo{position:absolute;inset:-20px;border-radius:50%;background:radial-gradient(circle, rgba(56,214,208,.30), rgba(124,88,255,.16) 46%, transparent 70%);animation:__dshr-halo 2.8s ease-in-out infinite}",
".__dsh-hub-ring{position:absolute;inset:-9px;border-radius:50%;border:1px dashed rgba(56,214,208,.42);animation:__dshr-spin 9s linear infinite}",
"/* 标记上只挂一个动画:两个动画抢同一属性时后声明的会覆盖前者,会让标记停在 0% 不可见 */",
"/* (2026-09-21 实测:pulse 覆盖 in 的 opacity 后 opacity=0,图片已加载也照样看不见)。 */",
"/* 呼吸感由 .__dsh-hub-halo 的 __dshr-halo 负责,旋转感由 .__dsh-hub-ring 负责。 */",
"/* ⛔ 抠底标记禁用 box-shadow:它沿**元素矩形边界**绘制 ⇒ 会在透明图外面画出一圈方形光晕, */",
"/* 看起来就像又加了个矩形底(2026-09-21 实测)。发光必须用 filter:drop-shadow,它跟随 alpha 轮廓。 */",
".__dsh-hub-icon{position:relative;z-index:2;display:block;width:132px;height:132px;filter:drop-shadow(0 0 16px rgba(56,214,208,.55)) drop-shadow(0 0 40px rgba(124,88,255,.42));animation:__dshr-in .62s cubic-bezier(.22,.9,.24,1) both}",
".__dsh-fail .__dsh-hub-icon{filter:grayscale(.75) brightness(.62) contrast(1.05) drop-shadow(0 0 16px rgba(229,72,77,.5));animation:none}",
".__dsh-fail .__dsh-hub-ring{border-color:rgba(229,72,77,.5);animation:none}",
".__dsh-fail .__dsh-hub-halo{background:radial-gradient(circle, rgba(229,72,77,.24), transparent 70%);animation:none}",
".__dsh-msg{",
"font:14.5px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;color:#e9f1fa;letter-spacing:.2px;text-align:center;",
"background:linear-gradient(90deg, rgba(233,241,250,.94) 0%, #ffffff 48%, rgba(214,247,245,.94) 96%);",
"-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;",
"background-size:230% 100%;animation:__dshr-sheen 3s linear infinite;",
"}",
".__dsh-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;color:rgba(140,192,196,.72);text-transform:uppercase}",
".__dsh-track{position:relative;width:208px;height:3px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden}",
".__dsh-track i{position:absolute;top:0;bottom:0;width:42%;border-radius:3px;background:linear-gradient(90deg, transparent, #38d6d0 45%, #7c58ff 70%, transparent);animation:__dshr-scan 1.45s ease-in-out infinite}",
"@media (prefers-reduced-motion: reduce){",
".__dsh-hub-icon,.__dsh-hub-ring,.__dsh-hub-halo,.__dsh-track i,.__dsh-msg{animation:none!important}",
"}"
].join(String.fromCharCode(10));
(document.head || document.documentElement).appendChild(st);
}
// spinner 只创建一次,之后仅改文案 —— 否则每次重设 innerHTML 都会重建元素,
// 旋转动画被打断,看起来像"卡住不动"。
function ensureBox() {
if (box) return;
ensureCss();
box = document.createElement('div');
box.id = '__dshRecover';
box.className = '__dsh-ov';
// 品牌标记 = 与站点 favicon 同一份静态资源(web/favicon-192.png,平台在实例子域同源提供)。
// 全部 createElement 构建(不用 innerHTML —— 实例页可能启用 Trusted Types)。
var card = document.createElement('div');
card.className = '__dsh-card';
var orb = document.createElement('div');
orb.className = '__dsh-hub';
var halo = document.createElement('div');
halo.className = '__dsh-hub-halo';
orb.appendChild(halo);
var ring = document.createElement('div');
ring.className = '__dsh-hub-ring';
orb.appendChild(ring);
// 静态资源取不到时只隐藏标记、保留光环(不出现破图)—— halo/ring 负责兜住存在感。
var mark = document.createElement('img');
mark.className = '__dsh-hub-icon';
// 用**抠底发光版**(无方形底),不是带深色矩形底的 favicon-192.png —— 后者在浮层里
// 是一块方块,与"发光标记"的观感不符(用户 2026-09-21 指正)。资源 = 用户标记的
// transparent-glow 加工版(luma 斜坡抠底,只适合叠在深/中低明度底上,浮层正好是深底)。
// ⚠️ 不能用子域相对路径:实例子域只放行 /favicon.svg,其它静态资源会被鉴权拦成 401
// (实测 guest.ai1net.com/favicon-192.png = 401、design.css = 401)。改取**门户根域**
// (实测 https://ai1net.com/favicon-192.png = 200,且无 CORP/CORS 限制 ⇒ 跨域 img 可加载)。
// 根域由当前 host 去掉首段推出(IP 或两级以内域名则原样使用)。
var _hn = location.hostname, _hp = _hn.split('.');
var _root = location.protocol + '//' + (/^[\d.]+$/.test(_hn) || _hp.length <= 2 ? _hn : _hp.slice(-2).join('.'))
+ (location.port ? ':' + location.port : '');
mark.src = _root + '/mark-glow-192.png';
mark.alt = '';
mark.decoding = 'async';
mark.onerror = function () { mark.style.display = 'none'; };
orb.appendChild(mark);
var tx = document.createElement('div');
tx.id = '__dshRecoverMsg';
tx.className = '__dsh-msg';
var lb = document.createElement('div');
lb.className = '__dsh-label';
lb.textContent = 'DSH · auto recovery';
var tr = document.createElement('div');
tr.className = '__dsh-track';
tr.appendChild(document.createElement('i'));
card.appendChild(orb);
card.appendChild(tx);
card.appendChild(lb);
card.appendChild(tr);
box.appendChild(card);
(document.body || document.documentElement).appendChild(box);
}
function setMsg(msg) {
var m = document.getElementById('__dshRecoverMsg');
if (m) m.textContent = msg;
}
function showPending(base) {
if (state === 'expired') return;
state = 'pending';
ensureBox();
var t0 = Date.now();
setMsg(base + '(已等待 0 秒)');
if (ticker) clearInterval(ticker);
ticker = setInterval(function () {
if (state !== 'pending') return;
setMsg(base + '(已等待 ' + Math.floor((Date.now() - t0) / 1000) + ' 秒)');
}, 1000);
}
function hidePending() {
if (state !== 'pending') return;
state = 'none';
if (ticker) {
clearInterval(ticker);
ticker = null;
}
if (box && box.parentNode) box.parentNode.removeChild(box);
box = null;
}
// ── 顶部轻提示条(不遮罩)────────────────────────────────────────────────
// 用户明确反馈「回到页面根本没看到过提示」——所以「看得见」本身就是要交付的东西。
// 只在"离开 ≥20 秒又回来"时出现;瞬时切换不打扰。至少显示 900ms 再撤,
// 否则一闪而过等于没提示。
var bar = null;
var barAt = 0;
var barTimer = null;
function ensureBar() {
if (bar) return;
ensureCss();
bar = document.createElement('div');
bar.id = '__dshConnBar';
bar.setAttribute(
'style',
'position:fixed;top:0;left:0;right:0;z-index:2147483646;padding:7px 12px;' +
'text-align:center;font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;' +
'color:#fff;background:rgba(24,95,165,.95)'
);
(document.body || document.documentElement).appendChild(bar);
}
function showBar(msg) {
if (state === 'pending' || state === 'expired') return;
ensureBar();
barAt = Date.now();
bar.textContent = msg;
if (barTimer) {
clearTimeout(barTimer);
barTimer = null;
}
}
function hideBar() {
if (!bar) return;
var wait = Math.max(0, 900 - (Date.now() - barAt));
if (barTimer) clearTimeout(barTimer);
barTimer = setTimeout(function () {
barTimer = null;
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
bar = null;
}, wait + 60);
}
function expire() {
if (state === 'expired') return;
hideBar();
hidePending();
state = 'expired';
ensureBox();
setMsg('正在重新连接你的工作区…');
setTimeout(function () {
location.reload();
}, 900);
}
function isApi(u) {
return String(u).indexOf('/api/') >= 0;
}
// ── 实例不可用(空闲回收 / 被关闭 / 崩溃)→ 自动唤醒并重建连接(档案 72)────────────
// 为什么需要:原来只认 401。实例被回收后代理对非导航请求返回 404 not_running,
// 脚本不认识 → 页面上的请求静默失败、SSE 静默断流 → 用户只能**手动刷新**。
//
// 注入脚本跑在实例子域上,而平台接口(/api/dsh/*)在门户域 → 必须跨域调用;
// server.ts 的 CORS 白名单允许 baseDomain 及其子域 + Allow-Credentials,故带 cookie 可用。
function portalOrigin() {
var l = location.hostname.split('.');
if (l.length > 2) return location.protocol + '//' + l.slice(1).join('.');
return location.origin;
}
var recovering = false;
// 保留原始 fetch:探针与探活走它,避免被下面的 watch() 当成"业务请求"而误亮覆盖层。
var rawFetch = window.fetch;
// ── 就地恢复(档案 77)───────────────────────────────────────────────────
// 不再跳门户过渡页 wake.html:那会换域名(子域 → 门户),且它的 next 是空的,
// 恢复后落回根地址、页面状态全丢 —— 用户明确反馈这样"不自然"。
// 改为:本页显示覆盖层 → 调一次平台 /api/dsh/enter(会拉起实例并返回**带新 token**
// 的直达地址)→ 原地跳回,并尽量保留当前路径与 hash。
function withPath(u) {
try {
var n = new URL(u, location.href);
if (n.hostname === location.hostname && location.pathname !== '/') n.pathname = location.pathname;
if (location.hash) n.hash = location.hash;
return n.href;
} catch (e) {
return u;
}
}
// ── 有界恢复(档案 81 · R1-③)──────────────────────────────────────────────
// 现场事故(2026-09-13 · guest):实例被 OOM 反复杀 → 页面「恢复 → 起来 → 又被杀」**无限循环**
// (每 ~35 s 一次 GET /)。恢复逻辑本身没错,错在**没有上限**。
// 规则:窗口内恢复次数超限后**停止自动恢复**,停在明确的失败态 + 手动「重试」(不自动 reload)。
var RECOVER_WINDOW_MS = 10 * 60 * 1000;
var RECOVER_MAX = 3;
var recFirstAt = 0;
var recCount = 0;
function overRecoverBudget() {
var now = Date.now();
if (now - recFirstAt > RECOVER_WINDOW_MS) {
recFirstAt = now;
recCount = 0;
}
recCount += 1;
return recCount > RECOVER_MAX;
}
function addRetryButton() {
var box = document.getElementById('__dshRecover');
if (!box || document.getElementById('__dshRetryBtn')) return;
var b = document.createElement('button');
b.id = '__dshRetryBtn';
b.type = 'button';
b.textContent = '重试';
b.setAttribute(
'style',
'margin-top:14px;padding:8px 18px;border:0;border-radius:8px;cursor:pointer;' +
'font:14px/1 system-ui,-apple-system,sans-serif;color:#062028;font-weight:600;' +
'background:linear-gradient(135deg,#38d6d0,#8af0e6);box-shadow:0 0 18px rgba(56,214,208,.4)'
);
b.addEventListener('click', function () {
recFirstAt = 0; // 手动重试 = 重置预算
recCount = 0;
recovering = false;
b.parentNode && b.parentNode.removeChild(b);
recover('正在重新连接…');
});
box.appendChild(b);
}
function showExhausted() {
recovering = true; // 保持在"不再自动恢复"的状态
hideBar();
ensureBox(); // 只建覆盖层,不启动 showPending 的秒表
// 视觉切「失败态」:中心节点转警示红、辐条与外环节点熄灭(配色见 ensureCss 的 .__dsh-fail)。
// 用宽匹配(不带 __dsh-hub 前缀)以兼容 hub 容器同时带两个类名的情况。
var hub = document.getElementById('__dshRecover');
hub = hub && hub.querySelector('.__dsh-hub');
if (hub) hub.className = '__dsh-hub __dsh-fail';
setMsg('工作区暂时不可用(已连续尝试恢复 ' + RECOVER_MAX + ' 次)。可能是实例反复重启或资源不足 —— 可点下方「重试」,或稍后再来。');
addRetryButton();
}
function recover(reason) {
if (recovering) return;
if (overRecoverBudget()) {
showExhausted();
return;
}
recovering = true;
hideBar();
showPending(reason || '工作区正在恢复,请稍候…');
var go = function (url) {
if (url) {
setMsg('已就绪,正在返回你的工作区…');
setTimeout(function () {
location.replace(withPath(url));
}, 700);
return;
}
setMsg('正在重新连接…');
setTimeout(function () {
location.reload();
}, 700);
};
rawFetch.call(window, portalOrigin() + '/api/dsh/enter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
credentials: 'include',
})
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (j) {
go(j && (j.url || j.redirect));
})
.catch(function () {
go(null);
});
}
// ── 探针:页面 ↔ 实例的连通性(档案 77 新增,解决"看不到提示")────────────
// 为什么需要:门户口 /api/dsh/status 的 running 只说明「进程在不在」,
// 而它把 starting(正在启动)也算在跑(dsh.ts 的 alive())。实测每次崩溃后新实例
// 1 秒内就起来 ⇒ 用户回到页面时几乎总是 starting/running ⇒ 旧判据永远不动手。
// 探针 GET / 走实例自己的鉴权入口:200 = 这个页面确实能用;超时 / 3xx / 异常 = 已脱节。
// 返回 404/405(官方改了入口形态)→ 'unknown' → 退回旧判据,**绝不误报**。
function poke() {
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
var t = setTimeout(function () {
try {
if (ctl) ctl.abort();
} catch (e) {}
}, 2500);
return rawFetch
.call(window, '/', {
credentials: 'same-origin',
cache: 'no-store',
redirect: 'manual',
signal: ctl ? ctl.signal : undefined,
})
.then(function (r) {
clearTimeout(t);
if (r.status === 200) return 'ok';
if (r.status === 404 || r.status === 405) return 'unknown';
return 'bad';
})
.catch(function () {
clearTimeout(t);
return 'bad';
});
}
// 主动探活。必要性:实例被回收后,页面上的存量请求不一定失败(SSE 静默断流),
// 所以"用户回到页面"这件事本身要主动问一次。
// withNotice:真的离开过一会儿又回来 → 先亮顶部提示条(看得见的反馈)。
var lastProbe = 0;
// soft = true:**静默触发面**(心跳 / 流断)用 —— 必须**连续两次**失败才动手。
// 为什么:恢复 = 原地 location.replace,页面内未保存的输入会丢;一次网络抖动不该触发它。
var softFails = 0;
function probe(withNotice, soft) {
if (recovering) return;
if (Date.now() - lastProbe < 15000) return;
lastProbe = Date.now();
if (withNotice) showBar('正在检查工作区连接…');
var go = function (reason) {
if (soft !== true) {
recover(reason);
return;
}
softFails += 1;
if (softFails >= 2) recover(reason);
};
rawFetch
.call(window, portalOrigin() + '/api/dsh/status', { credentials: 'include' })
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (j) {
if (!j) {
hideBar();
return;
}
if (j.running === false) {
go('工作区已休眠,正在唤醒…');
return;
}
return poke().then(function (verdict) {
if (verdict === 'ok' || verdict === 'unknown') {
softFails = 0;
hideBar();
return;
}
go('工作区正在恢复,请稍候…');
});
})
.catch(function () {
hideBar();
});
}
function hit(u, s, readBody) {
if (s === 401 && isApi(u)) {
expire();
return;
}
// 404 not_running = 实例已被回收或关闭。原先没人管这种情况,用户只能手动刷新。
if (s === 404 && isApi(u) && typeof readBody === 'function') {
readBody(function (text) {
if (String(text).indexOf('not_running') >= 0) {
recover('工作区正在恢复,请稍候…');
}
});
}
}
// 服务端在"实例未就绪"时会 hold 住请求(最长 20 秒)等拉起,期间浏览器端原本没有任何反馈
// —— 用户点了重连却看不出在等什么。这里给挂起的 API 请求加计时:≥3 秒就亮出覆盖层。
// 不会误伤流式接口:SSE / ReadableStream 在**响应头到达**时 promise 就已 resolve,计时已经清掉。
var SLOW_MS = 3000;
function watch(u) {
if (!isApi(u)) return function () {};
var shown = false;
var t = setTimeout(function () {
shown = true;
showPending('实例正在启动,请稍候…');
}, SLOW_MS);
return function () {
clearTimeout(t);
if (shown) hidePending();
};
}
var of = window.fetch;
if (of) {
window.fetch = function (i) {
var u = typeof i === 'string' ? i : (i && i.url) || '';
var end = watch(u);
return of.apply(this, arguments).then(
function (r) {
end();
try {
hit(u, r.status, function (cb) {
// clone 后再读:不能消费原响应的 body,否则调用方拿不到数据
try {
r.clone()
.text()
.then(cb)
.catch(function () {});
} catch (e) {}
});
} catch (e) {}
return r;
},
function (e) {
end();
throw e;
}
);
};
}
var oo = XMLHttpRequest.prototype.open;
var os = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, u) {
this.__u = u;
return oo.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var x = this;
var end = watch(x.__u);
x.addEventListener('loadend', function () {
end();
try {
hit(x.__u, x.status, function (cb) {
try {
cb(String(x.responseText || ''));
} catch (e) {}
});
} catch (e) {}
});
return os.apply(this, arguments);
};
// 「用户回到会话页面」= 切回标签页 / 点回窗口 / 从缓存恢复页面。
// 只有"真的离开过一会儿又回来"(≥20 秒)才亮顶部提示条 —— 瞬时切换不打扰;
// 但无论亮不亮,探针都会跑:一旦判定页面已与实例脱节就恢复并给覆盖层。
var LEFT_MS = 20000;
var leftAt = 0;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
var away = leftAt > 0 && Date.now() - leftAt >= LEFT_MS;
leftAt = 0;
probe(away);
} else {
leftAt = Date.now();
}
});
window.addEventListener('focus', function () {
probe(false);
});
window.addEventListener('pageshow', function (e) {
if (e.persisted) probe(true);
});
// ── 档案 77 补丁②(2026-09-13):补齐「连接悄悄断掉」的两条触发面 ─────────
// 现场故障:用户**一直盯着页面**(不切标签、不 focus),实例侧连接断了(模型流挂掉),
// 页面显示「连接异常」,而上面那些事件一个都不会来 ⇒ 既不提示也不恢复,只能手动刷新。
// 说明:原设计(档案 77 §五 方案 B「不做周期性探活」)的前提是"用户在场却不操作没有收益",
// 实测证明该前提不成立 —— 于是补两条**静默**触发面;两者都走 soft 模式(连续两次失败才恢复)。
// ① 心跳:页面**可见**时每 25 秒静默探一次(不可见时完全不付出成本)
// ② 流断:包装 EventSource / WebSocket 的 error / close,立即探一次(比心跳更早发现)
var HEARTBEAT_MS = 25000;
setInterval(function () {
if (document.visibilityState !== 'visible') return;
probe(false, true);
}, HEARTBEAT_MS);
function wrapStreams() {
var ES = window.EventSource;
if (typeof ES === 'function') {
var ES2 = function (url, opts) {
var es = new ES(url, opts);
es.addEventListener('error', function () {
probe(false, true);
});
return es;
};
ES2.prototype = ES.prototype;
if (ES.CONNECTING !== undefined) {
ES2.CONNECTING = ES.CONNECTING;
ES2.OPEN = ES.OPEN;
ES2.CLOSED = ES.CLOSED;
}
window.EventSource = ES2;
}
var WS = window.WebSocket;
if (typeof WS === 'function') {
var WS2 = function (url, protocols) {
var ws = protocols === undefined ? new WS(url) : new WS(url, protocols);
ws.addEventListener('close', function () {
probe(false, true);
});
ws.addEventListener('error', function () {
probe(false, true);
});
return ws;
};
WS2.prototype = WS.prototype;
if (WS.CONNECTING !== undefined) {
WS2.CONNECTING = WS.CONNECTING;
WS2.OPEN = WS.OPEN;
WS2.CLOSING = WS.CLOSING;
WS2.CLOSED = WS.CLOSED;
}
window.WebSocket = WS2;
}
}
wrapStreams();
})();