跳到主要内容

站点维护页面

最后更新于:
AI 摘要

image-20260730070851.avif

站点维护页面

问题

1新需求:
2博客后台有个按钮,点击后,立马把自己主站设为不可访问,并给读者提供一个漂亮的维护页面[破涕为笑]

回答:

主站是静态 Hugo + EdgeOne 边缘函数,后台是独立的 EdgeOne Admin + KV。要「点按钮立刻下线」,只能走 后台写开关 → 主站边缘函数拦截,不能靠 Hugo 重建(那要几分钟)。

定方案前先确认两点:

1、维护页文案

  • A. 只要开关:固定漂亮文案(如「站点维护中,马上回来」)
  • B. 开关 + 可编辑:后台可改标题、说明(可选预计恢复时间)

2、维护开启后,你自己还想临时看主站吗?

  • A. 不需要:主站对所有人只显示维护页;后台照常可用
  • B. 需要:带密钥参数 / Cookie 可绕过维护页预览主站

回 A/B(例如 1B 2A)后我再出具体实现计划。

选:1B 2A

一键站点维护模式

选定方案(已确认)

  • 文案:可编辑标题、说明、可选预计恢复时间
  • 绕过:不要密钥/Cookie 绕过;维护期间主站对所有人只显示维护页
  • 后台 oneblogadmin.onedayxyy.cn 始终可访问,用于关维护

不走 Hugo 重建:改配置再 CI 要几分钟,做不到「立马」。

flowchart LR AdminUI["Admin Tools.vue"] -->|"PUT auth"| AdminAPI["oneblog-admin Functions"] AdminAPI --> KV["ADMIN_KV site_maintenance"] EdgeFn["blog-cnb-origin.js"] -->|"GET public"| StatusAPI["/api/maintenance-status"] StatusAPI --> KV EdgeFn -->|"enabled"| MaintHTML["503 维护页"] EdgeFn -->|"disabled"| CNB["CNB dist 静态站"]

1. Admin API(KV + 鉴权写 / 公开读)

[oneblog-admin/functions/[[default]].js](oneblog-admin/functions/[[default]].js)

  • KV 键:site_maintenance
  • 值形状:
