chore(spec): 归档 blog-5n——八任务收敛(后台深度优化全家桶)
Spec: 2026-11-05_blog-5n-admin-deep-optimization Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
a555a3e450
commit
5c0e6ed900
29 files changed
+1559
-512
No files matched your search
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 后台部署状态条(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
|
||||
})();
|
||||
Reference in new issue
Block a user