perf(comments): 评论加载提速 + 骨架屏 + 友链弹窗 pjax 修复 + toast 统一 Message.js

优化:
- initArtalk 加节点级幂等守卫(mount.__atkInited,失败回滚),
  消除 artalk.html/mypjax 多入口导致的 4× comments + 4× pv 重复请求
- fetch 包装层对无 body 的 GET/HEAD 去掉多余 content-type,
  消掉 Artalk 对 GET /comments 的 CORS 预检(按完整 URL 缓存,
  每篇文章都重付一次 RTT)。实测评论列表 +1150~1565ms → +626ms

修复:
- 评论列表骨架屏改注入 .atk-list-body(原骨架在 #Comments 内,
  被 Artalk.init 清空),list-loaded/故障/12s 兜底移除
- 友链申请弹窗抽成 modules/friendlink.js 进 page-only bundle
  (原内联脚本在 #pjax-container 外,pjax 后 flApplyOpenModal 未定义)
- toast 统一走本地化 Message.js/Qmsg 适配层(保留 window.Toast/showToast 旧 API)

文档:
- README / 架构总览 更新;新增 评论加载优化方案.md
This commit is contained in:
zqlit committed 2026-10-04 15:00:52 +08:00
1 parent 9e972f2b61
commit 50a906e07c
14 files changed
+1018 -486

No files matched your search

+19
View File
@@ -54,6 +54,25 @@
: > "$CI_STATUS"
hugo version
# ── /conf 快照(评论首屏优化)────────────────────────────────────
# 构建期拉一次 Artalk 的 /conf 落到 data/,Hugo 会内联进每个页面。
# 前端据此对匿名 /conf 请求本地合成响应 —— Artalk 内部 conf 与评论列表
# 是严格串行的,省掉这一跳等于首屏少一整个跨境往返。
# 服务端这份 conf 只由 settings 表决定(唯一因人而异的 imgUpload 由前端
# 按登录态放行真实请求),所以匿名访客的快照人人相同。
# 拉取失败一律降级:删文件 → 模板不内联 → 前端自动走原生远程请求。
# 绝不让它拖垮构建(在 if 条件里失败不会触发 set -e)。
mkdir -p data
if curl -fsS --max-time 30 --retry 1 --retry-delay 2 \
"https://api.200181.xyz/api/v2/conf" -o data/artalk_conf.json \
&& python3 -c "import json;json.load(open('data/artalk_conf.json',encoding='utf-8'))"; then
echo "✅ conf 快照就绪 $(wc -c < data/artalk_conf.json | tr -d ' ') 字节"
else
rm -f data/artalk_conf.json
echo "⚠️ conf 快照拉取失败,已降级为运行时远程拉取"
fi
python3 scripts/add_draft_to_hidden.py
rm -rf public resources/_gen
hugo --minify --gc
+2
View File
@@ -15,3 +15,5 @@ node_modules/
.ci_status
# CNB 密钥仓库的本地草稿(含真实令牌,仅供往 Web 界面粘贴,绝不入库)
cnb-secrets.yml
# 构建期拉取的 Artalk /conf 快照(每次构建现拉,不入库,避免陈旧配置被 git 固化)
/data/artalk_conf.json
+143 -74
View File
@@ -1,86 +1,155 @@
# 博客项目说明
# 优世界博客(usj.cc)
本项目使用 Hugo 构建,主题为 `Ying`。以下是主要的配置说明与脚本使用指南,方便后续查阅和维护。
Hugo 静态博客 + 自研评论后端 + 写作后台。
**构建与发布跑在腾讯云 CNB**(国内节点),单次发布约 3.5 分钟,境内/境外两条线路一次推完。
## 1. 常用脚本与工具 (Scripts)
项目包含多个 PowerShell 和 Node.js 脚本,用于自动化维护、检查和部署。
### 1.1 日常维护
* **新建文章 (`./new_post.ps1`)**
* **用途**: 交互式创建新的博客文章。
* **功能**: 自动引导输入标题、Slug、分类、标签等信息,并按 `YYYY-MM-DD-Slug` 格式创建 Page Bundle 目录和文件。
* **运行**:
```powershell
./new_post.ps1
```
* **手动构建与检查 (`./deploy.ps1`)**
* **用途**: 手动执行构建,包含友链健康检查。
* **流程**:
1. 运行 `scripts/check_links.js` 检查友链连通性。
2. 如果检查通过,执行 `hugo --minify` 生成静态文件。
* **运行**:
```powershell
./deploy.ps1
```
### 1.2 自动化与 CI/CD 工具
以下脚本主要在 GitHub Actions (`.github/workflows/upy.yml`) 中自动运行,也可手动用于调试:
* **友链数据同步 (`scripts/update_link_lite_json.ps1`)**
* **功能**: 将 `themes/Ying/data/links.yaml` (YAML源数据) 转换为 `themes/Ying/static/json/link_lite.json`,供前端 JS 和检查脚本使用。
* **友链健康检查 (`scripts/check_links.js`)**
* **功能**: 读取 `link_lite.json`,并发检查所有友链的可访问性。
* **依赖**: `node-fetch` (内置于 Node 18+ 或作为依赖)。
* **朋友圈数据生成 (`scripts/generate_circle_data.js`)**
* **功能**: 根据友链抓取 RSS/Atom 订阅源,生成朋友圈更新数据 (`friend_circle_data.json`)。
* **依赖**: `rss-parser`.
* **构建安全预处理 (`scripts/add_draft_to_hidden.ps1`)**
* **功能**: 在构建前扫描 `content/post`,将标记为 `status: hidden` 的文章强制设置为 `draft: true`,防止隐私文章意外泄露到公共列表。
* **CDN 刷新 (`scripts/RefreshCDN.py`)**
* **功能**: 部署完成后调用 DogeCloud API 刷新 CDN 缓存。
* **配置**: 需要在环境变量中设置 `DOGECLOUD_ACCESS_KEY` 等参数。
> 架构全貌、迁移前后对比、运维要点 → [`架构总览.md`](架构总览.md)
---
## 2. 基础配置 (`hugo.toml`)
## 一、项目构成
位于项目根目录下,控制网站的全局行为。
| 子系统 | 位置 | 技术栈 |
|---|---|---|
| **内容** | `content/`、`themes/Ying/` | Hugo 0.128.2 extended + Ying 主题 |
| **评论后端** | `blog-admin/` | artalk-cf:Cloudflare Workers + D1 + KV(`api.200181.xyz`) |
| **写作后台** | `write-server/`(线上 `post.usj.cc`)、`write/`(本地 Windows) | Next.js |
| **发布** | `.cnb.yml`、`deploy/` | CNB 流水线 + Dockerfile |
* **网站信息**: 标题、BaseURL、语言等。
* **固定链接 (Permalinks)**:
```toml
[permalinks]
post = "/:slug"
```
文章页面使用 Front Matter 中的 `slug` 字段作为文件名(例如 `https://usj.cc/my-post.html`)。
---
## 3. 主题配置
## 二、目录结构
* **主题目录**: `themes/Ying/`
* **友链数据**: `themes/Ying/data/links.yaml`
* 添加友链请直接编辑此 YAML 文件,构建时会自动同步到 JSON。
## 4. 文章管理指南
### URL 设置
文章默认使用 `slug` 字段作为 URL 的文件名。
```yaml
title: "我的文章"
date: 2023-01-01
slug: "my-post"
```
生成的链接为: `https://usj.cc/my-post.html`
blog/
├── .cnb.yml # ★ CNB 流水线(push + 每日定时)
├── deploy/Dockerfile # 构建镜像(hugo 二进制由 bin/linux/hugo 提供)
├── bin/linux/hugo # Hugo extended 0.128.2(linux/amd64,供 CNB 构建用)
├── content/
│ ├── posts/<年>/<日期>-<标题>/ # 文章(Page Bundle,index.md + 图片)
│ ├── about.md / links.md / circles.md / archives.md
├── themes/Ying/ # 主题(layout / assets / data)
├── static/ # 原样复制进产物(emotion 表情、image、js …)
├── blog-admin/ # 评论后端(artalk-cf)
├── write-server/ # 线上写作后台
├── write/ # 本地写作前端
├── scripts/ # 各类工具脚本(见第六节)
├── hugo.toml # Hugo 主配置
└── 架构总览.md # ★ 架构文档
```
---
## 三、内容写作
### 文章结构
每篇文章是一个 **Page Bundle**:
```
content/posts/2024/2024-05-01-文章标题/
├── index.md # 正文
└── 配图.jpg # 同目录图片(可用相对路径引用)
```
### URL 规则
由 front matter 的 `slug` 决定(`hugo.toml` 里 `permalinks.post = "/:slug"`):
```yaml
---
title: "我的文章"
date: 2024-05-01
slug: "my-post"
---
```
生成 `https://usj.cc/my-post.html`(uglyURLs,带 `.html`)。
### 隐藏文章
如果你想写一篇不公开在列表显示的文章(但可以通过链接访问):
1. 在 Front Matter 中添加 `status: hidden`。
2. 自动化脚本会在构建时将其标记为 `draft: true` (配合特殊构建逻辑) 或进行其他处理。
*注:具体表现取决于 CI 脚本的逻辑,通常用于草稿或隐藏页。*
在 front matter 加 `status: hidden`。构建前 `scripts/add_draft_to_hidden.py` 会把它转成
`draft: true`,**不出现在列表里,但直达链接仍可访问**。
### 本地预览
```bash
hugo server -D # 含草稿
```
---
## 四、发布流程
```
写作(write-server / write/)
│ git pushall
▼
CNB 主仓 zqlit/blog ──触发──► CNB 流水线(国内节点,约 3.5 分钟)
├─ Hugo 构建
├─ 同步到又拍云(境内源站)
├─ 刷新又拍云 CDN
├─ 刷新多吉云 CDN
├─ 部署 EdgeOne Pages(境外线路)
└─ 邮件通知
```
- **推送**:`git pushall` = `git push origin main; git push gh main`
(`origin` = CNB 主仓,`gh` = GitHub 备份)
- **触发**:推送到 `main`;另有每日 `0 9 * * *`(北京时间)定时构建
- **密钥**:全部来自 CNB 密钥仓库 `zqlit/blog-secrets`,经 `.cnb.yml` 的 `imports` 注入,
**仓库里没有任何明文密钥**
- 改动 `main` 即自动上线,本地无需构建
---
## 五、三个子系统
### 内容系统
Hugo + Ying 主题。`hugo.toml` 控站点信息、永久链接、Artalk 地址、弹幕等。
### 评论系统(`blog-admin/`)
自研的 **Artalk v2 兼容服务端**,跑在 Cloudflare Workers + D1(SQLite)+ KV:
- 前端用官方 Artalk 客户端(`themes/Ying/assets/js/libs/Artalk.js`,本地打包,非 CDN)
- 后端 API 基址 `https://api.200181.xyz`(评论 `/api/v2/*` 与 RSS 订阅 `/api/*` 同一 Worker)
- 部署:`cd blog-admin && npm run deploy`(详细步骤见 `blog-admin/README.md`、`部署清单.md`)
### 写作后台
- `write-server/`:线上版(Next.js),部署在独立主机
- `write/`:本地 Windows 版
---
## 六、常用脚本(`scripts/`)
| 脚本 | 用途 | 在哪跑 |
|---|---|---|
| `add_draft_to_hidden.py` | 构建前把 `status: hidden` 转成草稿 | **CI** |
| `refresh_cdn.js` | 刷新多吉云 CDN(零依赖) | **CI** |
| `send_mail.js` | 构建结果邮件通知(零依赖 SMTP) | **CI** |
| `setup-cnb-remotes.sh` | 切换/重建 git 远端(CNB 主仓 + GitHub 备份) | 本机 |
| `optimize_images.js` | 图片批量压缩优化 | 本机 |
| `generate_circle_data.js` | 抓友链 RSS 生成朋友圈数据 | 本机 |
| `update_link_lite_json.ps1` | 友链 `links.yaml` → JSON | 本机 |
| `add_ancient_chars.py` / `check_ancient_chars.py` / `merge_chars.py` | 字体生僻字增补与校验 | 本机 |
| `cleanup_duplicates.js` / `migrate_slugs.js` | 一次性维护脚本 | 本机 |
> `deploy_*.sh`(又拍云 / EdgeOne / 定时)是本机手动部署的旧入口,**日常已不需要**——
> 推送 `main` 由 CNB 自动完成。
---
## 七、相关文档
| 文档 | 内容 |
|---|---|
| `架构总览.md` | **当前架构全貌**(子系统、发布链路、迁移前后对比、运维要点) |
| `CNB构建落地方案.md` | 迁 CNB 的实施方案与实测数据 |
| `代码源与构建平台选型.md` | 平台对比(CNB / Gitee / GitLab / EdgeOne / 阿里云 ESA) |
| `砍COS改造步骤.md` | 腾讯云 COS 下线记录 |
| `blog-admin/README.md` | 评论后端完整说明 |
| `blog-admin/部署清单.md` | 评论后端部署步骤 |
+19 -54
View File
@@ -4126,76 +4126,41 @@ input[type=submit] {
}
}
/* ── Toast ── */
.ying-toast-container {
position: fixed;
top: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
/* ── Toast(Message.js / Qmsg 适配,见 assets/js/modules/toast.js)──
组件自带样式在 css/libs/message.min.css(随 style.css 打包)。
这里只做主题融合:跟随主题变量 + 暗色适配。 */
.qmsg {
border-radius: 8px;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.ying-toast {
padding: 9px 18px;
border-radius: 6px;
font-size: 13px;
.qmsg-content {
color: var(--text-main, #333);
font-weight: 500;
line-height: 1.3;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.28s ease, transform 0.28s ease;
pointer-events: auto;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
will-change: transform, opacity;
}
.ying-toast--visible {
opacity: 1;
transform: translateY(0);
[data-theme="dark"] .qmsg {
background: #2a2e33;
box-shadow: 0 4px 12px rgba(0, 0, 0, .5);
}
.ying-toast--success {
background: rgba(34, 197, 94, 0.88);
color: #fff;
box-shadow: 0 8px 32px rgba(34, 197, 94, 0.18);
[data-theme="dark"] .qmsg-content {
color: #d6dade;
}
.ying-toast--error {
background: rgba(239, 68, 68, 0.88);
color: #fff;
box-shadow: 0 8px 32px rgba(239, 68, 68, 0.18);
[data-theme="dark"] .qmsg-close {
color: #7a828a;
}
.ying-toast--warning {
background: rgba(245, 158, 11, 0.88);
color: #fff;
box-shadow: 0 8px 32px rgba(245, 158, 11, 0.18);
}
.ying-toast--info {
background: rgba(29, 61, 108, 0.85);
color: #f0f4fb;
box-shadow: 0 8px 32px rgba(29, 61, 108, 0.18);
}
[data-theme="dark"] .ying-toast--info {
background: rgba(159, 196, 244, 0.22);
color: #f3f7fd;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
[data-theme="dark"] .qmsg-close:hover {
color: #a8b0b8;
}
@media screen and (max-width: 480px) {
.ying-toast {
.qmsg {
padding: 8px 14px;
font-size: 12px;
border-radius: 5px;
}
}
+218 -19
View File
@@ -21,17 +21,94 @@
internal: { ic: '⚠️', title: '评论暂时加载不出来', desc: '服务出了点小状况,请稍后再试。' }
};
// ── /api/v2/conf 内联快照 ──────────────────────────────────────────
// 构建期把 conf 响应内联进页面(见 partials/artalk.html)。服务端这份 conf
// 只由 settings 表决定,唯一因人而异的字段是 imgUpload(imgUpload || admin),
// 所以匿名访客看到的 conf 人人都一样 —— 匿名请求直接本地合成 Response,
// 不发网络。Artalk 内部 conf 与评论列表是严格串行的,省掉这一跳等于首屏
// 少一整个跨境往返。
// 带 Authorization 的请求来自已登录用户(管理员拿到 imgUpload=true),属个性化
// 响应,放行真实网络。
var CONF_URI = /\/api\/v2\/conf(\?|$)/;
function hasAuthHeader(init, input) {
try {
var h = (init && init.headers) || null;
if (!h && input && typeof input !== 'string') h = input.headers || null;
if (!h) return false;
if (typeof h.get === 'function') return !!h.get('Authorization');
if (Array.isArray(h)) {
for (var i = 0; i < h.length; i++) {
if (String(h[i][0]).toLowerCase() === 'authorization') return !!h[i][1];
}
return false;
}
return !!(h.Authorization || h.authorization);
} catch (e) {
return true; // 判不准就当有凭据 → 放行真实请求(保守,宁可多一跳也不串号)
}
}
// ── 0) 去掉 GET/HEAD 上多余的 content-type:省掉一次 CORS 预检 ──────
// Artalk 给「所有」请求都带上 content-type: application/json,可 GET 根本没有 body。
// content-type 属于「非简单头」→ 浏览器必须先发一个 OPTIONS 预检(Access-Control-Request-Headers: content-type)。
// 预检按「完整 URL」缓存(含 query),而 comments 的 URL 带 page_key、每篇文章都不同,
// 于是每进一篇新文章都要重付一次预检 —— 跨域下这是实打实的整整一个 RTT,
// 而它正好卡在评论列表的关键路径上(预检 → 才发 GET)。
// GET/HEAD 没有 body,删掉这个头不改变任何语义,却能直接把预检砍掉。
function sansContentType(init) {
var out = {}, k;
for (k in init) out[k] = init[k];
var h = out.headers;
try {
if (h && typeof Headers === 'function' && h instanceof Headers) {
var nh = new Headers();
h.forEach(function (v, n) { if (n.toLowerCase() !== 'content-type') nh.append(n, v); });
out.headers = nh;
} else if (Array.isArray(h)) {
out.headers = h.filter(function (p) { return String(p[0]).toLowerCase() !== 'content-type'; });
} else if (h && typeof h === 'object') {
var nh2 = {};
for (k in h) { if (k.toLowerCase() !== 'content-type') nh2[k] = h[k]; }
out.headers = nh2;
}
} catch (e) {
return init; // headers 形态不认识就原样放行(宁可多一次预检,不能出错)
}
return out;
}
// ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应,
// 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input) {
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
url = String(url);
var p = origFetch.apply(this, arguments);
if (url.indexOf('/api/v2/') === -1) return p;
// 学习 API 源(探测恢复用)
if (url.indexOf('/api/v2/') === -1) return origFetch.apply(this, arguments);
// 学习 API 源(探测恢复用)—— 拦快照之前先学,别因为少了一次网络请求而丢掉来源
if (!apiBase && url.indexOf('/api/v2/') > 0) apiBase = url.split('/api/v2/')[0];
// conf 快照命中:本地合成响应,完全不发网络
if (window.__artalkConfSnapshot && CONF_URI.test(url)
&& typeof Response === 'function' && !hasAuthHeader(init, input)) {
return Promise.resolve(new Response(
JSON.stringify(window.__artalkConfSnapshot),
{
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}
));
}
// GET/HEAD 且无 body:去掉 content-type,避免不必要的 CORS 预检
var fargs = arguments;
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if ((method === 'GET' || method === 'HEAD') && !(init && init.body)) {
fargs = [input, sansContentType(init || {})];
}
var p = origFetch.apply(this, fargs);
return p.then(function (res) {
if (res && res.ok === false) {
try {
@@ -105,6 +182,7 @@
// 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅
// 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status,
// 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。
// toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。
// conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮
// 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观,
@@ -130,22 +208,18 @@
var svcDown = false;
var downCode = '';
var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过)
var probeTimer = null;
var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址)
function svcToast(msg) {
var t = document.getElementById('atk-svc-toast');
if (!t) {
t = document.createElement('div');
t.id = 'atk-svc-toast';
t.style.cssText = 'position:fixed;left:50%;bottom:110px;transform:translateX(-50%);z-index:99999;' +
'padding:12px 24px;border-radius:999px;background:var(--theme-main,#07c160);color:#fff;' +
'font-size:14px;box-shadow:0 10px 32px rgba(0,0,0,.35);opacity:0;transition:opacity .35s,transform .35s;pointer-events:none';
document.body.appendChild(t);
// 统一通知组件(Message.js):Toast 在 core bundle 里先于本模块加载,
// 但 PJAX 极端时序下仍兜底判一次,避免 undefined 直接抛错
if (window.Toast && typeof window.Toast.info === 'function') {
window.Toast.info(msg, 3200);
} else {
try { console.log('[artalk]', msg); } catch (e) {}
}
t.textContent = msg;
requestAnimationFrame(function () { t.style.opacity = '1'; });
setTimeout(function () { t.style.opacity = '0'; }, 3200);
}
function ensureBar(code) {
@@ -185,8 +259,11 @@
function setSvcDown(code) {
downCode = code;
// 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片)
listSkeletonDone();
if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案
svcDown = true;
downAt = Date.now();
ensureBar(code);
if (!probeTimer) {
probeTimer = setInterval(function () {
@@ -203,12 +280,26 @@
svcDown = false;
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
removeBar();
svcToast('评论服务已恢复,可以继续评论了 ✓');
// 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音
// (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报)
if (downAt && Date.now() - downAt >= 3000) {
svcToast('评论服务已恢复,可以继续评论了 ✓');
}
downAt = 0;
}
function rewrite(layer) {
if (!layer) return;
// ── 不是评论列表的错误层,一律不碰 ──────────────────────────────
// Artalk 会把「编辑器插件资源加载失败」(表情包 / 图片等)的错误层也渲染成
// .atk-error-layer,挂在编辑器插件面板(.atk-plug-panel-wrap)里。它既不是
// 评论服务故障,也不该被改写成我们的故障卡片 —— 直接放行走 Artalk 原样式。
// 曾经没这道判断时:这类层的文本不在 .error-message 里 → raw 读成空串 →
// 兜底判成 internal → 因为 internal !== network 就置灰整个评论区,随后
// 数据正常返回又弹「评论服务已恢复」toast(读者视角:页面顿一下 + 莫名提示)。
if (layer.closest('.atk-plug-panel-wrap, .atk-editor-plug-emoticons')) return;
var raw = '';
var em = layer.querySelector('.error-message');
if (em) raw = (em.innerText || em.textContent || '').trim();
@@ -236,8 +327,10 @@
? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '')
: info.desc;
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅
if (code !== 'network') setSvcDown(code);
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。
// 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中,
// 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。
if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code);
// 原始「重试」节点:Vue 会整层重建,所以每次重新找;
// 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件)
@@ -501,8 +594,91 @@ window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callb
document.head.appendChild(script);
};
/* ── 评论列表骨架屏 ────────────────────────────────────────────────────
Artalk 初始化时会把 #Comments 整个清空(连服务端渲染出来的骨架屏一起清),
而评论列表要等一次跨境 /api/v2/comments 往返才有内容 —— 这段时间评论区是
一片空白(本地实测空档约 1.0~1.5s,跨境只会更久),读者视角就是"进来啥也没有"。
这里在 Artalk 挂载后往 .atk-list-body 里补一份骨架,list-loaded / 服务故障 /
12s 兜底时移除,把这段空档抹平。
延迟 120ms 才显示:命中缓存、极快返回的情况不会闪一下骨架。 */
var LIST_SKELETON_HTML =
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-40"></div>' +
'</div>' +
'</div>';
var listSkelTimer = null; // 延迟布防用的定时器
var listSkelSettled = false; // 本次导航的列表是否已经有结果(含失败)
function listSkeletonShow() {
var body = document.querySelector('#Comments .atk-list-body');
if (!body || body.querySelector('.atk-ys-skel')) return;
var skel = document.createElement('div');
skel.className = 'atk-ys-skel atk-loading-placeholder';
skel.setAttribute('aria-hidden', 'true');
skel.innerHTML = LIST_SKELETON_HTML;
body.insertBefore(skel, body.firstChild);
// 注意:Artalk 渲染评论时若整体重写 .atk-list-body 的 innerHTML,
// 骨架会随之被自然移除 —— 因此这里不做任何"占位"逻辑,移除是幂等的。
}
function listSkeletonHide() {
var skels = document.querySelectorAll('#Comments .atk-ys-skel');
for (var i = 0; i < skels.length; i++) {
if (skels[i].parentNode) skels[i].parentNode.removeChild(skels[i]);
}
}
/* 布防:Artalk 挂载后调用(可重复调用,只生效一次) */
function listSkeletonArm() {
if (listSkelSettled || listSkelTimer) return;
listSkelTimer = setTimeout(function () {
listSkelTimer = null;
if (listSkelSettled) return;
listSkeletonShow();
// 极端时序下 .atk-list-body 可能还没渲染出来:短轮询补一次
if (!document.querySelector('#Comments .atk-ys-skel')) {
var tries = 0;
var spin = setInterval(function () {
if (listSkelSettled || ++tries > 12) { clearInterval(spin); return; }
if (document.querySelector('#Comments .atk-list-body')) {
listSkeletonShow();
clearInterval(spin);
}
}, 100);
}
}, 120);
}
/* 撤防:列表有结果(成功/失败/超时)后调用 */
function listSkeletonDone() {
listSkelSettled = true;
if (listSkelTimer) { clearTimeout(listSkelTimer); listSkelTimer = null; }
listSkeletonHide();
}
window.initArtalk = function() {
if (!document.querySelector("#Comments")) return;
var mount = document.querySelector("#Comments");
if (!mount) return;
// Check if configuration exists
if (!window.artalkConfig) {
@@ -517,6 +693,17 @@ window.initArtalk = function() {
return;
}
// ── 幂等守卫:同一份 #Comments 只初始化一次 ──────────────────────────
// initArtalk 有多个调用入口(artalk.html 内联脚本 / main.js DOMContentLoaded /
// mypjax 的 script.onload 与 requestIdleCallback),不去重就会在同一页上创建
// 多个 Artalk 实例:实测每次 PJAX 进文章页发出 4× /api/v2/comments + 4×
// /pages/pv,列表还要等最慢那条响应才渲染(既拖首屏,又 4 倍消耗 D1 读额度)。
// 用「节点级」标记而不是全局标志:PJAX 换页会换掉 #Comments 节点,新节点无标记
// → 照常重新初始化,切页行为完全不受影响。
// 第二道判断(节点内已有 .atk-list-body)兜底 Artalk 若替换了挂载节点的情况。
if (mount.__atkInited || mount.querySelector('.atk-list-body')) return;
mount.__atkInited = true;
try {
// Logic for caching Artalk configuration to speed up rendering
// 1. Manual Refresh (F5): Request from server and save to localStorage
@@ -578,6 +765,14 @@ window.initArtalk = function() {
artalk.setDarkMode(currentTheme === 'dark');
}
// ── 评论列表骨架屏布防(见文件上方 listSkeleton* 注释) ──
listSkelSettled = false;
listSkeletonArm();
artalk.on('mounted', listSkeletonArm);
artalk.on('list-loaded', listSkeletonDone);
// 兜底:长时间没结果(严重慢网 / 列表接口异常)也别让骨架一直转
setTimeout(listSkeletonDone, 12000);
// ── 增强评论框字段属性 (兼容油猴自动填充脚本) ──
// 为 Artalk 输入框添加额外属性,确保第三方脚本(如博客网站留言评论信息自动填充)
// 能够通过 type/id/name/placeholder/class/aria-label 准确匹配
@@ -1183,5 +1378,9 @@ artalk.on('list-loaded', function() {
} catch (e) {
console.error('Artalk init failed:', e);
// 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来
mount.__atkInited = false;
listSkelSettled = true;
listSkeletonHide();
}
};
+136
View File
@@ -0,0 +1,136 @@
/**
* 友链自助申请弹窗
*
* 为什么独立成模块(而不是留在 layouts/page/comment.html 的内联 <script>):
* 那段脚本写在 #pjax-container 外面,PJAX 只换容器内容、不会重新执行它 ——
* 用户从首页 PJAX 进留言页时 window.flApplyOpenModal 根本没定义,
* 「申请友链」卡片点了没反应;直接刷新(整页加载)才正常。抽到
* page-only bundle 里随 PJAX 加载,两种进入方式行为一致。
*
* 幂等性:弹窗节点挂 document.body(跨 PJAX 存活),buildModal 二次调用直接复用;
* 事件监听只在首次构建时挂,不会重复注册。
*/
(function () {
if (window.__flApplyInit) return; // PJAX 重复执行防抖
window.__flApplyInit = true;
var BASE = (window.rssApiBase || 'https://api.200181.xyz').replace(/\/$/, '');
function buildModal() {
if (document.getElementById('flApplyMask')) return document.getElementById('flApplyMask');
var mask = null;
var style = document.createElement('style');
style.textContent =
'#flApplyMask{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;padding:16px;' +
'background:rgba(15,23,42,.35);-webkit-backdrop-filter:blur(10px) saturate(1.3);backdrop-filter:blur(10px) saturate(1.3)}' +
'#flApplyMask.on{display:flex}' +
'#flApplyBox{background:var(--bg-card,#fff);color:var(--text-main,#253244);border-radius:12px;max-width:420px;width:100%;' +
'box-sizing:border-box;padding:22px;box-shadow:0 16px 48px rgba(15,23,42,.25);max-height:88vh;overflow:auto}' +
'[data-theme="dark"] #flApplyBox{box-shadow:0 16px 48px rgba(0,0,0,.55)}' +
'#flApplyBox .fl-field{display:block;margin-bottom:10px}' +
'#flApplyBox .fl-label{display:block;font-size:12px;color:var(--text-muted,#8a94a6);margin-bottom:4px}' +
'#flApplyBox input,#flApplyBox textarea{width:100%;box-sizing:border-box;padding:8px 10px;' +
'border:1px solid var(--border-main,#e7e9ee);border-radius:6px;font-size:14px;' +
'background:var(--bg-input,#fff);color:inherit;font-family:inherit}' +
'#flApplyBox input:focus,#flApplyBox textarea:focus{outline:none;border-color:var(--theme-main,#07c160);' +
'box-shadow:0 0 0 3px var(--theme-main-20,rgba(7,193,96,.2))}' +
/* 主题 main.css 有一条全局 button 归一化规则(margin-left:23%; margin-top:10px;
border:1px solid; background:#222),会把提交按钮推右溢出弹窗 → 这里用 id 选择器压过它 */
'#flSubmit{display:block;width:100%;margin:0;background:var(--theme-main,#07c160);border:none;color:#fff;' +
'padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:filter .2s}' +
'#flSubmit:hover{filter:brightness(1.06)}' +
'#flSubmit:disabled{opacity:.6;cursor:default}' +
/* 弹窗滚动条:细一点、跟随主题,别用系统那根粗滚动条 */
'#flApplyBox{overflow-x:hidden;scrollbar-width:thin;scrollbar-color:rgba(140,150,160,.45) transparent}' +
'#flApplyBox::-webkit-scrollbar{width:6px;height:6px}' +
'#flApplyBox::-webkit-scrollbar-track{background:transparent}' +
'#flApplyBox::-webkit-scrollbar-thumb{background:rgba(140,150,160,.45);border-radius:3px}' +
'[data-theme="dark"] #flApplyBox{scrollbar-color:rgba(200,205,212,.35) transparent}' +
'[data-theme="dark"] #flApplyBox::-webkit-scrollbar-thumb{background:rgba(200,205,212,.35)}';
document.head.appendChild(style);
mask = document.createElement('div');
mask.id = 'flApplyMask';
mask.innerHTML =
'<div id="flApplyBox">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">' +
'<b style="font-size:16px;display:flex;align-items:center;gap:8px">' +
'<span style="width:4px;height:16px;border-radius:2px;background:var(--theme-main,#07c160);display:inline-block"></span>友链自助申请</b>' +
'<span id="flApplyClose" style="cursor:pointer;color:var(--text-muted,#8a94a6);font-size:22px;line-height:1" title="关闭">×</span>' +
'</div>' +
'<label class="fl-field"><span class="fl-label">站点名称 *</span>' +
'<input id="flName" placeholder="我的小站"></label>' +
'<label class="fl-field"><span class="fl-label">站点链接 *</span>' +
'<input id="flUrl" placeholder="https://example.com"></label>' +
'<label class="fl-field"><span class="fl-label">图标链接(选填,留空自动为你抓取站点图标)</span>' +
'<input id="flIcon" placeholder="https://example.com/logo.png"></label>' +
'<label class="fl-field"><span class="fl-label">订阅地址(RSS/Atom)*</span>' +
'<input id="flFeed" placeholder="https://example.com/feed"></label>' +
'<label class="fl-field"><span class="fl-label">邮箱 *(接收审核结果)</span>' +
'<input id="flEmail" type="email" placeholder="you@example.com"></label>' +
'<label class="fl-field" style="margin-bottom:14px"><span class="fl-label">站点简介(选填)</span>' +
'<textarea id="flDesc" rows="2" style="resize:vertical"></textarea></label>' +
'<div id="flMsg" hidden style="font-size:13px;margin-bottom:10px"></div>' +
'<button id="flSubmit">提交申请</button>' +
'</div>';
document.body.appendChild(mask);
mask.addEventListener('click', function (e) { if (e.target === mask) close(); });
document.getElementById('flApplyClose').addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.getElementById('flSubmit').addEventListener('click', function () {
var btn = this, msg = document.getElementById('flMsg');
var payload = {
name: document.getElementById('flName').value.trim(),
url: document.getElementById('flUrl').value.trim(),
image: document.getElementById('flIcon').value.trim(),
feed: document.getElementById('flFeed').value.trim(),
email: document.getElementById('flEmail').value.trim(),
description: document.getElementById('flDesc').value.trim()
};
if (!payload.name || !payload.url || !payload.feed || !payload.email) {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '请填写全部必填项'; return;
}
btn.disabled = true; btn.textContent = '提交中…';
msg.hidden = true;
fetch(BASE + '/api/link-apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json().then(function (d) { return { code: r.status, d: d }; }); })
.then(function (res) {
if (res.code === 200 && res.d.ok) {
msg.hidden = false; msg.style.color = 'var(--theme-main,#07c160)'; msg.textContent = res.d.message || '申请已提交';
btn.textContent = '已提交 ✓';
setTimeout(close, 2200);
} else {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = res.d.error || '提交失败,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
}
})
.catch(function () {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '网络异常,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
});
});
return mask;
}
function close() {
var m = document.getElementById('flApplyMask');
if (m) m.classList.remove('on');
}
/* 供 artalk.js 的 comment-rendered(置顶评论卡片)调用 */
window.flApplyOpenModal = function () {
buildModal().classList.add('on');
};
/* 兜底:卡片节点被 PJAX/Artalk 重建后自身监听可能丢失,
这里用 document 级事件委托再接一层(打开是幂等的,重复触发无害) */
document.addEventListener('click', function (e) {
var hit = e.target && e.target.closest && e.target.closest('.atk-fl-card, .atk-fl-card-wrap');
if (hit) window.flApplyOpenModal();
});
})();
+41 -49
View File
@@ -1,63 +1,55 @@
/**
* Toast — lightweight notification
* Usage: window.Toast.success('msg'), Toast.error('msg'), Toast.warning('msg'), Toast.info('msg')
* window.Toast.show('msg', { type: 'info', duration: 2500 })
* Toast — 统一通知组件(Message.js / Qmsg)
*
* 全站 toast 一律走 Message.js(https://github.com/snwjas/Message.js),
* 库本体已本地化:js/libs/message.min.js + css/libs/message.min.css(随 style.css 打包)。
* 这里只做一层薄适配,保留旧 API,调用点(utils/floating-tools/artalk…)不用改:
* Toast.show(text, { type, duration })
* Toast.success / error / warning / info(text, duration?)
* showToast(text, type) ← 全局快捷方式
*/
(function() {
var container = null;
var counter = 0;
(function () {
if (window.__yingToastReady) return; // PJAX 重复执行防抖
window.__yingToastReady = true;
function ensureContainer() {
if (container) return;
container = document.createElement('div');
container.className = 'ying-toast-container';
document.body.appendChild(container);
var Q = window.Qmsg;
if (!Q) {
// Message.js 没加载出来时兜底:不打断业务,仅降级为无样式 console
var fallback = function (text) {
try { console.log('[toast]', text); } catch (e) {}
};
fallback.success = fallback.error = fallback.warning = fallback.info = fallback;
window.Toast = fallback;
window.showToast = function (text) { fallback(text); };
return;
}
function remove(el, delay) {
setTimeout(function() {
el.classList.remove('ying-toast--visible');
el.addEventListener('transitionend', function onEnd(e) {
if (e.propertyName !== 'opacity') return;
el.removeEventListener('transitionend', onEnd);
if (el.parentNode) el.parentNode.removeChild(el);
});
}, delay);
// 全站统一默认值:最多同时 3 条,自动关闭
try { Q.config({ maxNums: 3, timeout: 2500 }); } catch (e) {}
function opt(duration) {
return duration ? { timeout: duration } : undefined;
}
window.Toast = {
show: function(text, options) {
show: function (text, options) {
options = options || {};
var type = options.type || 'info';
var duration = options.duration || 2200;
ensureContainer();
var el = document.createElement('div');
el.className = 'ying-toast ying-toast--' + type;
el.textContent = text;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
container.appendChild(el);
/* Force layout then animate in */
void el.offsetWidth;
el.classList.add('ying-toast--visible');
remove(el, duration);
if (typeof Q[type] === 'function') return Q[type](text, opt(options.duration));
return Q.info(text, opt(options.duration));
},
success: function (text, duration) { return Q.success(text, opt(duration)); },
error: function (text, duration) { return Q.error(text, opt(duration)); },
warning: function (text, duration) { return Q.warning(text, opt(duration)); },
info: function (text, duration) { return Q.info(text, opt(duration)); },
loading: function (text) { return Q.loading(text); },
closeAll: function () { try { Q.closeAll(); } catch (e) {} }
};
success: function(text, duration) {
this.show(text, { type: 'success', duration: duration });
},
error: function(text, duration) {
this.show(text, { type: 'error', duration: duration });
},
warning: function(text, duration) {
this.show(text, { type: 'warning', duration: duration });
},
info: function(text, duration) {
this.show(text, { type: 'info', duration: duration });
}
// 全局快捷方式(utils.js 原实现迁到这里,单一来源)
window.showToast = function (text, type) {
var t = type || 'info';
if (window.Toast && typeof window.Toast[t] === 'function') window.Toast[t](text);
else window.Toast.info(text);
};
})();
+2 -11
View File
@@ -20,17 +20,8 @@ function themeSweep(newTheme, callback) {
callback();
}
// Toast Message Helper
window.showToast = function(text, type) {
if (window.Toast) {
var t = type || 'info';
if (typeof window.Toast[t] === 'function') {
window.Toast[t](text);
} else {
window.Toast.info(text);
}
}
};
// Toast 快捷方式已统一迁到 modules/toast.js(Message.js/Qmsg 适配层),
// 这里不再重复定义 window.showToast,避免双实现漂移。
// Theme Toggle Logic
window.initThemeToggle = function() {
+4 -120
View File
@@ -20,126 +20,10 @@
</div>
<script>
/* 友链自助申请:弹窗动态挂到 document.body(评论容器带 transform 动画,
会把 position:fixed 劫持成相对定位,导致遮罩/表单跟着容器变形——
挂 body 彻底规避),遮罩用 backdrop-filter 毛玻璃(GPU 合成,不卡页面)。
显隐用 class 而非 hidden 属性(内联 display 会覆盖 hidden 导致关不掉);
盒子必须 box-sizing:border-box(否则 width:100% + padding 溢出出横向滚动条)。 */
(function () {
var BASE = '{{ $rssBase := site.Params.rssapi.base | default "https://api.200181.xyz" }}{{ $rssBase }}';
function buildModal() {
if (document.getElementById('flApplyMask')) return document.getElementById('flApplyMask');
var style = document.createElement('style');
style.textContent =
'#flApplyMask{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;padding:16px;' +
'background:rgba(15,23,42,.35);-webkit-backdrop-filter:blur(10px) saturate(1.3);backdrop-filter:blur(10px) saturate(1.3)}' +
'#flApplyMask.on{display:flex}' +
'#flApplyBox{background:var(--bg-card,#fff);color:var(--text-main,#253244);border-radius:12px;max-width:420px;width:100%;' +
'box-sizing:border-box;padding:22px;box-shadow:0 16px 48px rgba(15,23,42,.25);max-height:88vh;overflow:auto}' +
'[data-theme="dark"] #flApplyBox{box-shadow:0 16px 48px rgba(0,0,0,.55)}' +
'#flApplyBox .fl-field{display:block;margin-bottom:10px}' +
'#flApplyBox .fl-label{display:block;font-size:12px;color:var(--text-muted,#8a94a6);margin-bottom:4px}' +
'#flApplyBox input,#flApplyBox textarea{width:100%;box-sizing:border-box;padding:8px 10px;' +
'border:1px solid var(--border-main,#e7e9ee);border-radius:6px;font-size:14px;' +
'background:var(--bg-input,#fff);color:inherit;font-family:inherit}' +
'#flApplyBox input:focus,#flApplyBox textarea:focus{outline:none;border-color:var(--theme-main,#07c160);' +
'box-shadow:0 0 0 3px var(--theme-main-20,rgba(7,193,96,.2))}' +
/* 主题 main.css 有一条全局 button 归一化规则(margin-left:23%; margin-top:10px;
border:1px solid; background:#222),会把提交按钮推右溢出弹窗 → 这里用 id 选择器压过它 */
'#flSubmit{display:block;width:100%;margin:0;background:var(--theme-main,#07c160);border:none;color:#fff;' +
'padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:filter .2s}' +
'#flSubmit:hover{filter:brightness(1.06)}' +
'#flSubmit:disabled{opacity:.6;cursor:default}' +
/* 弹窗滚动条:细一点、跟随主题色,别用系统那根粗滚动条 */
'#flApplyBox{overflow-x:hidden;scrollbar-width:thin;scrollbar-color:rgba(140,150,160,.45) transparent}' +
'#flApplyBox::-webkit-scrollbar{width:6px;height:6px}' +
'#flApplyBox::-webkit-scrollbar-track{background:transparent}' +
'#flApplyBox::-webkit-scrollbar-thumb{background:rgba(140,150,160,.45);border-radius:3px}' +
'[data-theme="dark"] #flApplyBox{scrollbar-color:rgba(200,205,212,.35) transparent}' +
'[data-theme="dark"] #flApplyBox::-webkit-scrollbar-thumb{background:rgba(200,205,212,.35)}';
document.head.appendChild(style);
mask = document.createElement('div');
mask.id = 'flApplyMask';
mask.innerHTML =
'<div id="flApplyBox">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">' +
'<b style="font-size:16px;display:flex;align-items:center;gap:8px">' +
'<span style="width:4px;height:16px;border-radius:2px;background:var(--theme-main,#07c160);display:inline-block"></span>友链自助申请</b>' +
'<span id="flApplyClose" style="cursor:pointer;color:var(--text-muted,#8a94a6);font-size:22px;line-height:1" title="关闭">×</span>' +
'</div>' +
'<label class="fl-field"><span class="fl-label">站点名称 *</span>' +
'<input id="flName" placeholder="我的小站"></label>' +
'<label class="fl-field"><span class="fl-label">站点链接 *</span>' +
'<input id="flUrl" placeholder="https://example.com"></label>' +
'<label class="fl-field"><span class="fl-label">图标链接(选填,留空自动为你抓取站点图标)</span>' +
'<input id="flIcon" placeholder="https://example.com/logo.png"></label>' +
'<label class="fl-field"><span class="fl-label">订阅地址(RSS/Atom)*</span>' +
'<input id="flFeed" placeholder="https://example.com/feed"></label>' +
'<label class="fl-field"><span class="fl-label">邮箱 *(接收审核结果)</span>' +
'<input id="flEmail" type="email" placeholder="you@example.com"></label>' +
'<label class="fl-field" style="margin-bottom:14px"><span class="fl-label">站点简介(选填)</span>' +
'<textarea id="flDesc" rows="2" style="resize:vertical"></textarea></label>' +
'<div id="flMsg" hidden style="font-size:13px;margin-bottom:10px"></div>' +
'<button id="flSubmit">提交申请</button>' +
'</div>';
document.body.appendChild(mask);
mask.addEventListener('click', function (e) { if (e.target === mask) close(); });
document.getElementById('flApplyClose').addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.getElementById('flSubmit').addEventListener('click', function () {
var btn = this, msg = document.getElementById('flMsg');
var payload = {
name: document.getElementById('flName').value.trim(),
url: document.getElementById('flUrl').value.trim(),
image: document.getElementById('flIcon').value.trim(),
feed: document.getElementById('flFeed').value.trim(),
email: document.getElementById('flEmail').value.trim(),
description: document.getElementById('flDesc').value.trim()
};
if (!payload.name || !payload.url || !payload.feed || !payload.email) {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '请填写全部必填项'; return;
}
btn.disabled = true; btn.textContent = '提交中…';
msg.hidden = true;
fetch(BASE + '/api/link-apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json().then(function (d) { return { code: r.status, d: d }; }); })
.then(function (res) {
if (res.code === 200 && res.d.ok) {
msg.hidden = false; msg.style.color = 'var(--theme-main,#07c160)'; msg.textContent = res.d.message || '申请已提交';
btn.textContent = '已提交 ✓';
setTimeout(close, 2200);
} else {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = res.d.error || '提交失败,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
}
})
.catch(function () {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '网络异常,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
});
});
return mask;
}
function close() {
var m = document.getElementById('flApplyMask');
if (m) m.classList.remove('on');
}
/* 供置顶评论卡片调用(artalk.js 的 comment-rendered 里触发) */
window.flApplyOpenModal = function () {
buildModal().classList.add('on');
};
})();
</script>
{{/* 友链自助申请弹窗已抽到 assets/js/modules/friendlink.js(随 page-only.js 加载)。
原先这段内联脚本写在 #pjax-container 外面:PJAX 只换容器内容、不会重新执行它,
从首页 PJAX 进留言页时 window.flApplyOpenModal 未定义 → 「申请友链」卡片点了没反应;
直接刷新(整页加载)才正常。抽成模块后两种进入方式行为一致。 */}}
<script>
/* 人机验证(自研「一键验证」)—— 服务端见 artalk-cf/src/lib/human.ts
+10
View File
@@ -229,6 +229,16 @@
</div>
<!-- JS -->
<!-- <script src="/js/Artalk.js"></script> -->
<!-- ── /api/v2/conf 内联快照 ─────────────────────────────────────────────
构建期由 .cnb.yml 拉一次 /api/v2/conf 落到 data/artalk_conf.json,这里原样内联。
artalk.js 对匿名 /conf 请求直接本地合成响应,省掉一次跨境 RTT ——
Artalk 内部 conf 与评论列表是「严格串行」的,去掉这一跳首屏即少一整个往返。
文件不存在时(本地预览 / 拉取失败)不输出,前端自动回退到远程请求。 -->
{{ with site.Data.artalk_conf }}
{{- $snapJson := . | jsonify }}
{{- $snapJson = replace $snapJson "</" "<\\/" }}
<script>window.__artalkConfSnapshot = {{ $snapJson | safeJS }};</script>
{{ end }}
<script>
// Configuration for Artalk and AI Comment
window.artalkConfig = {
+10 -28
View File
@@ -53,6 +53,9 @@
{{ $iconfont := resources.Get "js/libs/iconfont.js" }}
{{ $uikit := resources.Get "js/libs/uikit.min.js" }}
{{ $viewimage := resources.Get "js/libs/view-image.min.js" }}
{{/* Message.js(Qmsg)+ Toast 适配层放在 utils 之前,保证任何 toast 调用点执行时组件已就绪 */}}
{{ $message := resources.Get "js/libs/message.min.js" }}
{{ $toast := resources.Get "js/modules/toast.js" }}
{{ $utils := resources.Get "js/modules/utils.js" }}
{{ $loader := resources.Get "js/modules/loader.js" }}
{{ $cache := resources.Get "js/modules/cache.js" }}
@@ -67,7 +70,7 @@
{{ $lazyload := resources.Get "js/modules/lazyload.js" }}
{{ $main := resources.Get "js/main.js" }}
{{ $coreScripts := slice $iconfont $uikit $viewimage $utils $loader $cache $search $floatingTools $nprogress $pjaxLib $mypjax $pangu $linkify $shortcodesJs $lazyload $main | resources.Concat "js/core.js" | resources.Minify | resources.Fingerprint }}
{{ $coreScripts := slice $iconfont $uikit $viewimage $message $toast $utils $loader $cache $search $floatingTools $nprogress $pjaxLib $mypjax $pangu $linkify $shortcodesJs $lazyload $main | resources.Concat "js/core.js" | resources.Minify | resources.Fingerprint }}
<script defer src="{{ $coreScripts.RelPermalink }}"></script>
@@ -136,21 +139,24 @@
{{/* 文章详情页专用JS */}}
{{ if .IsPage }}
{{/* 友链申请弹窗放最前:artalk.js 渲染申请卡片时会调 window.flApplyOpenModal */}}
{{ $friendlink := resources.Get "js/modules/friendlink.js" }}
{{ $artalkModule := resources.Get "js/modules/artalk.js" }}
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
{{ $reward := resources.Get "js/modules/reward.js" }}
{{ $pageScripts := slice $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
{{ $pageScripts := slice $friendlink $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
<script defer src="{{ $pageScripts.RelPermalink }}"></script>
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
{{ else }}
{{/* 非文章页面:仍然计算URL,但不加载,用于PJAX动态加载 */}}
{{ $friendlink := resources.Get "js/modules/friendlink.js" }}
{{ $artalkModule := resources.Get "js/modules/artalk.js" }}
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
{{ $reward := resources.Get "js/modules/reward.js" }}
{{ $pageScripts := slice $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
{{ $pageScripts := slice $friendlink $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
{{ end }}
@@ -169,31 +175,7 @@
<script defer src="{{ $tiaozhuan.RelPermalink }}"></script>
{{ end }}
{{/* ====== 5. 延迟加载的非关键JS ====== */}}
{{ $toast := resources.Get "js/modules/toast.js" }}
{{ $deferredScripts := slice $toast | resources.Concat "js/deferred.js" | resources.Minify | resources.Fingerprint }}
<script>
// 使用requestIdleCallback在浏览器空闲时加载
if ('requestIdleCallback' in window) {
requestIdleCallback(function() {
var script = document.createElement('script');
script.src = '{{ $deferredScripts.RelPermalink }}';
script.defer = true;
document.body.appendChild(script);
});
} else {
setTimeout(function() {
var script = document.createElement('script');
script.src = '{{ $deferredScripts.RelPermalink }}';
script.defer = true;
document.body.appendChild(script);
}, 1000);
}
</script>
{{/* ====== 6. 第三方脚本 ====== */}}
{{/* ====== 5. 第三方脚本 ====== */}}
<script async src="/js/bsz.js"></script>
</body>
+11 -1
View File
@@ -42,7 +42,17 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Resource Hints -->
<link rel="preconnect" href="https://cravatar.cn">
{{ with site.Params.rssapi.base }}<link rel="preconnect" href="{{ . }}">{{ end }}
{{- /* 评论 / RSS API:Artalk 与友链数据都发 CORS 跨域请求。
preconnect 必须带 crossorigin —— 浏览器对「带凭据」和「匿名」两条连接分池,
不带 crossorigin 预热的连接 CORS 请求无法复用,等于白预连(net::ERR 之外
还要重付一次 DNS + TCP + TLS)。dns-prefetch 兜底不支持 preconnect 的浏览器。 */ -}}
{{- $apiHosts := slice -}}
{{- with site.Params.artalk.server }}{{ $apiHosts = $apiHosts | append (strings.TrimSuffix "/" .) }}{{ end -}}
{{- with site.Params.rssapi.base }}{{ $apiHosts = $apiHosts | append (strings.TrimSuffix "/" .) }}{{ end -}}
{{- range ($apiHosts | uniq) }}
<link rel="preconnect" href="{{ . }}" crossorigin>
<link rel="dns-prefetch" href="{{ . }}">
{{- end }}
{{ with site.Params.rssapi.base }}<script>window.rssApiBase = "{{ . }}";</script>{{ end }}
<link rel="dns-prefetch" href="https://bsz.dusays.com:9001">
<!-- Analytics -->
+122 -130
View File
@@ -1,173 +1,165 @@
# 优世界博客 · 架构总览与复杂度审计
# 优世界博客 · 架构总览
> 梳理时间:2026-10-04
> 目的:把「复杂在哪、为什么复杂、哪些是自己加的、哪些能砍」讲清楚,供架构决策。
> 方法:以代码/配置为证据,不用推测。凡属推测的地方都标了「待确认」。
> 更新时间:2026-10-04(CNB 迁移完成当日)
> 状态:**迁移已落地并跑通**(四线路全绿,单次发布约 3.5 分钟)
> 本文档只描述**现状**;迁移前的复杂度审计与选型过程见文末「相关文档」。
---
## 0. 一句话结论
## 0. 一句话
**这不是「一个博客」,而是四个独立系统,绑在一条 7 环节、跨境的、需要自己运维的发布链路上。**
前三个系统都很轻、也很健康;**复杂度几乎全部来自第四个 —— 为了支撑前三个而自建的基础设施**(Gitea + act_runner + 中转机),以及为了绕过跨境链路而打的一层层补丁。
四个子系统,一条 **3 步**的托管发布链路。构建与发布已从「自建 Gitea + act_runner + 广州中转机」迁到
**腾讯云 CNB(cnb.cool)** —— **需要自己运维的机器从 3 台降到 0 台**。
---
## 1. 全景:哪里有什么
## 1. 全景
### 1.1 四个子系统
| # | 子系统 | 技术栈 | 跑在哪 | 健康度 |
| # | 子系统 | 技术栈 | 跑在哪 | 状态 |
|---|---|---|---|---|
| **1** | **内容系统** | Hugo 0.128.2 + Ying 主题,138 篇 md,产物 424MB / 3267 文件 | 产物分发到 3 个 CDN | 主体,正常 |
| **2** | **评论系统** | `blog-admin/` = artalk-cf(Artalk v2 兼容服务端)+ RSS 机器人 | Cloudflare Workers + D1 + KV → `api.200181.xyz` | ✅ 已上线,**零服务器零运维**,架构最干净的一块 |
| **3** | **写作系统** | `write-server/`(Next.js 16 + Docker,`post.usj.cc`)<br>`write/`(本地 Windows 前端,带 vbs/bat 自启动) | 独立 Docker 主机(**待确认**)/本机 | ⚠️ **两套前端功能重叠** |
| **4** | **基础设施** | Gitea 28.0.0 + act_runner + 1Panel 中转机 + 各类备份脚本 | 境外 VPS + 腾讯云广州 | ⚠️ 正在迁移,**复杂度的主要来源** |
| **1** | **内容系统** | Hugo 0.128.2 extended + Ying 主题,138 篇 md;产物约 3200 文件 / 424MB | 产物分发到 3 个 CDN | 正常 |
| **2** | **评论系统** | `blog-admin/` = artalk-cf(Artalk v2 兼容服务端)+ RSS 机器人 | Cloudflare Workers + D1 + KV → `api.200181.xyz` | ✅ 零服务器零运维 |
| **3** | **写作系统** | `write-server/`(Next.js 16,`post.usj.cc`)<br>`write/`(本地 Windows 前端) | 独立 Docker 主机/本机 | 可用(两套前端功能重叠) |
| **4** | **发布基础设施** | **CNB 流水线**(`.cnb.yml`)+ 又拍云 + 多吉云 + EdgeOne | 腾讯云 CNB(**托管**) | **新建,已跑通** |
### 1.2 机器与服务地图
### 1.2 服务与地址地图
| 标识 | 地址 / 位置 | 承担什么 |
| 角色 | 地址 / 位置 | 说明 |
|---|---|---|
| **Gitea 主机** | `23.254.236.47:3001`(境外) | Git 仓库 + **Gitea Actions / act_runner** + artifact 存储 |
| **国内中转机** | `119.29.215.187`(腾讯云广州) | 1Panel + OpenResty + mysql + alist + vaultwarden(7 个容器)+ `sync.sh` 每分钟轮询同步 |
| **EdgeOne Pages** | 项目 `hugo-blog`,`--area overseas` | **境外线路**的托管与 CDN(腾讯) |
| **又拍云** | 对象存储 | **国内源站** |
| **多吉云** | CDN,回源又拍云 | **国内加速** |
| 腾讯云 COS | `cos.ap-*.myqcloud.com` | 曾是「境外构建产物 → 国内中转机」的中转层,**正在砍** |
| Cloudflare | `api.200181.xyz` | 评论后端 + RSS 机器人 |
| 通知渠道 | Telegram / 飞书 / QQ 邮件 | 三套并行,内容 90% 重复 |
| **代码主仓** | `cnb.cool/zqlit/blog` | CNB,**私有**;构建由它触发 |
| **代码备份** | `github.com/zqlit/blog` | 仅作备份,**不参与构建** |
| **构建 + 发布** | CNB 流水线(腾讯云国内节点,4 核 8G) | 托管,0 元(免费额度内) |
| **密钥仓库** | `cnb.cool/zqlit/blog-secrets` | CNB「密钥仓库」类型,10 项变量经 `imports` 注入 |
| **境内源站** | 又拍云对象存储 | 由 CNB 国内节点**直传**(原为 `if: false` 的死代码) |
| **国内加速** | 多吉云 CDN | 回源又拍云 |
| **境外线路** | EdgeOne Pages(项目 `hugo-blog`,`--area overseas`) | 腾讯 EdgeOne 国际站 |
| **评论后端** | `api.200181.xyz` | Cloudflare Workers |
| **通知** | QQ 邮件(`imql@qq.com`) | 已收敛为**单一通道** |
> 关键点:**境外线路和国内线路是完全独立的两套。** 境外走 EdgeOne,国内走 多吉云 → 又拍云。任一篇文章上线,两条链路上的东西都得各自到位。
> 关键点:境内、境外仍是**两条独立线路**,但**由同一条流水线一次推完** ——
> 这是本次迁移最大的结构性改善。
---
## 2. 一次发文的完整旅程
## 2. 一次发布的完整旅程(3 步)
```
① 写作 write-server (web) 或 write/ (本地 Windows)
│
② 提交 git push ──► Gitea(自建,跨境) ← 你自己维护的机器 1
│
③ 构建 Gitea Actions / act_runner ← 你自己维护的 runner 2
│ ├─ hugo --minify --gc (要的)
│ ├─ 图片优化 (sharp, 482 张) (可砍,见 §3-C)
│ └─ 草稿隐藏处理
│
④ 传递 424MB 产物 → 打 tar.zst → Gitea artifact ← 踩过 3 个坑才通
│
┌────┴─────────────────────────────┐
▼ ▼
⑤ 境外线路 ⑥ 国内线路
EdgeOne Pages 广州中转机 sync.sh(每分钟轮询) ← 你自己维护的机器 3
(runner 直接 deploy) └─ 跨境下载 376MB
└─ upx sync → 又拍云
└─ upx purge
│
⑦ 多吉云 CDN 刷新 + TG / 飞书 / 邮件 三套通知
① 写作 write-server(网页) 或 write/(本地 Windows)
│
② git push git pushall = git push origin main ; git push gh main
│ ├─ origin → cnb.cool/zqlit/blog (主仓,触发构建)
│ └─ gh → github.com/zqlit/blog (备份,不构建)
▼
③ CNB 流水线(国内节点,约 3.5 分钟,7 个 stage 顺序执行)
├─ 1. Hugo 构建 草稿隐藏预处理 → hugo --minify --gc → 算构建哈希
├─ 2. 同步到又拍云 upx sync(境内直传,非阻断但如实上报)
├─ 3. 刷新又拍云 CDN purge 首页 + sitemap/rss/archives/posts 等固定入口
├─ 4. 刷新多吉云 CDN node scripts/refresh_cdn.js
├─ 5. 部署 EdgeOne edgeone pages deploy --area overseas
├─ 6. 上报部署状态 POST api.200181.xyz/api/deploy-status
└─ 7. 邮件通知 成功 → stages 末尾;失败 → failStages
```
**对照**:一个普通 Hugo 博客 + 托管 CI 的发布链路是 **3 步**(push → 构建 → 上线)。
你这里是 **7 步,中间有 3 个环节是你自己在运维的服务器/服务**。
这就是「复杂」最直观的度量。功能一点都不多,**多的是中继环节**。
**对照**:迁移前是 **7 步,中间有 3 个环节是你自己运维的服务器**。
---
## 3. 复杂度归因:三层,性质完全不同
## 3. 流水线细节(`.cnb.yml`,284 行)
### A 层 · 外部约束 —— 不是你的选择,但躲不掉
| 约束 | 连带出来的复杂度 |
|---|---|
| **GitHub 账号被标记** | 自建 Gitea → 连带 act_runner 自运维、artifact API 不合规、缓存服务跨网段不可达 |
| **跨境链路不稳** | 境外 runner 直传又拍云 v0 API 稳定 EOF → 必须有境内兜底 |
| **国内 / 境外访问质量不同** | 被迫维护两条独立线路(EdgeOne + 多吉云/又拍云) |
| **EdgeOne Pages 国际站必须 `--area overseas`** | 部署动作只能从境外发起 |
> A 层是**根因**。B、C 两层的大部分,都是 A 层长出来的。
### B 层 · 为绕开 A 打的补丁 —— 半被迫,可以简化但不能直接删
| 补丁 | 为什么存在 | 代价 |
| 触发 | 条件 | 说明 |
|---|---|---|
| 广州中转机 `sync.sh` 每分钟轮询 | 境外直传又拍云不通 | 多一台机器要维护;每分钟一次轮询;产物要跨境搬一次 |
| COS 中转层(正在砍) | 给「境外产物 → 国内源站」当缓冲区 | 存储 + 流量账单 |
| 一套 workflow 硬塞进 Gitea | 原本为 GitHub 写 | **8 处** `github.server_url == 'https://github.com'` 分支判断 |
| artifact 单文件 tar 化 | v4 不支持 / v3 传目录 finalize 500 | 额外的打包/解包步骤 |
| 备份脚本三套(aliyun / gitea / cleanup) | 自建基础设施的必然成本 | — |
| push | 推送到 `main` | 主链路 |
| crontab | `0 9 * * *`(Asia/Shanghai) | 每天 09:00,与 push **共用同一组 stage**(YAML 锚点) |
> ⚠️ **一个需要你注意的点**(推测,建议实测):砍掉 COS 之后,中转机变成**直接跨境从境外 Gitea 拉 376MB**。跨境传输这一步并没有消失,只是从「境外 → 境内 COS(一次跨境上传)」变成「境内 ← 境外 Gitea(一次跨境下载)」。**除非实测新链路更快更稳,否则「砍 COS」省的是账单,不是复杂度。** 这条直接关系到你在做的改造是不是真的划算。
### C 层 · 自己加的 —— 可以直接砍,收益最大
| # | 冗余 | 证据 | 能砍吗 |
|---|---|---|---|
| 1 | **图片优化塞进 CI** | `Optimize Images` + `Commit Optimized Images` + `Push Image Optimizations` 三步 + `npm ci` 装 sharp(~50MB)。扫 482 张图,其中 **15 张 2021–2023 的老图永远处理不掉**,每轮重扫重试 | ✅ **最该砍**。图片在写作时本地处理,CI 就永远不需要 sharp |
| 2 | **CI 反向 commit 回仓库** | `Commit Optimized Images` 把结果 commit,`Push Image Optimizations` 再 rebase push | ✅ 砍掉图片优化后,这两步一起消失 |
| 3 | **三套通知** | `Send Telegram` ×2 + `Send Feishu` ×2 + `Send email` ×1 = **5 个 step,约占 deploy.yml 的 1/3(~400 行)**,同一份内容写 3 遍 | ✅ 留一套(TG 或飞书),其余删 |
| 4 | **永久跳过的死代码** | `Upload to UpYun` 标了 `if: false`,但 **~100 行代码仍在**(upx 下载、登录重试、sync 重试) | ✅ 要么删,要么挪进中转机 |
| 5 | **脚本重复** | `add_draft_to_hidden` 有 `.ps1`(96行) 和 `.py`(110行) 两份,README 写的是 ps1、CI 用的是 py;`deploy_*.sh` 有 4 个共 225 行 | ✅ 各留一份 |
| 6 | **两套写作前端** | `write/`(本地 Windows)与 `write-server/`(网页)功能重叠 | ⚠️ 看你的使用习惯,可合并 |
---
## 4. 量化:复杂度有多少
| 指标 | 值 |
| 机制 | 实现 |
|---|---|
| `deploy.yml` 总行数 | **1169 行** |
| job 数 | 7 个(含 1 个手动触发的网络探测 job) |
| 单个 job 的 step 数(finalize) | 12 个 |
| 通知类 step | 5 个(TG×2 / 飞书×2 / 邮件×1),约 400 行 |
| `github.server_url` 兼容分支 | 8 处 |
| `if: false` 死代码 | 约 100 行 |
| 需要自己运维的机器 / 服务 | **3 个**(Gitea 主机、act_runner、广州中转机) |
| 发布链路环节数 | **7 步**(理想 3 步) |
| 涉及的 CDN / 存储 | 4 个(EdgeOne / 又拍云 / 多吉云 / COS) |
| 构建产物体积 | 424MB / 3267 文件 |
| 真正发布所需的机器数(理想) | **1 台**(构建 + 推送)或 0 台(托管 CI) |
**一句话**:功能是一个静态博客 + 评论 + 写作后台,但支撑它的是 **7 个 job、4 个 CDN、3 台自维护机器、1169 行流水线**。
| 构建镜像 | `deploy/Dockerfile`,hugo 二进制**随仓库携带**(`bin/linux/hugo`,linux/amd64) |
| ⚠️ docker 上下文 | `by: [bin/linux/hugo]` —— CNB 的 docker build **只看得见 Dockerfile + by 列出的文件**,漏写会报 `not found` |
| 密钥注入 | `imports: https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml` |
| stage 间传状态 | 落盘 `.ci_status`(原版靠 `needs.*.outputs`) |
| 并发控制 | `lock.cancel-in-progress`(对应原 `concurrency`) |
| 非阻断 | 又拍云三步 + 状态上报 + 邮件用 `allowFailure: true`,失败仍写状态如实上报 |
| 资源 | `cpus: 4`(4 核 8G) |
---
## 5. 四条可选的收敛方向
## 4. 迁移前后对比
| | 方向 A · 就地瘦身 | 方向 B · 构建源外包 | 方向 C · 构建下移境内 | 方向 D · 取消 CI |
|---|---|---|---|---|
| **做什么** | 砍 C 层:图片优化移出 CI、合并通知、删死代码、合并脚本 | 引入 gitlab.com(GitHub 已被封)或用 CF Pages 构建 | Gitea + runner 迁到广州机,构建发生在境内 | 写作后台直接构建 + 部署,Gitea 退化为纯代码托管 |
| **产物落地** | 不变 | gitlab.com 共享 runner | 境内直接推又拍云 ✓ | 写作后台直接推又拍云 + EdgeOne |
| **跨境传输** | 不变 | 视方案 | **只剩 EdgeOne 一步** | 同上 |
| **新增依赖** | 无 | gitlab.com 账号 + 跨境 push | 无 | 无 |
| **主要风险** | 只是变短,环节数不变 | **GitLab 免费版仅 400 分钟/月**(你月需约 400–900 分钟,踩线) | 广州机**内存 1.9G / 已跑 7 个容器**,hugo 构建未必跑得动 | 写作后台所在机器算力是否够;构建与发布耦合 |
| **代价** | 最低,纯收益 | 月额度可能超 | 需要瘦身后实测内存 | 需要改造写作后台 |
| **建议** | ✅ **先做,是所有方案的前置** | 备选 | 与 D 二选一,实测决定 | 与你「一键直达」的偏好最契合 |
| 维度 | 迁移前(Gitea 自建) | 迁移后(CNB) |
|---|---|---|
| 发布链路环节 | **7 步** | **3 步** |
| 自维护机器 / 服务 | **3 台**(Gitea 主机 + act_runner + 广州中转机) | **0**(构建托管) |
| 流水线定义 | GitHub Actions **1169 行** | `.cnb.yml` **284 行** |
| 产物传递 | 打 `tar.zst` → artifact → 中转机跨境下载 | 同一 pipeline **共享工作目录**,无需传递 |
| 又拍云直传 | `if: false`(境外 runner 必挂,**从未跑通**) | **已跑通**(国内节点直传) |
| COS 中转层 | 需要(存储 + 流量账单) | **已砍** |
| `github.server_url` 兼容分支 | **8 处** | **0** |
| 通知 | 3 套(TG / 飞书 / 邮件),约 400 行 | **1 套**(邮件) |
| 图片优化 + 反向 commit | CI 内每轮白跑(15 张老图处理不掉) | **未迁**(如需保留,照搬 `scripts/optimize_images.js`) |
| 构建环境 | 境外 VPS | 腾讯云**国内节点** 4 核 8G |
| 费用 | VPS 月租 | **0 元**(100GiB 仓库 + 160 核时/月) |
| 单次发布耗时 | — | **约 3.5 分钟** |
### 推荐路径
1. **先做方向 A**(无风险、纯收益、且是 B/C/D 的前置)。做完后 `deploy.yml` 预计能从 1169 行降到 600 行以内,构建里只剩 `hugo build`。
2. **再实测两件事**:① 广州机跑一次 `hugo` 的峰值内存与耗时;② 国内机跑一次 `hugo` 的耗时(对比 CI)。
3. **依据实测二选一**:
- 广州机跑得动 → **方向 C**(Gitea 迁境内,跨境只剩 EdgeOne)
- 写作后台机器跑得动 → **方向 D**(连 CI 都取消,最符合「一键直达」)
- 都跑不动 → **方向 B**(但要接受 GitLab 400 分钟/月的紧箍咒)
> 原链路里那些补丁(COS 中转 / `sync.sh` 每分钟轮询 / artifact 打包 / 兼容分支)
> **唯一根因是「runner 在境外」**。CNB 节点在国内,这一整层随之消失。
---
## 6. 需要你决定的三件事
## 5. 运维要点
1. **那 15 张 2021–2023 的老图能清吗?** 能清的话,我本地转 WebP + 修引用,然后 `Optimize Images` 这一步整个从 CI 删掉 —— 这是砍掉 C 层最大块的钥匙。
2. **构建最终放在哪一侧?** 境外(现状)/境内(方向 C/D)/外包(方向 B)。这决定后面所有改造的走向。
3. **通知留哪一套?** TG / 飞书 / 邮件 —— 留一个,其余删。另外邮件通知那份的长文本(约 50 行模板)可以整个去掉。
### 5.1 推送
```bash
git pushall # 别名 = git push origin main; git push gh main
```
用 `;` 而非 `&&` —— **一段失败不影响另一段**(实测:串联 `pushurl` 时第一个失败会中止后续)。
### 5.2 远端
| remote | 地址 | 角色 |
|---|---|---|
| `origin` | `https://cnb.cool/zqlit/blog.git` | **主仓**(fetch + push) |
| `gh` | `git@github.com:zqlit/blog.git` | **备份**(push) |
| `gitea` | 自建 `23.254.236.47:3001` | 过渡期只读参考,稳定后手工删 |
### 5.3 凭据
- 令牌存**仓库外**:`~/.workbuddy/secrets/cnb-token`
- 本仓 `credential.helper` 指向一个自定义脚本;`.git/config` **无明文**
- ⚠️ 本机全局 helper 是 **GCM**(会弹窗、对第三方 HTTPS 远端还会挂死);wincred 对 cnb.cool 有过「幽灵记录」删不掉 → 故用自定义 helper 绕开
### 5.4 密钥
- 修改密钥 → 编辑 CNB 密钥仓库的 `secrets.yml`(**网页编辑,禁 clone**)
- 本地 `cnb-secrets.yml` 只是粘贴草稿(已 gitignore,**不入库**)
- ⚠️ CNB 的 `imports` 是**一层映射**(key 就是变量名本身);又拍云服务名在 CNB 里必须叫
**`UPYUN_SERVICE`**(旧 Gitea 里叫 `UPYUN_BUCKET`,照抄会「变量未定义」)
---
## 附:本次梳理的证据出处
## 6. 遗留待办
- `.github/workflows/deploy.yml`(1169 行,全文通读)
- `blog-admin/README.md`、`write-server/README.md`、`write/`(模块定位)
- `hugo.toml`(`baseURL = https://usj.cc`)
- `gitea-backup/README.md`、`docker-compose.yml`(迁移目标与坑)
- `scripts/` 目录清单与行数统计
- 线上实测:Gitea `/api/v1/version` = `28.0.0`;广州机 `docker pull gitea/gitea:28.0.0-rootless` 可拉、`1.28.0-rootless` 不存在
| # | 项 | 说明 |
|---|---|---|
| 1 | `gitea` remote | 只读保留,稳定几天后 `git remote remove gitea` |
| 2 | GitHub 侧旧 workflow | `deploy.yml` / `cleanup.yml` / `aliyun-backup.yml` 已无用途,可删 |
| 3 | 令牌 scope | 缺 `repo-cnb-history:r`(读构建日志);补上后 agent 可自行排错 |
| 4 | Gitea 主机 | 发布链路已不依赖;是否退役取决于其它用途 |
| 5 | 广州中转机 | 同上(`sync.sh` 轮询已无用;机上另有 1Panel / vaultwarden 等服务) |
| 6 | `README.md` | 仍写着旧 GitHub Actions 流程,待更新 |
---
## 附:相关文档
- `CNB构建落地方案.md` — 迁移实施方案与实测数据
- `代码源与构建平台选型.md` — 平台对比(CNB / Gitee / GitLab / EdgeOne / 阿里云 ESA)
- `砍COS改造步骤.md` — COS 下线记录
- `EdgeOne双区域方案评估.md`、`Gitee方案评估.md`、`阿里云ESA评估.md`、`精简方案-只留CF和Hugo.md` — 决策期评估
**证据出处(现行)**:`.cnb.yml`(284 行)、`deploy/Dockerfile`、`bin/linux/hugo`、
`scripts/send_mail.js`、`scripts/refresh_cdn.js`、`scripts/setup-cnb-remotes.sh`。
已退役参考:`.github/workflows/deploy.yml`(1169 行)。
+281
View File
@@ -0,0 +1,281 @@
# 评论加载优化方案(Cloudflare 免费版)
> 起因:读者侧「评论加载太慢」。
> 约束:**Cloudflare 免费版**,无中国节点;全程不引回自维护机器(除非你主动选第三档)。
> 状态:**第一档 + 2-A 已实施**(本地,待预览确认);2-B 待做,2-C 已由实测数据关闭。
---
## 0. 先厘清「慢」到底慢在哪一层
这一点决定每一项优化的真实价值,否则容易做一堆「看起来很快」的改动却没有体感。
| 层 | 链路 | 现状 | 谁能修 |
|---|---|---|---|
| **L1 跨境** | 读者浏览器 → CF 边缘机房 | CF 免费版无中国节点,每次请求跨境 ≈ 250–400ms | 只能靠**少发请求**(前端),或第三档反代 |
| **L2 回源** | CF 边缘机房 → D1 主库 | D1 主库在 WNAM(美国西),代码注释:「每次读都要跨太平洋」 | **边缘缓存**(省掉这一跳) |
| **L3 结构** | CF 在中国大陆无节点 | 结构性,无解 | 只有第三档(境内反代) |
**关键结论:边缘缓存(第二档)治的是 L2 和额度,治不了 L1。** 读者感受到的「慢」主要在 L1,所以**降请求数比加缓存更直接**。
---
## 1. 当前一次评论首屏的请求链(来自代码,非推测)
Artalk 客户端 `libs/Artalk.js` 的初始化是**串行**的——已从压缩源码确认:
```js
// conf 请求(await)→ mounted → 之后才发评论列表
...getApi().conf.conf().catch(...)
... e.trigger("mounted"), e.conf.remoteConfModifier || e.fetch({offset:0})
```
即:**`conf` 不回来,评论列表请求根本不会发出**。所以首屏是严格串行的两跳:
```
浏览器 ──① GET /api/v2/conf ──────► CF 边缘 ──► D1 ≈ 1×L1 + 1×L2
──② GET /api/v2/comments ─► CF 边缘 ──► D1 ≈ 1×L1 + 1×L2(含多条查询)
──③ POST /api/v2/pages/pv ─► CF 边缘 ──► D1 (异步,不阻塞渲染)
```
页面加载时还会打 `/captcha/status` 或 `/human/*`(仅在需要时)。
**L1 一次 ≈ 250–400ms(国内实测 CF 0.75s vs EdgeOne 0.25s)**,所以 ①→② 两跳 ≈ 0.5–0.8s 纯网络。把 ① 干掉,首屏网络时间**直接减半**。
> 已有的优化不动:`window.friendLinks/friendFeeds` 是 Hugo **构建期内联**的(零运行时请求);Artalk 的 JS/CSS 走又拍云本地资源;`conf` 已有 localStorage 缓存(**但只在 PJAX 第二次以上生效,首屏吃不到**);`/api/favicon` 已有 KV 缓存 + `max-age=86400`;计数查询已有 KV 短缓存(10 分钟 + 版本号失效)。
---
## 2. ✅ 第一档(已实施):修 `preconnect`
**问题**:`themes/Ying/layouts/partials/head.html` 里给 API 域名预热了连接,但**没带 `crossorigin`**。
Artalk 发的是 CORS 跨域请求。浏览器把「带凭据的连接」和「匿名连接」分池缓存:不带 `crossorigin` 预热出来的是普通连接,**CORS 请求无法复用它**——等于白预热,还是要重付一次 DNS + TCP + TLS(约 2–3 个 RTT)。
**改法**:`preconnect ... crossorigin` + `dns-prefetch` 兜底(老浏览器不支持 preconnect);host 取 `artalk.server` 与 `rssapi.base` 去重后渲染,以后两处配置分家也不会漏。
**验证**:本地 `hugo` 构建产物确认(1270 个页面全部生效,两个 host 正确去重):
```html
<link rel="preconnect" href="https://cravatar.cn">
<link rel="preconnect" href="https://api.200181.xyz" crossorigin>
<link rel="dns-prefetch" href="https://api.200181.xyz">
```
**估算收益**:首屏第一跳省下 ~2–3 个 RTT 的握手(≈200–400ms),且**后续 ②③ 请求复用同一条连接**,各自只花 1 个 RTT。
---
## 3. 📋 第二档评估
### 2-A. ✅ 首屏内联 `conf`(已实施)
> ⚠️ **先记一次认知纠正:本节原评估的方向是错的。**
>
> 原方案写的是「前端首屏直接 `useBackendConf: false` 用内联配置起步,跳过 ①」。
> 读 `libs/Artalk.js` 压缩源码后确认**做不到**:
>
> ```js
> const { data: i } = await e.getApi().conf.conf() // ← 无条件发请求
> if (e.conf.useBackendConf) { ...merge frontend_conf... } // ← 只管要不要「用后端覆盖前端」
> ```
>
> `useBackendConf` 决定的是**要不要采用后端的配置**,与**发不发这次请求**无关。
> 所以内联配置在 Artalk 内部绕不过请求 ① —— **必须拦在 fetch 层**。
> (PJAX 缓存那条路径也没省掉请求,它省的是「重新渲染」,不是「重新请求」。)
**做法**(三段,缺一不可):
1. **构建期抓快照** —— `.cnb.yml` 的「Hugo 构建」stage 里 `curl` 一次 `/api/v2/conf`,
落到 `data/artalk_conf.json`(Hugo 读成 `site.Data.artalk_conf`)。
拉取失败就删文件降级(失败发生在 `if` 条件里,不触发 `set -e`,**绝不让它拖垮构建**)。
该文件已 gitignore:每次构建现拉,避免陈旧配置被 git 固化。
2. **模板内联** —— `partials/artalk.html` 输出 `window.__artalkConfSnapshot = {…}`,
放在 `window.artalkConfig` 之前。`</` 会被替换成 `<\/` 防 `</script>` 逃逸。
3. **fetch 拦截** —— `modules/artalk.js` 的包装层里命中 `/api/v2/conf` 时,
直接 `new Response(JSON.stringify(快照))` 返回,**完全不发网络**。
(Artalk 的通用请求函数 `w()` 只检查 `r.ok` 就把 Response 原样返回,所以合成响应可无缝接管。)
**为什么可以安全复用「一份固定响应」**:服务端 `getConf` 是
`{...DEFAULT_FRONTEND_CONF, ...settings.frontend_conf, imgUpload: imgUpload || admin}` ——
**除 `imgUpload` 外全部来自 settings 表,与访客无关**。于是:
- **匿名访客** → conf 人人相同 → 用快照 ✅
- **已登录用户** → 管理员会拿到 `imgUpload: true`(个性化)→ **放行真实请求**
判断方式:`w()` 在无 token 时会把 `Authorization` 头删掉,所以「该头存在」即「有凭据」;
判不准时保守放行(宁可多一跳,也不错配)。
**实测验证**(Chrome CDP 抓真实页面网络):
| | `/api/v2/conf` 请求数 |
|---|---|
| 改动前(对照:CDP 注入脚本吞掉快照变量的赋值) | **2 次** |
| **改动后** | **0 次** ✅ |
页面探针同时确认 Artalk 完全正常:`artalkInited: true`、`editorPresent: true`、
`listPresent: true`、`errText: ""`;且 conf 内容确实来自快照
(`sendBtn:"评论一下"` / `nestMax:2` / `locale:"zh-CN"` / `emoticons` 全对);
conf 之后的评论列表请求照常发出 —— **串行链没断**。
**成本**:conf 响应 771B,内联进 123 个带评论区的页面(占页面体积 **1.6%**),全站内容一致。
| | 结果 |
|---|---|
| 收益 | 首屏少一整跳跨境(≈250–400ms);**实际比预期多省一倍**,见下方「顺带发现」 |
| 风险 | 低。构建期失败自动降级;带凭据请求自动放行 |
| 代价 | 后台改评论配置后要等下次构建才生效(已有每日 9:00 定时构建兜底) |
**怎么回退**:删掉 `data/artalk_conf.json` 再构建即可 —— 模板不输出快照,前端自动走原生远程请求,行为等同今天(无需改代码)。
### 2-B. 给读接口加边缘缓存(Cache API)
**注意**:Worker 的响应默认**不进** Cloudflare 缓存。光加 `Cache-Control` 头是**没有效果的**,必须显式用 `caches.default.put()/match()`。(这也是为什么这项不能"顺手加个头"就完事。)
必须做对的四件事:
1. **只缓存匿名公开视图**。`listComments` 的结果依赖是否管理员(`is_pending` 过滤、`includeMarked`)、`name`/`email`、`scope=user`、`type=mine|pending|mentions`、`view_only_admin`——这些**一律不缓存**,只缓存 `scope=page` 的默认视图。`/conf` 同理(含 `imgUpload || admin`)。
2. **缓存 key 要带 origin**。`corsHeaders()` 已经把 `Access-Control-Allow-Origin` 回显成请求方的 Origin(并设了 `Vary: Origin`),缓存 key 里必须把 origin 编进去,否则 A 域名的响应会被回给 B 域名。
3. **失效策略复用现有机制**。项目里已有 `cml:ver:<site>:<page>` 这个 KV 版本号,评论新增/删除/审核会 `bumpCountVersion()`。**直接把它编进缓存 key** → 有评论变化时 key 自然变化,旧条目靠 TTL 自然过期,不用做 purge。
4. **TTL 取短**:30–60s。评论不是毫秒级强一致场景。
| | 评估 |
|---|---|
| 收益 | **D1 额度**:命中时省掉「计数 + 根列表 + 子评论 + page + site」多条查询(单页几十~几百 rows_read)。这个是真金白银——代码注释里记着当年额度被打爆过 |
| 收益 | **延迟**:命中时省掉 L2 那一跳(≈100–200ms)。但**L1 那一跳仍然要付**,所以体感提升有限 |
| 风险 | 低–中。要点全在「不可缓存哪些请求」上,漏一个就是串号/看到别人的数据 |
| 代价 | 评论提交后,**同一机房的其他读者最多看到 TTL 秒的旧列表**(可接受) |
### 2-C. Smart Placement(`[placement] mode = "smart"`)
让 Worker 跑在**离 D1 更近**的机房,代价是可能离**读者**更远。
**要不要开不用猜**:你的 `/api/v2/healthz` 已经专门返回这两个字段了(当初就是为这个判断留的):
```
colo —— Worker 实际执行机房
region —— D1 实际服务区域
```
- **同区域** → 开了没收益,还可能变慢,**别开**
- **不同区域** → 有意义,可以开
**实测结果(2026-10-04,用户提供)**:
```json
{"region":"WNAM","colo":"LAX"}
```
两者同为美西 → **结论:不开**。Worker 已经在 D1 同区域执行,Smart Placement 带不来收益,
还可能把请求推到离读者更远的机房。**本项关闭,不用再做。**
### 2-D. 「加 HTTP 缓存头」——不是独立项
它只是 2-B 的实现细节(`caches.default.put()` 要求响应显式可缓存)。**单独加头没有任何效果**,不要当成一项来做。
### 顺带发现(本轮实测捡到的既有问题,与 2-A 独立)
**① `initArtalk()` 被调用两次 → conf / comments / pv 全部发两遍。**
根因是既有代码的重复触发:
```
partials/artalk.html 内联脚本 → initArtalk 尚未定义 → 注册 DOMContentLoaded 回调(兜底)
assets/js/main.js:70 → 也在 DOMContentLoaded 里调 window.initArtalk()
↓
两个监听都会触发 → initArtalk 跑 2 次
```
CDP 实测(改动前):`GET /api/v2/conf` ×2、`GET /api/v2/comments` ×2、`POST /api/v2/pages/pv` ×2。
2-A 把 conf 那两次消掉了,但 **comments 仍多付一次跨境,`pv` 仍是双倍计数**
(**浏览量统计偏高 100%**,这是数据准确性问题,不只是性能)。
修法很轻:在 `initArtalk` 入口加幂等守卫(同一 `pageKey` 已初始化过就 `return`),
PJAX 切页时 `pageKey` 变化自然放行。**但改动落在 PJAX 路径上,建议单独一轮做 + 单独验证。**
**② `head.html:59` 有一个第三方统计脚本。**
```html
<script async src="https://019e3dec-3312-7873-862a-3f56ac99ea83.spst2.com/ustat.js"></script>
```
它不在主题自身模块里,是页面**唯一的外部脚本**,会把访问数据上报给第三方。
**确认是不是你自己加的**;若不是,建议连同这一行一起删掉。
**③ ✅ 已修:非评论类错误层会让整个评论区「假故障」一次(顿一下 + 弹「已恢复」toast)。**
**现象**:进文章页时评论区整体置灰、插「评论服务暂时不可用」横幅,约 1 秒后自己恢复并弹
`评论服务已恢复,可以继续评论了 ✓` —— 读者看到的就是「顿一下 + 一条莫名其妙的提示」。
**根因**(CDP 时间线实测,非推测):
```
表情包 /emotion/OwO.json 加载失败(本地预览跨域;线上则可能是 CDN 抖动)
→ Artalk 在「编辑器插件面板」内渲染 .atk-error-layer,文本:
Artalk Error / [表情] 加载失败: TypeError: Failed to fetch
→ rewrite() 只用 layer.querySelector('.error-message') 取文本 —— 而插件面板的错误层
没有这个 class,取到空串
→ 空串不匹配任何特征词 → 兜底 code = 'internal'
→ if (code !== 'network') setSvcDown(code) ← 只有 network 被豁免,internal 不豁免
→ 整个评论区置灰 + 插横幅(此时评论数据其实还没回来,也没失败)
→ 约 1s 后 Artalk 收掉错误层 / 评论数据正常返回 → setSvcUp() → 弹 toast
```
CDP 抓到的判定证据:错误卡片上的 `data-raw` = `"|internal"`(竖线左边就是空串 `raw`),
而错误层的真实 DOM 位置是 `atk-editor-plug-emoticons < atk-plug-panel-wrap < atk-main-editor`。
**修复**(`themes/Ying/assets/js/modules/artalk.js`,三处):
1. `rewrite()` 开头直接跳过 `.atk-plug-panel-wrap / .atk-editor-plug-emoticons` 内的错误层 ——
既不改写文案,也不参与故障判定(这类层是资源加载失败,不是评论服务故障)。
2. 故障判定加证据要求:`if (code !== 'network' && (raw || (fresh && fresh.code)))` ——
**读不到可归因证据时不下结论**,宁可只渲染卡片也不误置灰。
3. `setSvcUp()` 加最短门槛:禁用态不足 3 秒不弹 toast(真实故障不会两三秒就恢复)。
**验证**:CDP 复跑,`svc-down` / 横幅 / toast 三项全部 0 次,评论数据仍 200;
另做三组回归:真实故障层仍置灰 ✅、无文本层不置灰 ✅、插件面板层完全放行 ✅。
> ⚠️ **线上本来不出现这个现象**:线上页面在 `usj.cc`、表情包也在 `usj.cc`,同源不触发 CORS。
> 它只在本地预览(跨域)必现,线上要等 `OwO.json` 真出问题(CDN 抖动 / 404)才会踩到 ——
> 所以这算是一次「本地预览帮忙提前暴露了线上潜在缺陷」。
### 不建议做的
- **改 Artalk 客户端把 ① ② 并行**:要 fork 官方库,收益和 2-A 重叠,维护成本高。
- **给 `/pages/pv` 加速**:它已经是异步的,不阻塞渲染。
---
## 4. 建议的执行顺序
| 顺序 | 项目 | 收益 | 风险 | 状态 |
|---|---|---|---|---|
| ✅ 1 | 第一档 preconnect | 首屏 −200~400ms | 极低 | **已完成** |
| ✅ 2 | 2-A 首屏内联 conf | conf 请求 **2 次 → 0 次** | 低 | **已完成** |
| ✅ 3 | 2-C Smart Placement | — | 零 | **实测同区域 → 关闭** |
| 4 | 修「`initArtalk` 跑两次」 | 再省 1 跳,且**修正 PV 双倍计数** | 中(涉及 PJAX) | **建议做,单独一轮** |
| 5 | 2-B 读接口边缘缓存 | 保 D1 额度;命中 −100~200ms | 中 | 推荐 |
做完 2 + 4 + 5,首屏从「2 跳跨境」变成「1 跳 + 命中即回」,D1 读量大幅下降,PV 计数恢复正确。
**预期天花板**:即使全部做完,L1(跨境那一跳)依然存在——免费版 CF 在国内就是没有节点。
---
## 5. 第三档(真正的解法,需要你权衡)
`usj.cc` 的 NS 在 DNSPod,可以对 `api.usj.cc` 做**分线路解析**:境内 → 境内机器反代 → CF;境外 → 直连 CF。这才治本(L1 也消失)。
两条硬约束:
1. **`api.200181.xyz` 本身做不了**——200181.xyz 的 NS 在 Cloudflare,免费版不支持按国家分线路解析。所以必须换成 `api.usj.cc` 这类「NS 在 DNSPod」的域名。
2. **代价是把一台自维护机器重新拉回关键路径**——与 2026-10-04 刚完成的「0 台自维护机器」方向相反。
**没有白吃的午餐**:这一档能根治,但要把刚拆掉的东西装回去。
---
## 6. 不变量(别被顺手改坏)
- `SERVER_API_VERSION` 必须与博客里打包的 Artalk 客户端版本一致(当前 2.8.7),否则前端弹版本警告。
- `listComments` 里 `countFromSql` 的「不用 JOIN users 就不 JOIN」是额度优化,别回退。
- 任何新增缓存都必须先回答:**这个响应会不会因为「谁在问」而不同?** 会 → 不能缓存,或必须把身份编进 key。