1{
2  "enabled": true,
3  "title": "站点维护中",
4  "message": "正在升级,马上回来。",
5  "until": "2026-07-30 18:00",
6  "updatedAt": 1710000000000
7}
  • 公开(放在「以下需要鉴权」之前,与 health / auth/login 同级):
    • GET /api/maintenance-status → 返回上述 JSON(无敏感信息),Cache-Control: no-store
  • 鉴权(走现有 /api/v1/*,登录后才能改):
    • GET /api/v1/maintenance
    • PUT /api/v1/maintenance(body 同上字段)
  • shouldAutoRebuildV1Api不要因维护开关触发重建流水线

本地开发:在 [admin-frontend/plugins/localAdminApi.js](admin-frontend/plugins/localAdminApi.js).local/maintenance.json 做同样读写,便于后台 UI 联调(本地 Hugo 不会被拦,属预期)。

2. 后台 UI

[admin-frontend/src/views/System/Tools.vue](admin-frontend/src/views/System/Tools.vue) + [admin-frontend/src/api/settings.js](admin-frontend/src/api/settings.js)

  • 新增分区「站点维护」
  • 开关:开启 / 关闭(危险色确认:el-popconfirm
  • 表单:标题、说明、预计恢复时间(可选)
  • 加载当前状态;保存成功提示「主站将在数秒内切换」
  • 醒目提示:开启后读者无法访问 onedayxyy.cn;本后台不受影响;需已发布带维护逻辑的边缘函数

3. 主站边缘函数拦截

[edgeone/blog-cnb-origin.js](edgeone/blog-cnb-origin.js)

  • 文件顶部常量:MAINTENANCE_STATUS_URL = 'https://oneblogadmin.onedayxyy.cn/api/maintenance-status'
  • 在拉 CNB 源之前:
    1. 查维护状态(fetch + 短超时;可用 Cache API 缓存 5s,避免每次都打 Admin)
    2. enabled === true 时直接返回内嵌的漂亮 HTML,HTTP 503Cache-Control: no-storeRetry-After: 300
  • 状态接口失败/超时时:fail-open(照常提供站点),避免 Admin 挂了把主站一起锁死
  • 维护页:单文件内嵌 HTML(品牌 OneBlog / onedayxyy,标题+说明+until,简洁大气,非紫霓虹风);静态资源请求同样拦截,避免半残页面

更新 [edgeone/README.md](edgeone/README.md):说明必须把新函数整文件覆盖并发布后,后台开关才真正生效。

4. 缓存与 SW 注意点(实现时处理)

  • 维护响应 no-store,避免 EdgeOne/浏览器把维护页或正常页长期缓存错乱
  • 站点若启用 [sw.js](hugo-teek-site/themes/hugo-teek/static/sw.js):维护 HTML 加一句轻量提示,或边缘对 HTML 导航强制 503;若仍被 SW 旧缓存挡住,可在维护页文案里写「强制刷新 / 无痕」——本次不做完整 SW 改造,以免扩大范围

5. 你需要做的上线步骤(代码合入后)

  1. 部署 Admin(CI 或现有 Admin 发布流程)→ 公开状态接口可用
  2. EdgeOne 控制台:用更新后的 blog-cnb-origin.js 覆盖并发布
  3. 后台「系统工具」打开维护开关自测;再关回

不在本次范围

  • 不改 Hugo 主题 / 不重建静态站来做维护
  • 不做所有者密钥绕过
  • 不自动改 .cnb.yml 去部署边缘函数(仍为控制台手动发布)

v1-已实现

一键维护模式已按计划落地,改动如下:

🟢已实现

  1. Admin API(oneblog-admin/functions/[[default\]].js
    • KV:site_maintenance
    • 公开:GET /api/maintenance-status(无需登录)
    • 鉴权:GET/PUT /api/v1/maintenance(不触发重建)
    • 本地:localAdminApi.js 读写 .local/maintenance.json
  2. 后台 UI(系统工具)
    • 维护开关 + 确认
    • 可编辑标题 / 说明 / 预计恢复时间
  3. 主站边缘函数(edgeone/blog-cnb-origin.js
    • 开启时全站 503 维护页
    • 状态失败 fail-open;约 5 秒缓存

🟢上线顺序

  1. 部署 Admin(公开状态接口可用)
  2. EdgeOne 控制台用新版 blog-cnb-origin.js 整文件覆盖并发布
  3. 后台「系统工具 → 站点维护」自测开关

未发布边缘函数前,后台开关不会真正拦截主站。

blog-cnb-origin.js 内容:

  1/**
  2 * EdgeOne 边缘函数:从 CNB 公开产物库拉取静态站
  3 *
  4 * 用法:复制到腾讯云 EdgeOne 控制台 → 站点 → 边缘函数(整文件覆盖)
  5 *
  6 * 相对旧版改进:
  7 * 1. HTML/CSS/JS 等文本资源做 gzip(缓冲后再压,避免流式 CompressionStream 在 EO 静默失败)
  8 * 2. 指纹静态资源长缓存;HTML 短缓存
  9 * 3. 响应头 X-EO-Compress 便于确认压缩是否生效
 10 * 4. 找不到页面时回退到产物库中的 404.html(Hugo 自定义 404)
 11 * 5. 维护模式:轮询 Admin 公开状态;开启时对所有请求返回 503 维护页
 12 */
 13addEventListener('fetch', (event) => {
 14  event.respondWith(handleRequest(event.request));
 15});
 16
 17const BASE = 'https://cnb.cool/onedayxyy/hugo-teek-public-dist/-/git/raw/dist';
 18const MAINTENANCE_STATUS_URL = 'https://oneblogadmin.onedayxyy.cn/api/maintenance-status';
 19const MAINTENANCE_CACHE_TTL_MS = 5000;
 20const MAINTENANCE_FETCH_TIMEOUT_MS = 2500;
 21
 22async function handleRequest(request) {
 23  const maintenance = await getMaintenanceStatus();
 24  if (maintenance && maintenance.enabled) {
 25    return buildMaintenanceResponse(maintenance);
 26  }
 27
 28  const url = new URL(request.url);
 29  let pathname = url.pathname;
 30
 31  if (pathname !== '/' && !pathname.endsWith('/') && !isFile(pathname)) {
 32    const redirectUrl = new URL(request.url);
 33    redirectUrl.pathname = pathname + '/';
 34    return Response.redirect(redirectUrl.toString(), 301);
 35  }
 36
 37  let uri = pathname;
 38  if (uri.startsWith('/')) uri = uri.slice(1);
 39  if (!uri || uri === '') uri = 'index.html';
 40  if (uri.endsWith('/')) uri = uri + 'index.html';
 41
 42  let target = `${BASE}/${uri}`;
 43  let resp = await fetchOrigin(target);
 44
 45  if (!resp.ok && !isFile(uri)) {
 46    const indexUri = uri.endsWith('/') ? uri + 'index.html' : uri + '/index.html';
 47    target = `${BASE}/${indexUri}`;
 48    resp = await fetchOrigin(target);
 49    if (resp.ok) uri = indexUri;
 50  }
 51
 52  if (!resp.ok || !resp.body) {
 53    // 回退到 Hugo 构建的自定义 404 页,避免纯文本 "Page Not Found"
 54    const notFound = await fetchOrigin(`${BASE}/404.html`);
 55    if (notFound.ok && notFound.body) {
 56      const rawBuf = await notFound.arrayBuffer();
 57      const contentType = 'text/html; charset=utf-8';
 58      const headers = new Headers();
 59      headers.set('Content-Type', contentType);
 60      headers.set('Cache-Control', 'public, max-age=60, must-revalidate');
 61      headers.set('Content-Length', String(rawBuf.byteLength));
 62      const uncompressed = new Response(rawBuf, {
 63        status: 404,
 64        headers,
 65      });
 66      return maybeGzip(request, uncompressed, '404.html', rawBuf, contentType);
 67    }
 68
 69    return new Response('Page Not Found', {
 70      status: 404,
 71      headers: { 'Content-Type': 'text/html; charset=utf-8' },
 72    });
 73  }
 74
 75  const contentType =
 76    getContentType(uri) || resp.headers.get('content-type') || 'text/html; charset=utf-8';
 77
 78  // 大体积 CSS/JS(如 main.css ~1MB)若整包 arrayBuffer + gzip,EO 易直接 545。
 79  // 指纹静态资源改为直通,交给平台智能压缩。
 80  const originLen = Number(resp.headers.get('content-length') || 0);
 81  if (shouldPassthrough(uri, contentType, originLen)) {
 82    const headers = new Headers();
 83    headers.set('Content-Type', contentType);
 84    headers.set('Cache-Control', cacheControlFor(uri));
 85    if (originLen > 0) headers.set('Content-Length', String(originLen));
 86    headers.set('X-EO-Compress', 'passthrough');
 87    return new Response(resp.body, { status: resp.status, headers });
 88  }
 89
 90  // 先读入缓冲:EO 上对流式 pipeThrough(CompressionStream) 经常不生效
 91  let rawBuf;
 92  try {
 93    rawBuf = await resp.arrayBuffer();
 94  } catch (e) {
 95    const headers = new Headers();
 96    headers.set('Content-Type', contentType);
 97    headers.set('Cache-Control', cacheControlFor(uri));
 98    headers.set('X-EO-Compress', 'passthrough-read-fail');
 99    const retry = await fetchOrigin(target);
100    return new Response(retry.body, { status: retry.status, headers });
101  }
102
103  // 读完后发现超大:不再 gzip,避免边缘函数 OOM/545
104  if (rawBuf.byteLength > PASS_THROUGH_MAX_BYTES) {
105    const headers = new Headers();
106    headers.set('Content-Type', contentType);
107    headers.set('Cache-Control', cacheControlFor(uri));
108    headers.set('Content-Length', String(rawBuf.byteLength));
109    headers.set('X-EO-Compress', 'skip-large');
110    return new Response(rawBuf, { status: resp.status, headers });
111  }
112
113  const headers = new Headers();
114  headers.set('Content-Type', contentType);
115  headers.set('Cache-Control', cacheControlFor(uri));
116  headers.set('Content-Length', String(rawBuf.byteLength));
117
118  const uncompressed = new Response(rawBuf, {
119    status: resp.status,
120    headers,
121  });
122
123  return maybeGzip(request, uncompressed, uri, rawBuf, contentType);
124}
125
126/** 超过该体积不再做边缘 gzip 缓冲(main.css 约 1MB) */
127const PASS_THROUGH_MAX_BYTES = 400 * 1024;
128
129function shouldPassthrough(uri, contentType, contentLength) {
130  if (contentLength > PASS_THROUGH_MAX_BYTES) return true;
131  // 指纹 CSS/JS 体积常偏大,优先直通
132  if (/\.min\.[a-f0-9]{8,}\.(?:css|js)(?:$|\?)/i.test(uri)) {
133    if (contentLength === 0) return true; // 未知长度时也不冒险缓冲
134    if (contentLength > 200 * 1024) return true;
135  }
136  return false;
137}
138
139function fetchOrigin(target) {
140  return fetch(target, {
141    headers: {
142      'User-Agent': 'curl/8.0.0',
143      Accept: '*/*',
144      'Accept-Encoding': 'identity',
145    },
146  });
147}
148
149function isFile(path) {
150  return /\.[^/]+$/.test(path);
151}
152
153function getContentType(uri) {
154  const map = {
155    html: 'text/html; charset=utf-8',
156    css: 'text/css; charset=utf-8',
157    js: 'application/javascript; charset=utf-8',
158    mjs: 'application/javascript; charset=utf-8',
159    json: 'application/json; charset=utf-8',
160    png: 'image/png',
161    jpg: 'image/jpeg',
162    jpeg: 'image/jpeg',
163    gif: 'image/gif',
164    svg: 'image/svg+xml',
165    webp: 'image/webp',
166    avif: 'image/avif',
167    ico: 'image/x-icon',
168    woff: 'font/woff',
169    woff2: 'font/woff2',
170    txt: 'text/plain; charset=utf-8',
171    xml: 'application/xml; charset=utf-8',
172    map: 'application/json',
173  };
174  if (!isFile(uri)) return 'text/html; charset=utf-8';
175  const ext = uri.split('.').pop().toLowerCase();
176  return map[ext];
177}
178
179function cacheControlFor(uri) {
180  if (/\.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)(?:$|\?)/i.test(uri)) {
181    return 'public, max-age=31536000, immutable';
182  }
183  return 'public, max-age=60, must-revalidate';
184}
185
186function isCompressible(uri, contentType) {
187  if (/\.(?:png|jpe?g|gif|webp|avif|woff2?|mp4|webm|gz|br)(?:$|\?)/i.test(uri)) {
188    return false;
189  }
190  const type = contentType || '';
191  return /text\/|javascript|json|xml|svg/.test(type);
192}
193
194async function compressGzip(rawBuf) {
195  if (typeof CompressionStream === 'undefined') {
196    return { ok: false, reason: 'no-compression-stream' };
197  }
198  try {
199    const cs = new CompressionStream('gzip');
200    const writer = cs.writable.getWriter();
201    await writer.write(new Uint8Array(rawBuf));
202    await writer.close();
203    const compressed = await new Response(cs.readable).arrayBuffer();
204    if (!compressed || compressed.byteLength < 2) {
205      return { ok: false, reason: 'empty-output' };
206    }
207    // gzip magic 1F 8B
208    const u8 = new Uint8Array(compressed);
209    if (u8[0] !== 0x1f || u8[1] !== 0x8b) {
210      return { ok: false, reason: 'bad-magic' };
211    }
212    return { ok: true, buf: compressed };
213  } catch (e) {
214    return { ok: false, reason: 'err:' + (e && e.message ? String(e.message).slice(0, 80) : 'unknown') };
215  }
216}
217
218async function maybeGzip(request, response, uri, rawBuf, contentType) {
219  const accept = (request.headers.get('Accept-Encoding') || '').toLowerCase();
220  // EdgeOne 常会剥掉/改写传入边缘函数的 Accept-Encoding,导致误判 skip-client。
221  // 现代浏览器都支持 gzip:仅当明确只要 identity 时才跳过。
222  const identityOnly =
223    accept === 'identity' ||
224    (/identity/.test(accept) && !/gzip|\*|br/.test(accept));
225  if (identityOnly) {
226    const h = new Headers(response.headers);
227    h.set('X-EO-Compress', 'skip-identity');
228    return new Response(rawBuf, { status: response.status, headers: h });
229  }
230  if (!isCompressible(uri, contentType)) {
231    const h = new Headers(response.headers);
232    h.set('X-EO-Compress', 'skip-type');
233    return new Response(rawBuf, { status: response.status, headers: h });
234  }
235
236  const result = await compressGzip(rawBuf);
237  if (!result.ok) {
238    const h = new Headers(response.headers);
239    h.set('X-EO-Compress', result.reason || 'fail');
240    // 不设 Vary,尽量让 EO「智能压缩」有机会接管
241    return new Response(rawBuf, { status: response.status, headers: h });
242  }
243
244  const headers = new Headers(response.headers);
245  headers.set('Content-Encoding', 'gzip');
246  headers.set('Content-Length', String(result.buf.byteLength));
247  headers.set('Vary', 'Accept-Encoding');
248  headers.set(
249    'X-EO-Compress',
250    'gzip-ok;ae=' + (accept ? accept.slice(0, 40) : 'missing')
251  );
252  return new Response(result.buf, { status: response.status, headers });
253}
254
255// ==================== 站点维护模式 ====================
256// fail-open:状态接口失败/超时则照常提供站点,避免 Admin 故障锁死主站。
257
258let maintenanceMemoryCache = { at: 0, data: null };
259
260async function getMaintenanceStatus() {
261  const now = Date.now();
262  if (
263    maintenanceMemoryCache.data &&
264    now - maintenanceMemoryCache.at < MAINTENANCE_CACHE_TTL_MS
265  ) {
266    return maintenanceMemoryCache.data;
267  }
268
269  try {
270    if (typeof caches !== 'undefined' && caches.default) {
271      const cache = caches.default;
272      const cacheReq = new Request(MAINTENANCE_STATUS_URL, { method: 'GET' });
273      const cached = await cache.match(cacheReq);
274      if (cached) {
275        const data = await cached.json();
276        maintenanceMemoryCache = { at: now, data };
277        return data;
278      }
279    }
280  } catch (_) {
281    /* ignore cache read */
282  }
283
284  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
285  const timer = controller
286    ? setTimeout(() => controller.abort(), MAINTENANCE_FETCH_TIMEOUT_MS)
287    : null;
288
289  try {
290    const resp = await fetch(MAINTENANCE_STATUS_URL, {
291      method: 'GET',
292      headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' },
293      signal: controller ? controller.signal : undefined,
294    });
295    if (!resp.ok) return null;
296    const data = await resp.json();
297    maintenanceMemoryCache = { at: now, data };
298
299    try {
300      if (typeof caches !== 'undefined' && caches.default) {
301        const cache = caches.default;
302        const cacheResp = new Response(JSON.stringify(data), {
303          headers: {
304            'Content-Type': 'application/json',
305            'Cache-Control': 'max-age=5',
306          },
307        });
308        await cache.put(MAINTENANCE_STATUS_URL, cacheResp);
309      }
310    } catch (_) {
311      /* ignore cache write */
312    }
313
314    return data;
315  } catch (_) {
316    return null;
317  } finally {
318    if (timer) clearTimeout(timer);
319  }
320}
321
322function escapeHtml(str) {
323  return String(str || '')
324    .replace(/&/g, '&amp;')
325    .replace(/</g, '&lt;')
326    .replace(/>/g, '&gt;')
327    .replace(/"/g, '&quot;')
328    .replace(/'/g, '&#39;');
329}
330
331function buildMaintenanceHtml(status) {
332  const title = escapeHtml(status.title || '站点维护中');
333  const message = escapeHtml(status.message || '正在升级,马上回来。');
334  const until = status.until ? escapeHtml(status.until) : '';
335  const untilBlock = until
336    ? `<p class="until"><span>预计恢复</span><strong>${until}</strong></p>`
337    : '';
338
339  return `<!DOCTYPE html>
340<html lang="zh-CN">
341<head>
342  <meta charset="utf-8">
343  <meta name="viewport" content="width=device-width, initial-scale=1">
344  <meta name="robots" content="noindex">
345  <title>${title} · One Blog</title>
346  <link rel="preconnect" href="https://fonts.googleapis.com">
347  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
348  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
349  <style>
350    :root {
351      --ink: #1a2e2a;
352      --muted: #5a6f68;
353      --accent: #2f8f73;
354      --accent-soft: #7bc4a8;
355      --paper: #f6f3eb;
356      --glow: #d8efe4;
357    }
358    * { box-sizing: border-box; }
359    html, body { height: 100%; margin: 0; }
360    body {
361      min-height: 100%;
362      display: grid;
363      place-items: center;
364      padding: 28px 20px;
365      color: var(--ink);
366      font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
367      background:
368        radial-gradient(ellipse 80% 55% at 18% 12%, #dff3ea 0%, transparent 55%),
369        radial-gradient(ellipse 70% 50% at 88% 88%, #efe4c8 0%, transparent 50%),
370        radial-gradient(ellipse 50% 40% at 70% 20%, #cfe8de 0%, transparent 45%),
371        linear-gradient(165deg, #eef6f2 0%, var(--paper) 48%, #ebe4d6 100%);
372      overflow: hidden;
373      position: relative;
374    }
375    .blob {
376      position: absolute;
377      border-radius: 50%;
378      filter: blur(48px);
379      opacity: .55;
380      pointer-events: none;
381      animation: drift 14s ease-in-out infinite;
382    }
383    .blob-a {
384      width: 340px; height: 340px;
385      left: -80px; top: -40px;
386      background: #9fd9c2;
387    }
388    .blob-b {
389      width: 280px; height: 280px;
390      right: -60px; bottom: -30px;
391      background: #e8d5a3;
392      animation-delay: -5s;
393      animation-duration: 18s;
394    }
395    .blob-c {
396      width: 180px; height: 180px;
397      left: 42%; top: 62%;
398      background: #b7e0d2;
399      animation-delay: -9s;
400      animation-duration: 16s;
401    }
402    .grain {
403      position: absolute;
404      inset: 0;
405      pointer-events: none;
406      opacity: .04;
407      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
408    }
409    .stage {
410      position: relative;
411      z-index: 1;
412      width: min(520px, 100%);
413      text-align: center;
414      animation: enter .8s cubic-bezier(.22,1,.36,1) both;
415    }
416    .visual {
417      width: 168px;
418      height: 168px;
419      margin: 0 auto 28px;
420      position: relative;
421      animation: float 5.5s ease-in-out infinite;
422    }
423    .visual svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 18px 28px rgba(47,143,115,.18)); }
424    .ring {
425      position: absolute;
426      inset: -10px;
427      border-radius: 50%;
428      border: 1.5px dashed rgba(47,143,115,.28);
429      animation: spin 28s linear infinite;
430    }
431    .brand {
432      font-family: Fraunces, "Noto Serif SC", Georgia, serif;
433      font-size: clamp(34px, 7vw, 48px);
434      font-weight: 700;
435      letter-spacing: -.02em;
436      line-height: 1;
437      margin: 0 0 8px;
438      color: var(--ink);
439    }
440    .brand em {
441      font-style: normal;
442      background: linear-gradient(120deg, var(--accent), #4aa888 45%, #c4a35a);
443      -webkit-background-clip: text;
444      background-clip: text;
445      color: transparent;
446    }
447    .domain {
448      margin: 0 0 28px;
449      font-size: 13px;
450      letter-spacing: .16em;
451      text-transform: uppercase;
452      color: var(--muted);
453      font-weight: 500;
454    }
455    h1 {
456      margin: 0 0 12px;
457      font-size: clamp(24px, 4.5vw, 32px);
458      font-weight: 700;
459      letter-spacing: .02em;
460    }
461    .message {
462      margin: 0 auto;
463      max-width: 34em;
464      font-size: 16px;
465      line-height: 1.75;
466      color: var(--muted);
467      white-space: pre-wrap;
468      font-weight: 400;
469    }
470    .progress {
471      width: min(220px, 70%);
472      height: 4px;
473      margin: 28px auto 0;
474      border-radius: 999px;
475      background: rgba(47,143,115,.12);
476      overflow: hidden;
477    }
478    .progress > i {
479      display: block;
480      height: 100%;
481      width: 42%;
482      border-radius: inherit;
483      background: linear-gradient(90deg, var(--accent-soft), var(--accent), #c4a35a);
484      animation: slide 2.4s ease-in-out infinite;
485    }
486    .until {
487      display: inline-flex;
488      align-items: center;
489      gap: 10px;
490      margin: 22px 0 0;
491      padding: 8px 14px;
492      border-radius: 999px;
493      background: rgba(255,255,255,.55);
494      border: 1px solid rgba(47,143,115,.16);
495      font-size: 13px;
496      color: var(--muted);
497      backdrop-filter: blur(8px);
498    }
499    .until span { opacity: .75; }
500    .until strong { color: var(--ink); font-weight: 600; }
501    .hint {
502      margin: 26px 0 0;
503      font-size: 12px;
504      color: rgba(90,111,104,.72);
505    }
506    @keyframes enter {
507      from { opacity: 0; transform: translateY(18px) scale(.98); }
508      to { opacity: 1; transform: none; }
509    }
510    @keyframes float {
511      0%, 100% { transform: translateY(0); }
512      50% { transform: translateY(-10px); }
513    }
514    @keyframes drift {
515      0%, 100% { transform: translate(0, 0) scale(1); }
516      50% { transform: translate(24px, -18px) scale(1.08); }
517    }
518    @keyframes spin { to { transform: rotate(360deg); } }
519    @keyframes slide {
520      0% { transform: translateX(-120%); }
521      100% { transform: translateX(280%); }
522    }
523    @media (prefers-reduced-motion: reduce) {
524      .blob, .visual, .ring, .progress > i, .stage { animation: none !important; }
525    }
526  </style>
527</head>
528<body>
529  <div class="blob blob-a" aria-hidden="true"></div>
530  <div class="blob blob-b" aria-hidden="true"></div>
531  <div class="blob blob-c" aria-hidden="true"></div>
532  <div class="grain" aria-hidden="true"></div>
533  <main class="stage">
534    <div class="visual" aria-hidden="true">
535      <div class="ring"></div>
536      <svg viewBox="0 0 168 168" fill="none" xmlns="http://www.w3.org/2000/svg">
537        <defs>
538          <linearGradient id="g1" x1="28" y1="24" x2="140" y2="148" gradientUnits="userSpaceOnUse">
539            <stop stop-color="#E8F7F1"/>
540            <stop offset="1" stop-color="#B7E0D2"/>
541          </linearGradient>
542          <linearGradient id="g2" x1="56" y1="62" x2="118" y2="118" gradientUnits="userSpaceOnUse">
543            <stop stop-color="#2F8F73"/>
544            <stop offset="1" stop-color="#C4A35A"/>
545          </linearGradient>
546        </defs>
547        <circle cx="84" cy="84" r="72" fill="url(#g1)"/>
548        <circle cx="84" cy="84" r="54" fill="#F8FBF9" fill-opacity=".72"/>
549        <path d="M58 92c8-18 22-28 36-28s28 10 36 28" stroke="url(#g2)" stroke-width="5" stroke-linecap="round"/>
550        <path d="M70 78c4-8 10-12 14-12s10 4 14 12" stroke="#2F8F73" stroke-width="4" stroke-linecap="round" opacity=".55"/>
551        <circle cx="84" cy="104" r="7" fill="#2F8F73"/>
552        <path d="M84 111v18" stroke="#2F8F73" stroke-width="4" stroke-linecap="round"/>
553        <path d="M74 126h20" stroke="#C4A35A" stroke-width="4" stroke-linecap="round"/>
554        <circle cx="118" cy="58" r="5" fill="#C4A35A" opacity=".8"/>
555        <circle cx="52" cy="64" r="3.5" fill="#2F8F73" opacity=".45"/>
556      </svg>
557    </div>
558    <p class="brand">One <em>Blog</em></p>
559    <p class="domain">onedayxyy.cn</p>
560    <h1>${title}</h1>
561    <p class="message">${message}</p>
562    <div class="progress" aria-hidden="true"><i></i></div>
563    ${untilBlock}
564    <p class="hint">若仍看到旧页面,请强制刷新或使用无痕窗口</p>
565  </main>
566</body>
567</html>`;
568}
569
570function buildMaintenanceResponse(status) {
571  const html = buildMaintenanceHtml(status);
572  return new Response(html, {
573    status: 503,
574    headers: {
575      'Content-Type': 'text/html; charset=utf-8',
576      'Cache-Control': 'no-store, no-cache, must-revalidate',
577      Pragma: 'no-cache',
578      'Retry-After': '300',
579      'X-OneBlog-Maintenance': '1',
580    },
581  });
582}

效果:

v2-优化样式

维护页已重做成更亮、更有氛围的版本,主要变化:

  • 浅色茶青氛围:雾青绿 + 暖纸色渐变,替代原先深色底
  • 品牌前置:大号 One Blog(Fraunces)作为视觉主信号
  • 插画锚点:中心 SVG 图标 + 虚线圆环慢旋、轻浮浮动
  • 动效:背景光斑漂移、入场动画、进度条扫光(尊重 prefers-reduced-motion

文案仍走后台配置的标题 / 说明 / 预计恢复时间。

生效方式:把更新后的 edgeone/blog-cnb-origin.js 在 EdgeOne 控制台整文件覆盖并发布;不必重建 Hugo。需要的话我可以帮你提交推送。

blog-cnb-origin.js 内容:

  1/**
  2 * EdgeOne 边缘函数:从 CNB 公开产物库拉取静态站
  3 *
  4 * 用法:复制到腾讯云 EdgeOne 控制台 → 站点 → 边缘函数(整文件覆盖)
  5 *
  6 * 相对旧版改进:
  7 * 1. HTML/CSS/JS 等文本资源做 gzip(缓冲后再压,避免流式 CompressionStream 在 EO 静默失败)
  8 * 2. 指纹静态资源长缓存;HTML 短缓存
  9 * 3. 响应头 X-EO-Compress 便于确认压缩是否生效
 10 * 4. 找不到页面时回退到产物库中的 404.html(Hugo 自定义 404)
 11 * 5. 维护模式:轮询 Admin 公开状态;开启时对所有请求返回 503 维护页
 12 */
 13addEventListener('fetch', (event) => {
 14  event.respondWith(handleRequest(event.request));
 15});
 16
 17const BASE = 'https://cnb.cool/onedayxyy/hugo-teek-public-dist/-/git/raw/dist';
 18const MAINTENANCE_STATUS_URL = 'https://oneblogadmin.onedayxyy.cn/api/maintenance-status';
 19const MAINTENANCE_CACHE_TTL_MS = 5000;
 20const MAINTENANCE_FETCH_TIMEOUT_MS = 2500;
 21
 22async function handleRequest(request) {
 23  const maintenance = await getMaintenanceStatus();
 24  if (maintenance && maintenance.enabled) {
 25    return buildMaintenanceResponse(maintenance);
 26  }
 27
 28  const url = new URL(request.url);
 29  let pathname = url.pathname;
 30
 31  if (pathname !== '/' && !pathname.endsWith('/') && !isFile(pathname)) {
 32    const redirectUrl = new URL(request.url);
 33    redirectUrl.pathname = pathname + '/';
 34    return Response.redirect(redirectUrl.toString(), 301);
 35  }
 36
 37  let uri = pathname;
 38  if (uri.startsWith('/')) uri = uri.slice(1);
 39  if (!uri || uri === '') uri = 'index.html';
 40  if (uri.endsWith('/')) uri = uri + 'index.html';
 41
 42  let target = `${BASE}/${uri}`;
 43  let resp = await fetchOrigin(target);
 44
 45  if (!resp.ok && !isFile(uri)) {
 46    const indexUri = uri.endsWith('/') ? uri + 'index.html' : uri + '/index.html';
 47    target = `${BASE}/${indexUri}`;
 48    resp = await fetchOrigin(target);
 49    if (resp.ok) uri = indexUri;
 50  }
 51
 52  if (!resp.ok || !resp.body) {
 53    // 回退到 Hugo 构建的自定义 404 页,避免纯文本 "Page Not Found"
 54    const notFound = await fetchOrigin(`${BASE}/404.html`);
 55    if (notFound.ok && notFound.body) {
 56      const rawBuf = await notFound.arrayBuffer();
 57      const contentType = 'text/html; charset=utf-8';
 58      const headers = new Headers();
 59      headers.set('Content-Type', contentType);
 60      headers.set('Cache-Control', 'public, max-age=60, must-revalidate');
 61      headers.set('Content-Length', String(rawBuf.byteLength));
 62      const uncompressed = new Response(rawBuf, {
 63        status: 404,
 64        headers,
 65      });
 66      return maybeGzip(request, uncompressed, '404.html', rawBuf, contentType);
 67    }
 68
 69    return new Response('Page Not Found', {
 70      status: 404,
 71      headers: { 'Content-Type': 'text/html; charset=utf-8' },
 72    });
 73  }
 74
 75  const contentType =
 76    getContentType(uri) || resp.headers.get('content-type') || 'text/html; charset=utf-8';
 77
 78  // 大体积 CSS/JS(如 main.css ~1MB)若整包 arrayBuffer + gzip,EO 易直接 545。
 79  // 指纹静态资源改为直通,交给平台智能压缩。
 80  const originLen = Number(resp.headers.get('content-length') || 0);
 81  if (shouldPassthrough(uri, contentType, originLen)) {
 82    const headers = new Headers();
 83    headers.set('Content-Type', contentType);
 84    headers.set('Cache-Control', cacheControlFor(uri));
 85    if (originLen > 0) headers.set('Content-Length', String(originLen));
 86    headers.set('X-EO-Compress', 'passthrough');
 87    return new Response(resp.body, { status: resp.status, headers });
 88  }
 89
 90  // 先读入缓冲:EO 上对流式 pipeThrough(CompressionStream) 经常不生效
 91  let rawBuf;
 92  try {
 93    rawBuf = await resp.arrayBuffer();
 94  } catch (e) {
 95    const headers = new Headers();
 96    headers.set('Content-Type', contentType);
 97    headers.set('Cache-Control', cacheControlFor(uri));
 98    headers.set('X-EO-Compress', 'passthrough-read-fail');
 99    const retry = await fetchOrigin(target);
100    return new Response(retry.body, { status: retry.status, headers });
101  }
102
103  // 读完后发现超大:不再 gzip,避免边缘函数 OOM/545
104  if (rawBuf.byteLength > PASS_THROUGH_MAX_BYTES) {
105    const headers = new Headers();
106    headers.set('Content-Type', contentType);
107    headers.set('Cache-Control', cacheControlFor(uri));
108    headers.set('Content-Length', String(rawBuf.byteLength));
109    headers.set('X-EO-Compress', 'skip-large');
110    return new Response(rawBuf, { status: resp.status, headers });
111  }
112
113  const headers = new Headers();
114  headers.set('Content-Type', contentType);
115  headers.set('Cache-Control', cacheControlFor(uri));
116  headers.set('Content-Length', String(rawBuf.byteLength));
117
118  const uncompressed = new Response(rawBuf, {
119    status: resp.status,
120    headers,
121  });
122
123  return maybeGzip(request, uncompressed, uri, rawBuf, contentType);
124}
125
126/** 超过该体积不再做边缘 gzip 缓冲(main.css 约 1MB) */
127const PASS_THROUGH_MAX_BYTES = 400 * 1024;
128
129function shouldPassthrough(uri, contentType, contentLength) {
130  if (contentLength > PASS_THROUGH_MAX_BYTES) return true;
131  // 指纹 CSS/JS 体积常偏大,优先直通
132  if (/\.min\.[a-f0-9]{8,}\.(?:css|js)(?:$|\?)/i.test(uri)) {
133    if (contentLength === 0) return true; // 未知长度时也不冒险缓冲
134    if (contentLength > 200 * 1024) return true;
135  }
136  return false;
137}
138
139function fetchOrigin(target) {
140  return fetch(target, {
141    headers: {
142      'User-Agent': 'curl/8.0.0',
143      Accept: '*/*',
144      'Accept-Encoding': 'identity',
145    },
146  });
147}
148
149function isFile(path) {
150  return /\.[^/]+$/.test(path);
151}
152
153function getContentType(uri) {
154  const map = {
155    html: 'text/html; charset=utf-8',
156    css: 'text/css; charset=utf-8',
157    js: 'application/javascript; charset=utf-8',
158    mjs: 'application/javascript; charset=utf-8',
159    json: 'application/json; charset=utf-8',
160    png: 'image/png',
161    jpg: 'image/jpeg',
162    jpeg: 'image/jpeg',
163    gif: 'image/gif',
164    svg: 'image/svg+xml',
165    webp: 'image/webp',
166    avif: 'image/avif',
167    ico: 'image/x-icon',
168    woff: 'font/woff',
169    woff2: 'font/woff2',
170    txt: 'text/plain; charset=utf-8',
171    xml: 'application/xml; charset=utf-8',
172    map: 'application/json',
173  };
174  if (!isFile(uri)) return 'text/html; charset=utf-8';
175  const ext = uri.split('.').pop().toLowerCase();
176  return map[ext];
177}
178
179function cacheControlFor(uri) {
180  if (/\.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)(?:$|\?)/i.test(uri)) {
181    return 'public, max-age=31536000, immutable';
182  }
183  return 'public, max-age=60, must-revalidate';
184}
185
186function isCompressible(uri, contentType) {
187  if (/\.(?:png|jpe?g|gif|webp|avif|woff2?|mp4|webm|gz|br)(?:$|\?)/i.test(uri)) {
188    return false;
189  }
190  const type = contentType || '';
191  return /text\/|javascript|json|xml|svg/.test(type);
192}
193
194async function compressGzip(rawBuf) {
195  if (typeof CompressionStream === 'undefined') {
196    return { ok: false, reason: 'no-compression-stream' };
197  }
198  try {
199    const cs = new CompressionStream('gzip');
200    const writer = cs.writable.getWriter();
201    await writer.write(new Uint8Array(rawBuf));
202    await writer.close();
203    const compressed = await new Response(cs.readable).arrayBuffer();
204    if (!compressed || compressed.byteLength < 2) {
205      return { ok: false, reason: 'empty-output' };
206    }
207    // gzip magic 1F 8B
208    const u8 = new Uint8Array(compressed);
209    if (u8[0] !== 0x1f || u8[1] !== 0x8b) {
210      return { ok: false, reason: 'bad-magic' };
211    }
212    return { ok: true, buf: compressed };
213  } catch (e) {
214    return { ok: false, reason: 'err:' + (e && e.message ? String(e.message).slice(0, 80) : 'unknown') };
215  }
216}
217
218async function maybeGzip(request, response, uri, rawBuf, contentType) {
219  const accept = (request.headers.get('Accept-Encoding') || '').toLowerCase();
220  // EdgeOne 常会剥掉/改写传入边缘函数的 Accept-Encoding,导致误判 skip-client。
221  // 现代浏览器都支持 gzip:仅当明确只要 identity 时才跳过。
222  const identityOnly =
223    accept === 'identity' ||
224    (/identity/.test(accept) && !/gzip|\*|br/.test(accept));
225  if (identityOnly) {
226    const h = new Headers(response.headers);
227    h.set('X-EO-Compress', 'skip-identity');
228    return new Response(rawBuf, { status: response.status, headers: h });
229  }
230  if (!isCompressible(uri, contentType)) {
231    const h = new Headers(response.headers);
232    h.set('X-EO-Compress', 'skip-type');
233    return new Response(rawBuf, { status: response.status, headers: h });
234  }
235
236  const result = await compressGzip(rawBuf);
237  if (!result.ok) {
238    const h = new Headers(response.headers);
239    h.set('X-EO-Compress', result.reason || 'fail');
240    // 不设 Vary,尽量让 EO「智能压缩」有机会接管
241    return new Response(rawBuf, { status: response.status, headers: h });
242  }
243
244  const headers = new Headers(response.headers);
245  headers.set('Content-Encoding', 'gzip');
246  headers.set('Content-Length', String(result.buf.byteLength));
247  headers.set('Vary', 'Accept-Encoding');
248  headers.set(
249    'X-EO-Compress',
250    'gzip-ok;ae=' + (accept ? accept.slice(0, 40) : 'missing')
251  );
252  return new Response(result.buf, { status: response.status, headers });
253}
254
255// ==================== 站点维护模式 ====================
256// fail-open:状态接口失败/超时则照常提供站点,避免 Admin 故障锁死主站。
257
258let maintenanceMemoryCache = { at: 0, data: null };
259
260async function getMaintenanceStatus() {
261  const now = Date.now();
262  if (
263    maintenanceMemoryCache.data &&
264    now - maintenanceMemoryCache.at < MAINTENANCE_CACHE_TTL_MS
265  ) {
266    return maintenanceMemoryCache.data;
267  }
268
269  try {
270    if (typeof caches !== 'undefined' && caches.default) {
271      const cache = caches.default;
272      const cacheReq = new Request(MAINTENANCE_STATUS_URL, { method: 'GET' });
273      const cached = await cache.match(cacheReq);
274      if (cached) {
275        const data = await cached.json();
276        maintenanceMemoryCache = { at: now, data };
277        return data;
278      }
279    }
280  } catch (_) {
281    /* ignore cache read */
282  }
283
284  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
285  const timer = controller
286    ? setTimeout(() => controller.abort(), MAINTENANCE_FETCH_TIMEOUT_MS)
287    : null;
288
289  try {
290    const resp = await fetch(MAINTENANCE_STATUS_URL, {
291      method: 'GET',
292      headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' },
293      signal: controller ? controller.signal : undefined,
294    });
295    if (!resp.ok) return null;
296    const data = await resp.json();
297    maintenanceMemoryCache = { at: now, data };
298
299    try {
300      if (typeof caches !== 'undefined' && caches.default) {
301        const cache = caches.default;
302        const cacheResp = new Response(JSON.stringify(data), {
303          headers: {
304            'Content-Type': 'application/json',
305            'Cache-Control': 'max-age=5',
306          },
307        });
308        await cache.put(MAINTENANCE_STATUS_URL, cacheResp);
309      }
310    } catch (_) {
311      /* ignore cache write */
312    }
313
314    return data;
315  } catch (_) {
316    return null;
317  } finally {
318    if (timer) clearTimeout(timer);
319  }
320}
321
322function escapeHtml(str) {
323  return String(str || '')
324    .replace(/&/g, '&amp;')
325    .replace(/</g, '&lt;')
326    .replace(/>/g, '&gt;')
327    .replace(/"/g, '&quot;')
328    .replace(/'/g, '&#39;');
329}
330
331function buildMaintenanceHtml(status) {
332  const title = escapeHtml(status.title || '站点维护中');
333  const message = escapeHtml(status.message || '正在升级,马上回来。');
334  const until = status.until ? escapeHtml(status.until) : '';
335  const untilBlock = until
336    ? `<p class="until"><span>预计恢复</span><strong>${until}</strong></p>`
337    : '';
338
339  return `<!DOCTYPE html>
340<html lang="zh-CN">
341<head>
342  <meta charset="utf-8">
343  <meta name="viewport" content="width=device-width, initial-scale=1">
344  <meta name="robots" content="noindex">
345  <title>${title} · One Blog</title>
346  <link rel="preconnect" href="https://fonts.googleapis.com">
347  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
348  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
349  <style>
350    :root {
351      --ink: #1a2e2a;
352      --muted: #5a6f68;
353      --accent: #2f8f73;
354      --accent-soft: #7bc4a8;
355      --paper: #f6f3eb;
356      --glow: #d8efe4;
357    }
358    * { box-sizing: border-box; }
359    html, body { height: 100%; margin: 0; }
360    body {
361      min-height: 100%;
362      display: grid;
363      place-items: center;
364      padding: 28px 20px;
365      color: var(--ink);
366      font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
367      background:
368        radial-gradient(ellipse 80% 55% at 18% 12%, #dff3ea 0%, transparent 55%),
369        radial-gradient(ellipse 70% 50% at 88% 88%, #efe4c8 0%, transparent 50%),
370        radial-gradient(ellipse 50% 40% at 70% 20%, #cfe8de 0%, transparent 45%),
371        linear-gradient(165deg, #eef6f2 0%, var(--paper) 48%, #ebe4d6 100%);
372      overflow: hidden;
373      position: relative;
374    }
375    .blob {
376      position: absolute;
377      border-radius: 50%;
378      filter: blur(48px);
379      opacity: .55;
380      pointer-events: none;
381      animation: drift 14s ease-in-out infinite;
382    }
383    .blob-a {
384      width: 340px; height: 340px;
385      left: -80px; top: -40px;
386      background: #9fd9c2;
387    }
388    .blob-b {
389      width: 280px; height: 280px;
390      right: -60px; bottom: -30px;
391      background: #e8d5a3;
392      animation-delay: -5s;
393      animation-duration: 18s;
394    }
395    .blob-c {
396      width: 180px; height: 180px;
397      left: 42%; top: 62%;
398      background: #b7e0d2;
399      animation-delay: -9s;
400      animation-duration: 16s;
401    }
402    .grain {
403      position: absolute;
404      inset: 0;
405      pointer-events: none;
406      opacity: .04;
407      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
408    }
409    .stage {
410      position: relative;
411      z-index: 1;
412      width: min(520px, 100%);
413      text-align: center;
414      animation: enter .8s cubic-bezier(.22,1,.36,1) both;
415    }
416    .visual {
417      width: 168px;
418      height: 168px;
419      margin: 0 auto 28px;
420      position: relative;
421      animation: float 5.5s ease-in-out infinite;
422    }
423    .visual svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 18px 28px rgba(47,143,115,.18)); }
424    .ring {
425      position: absolute;
426      inset: -10px;
427      border-radius: 50%;
428      border: 1.5px dashed rgba(47,143,115,.28);
429      animation: spin 28s linear infinite;
430    }
431    .brand {
432      font-family: Fraunces, "Noto Serif SC", Georgia, serif;
433      font-size: clamp(34px, 7vw, 48px);
434      font-weight: 700;
435      letter-spacing: -.02em;
436      line-height: 1;
437      margin: 0 0 8px;
438      color: var(--ink);
439    }
440    .brand em {
441      font-style: normal;
442      background: linear-gradient(120deg, var(--accent), #4aa888 45%, #c4a35a);
443      -webkit-background-clip: text;
444      background-clip: text;
445      color: transparent;
446    }
447    .domain {
448      margin: 0 0 28px;
449      font-size: 13px;
450      letter-spacing: .16em;
451      text-transform: uppercase;
452      color: var(--muted);
453      font-weight: 500;
454    }
455    h1 {
456      margin: 0 0 12px;
457      font-size: clamp(24px, 4.5vw, 32px);
458      font-weight: 700;
459      letter-spacing: .02em;
460    }
461    .message {
462      margin: 0 auto;
463      max-width: 34em;
464      font-size: 16px;
465      line-height: 1.75;
466      color: var(--muted);
467      white-space: pre-wrap;
468      font-weight: 400;
469    }
470    .progress {
471      width: min(220px, 70%);
472      height: 4px;
473      margin: 28px auto 0;
474      border-radius: 999px;
475      background: rgba(47,143,115,.12);
476      overflow: hidden;
477    }
478    .progress > i {
479      display: block;
480      height: 100%;
481      width: 42%;
482      border-radius: inherit;
483      background: linear-gradient(90deg, var(--accent-soft), var(--accent), #c4a35a);
484      animation: slide 2.4s ease-in-out infinite;
485    }
486    .until {
487      display: inline-flex;
488      align-items: center;
489      gap: 10px;
490      margin: 22px 0 0;
491      padding: 8px 14px;
492      border-radius: 999px;
493      background: rgba(255,255,255,.55);
494      border: 1px solid rgba(47,143,115,.16);
495      font-size: 13px;
496      color: var(--muted);
497      backdrop-filter: blur(8px);
498    }
499    .until span { opacity: .75; }
500    .until strong { color: var(--ink); font-weight: 600; }
501    .hint {
502      margin: 26px 0 0;
503      font-size: 12px;
504      color: rgba(90,111,104,.72);
505    }
506    @keyframes enter {
507      from { opacity: 0; transform: translateY(18px) scale(.98); }
508      to { opacity: 1; transform: none; }
509    }
510    @keyframes float {
511      0%, 100% { transform: translateY(0); }
512      50% { transform: translateY(-10px); }
513    }
514    @keyframes drift {
515      0%, 100% { transform: translate(0, 0) scale(1); }
516      50% { transform: translate(24px, -18px) scale(1.08); }
517    }
518    @keyframes spin { to { transform: rotate(360deg); } }
519    @keyframes slide {
520      0% { transform: translateX(-120%); }
521      100% { transform: translateX(280%); }
522    }
523    @media (prefers-reduced-motion: reduce) {
524      .blob, .visual, .ring, .progress > i, .stage { animation: none !important; }
525    }
526  </style>
527</head>
528<body>
529  <div class="blob blob-a" aria-hidden="true"></div>
530  <div class="blob blob-b" aria-hidden="true"></div>
531  <div class="blob blob-c" aria-hidden="true"></div>
532  <div class="grain" aria-hidden="true"></div>
533  <main class="stage">
534    <div class="visual" aria-hidden="true">
535      <div class="ring"></div>
536      <svg viewBox="0 0 168 168" fill="none" xmlns="http://www.w3.org/2000/svg">
537        <defs>
538          <linearGradient id="g1" x1="28" y1="24" x2="140" y2="148" gradientUnits="userSpaceOnUse">
539            <stop stop-color="#E8F7F1"/>
540            <stop offset="1" stop-color="#B7E0D2"/>
541          </linearGradient>
542          <linearGradient id="g2" x1="56" y1="62" x2="118" y2="118" gradientUnits="userSpaceOnUse">
543            <stop stop-color="#2F8F73"/>
544            <stop offset="1" stop-color="#C4A35A"/>
545          </linearGradient>
546        </defs>
547        <circle cx="84" cy="84" r="72" fill="url(#g1)"/>
548        <circle cx="84" cy="84" r="54" fill="#F8FBF9" fill-opacity=".72"/>
549        <path d="M58 92c8-18 22-28 36-28s28 10 36 28" stroke="url(#g2)" stroke-width="5" stroke-linecap="round"/>
550        <path d="M70 78c4-8 10-12 14-12s10 4 14 12" stroke="#2F8F73" stroke-width="4" stroke-linecap="round" opacity=".55"/>
551        <circle cx="84" cy="104" r="7" fill="#2F8F73"/>
552        <path d="M84 111v18" stroke="#2F8F73" stroke-width="4" stroke-linecap="round"/>
553        <path d="M74 126h20" stroke="#C4A35A" stroke-width="4" stroke-linecap="round"/>
554        <circle cx="118" cy="58" r="5" fill="#C4A35A" opacity=".8"/>
555        <circle cx="52" cy="64" r="3.5" fill="#2F8F73" opacity=".45"/>
556      </svg>
557    </div>
558    <p class="brand">One <em>Blog</em></p>
559    <p class="domain">onedayxyy.cn</p>
560    <h1>${title}</h1>
561    <p class="message">${message}</p>
562    <div class="progress" aria-hidden="true"><i></i></div>
563    ${untilBlock}
564    <p class="hint">若仍看到旧页面,请强制刷新或使用无痕窗口</p>
565  </main>
566</body>
567</html>`;
568}
569
570function buildMaintenanceResponse(status) {
571  const html = buildMaintenanceHtml(status);
572  return new Response(html, {
573    status: 503,
574    headers: {
575      'Content-Type': 'text/html; charset=utf-8',
576      'Cache-Control': 'no-store, no-cache, must-revalidate',
577      Pragma: 'no-cache',
578      'Retry-After': '300',
579      'X-OneBlog-Maintenance': '1',
580    },
581  });
582}

效果:

v3-标签页logo图片更新

问题:

这里的标签页自己logo图片怎么没得?

回答:

因为维护模式把所有请求都拦成了维护 HTML,浏览器去拉 /favicon.ico 时拿到的也是网页,不是图标;维护页里原先也没写 <link rel="icon">

已修好两处:

  1. 维护页 head 加上与主站一致的头像 CDN 图标
  2. 维护开启时仍放行 /favicon.ico 等路径

请把更新后的 edgeone/blog-cnb-origin.js 在 EdgeOne 覆盖发布;发布后强制刷新标签页即可看到 logo。

edgeone/blog-cnb-origin.js内容:

  1/**
  2 * EdgeOne 边缘函数:从 CNB 公开产物库拉取静态站
  3 *
  4 * 用法:复制到腾讯云 EdgeOne 控制台 → 站点 → 边缘函数(整文件覆盖)
  5 *
  6 * 相对旧版改进:
  7 * 1. HTML/CSS/JS 等文本资源做 gzip(缓冲后再压,避免流式 CompressionStream 在 EO 静默失败)
  8 * 2. 指纹静态资源长缓存;HTML 短缓存
  9 * 3. 响应头 X-EO-Compress 便于确认压缩是否生效
 10 * 4. 找不到页面时回退到产物库中的 404.html(Hugo 自定义 404)
 11 * 5. 维护模式:轮询 Admin 公开状态;开启时对所有请求返回 503 维护页
 12 */
 13addEventListener('fetch', (event) => {
 14  event.respondWith(dispatch(event.request));
 15});
 16
 17const BASE = 'https://cnb.cool/onedayxyy/hugo-teek-public-dist/-/git/raw/dist';
 18const MAINTENANCE_STATUS_URL = 'https://oneblogadmin.onedayxyy.cn/api/maintenance-status';
 19const MAINTENANCE_CACHE_TTL_MS = 5000;
 20const MAINTENANCE_FETCH_TIMEOUT_MS = 2500;
 21
 22async function dispatch(request) {
 23  const maintResult = await getMaintenanceStatus();
 24  const pathname = new URL(request.url).pathname;
 25  // 维护模式下仍放行站点图标,避免标签页变成空白文档图标
 26  if (maintResult.data && maintResult.data.enabled && !isFaviconPath(pathname)) {
 27    return buildMaintenanceResponse(maintResult.data, maintResult.source);
 28  }
 29  const response = await handleRequest(request);
 30  return withMaintProbe(response, maintResult);
 31}
 32
 33async function handleRequest(request) {
 34  const url = new URL(request.url);
 35  let pathname = url.pathname;
 36
 37  if (pathname !== '/' && !pathname.endsWith('/') && !isFile(pathname)) {
 38    const redirectUrl = new URL(request.url);
 39    redirectUrl.pathname = pathname + '/';
 40    return Response.redirect(redirectUrl.toString(), 301);
 41  }
 42
 43  let uri = pathname;
 44  if (uri.startsWith('/')) uri = uri.slice(1);
 45  if (!uri || uri === '') uri = 'index.html';
 46  if (uri.endsWith('/')) uri = uri + 'index.html';
 47
 48  let target = `${BASE}/${uri}`;
 49  let resp = await fetchOrigin(target);
 50
 51  if (!resp.ok && !isFile(uri)) {
 52    const indexUri = uri.endsWith('/') ? uri + 'index.html' : uri + '/index.html';
 53    target = `${BASE}/${indexUri}`;
 54    resp = await fetchOrigin(target);
 55    if (resp.ok) uri = indexUri;
 56  }
 57
 58  if (!resp.ok || !resp.body) {
 59    // 回退到 Hugo 构建的自定义 404 页,避免纯文本 "Page Not Found"
 60    const notFound = await fetchOrigin(`${BASE}/404.html`);
 61    if (notFound.ok && notFound.body) {
 62      const rawBuf = await notFound.arrayBuffer();
 63      const contentType = 'text/html; charset=utf-8';
 64      const headers = new Headers();
 65      headers.set('Content-Type', contentType);
 66      headers.set('Cache-Control', 'public, max-age=60, must-revalidate');
 67      headers.set('Content-Length', String(rawBuf.byteLength));
 68      const uncompressed = new Response(rawBuf, {
 69        status: 404,
 70        headers,
 71      });
 72      return maybeGzip(request, uncompressed, '404.html', rawBuf, contentType);
 73    }
 74
 75    return new Response('Page Not Found', {
 76      status: 404,
 77      headers: { 'Content-Type': 'text/html; charset=utf-8' },
 78    });
 79  }
 80
 81  const contentType =
 82    getContentType(uri) || resp.headers.get('content-type') || 'text/html; charset=utf-8';
 83
 84  // 大体积 CSS/JS(如 main.css ~1MB)若整包 arrayBuffer + gzip,EO 易直接 545。
 85  // 指纹静态资源改为直通,交给平台智能压缩。
 86  const originLen = Number(resp.headers.get('content-length') || 0);
 87  if (shouldPassthrough(uri, contentType, originLen)) {
 88    const headers = new Headers();
 89    headers.set('Content-Type', contentType);
 90    headers.set('Cache-Control', cacheControlFor(uri));
 91    if (originLen > 0) headers.set('Content-Length', String(originLen));
 92    headers.set('X-EO-Compress', 'passthrough');
 93    return new Response(resp.body, { status: resp.status, headers });
 94  }
 95
 96  // 先读入缓冲:EO 上对流式 pipeThrough(CompressionStream) 经常不生效
 97  let rawBuf;
 98  try {
 99    rawBuf = await resp.arrayBuffer();
100  } catch (e) {
101    const headers = new Headers();
102    headers.set('Content-Type', contentType);
103    headers.set('Cache-Control', cacheControlFor(uri));
104    headers.set('X-EO-Compress', 'passthrough-read-fail');
105    const retry = await fetchOrigin(target);
106    return new Response(retry.body, { status: retry.status, headers });
107  }
108
109  // 读完后发现超大:不再 gzip,避免边缘函数 OOM/545
110  if (rawBuf.byteLength > PASS_THROUGH_MAX_BYTES) {
111    const headers = new Headers();
112    headers.set('Content-Type', contentType);
113    headers.set('Cache-Control', cacheControlFor(uri));
114    headers.set('Content-Length', String(rawBuf.byteLength));
115    headers.set('X-EO-Compress', 'skip-large');
116    return new Response(rawBuf, { status: resp.status, headers });
117  }
118
119  const headers = new Headers();
120  headers.set('Content-Type', contentType);
121  headers.set('Cache-Control', cacheControlFor(uri));
122  headers.set('Content-Length', String(rawBuf.byteLength));
123
124  const uncompressed = new Response(rawBuf, {
125    status: resp.status,
126    headers,
127  });
128
129  return maybeGzip(request, uncompressed, uri, rawBuf, contentType);
130}
131
132/** 超过该体积不再做边缘 gzip 缓冲(main.css 约 1MB) */
133const PASS_THROUGH_MAX_BYTES = 400 * 1024;
134
135function shouldPassthrough(uri, contentType, contentLength) {
136  if (contentLength > PASS_THROUGH_MAX_BYTES) return true;
137  // 指纹 CSS/JS 体积常偏大,优先直通
138  if (/\.min\.[a-f0-9]{8,}\.(?:css|js)(?:$|\?)/i.test(uri)) {
139    if (contentLength === 0) return true; // 未知长度时也不冒险缓冲
140    if (contentLength > 200 * 1024) return true;
141  }
142  return false;
143}
144
145function fetchOrigin(target) {
146  return fetch(target, {
147    headers: {
148      'User-Agent': 'curl/8.0.0',
149      Accept: '*/*',
150      'Accept-Encoding': 'identity',
151    },
152  });
153}
154
155function isFile(path) {
156  return /\.[^/]+$/.test(path);
157}
158
159function isFaviconPath(pathname) {
160  const p = String(pathname || '').toLowerCase();
161  return (
162    p === '/favicon.ico' ||
163    p === '/favicon.png' ||
164    p === '/favicon.svg' ||
165    p === '/apple-touch-icon.png' ||
166    p === '/apple-touch-icon-precomposed.png' ||
167    p.endsWith('/favicon.ico')
168  );
169}
170
171function getContentType(uri) {
172  const map = {
173    html: 'text/html; charset=utf-8',
174    css: 'text/css; charset=utf-8',
175    js: 'application/javascript; charset=utf-8',
176    mjs: 'application/javascript; charset=utf-8',
177    json: 'application/json; charset=utf-8',
178    png: 'image/png',
179    jpg: 'image/jpeg',
180    jpeg: 'image/jpeg',
181    gif: 'image/gif',
182    svg: 'image/svg+xml',
183    webp: 'image/webp',
184    avif: 'image/avif',
185    ico: 'image/x-icon',
186    woff: 'font/woff',
187    woff2: 'font/woff2',
188    txt: 'text/plain; charset=utf-8',
189    xml: 'application/xml; charset=utf-8',
190    map: 'application/json',
191  };
192  if (!isFile(uri)) return 'text/html; charset=utf-8';
193  const ext = uri.split('.').pop().toLowerCase();
194  return map[ext];
195}
196
197function cacheControlFor(uri) {
198  if (/\.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)(?:$|\?)/i.test(uri)) {
199    return 'public, max-age=31536000, immutable';
200  }
201  return 'public, max-age=60, must-revalidate';
202}
203
204function isCompressible(uri, contentType) {
205  if (/\.(?:png|jpe?g|gif|webp|avif|woff2?|mp4|webm|gz|br)(?:$|\?)/i.test(uri)) {
206    return false;
207  }
208  const type = contentType || '';
209  return /text\/|javascript|json|xml|svg/.test(type);
210}
211
212async function compressGzip(rawBuf) {
213  if (typeof CompressionStream === 'undefined') {
214    return { ok: false, reason: 'no-compression-stream' };
215  }
216  try {
217    const cs = new CompressionStream('gzip');
218    const writer = cs.writable.getWriter();
219    await writer.write(new Uint8Array(rawBuf));
220    await writer.close();
221    const compressed = await new Response(cs.readable).arrayBuffer();
222    if (!compressed || compressed.byteLength < 2) {
223      return { ok: false, reason: 'empty-output' };
224    }
225    // gzip magic 1F 8B
226    const u8 = new Uint8Array(compressed);
227    if (u8[0] !== 0x1f || u8[1] !== 0x8b) {
228      return { ok: false, reason: 'bad-magic' };
229    }
230    return { ok: true, buf: compressed };
231  } catch (e) {
232    return { ok: false, reason: 'err:' + (e && e.message ? String(e.message).slice(0, 80) : 'unknown') };
233  }
234}
235
236async function maybeGzip(request, response, uri, rawBuf, contentType) {
237  const accept = (request.headers.get('Accept-Encoding') || '').toLowerCase();
238  // EdgeOne 常会剥掉/改写传入边缘函数的 Accept-Encoding,导致误判 skip-client。
239  // 现代浏览器都支持 gzip:仅当明确只要 identity 时才跳过。
240  const identityOnly =
241    accept === 'identity' ||
242    (/identity/.test(accept) && !/gzip|\*|br/.test(accept));
243  if (identityOnly) {
244    const h = new Headers(response.headers);
245    h.set('X-EO-Compress', 'skip-identity');
246    return new Response(rawBuf, { status: response.status, headers: h });
247  }
248  if (!isCompressible(uri, contentType)) {
249    const h = new Headers(response.headers);
250    h.set('X-EO-Compress', 'skip-type');
251    return new Response(rawBuf, { status: response.status, headers: h });
252  }
253
254  const result = await compressGzip(rawBuf);
255  if (!result.ok) {
256    const h = new Headers(response.headers);
257    h.set('X-EO-Compress', result.reason || 'fail');
258    // 不设 Vary,尽量让 EO「智能压缩」有机会接管
259    return new Response(rawBuf, { status: response.status, headers: h });
260  }
261
262  const headers = new Headers(response.headers);
263  headers.set('Content-Encoding', 'gzip');
264  headers.set('Content-Length', String(result.buf.byteLength));
265  headers.set('Vary', 'Accept-Encoding');
266  headers.set(
267    'X-EO-Compress',
268    'gzip-ok;ae=' + (accept ? accept.slice(0, 40) : 'missing')
269  );
270  return new Response(result.buf, { status: response.status, headers });
271}
272
273// ==================== 站点维护模式 ====================
274// fail-open:状态接口失败/超时则照常提供站点,避免 Admin 故障锁死主站。
275// 仅用内存短缓存;不用 Cache API(EdgeOne 上 max-age 可能不失效,会把旧的 enabled:false 粘住)。
276
277let maintenanceMemoryCache = { at: 0, result: null };
278
279/**
280 * @returns {{ data: object|null, source: string }}
281 * source: memory | fetch | http-<code> | timeout | error | empty
282 */
283async function getMaintenanceStatus() {
284  const now = Date.now();
285  if (
286    maintenanceMemoryCache.result &&
287    now - maintenanceMemoryCache.at < MAINTENANCE_CACHE_TTL_MS
288  ) {
289    return {
290      data: maintenanceMemoryCache.result.data,
291      source: 'memory',
292    };
293  }
294
295  // 不用 AbortController:部分 EO 运行时对 signal 支持不完整,改为 Promise.race 超时
296  let timer = null;
297  let fetchPromise = null;
298  try {
299    fetchPromise = fetch(MAINTENANCE_STATUS_URL, {
300      method: 'GET',
301      headers: {
302        Accept: 'application/json',
303        'Cache-Control': 'no-cache',
304        Pragma: 'no-cache',
305      },
306      redirect: 'follow',
307    });
308    const timeoutPromise = new Promise((_, reject) => {
309      timer = setTimeout(
310        () => reject(new Error('maintenance-status-timeout')),
311        MAINTENANCE_FETCH_TIMEOUT_MS
312      );
313    });
314    const resp = await Promise.race([fetchPromise, timeoutPromise]);
315    if (!resp || !resp.ok) {
316      const result = {
317        data: null,
318        source: resp ? `http-${resp.status}` : 'empty',
319      };
320      maintenanceMemoryCache = { at: now, result };
321      return result;
322    }
323    const data = await resp.json();
324    const result = { data, source: 'fetch' };
325    maintenanceMemoryCache = { at: now, result };
326    return result;
327  } catch (err) {
328    const msg = err && err.message ? String(err.message) : '';
329    const result = {
330      data: null,
331      source: msg.indexOf('timeout') >= 0 ? 'timeout' : 'error',
332    };
333    maintenanceMemoryCache = { at: now, result };
334    return result;
335  } finally {
336    if (timer) clearTimeout(timer);
337    // 超时获胜时避免未处理的 fetch rejection
338    if (fetchPromise) fetchPromise.catch(() => {});
339  }
340}
341
342/** 给普通响应打诊断头。必须原地改 headers,禁止 new Response(body) 重建:
343 *  EdgeOne 重建带 Content-Encoding:gzip 的响应对 body 可能已是明文,浏览器会白屏。 */
344function withMaintProbe(response, maintResult) {
345  const enabled = !!(maintResult && maintResult.data && maintResult.data.enabled);
346  const source = (maintResult && maintResult.source) || 'unknown';
347  let probe = 'off';
348  if (source === 'fetch' || source === 'memory') {
349    probe = enabled ? 'on' : 'off';
350  } else {
351    probe = `fail:${source}`;
352  }
353  try {
354    response.headers.set('X-OneBlog-Maint', probe);
355    response.headers.set('X-OneBlog-Maint-Src', source);
356    return response;
357  } catch (_) {
358    // 极端情况下 headers 只读:宁可不打诊断头,也不重建 body(避免 gzip 错乱)
359    return response;
360  }
361}
362
363function escapeHtml(str) {
364  return String(str || '')
365    .replace(/&/g, '&amp;')
366    .replace(/</g, '&lt;')
367    .replace(/>/g, '&gt;')
368    .replace(/"/g, '&quot;')
369    .replace(/'/g, '&#39;');
370}
371
372function buildMaintenanceHtml(status) {
373  const title = escapeHtml(status.title || '站点维护中');
374  const message = escapeHtml(status.message || '正在升级,马上回来。');
375  const until = status.until ? escapeHtml(status.until) : '';
376  const untilBlock = until
377    ? `<p class="until"><span>预计恢复</span><strong>${until}</strong></p>`
378    : '';
379
380  return `<!DOCTYPE html>
381<html lang="zh-CN">
382<head>
383  <meta charset="utf-8">
384  <meta name="viewport" content="width=device-width, initial-scale=1">
385  <meta name="robots" content="noindex">
386  <title>${title} · One Blog</title>
387  <link rel="icon" type="image/webp" href="https://oneimg.onedayxyy.cn/images/site-icons/xyy-logo_58fed615.avif?w=150&h=150&fit=crop&fm=webp&q=80">
388  <link rel="apple-touch-icon" href="https://oneimg.onedayxyy.cn/images/site-icons/xyy-logo_58fed615.avif?w=180&h=180&fit=crop&fm=webp&q=80">
389  <link rel="preconnect" href="https://fonts.googleapis.com">
390  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
391  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
392  <style>
393    :root {
394      --ink: #1a2e2a;
395      --muted: #5a6f68;
396      --accent: #2f8f73;
397      --accent-soft: #7bc4a8;
398      --paper: #f6f3eb;
399      --glow: #d8efe4;
400    }
401    * { box-sizing: border-box; }
402    html, body { height: 100%; margin: 0; }
403    body {
404      min-height: 100%;
405      display: grid;
406      place-items: center;
407      padding: 28px 20px;
408      color: var(--ink);
409      font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
410      background:
411        radial-gradient(ellipse 80% 55% at 18% 12%, #dff3ea 0%, transparent 55%),
412        radial-gradient(ellipse 70% 50% at 88% 88%, #efe4c8 0%, transparent 50%),
413        radial-gradient(ellipse 50% 40% at 70% 20%, #cfe8de 0%, transparent 45%),
414        linear-gradient(165deg, #eef6f2 0%, var(--paper) 48%, #ebe4d6 100%);
415      overflow: hidden;
416      position: relative;
417    }
418    .blob {
419      position: absolute;
420      border-radius: 50%;
421      filter: blur(48px);
422      opacity: .55;
423      pointer-events: none;
424      animation: drift 14s ease-in-out infinite;
425    }
426    .blob-a {
427      width: 340px; height: 340px;
428      left: -80px; top: -40px;
429      background: #9fd9c2;
430    }
431    .blob-b {
432      width: 280px; height: 280px;
433      right: -60px; bottom: -30px;
434      background: #e8d5a3;
435      animation-delay: -5s;
436      animation-duration: 18s;
437    }
438    .blob-c {
439      width: 180px; height: 180px;
440      left: 42%; top: 62%;
441      background: #b7e0d2;
442      animation-delay: -9s;
443      animation-duration: 16s;
444    }
445    .grain {
446      position: absolute;
447      inset: 0;
448      pointer-events: none;
449      opacity: .04;
450      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
451    }
452    .stage {
453      position: relative;
454      z-index: 1;
455      width: min(520px, 100%);
456      text-align: center;
457      animation: enter .8s cubic-bezier(.22,1,.36,1) both;
458    }
459    .visual {
460      width: 168px;
461      height: 168px;
462      margin: 0 auto 28px;
463      position: relative;
464      animation: float 5.5s ease-in-out infinite;
465    }
466    .visual svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 18px 28px rgba(47,143,115,.18)); }
467    .ring {
468      position: absolute;
469      inset: -10px;
470      border-radius: 50%;
471      border: 1.5px dashed rgba(47,143,115,.28);
472      animation: spin 28s linear infinite;
473    }
474    .brand {
475      font-family: Fraunces, "Noto Serif SC", Georgia, serif;
476      font-size: clamp(34px, 7vw, 48px);
477      font-weight: 700;
478      letter-spacing: -.02em;
479      line-height: 1;
480      margin: 0 0 8px;
481      color: var(--ink);
482    }
483    .brand em {
484      font-style: normal;
485      background: linear-gradient(120deg, var(--accent), #4aa888 45%, #c4a35a);
486      -webkit-background-clip: text;
487      background-clip: text;
488      color: transparent;
489    }
490    .domain {
491      margin: 0 0 28px;
492      font-size: 13px;
493      letter-spacing: .16em;
494      text-transform: uppercase;
495      color: var(--muted);
496      font-weight: 500;
497    }
498    h1 {
499      margin: 0 0 12px;
500      font-size: clamp(24px, 4.5vw, 32px);
501      font-weight: 700;
502      letter-spacing: .02em;
503    }
504    .message {
505      margin: 0 auto;
506      max-width: 34em;
507      font-size: 16px;
508      line-height: 1.75;
509      color: var(--muted);
510      white-space: pre-wrap;
511      font-weight: 400;
512    }
513    .progress {
514      width: min(220px, 70%);
515      height: 4px;
516      margin: 28px auto 0;
517      border-radius: 999px;
518      background: rgba(47,143,115,.12);
519      overflow: hidden;
520    }
521    .progress > i {
522      display: block;
523      height: 100%;
524      width: 42%;
525      border-radius: inherit;
526      background: linear-gradient(90deg, var(--accent-soft), var(--accent), #c4a35a);
527      animation: slide 2.4s ease-in-out infinite;
528    }
529    .until {
530      display: inline-flex;
531      align-items: center;
532      gap: 10px;
533      margin: 22px 0 0;
534      padding: 8px 14px;
535      border-radius: 999px;
536      background: rgba(255,255,255,.55);
537      border: 1px solid rgba(47,143,115,.16);
538      font-size: 13px;
539      color: var(--muted);
540      backdrop-filter: blur(8px);
541    }
542    .until span { opacity: .75; }
543    .until strong { color: var(--ink); font-weight: 600; }
544    .hint {
545      margin: 26px 0 0;
546      font-size: 12px;
547      color: rgba(90,111,104,.72);
548    }
549    @keyframes enter {
550      from { opacity: 0; transform: translateY(18px) scale(.98); }
551      to { opacity: 1; transform: none; }
552    }
553    @keyframes float {
554      0%, 100% { transform: translateY(0); }
555      50% { transform: translateY(-10px); }
556    }
557    @keyframes drift {
558      0%, 100% { transform: translate(0, 0) scale(1); }
559      50% { transform: translate(24px, -18px) scale(1.08); }
560    }
561    @keyframes spin { to { transform: rotate(360deg); } }
562    @keyframes slide {
563      0% { transform: translateX(-120%); }
564      100% { transform: translateX(280%); }
565    }
566    @media (prefers-reduced-motion: reduce) {
567      .blob, .visual, .ring, .progress > i, .stage { animation: none !important; }
568    }
569  </style>
570</head>
571<body>
572  <div class="blob blob-a" aria-hidden="true"></div>
573  <div class="blob blob-b" aria-hidden="true"></div>
574  <div class="blob blob-c" aria-hidden="true"></div>
575  <div class="grain" aria-hidden="true"></div>
576  <main class="stage">
577    <div class="visual" aria-hidden="true">
578      <div class="ring"></div>
579      <svg viewBox="0 0 168 168" fill="none" xmlns="http://www.w3.org/2000/svg">
580        <defs>
581          <linearGradient id="g1" x1="28" y1="24" x2="140" y2="148" gradientUnits="userSpaceOnUse">
582            <stop stop-color="#E8F7F1"/>
583            <stop offset="1" stop-color="#B7E0D2"/>
584          </linearGradient>
585          <linearGradient id="g2" x1="56" y1="62" x2="118" y2="118" gradientUnits="userSpaceOnUse">
586            <stop stop-color="#2F8F73"/>
587            <stop offset="1" stop-color="#C4A35A"/>
588          </linearGradient>
589        </defs>
590        <circle cx="84" cy="84" r="72" fill="url(#g1)"/>
591        <circle cx="84" cy="84" r="54" fill="#F8FBF9" fill-opacity=".72"/>
592        <path d="M58 92c8-18 22-28 36-28s28 10 36 28" stroke="url(#g2)" stroke-width="5" stroke-linecap="round"/>
593        <path d="M70 78c4-8 10-12 14-12s10 4 14 12" stroke="#2F8F73" stroke-width="4" stroke-linecap="round" opacity=".55"/>
594        <circle cx="84" cy="104" r="7" fill="#2F8F73"/>
595        <path d="M84 111v18" stroke="#2F8F73" stroke-width="4" stroke-linecap="round"/>
596        <path d="M74 126h20" stroke="#C4A35A" stroke-width="4" stroke-linecap="round"/>
597        <circle cx="118" cy="58" r="5" fill="#C4A35A" opacity=".8"/>
598        <circle cx="52" cy="64" r="3.5" fill="#2F8F73" opacity=".45"/>
599      </svg>
600    </div>
601    <p class="brand">One <em>Blog</em></p>
602    <p class="domain">onedayxyy.cn</p>
603    <h1>${title}</h1>
604    <p class="message">${message}</p>
605    <div class="progress" aria-hidden="true"><i></i></div>
606    ${untilBlock}
607    <p class="hint">若仍看到旧页面,请强制刷新或使用无痕窗口</p>
608  </main>
609</body>
610</html>`;
611}
612
613function buildMaintenanceResponse(status, source) {
614  const html = buildMaintenanceHtml(status);
615  return new Response(html, {
616    status: 503,
617    headers: {
618      'Content-Type': 'text/html; charset=utf-8',
619      'Cache-Control': 'no-store, no-cache, must-revalidate',
620      Pragma: 'no-cache',
621      'Retry-After': '300',
622      'X-OneBlog-Maintenance': '1',
623      'X-OneBlog-Maint': 'on',
624      'X-OneBlog-Maint-Src': source || 'fetch',
625    },
626  });
627}

效果:

结束。

最新文章

文档导航