Files
blog/public/admin/deploy-status.js
T
wait4xandGLM-5.3 5c0e6ed900 chore(spec): 归档 blog-5n——八任务收敛(后台深度优化全家桶)
Spec: 2026-11-05_blog-5n-admin-deep-optimization

Co-authored-by: GLM-5.3
2026-11-05 20:13:09 +08:00

162 lines
6.5 KiB
JavaScript
Raw 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.
/**
* 后台部署状态条(5n B1):Sveltia 管理页右下角浮层,轮询站点构建状态。
* spec:.spec/specs/2026-11-05_blog-5n-admin-deep-optimization(T4)
*
* JSON 接口契约(build.sh 每次部署写 /admin/build-status.json):
* { "sha": "a555a3e…", // 部署 commit(展示取前 7 位)
* "time": "2026-11-05T19:20:25+08:00", // 构建完成时刻(展示取 HH:MM)
* "state": "ok" | "building" | "fail",
* "pages": 53 } // 页面数(仪表盘消费,本条不读)
* 文件缺失 / 响应非 200 / JSON 解析失败 / state 非法 → 视为「不可知」:
* 胶囊淡出、不报错,下一轮照常再试——状态条是增益件,接口坏了不能吵主人。
*
* 为什么轮询而不是 SSE/WebSocket:本站是纯静态托管(CDN 上没有可驻留的
* 服务端进程,无推送通道);「保存→看部署结果」的节奏里 10s 间隔已足够
* 感知,且零服务端成本、零额外基础设施。
*/
(function () {
'use strict';
if (window.DEPLOY_STATUS) return; // 防重复初始化:脚本被引入两次时静默退出
var POLL_MS = 10000;
var timer = null; // null = 停表中(初始 / 页面隐藏)
var STATES = {
ok: { bg: 'rgba(28,30,34,.92)', label: '已上线' },
building: { bg: 'rgba(52,84,209,.92)', label: '构建中…' },
fail: { bg: 'rgba(179,32,32,.92)', label: '构建失败' },
};
/** 构建中呼吸动画的 keyframes 无法用元素内联样式表达,注入一次 <style>(仍零外部资源) */
function injectKeyframes() {
var style = document.createElement('style');
style.id = 'deploy-status-style';
style.textContent = '@keyframes deploy-status-breath{0%,100%{opacity:.7}50%{opacity:1}}';
document.head.appendChild(style);
return style;
}
function createPill() {
var el = document.createElement('div');
el.setAttribute('role', 'status'); // 状态变化对读屏器温和播报
// 全部样式内联在 JS 里:不依赖外部 CSS,单文件即可被任意页面引入
el.style.cssText = [
'position:fixed',
'right:14px',
'bottom:14px',
'z-index:10000',
'padding:6px 12px',
'font:12px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif',
'color:#fff',
'border-radius:999px',
'box-shadow:0 2px 10px rgba(0,0,0,.18)',
'user-select:none',
'opacity:0', // 初始隐身,拿到合法数据才淡入
'transition:opacity .2s,background-color .2s', // 出现与状态切换均为 200ms 过渡
'pointer-events:none', // 默认不挡下层 UI;仅 fail 态(可点击)放开
].join(';');
el.addEventListener('click', function () {
// 只有 fail 态放开 pointer-events,此监听只会在「构建失败」时被触发
location.reload();
});
document.body.appendChild(el);
return el;
}
/** 从 ISO 串直接截 HH:MM 而不走 Date 本地化:展示构建机墙钟时间,不随浏览器时区漂移 */
function hhmm(iso) {
var m = /T(\d{2}:\d{2})/.exec(String(iso || ''));
if (m) return m[1];
var d = new Date(iso); // 兜底:万一 build.sh 改用非 ISO 格式,退本地时区展示
if (isNaN(d.getTime())) return '';
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function boot() {
var el = createPill();
function hide() {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
el.style.animation = 'none';
el.title = '';
}
function render(data) {
var conf = STATES[data && data.state];
if (!conf) { // 不可知 = 淡出静默,不区分缺文件/坏 JSON/非法枚举(对用户都是同一件事)
hide();
return;
}
if (data.state === 'ok') {
var parts = [];
var sha7 = String(data.sha || '').slice(0, 7);
if (sha7) parts.push(sha7);
var t = hhmm(data.time);
if (t) parts.push(t);
el.textContent = parts.length ? conf.label + ' · ' + parts.join(' · ') : conf.label;
} else {
el.textContent = conf.label;
}
el.style.backgroundColor = conf.bg;
// 呼吸仅限 building;离开时置 none,状态间观感交给 background-color 的 200ms 过渡
el.style.animation = data.state === 'building'
? 'deploy-status-breath 1.6s ease-in-out infinite'
: 'none';
if (data.state === 'fail') {
el.style.pointerEvents = 'auto';
el.style.cursor = 'pointer';
el.title = '点击刷新页面'; // fail 的唯一动作是刷新:取回最新静态产物(含本文件与状态 JSON)
} else {
el.style.pointerEvents = 'none';
el.style.cursor = 'default';
el.title = '';
}
el.style.opacity = '1';
}
function poll() {
fetch('/admin/build-status.json', { cache: 'no-store' }) // no-store:状态文件必须每次拿实况
.then(function (r) { return r.ok ? r.json() : null; }) // 404 等非 200 → 当不可知处理
.then(function (data) { render(data && typeof data === 'object' ? data : {}); })
.catch(function () { hide(); }); // 网络层与 JSON 解析失败同路:淡出,下一轮再试
}
function loop() {
poll();
timer = setTimeout(loop, POLL_MS);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
// 回来立即拉(隐藏期间可能已构建完成),并重置 10s 节拍
if (timer) clearTimeout(timer);
loop();
} else if (timer) {
// 隐藏即停:省请求,也避免后台标签被浏览器节流的定时器醒来后集中补发
clearTimeout(timer);
timer = null;
}
});
window.DEPLOY_STATUS = {
// 挂载即去重标记;stop 供控制台/测试卸载(页面自身是 SPA,正常导航不会触发)
stop: function () {
if (timer) clearTimeout(timer);
timer = null;
el.remove();
var style = document.getElementById('deploy-status-style');
if (style) style.remove();
delete window.DEPLOY_STATUS;
},
};
if (document.visibilityState === 'visible') loop();
// 初始就隐藏(预渲染标签页):等 visibilitychange 回来再首拉
}
if (document.body) boot();
else document.addEventListener('DOMContentLoaded', boot); // 若被放到 <head> 且未加 defer
})();