## 友链自助申请(通过不了 / 删除不掉)
根因:admin.js 全局事件委托把 data-id 一律 parseInt,
而友链申请 id 是字符串(lamus5uptzcdam)→ NaN → JSON 成 {"id":null}
→ 后端 String(null||'') 为空 → 400 id and action required,查看/通过/拒绝全失效。
引入于 a74b3c71(10-04 归档 rss-robot 时;后台只有友链申请用字符串 id)。
- admin.js:纯数字才转数字,其余原样(评论/用户/站点按钮请求完全不变)
- admin.js:待审卡片加「删除」按钮
- links.ts:新增 action:'delete'(不受「已处理过」限制,处理过的也能删)
- links.ts:approve 改为先写友链再改状态 + URL 去重(重试幂等)
- links.ts:已是目标状态时返回 {ok:true,already:true},不再 409
—— 否则网络抖动后重试会让用户以为「怎么点都通不过」
## 评论通知按文章作者分派
编辑角色(女朋友)写的文章,评论通知改发给她,不再打扰博主。
不需要「文章→作者」同步表:Hugo 模板把 author_id 注入 artalkConfig,
artalk.js 已有的 fetch 拦截器把它塞进评论提交体,Worker 直接读。
- artalk.html:artalkConfig 增 pageAuthorId(老文章 0)
- artalk.js:提交评论时带上 author_id(读 window.artalkConfig,防 PJAX 过期)
- comments.ts:createComment 解析 author_id 并传给通知
- mail.ts:收件人改为「这篇文章的主人」——作者非管理员且开了邮件则发给他,
否则回退博主;已被回复通知过 / 作者本人评论自己的文章都不重复发
- mail.ts:增一行收件人决策日志,便于线上排查
验证:Worker tsc 通过;Hugo 构建通过;artalk.js 语法通过;
友链本地跑通(通过 200 → 再通过 already:true → 友链仅 1 条 → 删除 200 → 再删 404)。
1778 lines
92 KiB
JavaScript
1778 lines
92 KiB
JavaScript
// ============================================================================
|
||
// 评论加载失败 —— 给读者看的清晰提示
|
||
//
|
||
// Artalk 自带的错误层只会写「Failed to load comments / TypeError: Failed to fetch」,
|
||
// 既没说原因也没给下一步。服务端现在已经会返回结构化的错误码
|
||
// (见 artalk-cf/src/lib/errors.ts:d1_quota / d1_unavailable / timeout / ...),
|
||
// 所以这里把它换成一句人话 + 可展开的技术详情(留着给站长排查)。
|
||
// ============================================================================
|
||
|
||
// ── 本地调试开关:给 URL 加 ?atk_svc=<code> 强制复现服务故障态 ──────────────
|
||
// 例:/comment.html?atk_svc=d1_quota
|
||
// 仅 localhost 生效(生产环境这段完全不执行),用来本地看禁用态/错误卡片的观感,
|
||
// 不必等真的把 D1 额度跑满。支持的 code 见下面的 INFO 表。
|
||
(function () {
|
||
var host = location.hostname;
|
||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '') return;
|
||
var m = /[?&]atk_svc=([a-z0-9_]+)/.exec(location.search || '');
|
||
if (!m) return;
|
||
var code = m[1];
|
||
var MSG = {
|
||
d1_quota: '数据库今日读取额度已用尽,明早 8:00 自动恢复。你可以先看看文章,稍后再来~',
|
||
d1_unavailable: '数据库连接异常,我们正在处理,请稍后再试。',
|
||
timeout: '评论服务响应超时,请稍后重试。',
|
||
unavailable: '服务或网络有点问题,请稍后重试。',
|
||
internal: '评论服务出了点小问题,请稍后重试。'
|
||
};
|
||
if (!MSG[code]) return;
|
||
var orig = window.fetch;
|
||
window.fetch = function (input, init) {
|
||
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
|
||
if (/\/api\/v2\/comments(\?|$)/.test(url)) {
|
||
return Promise.resolve(new Response(
|
||
JSON.stringify({ code: code, msg: MSG[code], detail: '[demo] forced by ?atk_svc=' + code }),
|
||
{ status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json; charset=utf-8' } }
|
||
));
|
||
}
|
||
return orig.apply(this, arguments);
|
||
};
|
||
try { console.info('[artalk] 调试模式:已强制评论接口返回 ' + code); } catch (e) {}
|
||
})();
|
||
|
||
(function () {
|
||
if (window.__atkErrGuard) return; // PJAX 会重复执行本文件,只装一次
|
||
window.__atkErrGuard = true;
|
||
|
||
var last = null; // 最近一次 /api/v2 失败:{ status, code, msg, detail, at }
|
||
|
||
var INFO = {
|
||
d1_quota: { ic: '😴', title: '评论暂停中', desc: '数据库今日读取额度已用尽,明早 8:00 自动恢复。你可以先看看文章,稍后再来~' },
|
||
d1_unavailable: { ic: '🛠️', title: '评论服务正在维护', desc: '数据库连接异常,我们已经在处理,请稍后再试。' },
|
||
timeout: { ic: '🐢', title: '评论服务响应有点慢', desc: '这次请求超时了,稍后再试一次。' },
|
||
unavailable: { ic: '📡', title: '评论服务暂时不可用', desc: '服务或网络有点问题,请稍后再试。' },
|
||
network: { ic: '📡', title: '网络好像不太稳定', desc: '评论没能加载出来,检查一下网络再点重试。' },
|
||
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 自己读)。
|
||
// ── 1.5) 瞬断自动重试:PJAX 进文章偶尔「连不上服务器」,手动刷新就好
|
||
// —— 跨境链路抖一下(Failed to fetch / 5xx)列表就整块报错。GET/HEAD
|
||
// 幂等,网络错误或 5xx 时静默重试两次(700ms / 1800ms 退避);POST
|
||
// 绝不重试(重复提交风险);请求已被 PJAX abort 的也不再续命。
|
||
var RETRYABLE_STATUS = { 502: 1, 503: 1, 504: 1, 520: 1, 521: 1, 522: 1, 523: 1, 524: 1, 525: 1, 527: 1, 530: 1 };
|
||
function retryWait(ms) {
|
||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||
}
|
||
function fetchRetry(doFetch) {
|
||
var delays = [700, 1800];
|
||
function run(i) {
|
||
return doFetch().then(function (res) {
|
||
if (res && RETRYABLE_STATUS[res.status] && i < delays.length) {
|
||
try { if (res.body && res.body.cancel) res.body.cancel(); } catch (e) {}
|
||
return retryWait(delays[i]).then(function () { return run(i + 1); });
|
||
}
|
||
return res;
|
||
}, function (err) {
|
||
if (i >= delays.length) throw err;
|
||
return retryWait(delays[i]).then(function () { return run(i + 1); });
|
||
});
|
||
}
|
||
return run(0);
|
||
}
|
||
var origFetch = window.fetch;
|
||
if (typeof origFetch === 'function') {
|
||
window.fetch = function (input, init) {
|
||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||
url = String(url);
|
||
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 self = this;
|
||
function doFetch() { return origFetch.apply(self, fargs); }
|
||
var canRetry = (method === 'GET' || method === 'HEAD') &&
|
||
!(init && init.signal && init.signal.aborted);
|
||
var p = canRetry ? fetchRetry(doFetch) : doFetch();
|
||
return p.then(function (res) {
|
||
if (res && res.ok === false) {
|
||
try {
|
||
res.clone().json().then(function (d) {
|
||
last = {
|
||
status: res.status,
|
||
code: (d && d.code) || '',
|
||
msg: (d && d.msg) || '',
|
||
detail: (d && d.detail) || '',
|
||
at: Date.now()
|
||
};
|
||
if (last.code && last.code !== 'network') setSvcDown(last.code);
|
||
}).catch(function () {
|
||
last = { status: res.status, code: '', msg: '', detail: 'HTTP ' + res.status, at: Date.now() };
|
||
});
|
||
} catch (e) { /* clone 不支持就放弃记录,不影响请求 */ }
|
||
} else if (res && res.ok) {
|
||
// 「服务恢复」只认评论列表本身:禁用的直接原因就是列表挂了,
|
||
// 列表回来了才算好。votes/pv/captcha 这些端点不读 D1 或只读
|
||
// 一行(KV),它们成功≠列表能拉出来 —— 曾据此误判恢复,把
|
||
// 置灰和横幅撤了,只剩一张孤零零的错误卡片挂在页面底部。
|
||
if (svcDown && /\/comments(\?|$)/.test(url)) setSvcUp();
|
||
}
|
||
return res;
|
||
}).catch(function (e) {
|
||
last = { status: 0, code: 'network', msg: '', detail: (e && e.message) || 'network error', at: Date.now() };
|
||
throw e; // 原样抛回,Artalk 自己的重试逻辑不受影响
|
||
});
|
||
};
|
||
}
|
||
|
||
function esc(x) {
|
||
return String(x == null ? '' : x)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function injectCss() {
|
||
if (document.getElementById('atk-err-css')) return;
|
||
var st = document.createElement('style');
|
||
st.id = 'atk-err-css';
|
||
st.textContent =
|
||
'.atk-error-layer{position:relative!important;top:0!important;left:0!important;height:auto!important;' +
|
||
'padding:0!important;margin:14px 0 40px!important;background:transparent!important;z-index:auto!important}' +
|
||
'.atk-err-card{display:block;width:100%;box-sizing:border-box;text-align:center;padding:30px 22px;' +
|
||
'animation:atkErrIn .35s cubic-bezier(.22,1,.36,1)}' +
|
||
'@keyframes atkErrIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
|
||
'.atk-err-ic{display:block;font-size:30px;line-height:1;margin-bottom:12px}' +
|
||
'.atk-err-t{font-size:15px;font-weight:600;color:var(--text-main,#333);margin-bottom:6px}' +
|
||
'.atk-err-d{font-size:13px;line-height:1.8;color:var(--text-muted,#999);' +
|
||
'max-width:420px;margin:0 auto 16px;word-break:break-word}' +
|
||
'.atk-err-acts{display:flex;justify-content:center;align-items:center;gap:10px}' +
|
||
'.atk-err-retry{display:inline-flex;align-items:center;padding:6px 20px;border-radius:999px;' +
|
||
'background:var(--theme-main-20,rgba(7,193,96,.12));color:var(--theme-main,#07c160);' +
|
||
'font-size:13px;font-weight:600;cursor:pointer;transition:background .2s,color .2s,transform .2s;user-select:none}' +
|
||
'.atk-err-retry:hover{background:var(--theme-main,#07c160);color:#fff}' +
|
||
'.atk-err-retry:active{transform:scale(.96)}' +
|
||
'@media (max-width:640px){.atk-error-layer{margin:10px 0 30px!important}.atk-err-card{padding:20px 14px}.atk-err-ic{font-size:26px}' +
|
||
'.atk-err-t{font-size:14px}.atk-err-d{font-size:12.5px}}' +
|
||
// 禁用态不改编辑器外观(降透明/灰化整块很难看);只禁交互,
|
||
// 为什么点不动由上方横幅说明
|
||
'.atk-svc-down .atk-copyright{display:none!important}' +
|
||
'.atk-svc-down .atk-main-editor *{pointer-events:none}' +
|
||
'.atk-svc-down .atk-human-tf{display:none}' +
|
||
'.atk-svc-down .atk-list-body{min-height:0!important}' +
|
||
'.atk-editor-off-bar{display:flex;align-items:center;gap:8px;margin:12px 0 10px;padding:9px 14px;border-radius:8px;' +
|
||
'background:rgba(7,193,96,.06);border:1px solid rgba(7,193,96,.22);font-size:13px;line-height:1.6;color:var(--text-main,#333)}' +
|
||
'[data-theme="dark"] .atk-editor-off-bar{background:rgba(7,193,96,.08);border-color:rgba(7,193,96,.3)}' +
|
||
'@media (max-width:640px){.atk-editor-off-bar{margin:10px 0 8px;padding:8px 12px;font-size:12.5px}}';
|
||
document.head.appendChild(st);
|
||
}
|
||
|
||
// ── 服务不可用时的编辑器禁用态 ─────────────────────────────────────
|
||
// 列表挂了编辑器还开着,用户打完字点提交却毫无反馈 —— 最差的体验。
|
||
// 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅
|
||
// 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status,
|
||
// 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。
|
||
// toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。
|
||
|
||
// conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮
|
||
// 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观,
|
||
// 这里按官方标记把缺的字和图标补齐(幂等,Artalk 重建编辑器后可重复跑)。
|
||
// SVG 与 libs/Artalk.js 里 useBtn() 的原生节点逐字节一致,视觉与正常态无差。
|
||
var PLUG_BTNS =
|
||
'<span class="atk-plug-btn"><i aria-label="表情"><svg fill="currentColor" aria-hidden="true" height="14" viewBox="0 0 14 14" width="14"><path d="m4.26829 5.29294c0-.94317.45893-1.7074 1.02439-1.7074.56547 0 1.02439.76423 1.02439 1.7074s-.45892 1.7074-1.02439 1.7074c-.56546 0-1.02439-.76423-1.02439-1.7074zm4.43903 1.7074c.56546 0 1.02439-.76423 1.02439-1.7074s-.45893-1.7074-1.02439-1.7074c-.56547 0-1.02439.76423-1.02439 1.7074s.45892 1.7074 1.02439 1.7074zm-1.70732 2.73184c-1.51883 0-2.06312-1.52095-2.08361-1.58173l-1.29551.43231c.03414.10244.868 2.51604 3.3798 2.51604 2.51181 0 3.34502-2.41291 3.37982-2.51604l-1.29484-.43573c-.02254.06488-.56683 1.58583-2.08498 1.58583zm7-2.73252c0 3.86004-3.1401 7.00034-7 7.00034s-7-3.1396-7-6.99966c0-3.86009 3.1401-7.00034 7-7.00034s7 3.14025 7 7.00034zm-1.3659 0c0-3.10679-2.5275-5.63442-5.6341-5.63442-3.10663 0-5.63415 2.52832-5.63415 5.6351 0 3.10676 2.52752 5.63446 5.63415 5.63446 3.1066 0 5.6341-2.5277 5.6341-5.63446z"/></svg></i></span>' +
|
||
'<span class="atk-plug-btn"><i aria-label="预览"><svg fill="currentColor" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"></path></svg></i></span>';
|
||
|
||
function dressEditor(ed) {
|
||
var btn = ed.querySelector('.atk-send-btn');
|
||
if (btn && !btn.textContent.trim()) btn.textContent = '发送';
|
||
var ta = ed.querySelector('.atk-textarea');
|
||
if (ta && !ta.getAttribute('placeholder')) ta.setAttribute('placeholder', '输入内容(支持 Markdown)…');
|
||
var ph = { 'atk-nick': '昵称', 'atk-email': '邮箱', 'atk-link': '网址' };
|
||
for (var cls in ph) {
|
||
var inp = ed.querySelector('input.' + cls);
|
||
if (inp && !inp.getAttribute('placeholder')) inp.setAttribute('placeholder', ph[cls]);
|
||
}
|
||
var plugWrap = ed.querySelector('.atk-plug-btn-wrap');
|
||
if (plugWrap && !plugWrap.childElementCount) plugWrap.innerHTML = PLUG_BTNS;
|
||
}
|
||
|
||
var svcDown = false;
|
||
var downCode = '';
|
||
var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过)
|
||
var probeTimer = null;
|
||
var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址)
|
||
|
||
function svcToast(msg) {
|
||
// 统一通知组件(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) {}
|
||
}
|
||
}
|
||
|
||
function ensureBar(code) {
|
||
// 状态类无条件加:conf 失败时 Artalk 可能连编辑器都不渲染,
|
||
// 但类挂在 body 上无副作用;编辑器晚点出现时 scan() 会补挂横幅
|
||
document.body.classList.add('atk-svc-down');
|
||
var ed = document.querySelector('.atk-main-editor');
|
||
if (!ed || !ed.parentNode) return;
|
||
dressEditor(ed); // 幂等:横幅已插好也要补(PJAX 重建编辑器 / 晚渲染场景)
|
||
var bar = document.getElementById('atk-editor-off-bar');
|
||
if (bar && bar.parentNode === ed.parentNode) return; // 已插好
|
||
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
|
||
bar = document.createElement('div');
|
||
bar.id = 'atk-editor-off-bar';
|
||
bar.className = 'atk-editor-off-bar uk-animation-slide-top-small'; // UIKit 入场动画
|
||
// 只说一句状态 + 恢复时间:详细解释由错误卡片负责(列表区),这里不重复
|
||
bar.innerHTML = code === 'd1_quota'
|
||
? '⏸ 评论暂停中,预计明早 8:00 恢复'
|
||
: '⏸ 评论暂停中,请稍后再试';
|
||
ed.parentNode.insertBefore(bar, ed);
|
||
}
|
||
|
||
function removeBar() {
|
||
document.body.classList.remove('atk-svc-down');
|
||
var bar = document.getElementById('atk-editor-off-bar');
|
||
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
|
||
}
|
||
|
||
function setSvcDown(code) {
|
||
downCode = code;
|
||
// 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片)
|
||
listSkeletonDone();
|
||
if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案
|
||
svcDown = true;
|
||
downAt = Date.now();
|
||
ensureBar(code);
|
||
if (!probeTimer) {
|
||
probeTimer = setInterval(function () {
|
||
if (!apiBase) return;
|
||
// 探针必须真读 D1:/captcha/status 走 KV,额度没恢复它也 200,
|
||
// 会把故障态 60s 一轮地误解除。评论列表 limit=1 是最便宜的
|
||
// 「真读 D1」请求(rows_read 个位数),它通了才算恢复。
|
||
fetch(apiBase + '/api/v2/comments?page_key=' + encodeURIComponent('/') + '&limit=1').then(function (r) {
|
||
if (r.ok) setSvcUp();
|
||
}).catch(function () { /* 还没恢复 */ });
|
||
}, 60000);
|
||
}
|
||
}
|
||
|
||
function setSvcUp() {
|
||
if (!svcDown) return;
|
||
svcDown = false;
|
||
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
|
||
removeBar();
|
||
// 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音
|
||
// (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报)
|
||
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();
|
||
|
||
var prev = layer.querySelector('.atk-err-card');
|
||
|
||
// 优先用结构化错误码;没有就从 Artalk 原始错误文本里认特征词
|
||
// (D1 额度用尽 / 网络失败都有固定措辞)。结构化记录有时效且 Artalk
|
||
// 走某些路径时抓不到,只靠它会出现"有时显示通用文案"的不稳定 ——
|
||
// 双保险后,额度用尽这类固定故障任何时候都能显示出对应文案。
|
||
var fresh = last && (Date.now() - last.at < 30000) ? last : null;
|
||
var code = (fresh && fresh.code)
|
||
|| (/数据库连接异常|d1_unavailable/i.test(raw) ? 'd1_unavailable'
|
||
: /D1_ERROR|exceeded D1|free tier|额度已用尽|暂时不可用|加载失败|无法获取|出了点小状况/i.test(raw) ? 'd1_quota'
|
||
: /failed to fetch|network ?error|load failed/i.test(raw) ? 'network'
|
||
: 'internal');
|
||
// 幂等:同一份「原文+结论」渲染过就不再动。结论会变(结构化记录晚到时
|
||
// internal 升级成 d1_quota),所以 key 里带上 code,变了就重渲染
|
||
if (prev && prev.getAttribute('data-raw') === raw + '|' + code) return;
|
||
|
||
var info = INFO[code] || INFO.internal;
|
||
// 服务端文案是给「直接调 API 的人」写的,自带一句"评论服务暂时不可用:",
|
||
// 放到卡片里会和标题重复,这里去掉冗余前缀;顺带规整两句别扭的措辞
|
||
// (服务端旧文案,下次 Worker 部署后这两条 replace 自然变成空操作)
|
||
var desc = fresh && fresh.msg
|
||
? fresh.msg
|
||
.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::,,]\s*/, '')
|
||
.replace('北京时间明早', '明早')
|
||
.replace(/给你带来不便,可以先看看文章,稍后再来[。~~]?/, '你可以先看看文章,稍后再来~')
|
||
: info.desc;
|
||
|
||
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。
|
||
// 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中,
|
||
// 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。
|
||
if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code);
|
||
|
||
// 原始「重试」节点:Vue 会整层重建,所以每次重新找;
|
||
// 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件)
|
||
var retry = prev ? prev.querySelector('.atk-err-retry') : null;
|
||
if (!retry) {
|
||
var box = layer.querySelector('.atk-error-text span');
|
||
if (box) {
|
||
box.querySelectorAll('span').forEach(function (sp) {
|
||
if (!retry && /(重试|重新|retry)/i.test(sp.textContent || '')) retry = sp;
|
||
});
|
||
}
|
||
}
|
||
var admin = layer.querySelector('[atk-only-admin-show]');
|
||
|
||
injectCss();
|
||
var card = document.createElement('div');
|
||
card.className = 'atk-err-card';
|
||
card.setAttribute('data-raw', raw + '|' + code);
|
||
card.innerHTML =
|
||
'<span class="atk-err-ic">' + info.ic + '</span>' +
|
||
'<div class="atk-err-t">' + esc(info.title) + '</div>' +
|
||
'<div class="atk-err-d">' + esc(desc) + '</div>' +
|
||
'<div class="atk-err-acts"></div>';
|
||
|
||
layer.textContent = '';
|
||
layer.appendChild(card);
|
||
|
||
var acts = card.querySelector('.atk-err-acts');
|
||
if (retry) {
|
||
retry.textContent = '重新加载';
|
||
retry.removeAttribute('style');
|
||
retry.className = 'atk-err-retry';
|
||
acts.appendChild(retry); // 移动节点 → 原点击事件保留
|
||
} else {
|
||
var b = document.createElement('span');
|
||
b.className = 'atk-err-retry';
|
||
b.textContent = '重新加载';
|
||
b.onclick = function () { location.reload(); };
|
||
acts.appendChild(b);
|
||
}
|
||
if (admin && admin.offsetParent !== null) acts.appendChild(admin);
|
||
}
|
||
|
||
function scan() {
|
||
var layers = document.querySelectorAll('.atk-error-layer');
|
||
for (var i = 0; i < layers.length; i++) rewrite(layers[i]);
|
||
// PJAX 换页后编辑器是新的 DOM:禁用态还在就把横幅补回去;恢复了就清掉残留
|
||
if (svcDown) {
|
||
if (downCode) ensureBar(downCode);
|
||
} else if (document.getElementById('atk-editor-off-bar')) {
|
||
removeBar();
|
||
}
|
||
}
|
||
|
||
function boot() {
|
||
scan();
|
||
if (!document.body) { setTimeout(boot, 300); return; }
|
||
// 错误层是 Artalk 异步渲染出来的,用观察器盯着;PJAX 重建也能命中
|
||
var scanT = null;
|
||
new MutationObserver(function () {
|
||
scan();
|
||
// 延迟再扫一次:等 fetch 包装层的结构化记录落地(竞态兜底 ——
|
||
// 晚到的结论会通过 data-raw 变化触发重渲染)
|
||
if (scanT) clearTimeout(scanT);
|
||
scanT = setTimeout(scan, 250);
|
||
}).observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
||
else boot();
|
||
})();
|
||
|
||
// Global Artalk Initialization Function (Supports PJAX)
|
||
|
||
// ── 持久化 artalkInitCount (跨 PJAX 切换保留) ──
|
||
// PJAX 切换时 artalk.js 会被重新加载,普通变量会重置。
|
||
// 使用 sessionStorage 持久化,让缓存机制在 PJAX 切换时继续生效。
|
||
if (typeof window.artalkInitCount === 'undefined') {
|
||
try {
|
||
var stored = sessionStorage.getItem('artalk_init_count');
|
||
window.artalkInitCount = stored ? parseInt(stored, 10) : 0;
|
||
} catch(e) {
|
||
window.artalkInitCount = 0;
|
||
}
|
||
}
|
||
var saveInitCount = function() {
|
||
try {
|
||
sessionStorage.setItem('artalk_init_count', String(window.artalkInitCount));
|
||
} catch(e) {}
|
||
};
|
||
|
||
// ── 增强评论框字段 (PJAX 适配版本) ──
|
||
// 在 Artalk 完全加载前就开始监控,确保油猴脚本能识别到字段
|
||
window.__artalkEnhancer = window.__artalkEnhancer || {
|
||
observer: null,
|
||
running: false,
|
||
|
||
enhance: function() {
|
||
var self = this;
|
||
var count = 0;
|
||
|
||
// 昵称输入框
|
||
var nickInputs = document.querySelectorAll('input[name="nick"]:not([data-autofill-done])');
|
||
nickInputs.forEach(function(input) {
|
||
input.setAttribute('autocomplete', 'name');
|
||
input.setAttribute('aria-label', '昵称 author nick name user');
|
||
input.classList.add('comment-author', 'user-nick', 'user-name', 'user-displayname', '昵称');
|
||
input.setAttribute('data-autofill-done', '1');
|
||
count++;
|
||
});
|
||
|
||
// 邮箱输入框
|
||
var emailInputs = document.querySelectorAll('input[name="email"]:not([data-autofill-done])');
|
||
emailInputs.forEach(function(input) {
|
||
input.setAttribute('autocomplete', 'email');
|
||
input.setAttribute('aria-label', '邮箱 email mail');
|
||
input.classList.add('comment-email', 'user-email', 'user-mail', '邮箱');
|
||
input.setAttribute('data-autofill-done', '1');
|
||
count++;
|
||
});
|
||
|
||
// 网址输入框
|
||
var linkInputs = document.querySelectorAll('input[name="link"]:not([data-autofill-done])');
|
||
linkInputs.forEach(function(input) {
|
||
input.setAttribute('autocomplete', 'url');
|
||
input.setAttribute('aria-label', '网址 website url link homepage site web');
|
||
input.classList.add('comment-url', 'user-link', 'user-website', 'user-site', 'user-web', '网址');
|
||
input.setAttribute('data-autofill-done', '1');
|
||
count++;
|
||
});
|
||
|
||
// 内容输入框(避免被误识别为昵称)
|
||
var contentInputs = document.querySelectorAll('textarea[name="content"]:not([data-autofill-done])');
|
||
contentInputs.forEach(function(input) {
|
||
input.setAttribute('autocomplete', 'off');
|
||
input.setAttribute('aria-label', '评论内容 content comment message');
|
||
input.setAttribute('data-autofill-done', '1');
|
||
count++;
|
||
});
|
||
|
||
if (count > 0 && window.console && console.log) {
|
||
console.log('[ArtalkEnhancer] 增强了 ' + count + ' 个评论输入框');
|
||
}
|
||
|
||
return count;
|
||
},
|
||
|
||
// 监控 DOM 变化(PJAX 切换、动态加载)
|
||
observe: function() {
|
||
var self = this;
|
||
if (typeof MutationObserver === 'undefined') return;
|
||
if (this.observer) return;
|
||
|
||
this.observer = new MutationObserver(function(mutations) {
|
||
var shouldEnhance = false;
|
||
for (var i = 0; i < mutations.length; i++) {
|
||
var added = mutations[i].addedNodes;
|
||
for (var j = 0; j < added.length; j++) {
|
||
var node = added[j];
|
||
if (node.nodeType === 1) {
|
||
// 检查是否是 input 或 textarea 元素
|
||
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
|
||
shouldEnhance = true;
|
||
break;
|
||
}
|
||
// 检查子节点
|
||
if (node.querySelectorAll) {
|
||
var inputs = node.querySelectorAll('input, textarea');
|
||
if (inputs.length > 0) {
|
||
shouldEnhance = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (shouldEnhance) break;
|
||
}
|
||
if (shouldEnhance) {
|
||
// 稍微延迟,确保 DOM 完全插入
|
||
setTimeout(function() { self.enhance(); }, 10);
|
||
}
|
||
});
|
||
|
||
this.observer.observe(document.body, { childList: true, subtree: true });
|
||
},
|
||
|
||
// 启动全局监控
|
||
start: function() {
|
||
if (this.running) return;
|
||
this.running = true;
|
||
|
||
if (window.console && console.log) {
|
||
console.log('[ArtalkEnhancer] 启动评论框字段增强');
|
||
}
|
||
|
||
// 立即尝试增强一次
|
||
this.enhance();
|
||
|
||
// 启动 DOM 监控
|
||
this.observe();
|
||
|
||
// 兜底:定时检查(Artalk 加载慢时)
|
||
var checkCount = 0;
|
||
var maxChecks = 20; // 最多检查 20 次(10 秒)
|
||
var interval = setInterval(function() {
|
||
checkCount++;
|
||
var enhanced = window.__artalkEnhancer.enhance();
|
||
if (enhanced > 0 || checkCount >= maxChecks) {
|
||
clearInterval(interval);
|
||
}
|
||
}, 500);
|
||
}
|
||
};
|
||
|
||
// 立即启动(不等待 DOMContentLoaded,尽早开始监控)
|
||
if (typeof window.__artalkEnhancer !== 'undefined') {
|
||
window.__artalkEnhancer.start();
|
||
}
|
||
|
||
// 监听 PJAX 切换:重置并重新增强
|
||
document.addEventListener('pjax:send', function() {
|
||
if (window.__artalkEnhancer && window.console && console.log) {
|
||
console.log('[ArtalkEnhancer] PJAX 切换,准备重新增强');
|
||
}
|
||
});
|
||
|
||
document.addEventListener('pjax:success', function() {
|
||
if (window.__artalkEnhancer) {
|
||
// 立即尝试
|
||
window.__artalkEnhancer.enhance();
|
||
// 延迟再试(Artalk 重新初始化需要时间)
|
||
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 200);
|
||
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 500);
|
||
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 1000);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('pjax:complete', function() {
|
||
if (window.__artalkEnhancer) {
|
||
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 300);
|
||
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 800);
|
||
}
|
||
});
|
||
window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callback) {
|
||
if (globalName && window[globalName]) {
|
||
callback();
|
||
return;
|
||
}
|
||
|
||
var existing = document.querySelector('script[data-dynamic-src="' + src + '"]');
|
||
if (existing) {
|
||
existing.addEventListener('load', callback, { once: true });
|
||
return;
|
||
}
|
||
|
||
var script = document.createElement('script');
|
||
script.src = src;
|
||
script.defer = true;
|
||
script.dataset.dynamicSrc = src;
|
||
script.onload = callback;
|
||
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() {
|
||
var mount = document.querySelector("#Comments");
|
||
if (!mount) return;
|
||
|
||
// Check if configuration exists
|
||
if (!window.artalkConfig) {
|
||
console.warn('window.artalkConfig is not defined. Skipping Artalk init.');
|
||
return;
|
||
}
|
||
|
||
if (typeof Artalk === 'undefined') {
|
||
var artalkUrl = window.optionalAssetUrls && window.optionalAssetUrls.artalk;
|
||
if (!artalkUrl) return;
|
||
window.loadScriptOnce(artalkUrl, 'Artalk', window.initArtalk);
|
||
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
|
||
// 2. PJAX: Load from localStorage if available
|
||
|
||
// Initialize initialization count to track PJAX usage
|
||
// (从 sessionStorage 加载,由文件顶部的逻辑处理)
|
||
if (typeof window.artalkInitCount === 'undefined') {
|
||
window.artalkInitCount = 0;
|
||
}
|
||
window.artalkInitCount++;
|
||
saveInitCount(); // 持久化到 sessionStorage
|
||
|
||
var config = Object.assign({}, window.artalkConfig); // Clone config
|
||
|
||
// ── 提交前校验(昵称 / 邮箱必填;网址随意)──────────────────────────
|
||
// Artalk 的 editor.submit() 实现是:
|
||
// conf.beforeSubmit ? conf.beforeSubmit(editor, next) : trigger('editor-submit')
|
||
// 也就是官方就留了拦截点 —— 不调 next() 就不会走提交流程。
|
||
// 挂在这份 config 上(而不是内联 <script> 里的 window.artalkConfig)是因为
|
||
// 两条 init 路径(带 / 不带 localStorage 缓存)都从它派生,挂这里两个分支
|
||
// 必然都带上;何况缓存那份只存简单类型,函数本来就存不下来。
|
||
if (window.__atkSubmit && window.__atkSubmit.beforeSubmit) {
|
||
config.beforeSubmit = window.__atkSubmit.beforeSubmit;
|
||
}
|
||
|
||
var server = config.server.replace(/\/$/, '');
|
||
var confKey = 'artalk_conf_' + btoa(server);
|
||
var cachedConf = null;
|
||
|
||
// Try to read cache
|
||
try {
|
||
var raw = localStorage.getItem(confKey);
|
||
if (raw) {
|
||
cachedConf = JSON.parse(raw);
|
||
// 剔除页面级字段:缓存来自「上一个页面」的实例 conf,若把它的
|
||
// pageKey/pageTitle 合并进当前页就会串页 —— 历史上文章页在后台
|
||
// 标题被污染成「留言」(用户从留言页 PJAX 进文章页)就是这个原因。
|
||
// pageKey / pageTitle 现已全部由 window.artalkConfig 显式提供,
|
||
// 这里删掉属于双保险,防止 config 哪天漏传又被旧值钻空子。
|
||
if (cachedConf && typeof cachedConf === 'object') {
|
||
['pageKey', 'pageTitle', 'pageThumb'].forEach(function(k) {
|
||
delete cachedConf[k];
|
||
});
|
||
}
|
||
}
|
||
} catch(e) {
|
||
console.warn('Failed to parse Artalk cache:', e);
|
||
}
|
||
|
||
// Decision: Use Cache or Fetch?
|
||
// Use cache only if:
|
||
// 1. Not the first load (PJAX)
|
||
// 2. Cache exists
|
||
if (window.artalkInitCount > 1 && cachedConf) {
|
||
// Merge cache into config
|
||
// CAUTION: cachedConf contains page-specific data (like pageKey) from the previous page.
|
||
// We must ensure current window.artalkConfig overwrites it.
|
||
try {
|
||
// Defensive copy to avoid mutating cached object
|
||
var mergedConfig = Object.assign({}, config);
|
||
Object.assign(mergedConfig, cachedConf);
|
||
Object.assign(mergedConfig, window.artalkConfig); // Re-apply current page config
|
||
mergedConfig.useBackendConf = false;
|
||
|
||
// Attempt to init with cache
|
||
window.artalk = Artalk.init(mergedConfig);
|
||
} catch (initErr) {
|
||
console.warn('Artalk init with cache failed, falling back to default:', initErr);
|
||
localStorage.removeItem(confKey); // Clear bad cache
|
||
window.artalk = Artalk.init(config); // Fallback to normal init
|
||
}
|
||
} else {
|
||
// First load or no cache: Allow Artalk to fetch (useBackendConf defaults to true)
|
||
window.artalk = Artalk.init(config);
|
||
}
|
||
|
||
var artalk = window.artalk; // Local reference
|
||
|
||
// ── 评论提交反馈(发送中 / 成功;失败由文件末尾的 fetch 拦截兜底)──
|
||
// editor-submit = 提交开始(beforeSubmit 放行后才会触发)
|
||
// editor-submitted = 提交成功(Artalk 的失败分支直接 return,不触发本事件)
|
||
if (window.__atkSubmit) {
|
||
artalk.on('editor-submit', window.__atkSubmit.begin);
|
||
artalk.on('editor-submitted', window.__atkSubmit.done);
|
||
}
|
||
|
||
// Sync Theme Mode Immediately
|
||
var currentTheme = document.documentElement.getAttribute('data-theme');
|
||
if (typeof artalk.setDarkMode === '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 准确匹配
|
||
var enhanceEditorInputs = function() {
|
||
var editor = document.querySelector('.atk-editor');
|
||
if (!editor) return 0;
|
||
|
||
var enhanced = 0;
|
||
|
||
// 昵称输入框
|
||
var nickInput = editor.querySelector('input[name="nick"]');
|
||
if (nickInput && !nickInput.getAttribute('data-autofill-done')) {
|
||
nickInput.setAttribute('autocomplete', 'name');
|
||
nickInput.setAttribute('aria-label', '昵称 author nick name');
|
||
nickInput.classList.add('comment-author', 'user-nick');
|
||
nickInput.setAttribute('data-autofill-done', '1');
|
||
enhanced++;
|
||
}
|
||
|
||
// 邮箱输入框
|
||
var emailInput = editor.querySelector('input[name="email"]');
|
||
if (emailInput && !emailInput.getAttribute('data-autofill-done')) {
|
||
emailInput.setAttribute('autocomplete', 'email');
|
||
emailInput.setAttribute('aria-label', '邮箱 email mail');
|
||
emailInput.classList.add('comment-email', 'user-email');
|
||
emailInput.setAttribute('data-autofill-done', '1');
|
||
enhanced++;
|
||
}
|
||
|
||
// 网址输入框
|
||
var linkInput = editor.querySelector('input[name="link"]');
|
||
if (linkInput && !linkInput.getAttribute('data-autofill-done')) {
|
||
linkInput.setAttribute('autocomplete', 'url');
|
||
linkInput.setAttribute('aria-label', '网址 website url link homepage site web');
|
||
linkInput.classList.add('comment-url', 'user-link', 'user-website');
|
||
linkInput.setAttribute('data-autofill-done', '1');
|
||
enhanced++;
|
||
}
|
||
|
||
// 内容输入框(避免被误识别为昵称)
|
||
var contentInput = editor.querySelector('textarea[name="content"]');
|
||
if (contentInput && !contentInput.getAttribute('data-autofill-done')) {
|
||
contentInput.setAttribute('autocomplete', 'off');
|
||
contentInput.setAttribute('aria-label', '评论内容 content comment');
|
||
contentInput.setAttribute('data-autofill-done', '1');
|
||
enhanced++;
|
||
}
|
||
|
||
// 触发油猴脚本自动填充(延迟到评论框稳定后)
|
||
if (enhanced > 0) {
|
||
triggerAutoFill(editor);
|
||
}
|
||
|
||
return enhanced;
|
||
};
|
||
|
||
// ── 触发油猴脚本自动填充 ──
|
||
// 问题:油猴脚本可能在评论框还没完全加载时就填充了,
|
||
// 导致填充的数据在 Artalk 重新渲染后丢失。
|
||
// 解决:使用 MutationObserver 监控评论框 DOM 变化,
|
||
// 在评论框稳定后(300ms 内没有变化)才触发填充。
|
||
var pendingFillTimer = null;
|
||
var fillDebounceDelay = 300; // 评论框稳定后 300ms 才触发填充
|
||
|
||
var triggerAutoFill = function(editor) {
|
||
if (pendingFillTimer) {
|
||
clearTimeout(pendingFillTimer);
|
||
}
|
||
|
||
// 延迟触发,等待评论框稳定
|
||
pendingFillTimer = setTimeout(function() {
|
||
pendingFillTimer = null;
|
||
|
||
// 再次检查评论框是否存在
|
||
editor = document.querySelector('.atk-editor');
|
||
if (!editor) return;
|
||
|
||
// 获取评论框中的输入框
|
||
var nickInput = editor.querySelector('input[name="nick"]');
|
||
var emailInput = editor.querySelector('input[name="email"]');
|
||
var linkInput = editor.querySelector('input[name="link"]');
|
||
|
||
// 保存当前输入框的值到 sessionStorage
|
||
var fillData = {
|
||
nick: nickInput ? nickInput.value : '',
|
||
email: emailInput ? emailInput.value : '',
|
||
link: linkInput ? linkInput.value : '',
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// 如果有填充数据,保存到 sessionStorage
|
||
if (fillData.nick || fillData.email || fillData.link) {
|
||
try {
|
||
sessionStorage.setItem('artalk_autofill_data', JSON.stringify(fillData));
|
||
console.log('[ArtalkEnhancer] 保存填充数据到 sessionStorage:', fillData);
|
||
} catch(e) {}
|
||
}
|
||
|
||
// 手动触发 input 事件,通知可能存在的自动填充脚本
|
||
// 一些油猴脚本会监听 input/change 事件来检测新字段
|
||
[nickInput, emailInput, linkInput].forEach(function(input) {
|
||
if (input && input.value) {
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
});
|
||
|
||
// 尝试调用油猴脚本的公开 API(如果有)
|
||
// 例如 Greasemonkey/Tampermonkey 的 GM_setValue 配合 GM_getValue
|
||
if (typeof window.GM !== 'undefined' && window.GM.setValue) {
|
||
// 通知油猴脚本评论框已加载完成
|
||
window.GM_setValue('artalk_editor_ready', true);
|
||
}
|
||
|
||
console.log('[ArtalkEnhancer] 评论框稳定,触发自动填充');
|
||
}, fillDebounceDelay);
|
||
};
|
||
|
||
// ── 恢复 sessionStorage 中的填充数据 ──
|
||
// 问题:Artalk 评论框可能在填充后又重新渲染,导致填充的数据丢失。
|
||
// 解决:在评论框稳定后,检查 sessionStorage 是否有之前保存的填充数据,
|
||
// 如果有,自动恢复到输入框中。
|
||
var restoreAutoFillData = function() {
|
||
var editor = document.querySelector('.atk-editor');
|
||
if (!editor) return;
|
||
|
||
try {
|
||
var stored = sessionStorage.getItem('artalk_autofill_data');
|
||
if (!stored) return;
|
||
|
||
var fillData = JSON.parse(stored);
|
||
if (!fillData || !fillData.timestamp) return;
|
||
|
||
// 数据是否在 24 小时内有效
|
||
var maxAge = 24 * 60 * 60 * 1000; // 24 小时
|
||
if (Date.now() - fillData.timestamp > maxAge) {
|
||
sessionStorage.removeItem('artalk_autofill_data');
|
||
return;
|
||
}
|
||
|
||
// 恢复到输入框
|
||
var nickInput = editor.querySelector('input[name="nick"]');
|
||
var emailInput = editor.querySelector('input[name="email"]');
|
||
var linkInput = editor.querySelector('input[name="link"]');
|
||
|
||
if (fillData.nick && nickInput && !nickInput.value) {
|
||
nickInput.value = fillData.nick;
|
||
nickInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
if (fillData.email && emailInput && !emailInput.value) {
|
||
emailInput.value = fillData.email;
|
||
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
if (fillData.link && linkInput && !linkInput.value) {
|
||
linkInput.value = fillData.link;
|
||
linkInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
if (fillData.nick || fillData.email || fillData.link) {
|
||
console.log('[ArtalkEnhancer] 从 sessionStorage 恢复填充数据:', fillData);
|
||
}
|
||
} catch(e) {
|
||
console.warn('[ArtalkEnhancer] 恢复填充数据失败:', e);
|
||
}
|
||
};
|
||
|
||
// 立即执行一次
|
||
enhanceEditorInputs();
|
||
|
||
// 评论框加载完成(Artalk 内置事件)
|
||
artalk.on('mounted', function() {
|
||
setTimeout(function() {
|
||
enhanceEditorInputs();
|
||
restoreAutoFillData();
|
||
}, 100);
|
||
});
|
||
|
||
// 评论列表加载完成(PJAX 切换后)
|
||
artalk.on('list-loaded', function() {
|
||
setTimeout(function() {
|
||
enhanceEditorInputs();
|
||
restoreAutoFillData();
|
||
}, 200);
|
||
});
|
||
|
||
// 评论发送成功后保存填充数据
|
||
artalk.on('comment-submit', function() {
|
||
try {
|
||
var editor = document.querySelector('.atk-editor');
|
||
if (editor) {
|
||
var fillData = {
|
||
nick: editor.querySelector('input[name="nick"]') ? editor.querySelector('input[name="nick"]').value : '',
|
||
email: editor.querySelector('input[name="email"]') ? editor.querySelector('input[name="email"]').value : '',
|
||
link: editor.querySelector('input[name="link"]') ? editor.querySelector('input[name="link"]').value : '',
|
||
timestamp: Date.now()
|
||
};
|
||
if (fillData.nick || fillData.email || fillData.link) {
|
||
sessionStorage.setItem('artalk_autofill_data', JSON.stringify(fillData));
|
||
console.log('[ArtalkEnhancer] 评论提交后保存填充数据');
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
|
||
// 兜底:定时检查(防止某些情况下事件未触发)
|
||
var enhanceInterval = setInterval(function() {
|
||
enhanceEditorInputs();
|
||
restoreAutoFillData();
|
||
// 找到所有字段都已增强后停止
|
||
var editor = document.querySelector('.atk-editor');
|
||
if (editor) {
|
||
var inputs = editor.querySelectorAll('input[name="nick"], input[name="email"], input[name="link"], textarea[name="content"]');
|
||
var allDone = true;
|
||
inputs.forEach(function(input) {
|
||
if (!input.getAttribute('data-autofill-done')) allDone = false;
|
||
});
|
||
if (allDone && inputs.length > 0) {
|
||
clearInterval(enhanceInterval);
|
||
}
|
||
}
|
||
}, 1000);
|
||
// 30秒后停止轮询
|
||
setTimeout(function() { clearInterval(enhanceInterval); }, 30000);
|
||
|
||
// Aggressive Config Saving Strategy
|
||
var saveConf = function() {
|
||
try {
|
||
// Attempt to find the full configuration object
|
||
// artalk.conf is the standard property in v2+
|
||
// artalk.ctx.conf is for internal context access
|
||
var remoteConf = artalk.conf || (artalk.ctx && artalk.ctx.conf);
|
||
|
||
if (remoteConf) {
|
||
// Filter out non-serializable properties (like DOM elements, circular refs)
|
||
var safeConf = {};
|
||
for (var key in remoteConf) {
|
||
var val = remoteConf[key];
|
||
// Skip if value is a DOM element
|
||
if (val instanceof Element || val instanceof Node) continue;
|
||
// Skip if value is window or document
|
||
if (val === window || val === document) continue;
|
||
// Skip internal private properties starting with $ or _
|
||
if (key.startsWith('$') || key.startsWith('_')) continue;
|
||
// Only allow simple types or simple objects
|
||
var type = typeof val;
|
||
if (type === 'string' || type === 'number' || type === 'boolean' || val === null) {
|
||
safeConf[key] = val;
|
||
} else if (type === 'object') {
|
||
// Simple array check
|
||
if (Array.isArray(val)) {
|
||
safeConf[key] = val;
|
||
} else {
|
||
// For objects, deep check might be too expensive/complex here.
|
||
// We'll try to shallow copy if it looks like a plain object
|
||
// and doesn't have circular markers like 'el'
|
||
if (val.constructor === Object && !val.el && !val.$el) {
|
||
safeConf[key] = val;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
localStorage.setItem(confKey, JSON.stringify(safeConf));
|
||
// console.log('Artalk: Config cached to', confKey);
|
||
}
|
||
} catch(e) {
|
||
console.error('Artalk saveConf error:', e);
|
||
}
|
||
};
|
||
|
||
// Hook into multiple events to ensure we capture the config after it's loaded
|
||
// 'conf-loaded' is the ideal event if supported
|
||
artalk.on('conf-loaded', saveConf);
|
||
|
||
// 'mounted' is when the widget is ready
|
||
artalk.on('mounted', saveConf);
|
||
|
||
// 'list-loaded' means comments (and likely config) are fetched
|
||
artalk.on('list-loaded', saveConf);
|
||
|
||
// Fallback: Poll check after a few seconds (for slow networks)
|
||
setTimeout(saveConf, 2000);
|
||
setTimeout(saveConf, 5000);
|
||
|
||
// ── Badge system: 友链 / 友圈 / 评论等级 ──
|
||
|
||
var normalizeHost = function(h) {
|
||
return (h || '').replace(/^www\./, '');
|
||
};
|
||
|
||
var buildMap = function(data) {
|
||
var hosts = new Map();
|
||
var names = new Map();
|
||
if (data && data.hosts) {
|
||
data.hosts.forEach(function(item) {
|
||
if (item.host) hosts.set(item.host, item.title || item.host);
|
||
});
|
||
}
|
||
if (data && data.names) {
|
||
data.names.forEach(function(n) {
|
||
if (n.lower) names.set(n.lower, n.title);
|
||
});
|
||
}
|
||
return { hosts: hosts, names: names };
|
||
};
|
||
|
||
var linkMap = null;
|
||
var feedMap = null;
|
||
|
||
// Comment rank table — Song dynasty 九品十八阶
|
||
// Color scale: warm → cool gradient for visual hierarchy
|
||
var RANK_TABLE = [
|
||
{ min: 2000, title: '正一品·太师', short: '太师', color: '#c5881b', bg: 'rgba(197,136,27,0.16)' },
|
||
{ min: 1400, title: '从一品·太尉', short: '太尉', color: '#cc942a', bg: 'rgba(204,148,42,0.13)' },
|
||
{ min: 1050, title: '正二品·参知政事', short: '参知政事', color: '#d46d33', bg: 'rgba(212,109,51,0.16)' },
|
||
{ min: 800, title: '从二品·节度使', short: '节度使', color: '#d87940', bg: 'rgba(216,121,64,0.13)' },
|
||
{ min: 600, title: '正三品·御史中丞', short: '御史中丞', color: '#cf4a58', bg: 'rgba(207,74,88,0.16)' },
|
||
{ min: 480, title: '从三品·秘书监', short: '秘书监', color: '#d45d68', bg: 'rgba(212,93,104,0.13)' },
|
||
{ min: 380, title: '正四品·谏议大夫', short: '谏议大夫', color: '#b05385', bg: 'rgba(176,83,133,0.16)' },
|
||
{ min: 300, title: '从四品·侍读学士', short: '侍读学士', color: '#b8638f', bg: 'rgba(184,99,143,0.13)' },
|
||
{ min: 240, title: '正五品·给事中', short: '给事中', color: '#9570d6', bg: 'rgba(149,112,214,0.16)' },
|
||
{ min: 190, title: '从五品·知州', short: '知州', color: '#a07bda', bg: 'rgba(160,123,218,0.13)' },
|
||
{ min: 150, title: '正六品·侍御史', short: '侍御史', color: '#5f7ace', bg: 'rgba(95,122,206,0.16)' },
|
||
{ min: 115, title: '从六品·通判', short: '通判', color: '#6b85d0', bg: 'rgba(107,133,208,0.13)' },
|
||
{ min: 85, title: '正七品·知县', short: '知县', color: '#2a9a84', bg: 'rgba(42,154,132,0.16)' },
|
||
{ min: 60, title: '从七品·殿中侍御史', short: '殿中侍御史', color: '#34a390', bg: 'rgba(52,163,144,0.13)' },
|
||
{ min: 40, title: '正八品·大理评事', short: '大理评事', color: '#628530', bg: 'rgba(98,133,48,0.16)' },
|
||
{ min: 25, title: '从八品·录事参军', short: '录事参军', color: '#6e913d', bg: 'rgba(110,145,61,0.13)' },
|
||
{ min: 15, title: '正九品·主簿', short: '主簿', color: '#8a7050', bg: 'rgba(138,112,80,0.16)' },
|
||
{ min: 5, title: '从九品·司户参军', short: '司户参军', color: '#917a5c', bg: 'rgba(145,122,92,0.13)' },
|
||
{ min: 0, title: '庶民', short: '庶民', color: '#8a8e98', bg: 'rgba(138,142,152,0.10)' }
|
||
];
|
||
|
||
var getRank = function(count) {
|
||
for (var i = 0; i < RANK_TABLE.length; i++) {
|
||
if (count >= RANK_TABLE[i].min) return RANK_TABLE[i];
|
||
}
|
||
return RANK_TABLE[RANK_TABLE.length - 1];
|
||
};
|
||
|
||
// localStorage-based cross-page user comment counter with page dedup
|
||
var USER_COUNT_KEY = 'atk_user_counts_v2';
|
||
var loadUserCounts = function() {
|
||
try {
|
||
var raw = localStorage.getItem(USER_COUNT_KEY);
|
||
return raw ? JSON.parse(raw) : { _pages: {} };
|
||
} catch(e) { return { _pages: {} }; }
|
||
};
|
||
var saveUserCounts = function(counts) {
|
||
try { localStorage.setItem(USER_COUNT_KEY, JSON.stringify(counts)); } catch(e) {}
|
||
};
|
||
var getPageKey = function() {
|
||
return window.artalkConfig ? (window.artalkConfig.pageKey || window.location.pathname) : window.location.pathname;
|
||
};
|
||
|
||
|
||
|
||
// ── 评论区徽章统一注入(comment-rendered 每条触发)──
|
||
// 优先级:官方系统徽章(博主等,Artalk 自渲染)独立显示;
|
||
// 主题徽章互斥取最高一级:友链 > 友圈 > 评论官职。
|
||
// 数据源:window.friendLinks/friendFeeds(友链、友圈)+ 后端 rank_* 字段(官职)。
|
||
var ensureBadgeMaps = function() {
|
||
if (!linkMap && window.friendLinks) linkMap = buildMap(window.friendLinks);
|
||
if (!feedMap && window.friendFeeds) feedMap = buildMap(window.friendFeeds);
|
||
return !!(linkMap || feedMap);
|
||
};
|
||
|
||
var matchBadge = function(map, host, nickLower) {
|
||
if (!map) return null;
|
||
if (host && map.hosts.has(host)) return map.hosts.get(host);
|
||
if (map.names.has(nickLower)) return map.names.get(nickLower);
|
||
return null;
|
||
};
|
||
|
||
var injectBadge = function(nickEl, cls, text, title, color, bg) {
|
||
if (nickEl.querySelector('.' + cls)) return;
|
||
var b = document.createElement('span');
|
||
b.className = cls;
|
||
b.textContent = text;
|
||
if (title) b.title = title;
|
||
if (color) b.style.color = color;
|
||
if (bg) b.style.backgroundColor = bg;
|
||
nickEl.appendChild(b);
|
||
};
|
||
|
||
artalk.on('comment-rendered', function(node) {
|
||
try {
|
||
var data = null;
|
||
if (node && typeof node.getData === 'function') data = node.getData();
|
||
else if (node && node.comment && typeof node.comment.getData === 'function') data = node.comment.getData();
|
||
else data = node;
|
||
if (!data) return;
|
||
var el = null;
|
||
if (node && node.$el) el = node.$el;
|
||
else if (node && typeof node.getEl === 'function') el = node.getEl();
|
||
if (!el || !el.querySelector) return;
|
||
var nickEl = el.querySelector('.atk-nick');
|
||
if (!nickEl) return;
|
||
|
||
if (!ensureBadgeMaps()) return;
|
||
var nickLower = (data.nick || '').toLowerCase();
|
||
var host = null;
|
||
try { if (data.link) host = normalizeHost(new URL(data.link).hostname); } catch (e) {}
|
||
|
||
var linkMatch = matchBadge(linkMap, host, nickLower);
|
||
var feedMatch = matchBadge(feedMap, host, nickLower);
|
||
|
||
// 系统徽章(博主/置顶)由 Artalk 渲染在 .atk-badge-wrap 里,头衔/友链/友圈/官职
|
||
// 则是直接插进昵称的 —— 两套 DOM 会导致尺寸与垂直对齐都不一致。这里把包装层
|
||
// 拆掉,让所有徽章都成为昵称的直接子元素,视觉顺序:
|
||
// 系统徽章 > 头衔 > 友链 > 友圈 > 官职
|
||
var badgeWrap = el.querySelector('.atk-badge-wrap');
|
||
if (badgeWrap && badgeWrap.parentNode !== nickEl) nickEl.appendChild(badgeWrap);
|
||
|
||
// 1) 头衔徽章(金色,管理员在后台手动设置,仅次于系统徽章;配色走 .atk-title-badge 类)
|
||
if (data.title_name && !nickEl.querySelector('.atk-title-badge')) {
|
||
injectBadge(nickEl, 'atk-title-badge', data.title_name, '头衔: ' + data.title_name);
|
||
}
|
||
// 2) 友链
|
||
if (linkMatch) injectBadge(nickEl, 'atk-link-badge', '友链', '友链: ' + linkMatch);
|
||
// 3) 友圈
|
||
if (feedMatch) injectBadge(nickEl, 'atk-feed-badge', '友圈', '友圈: ' + feedMatch);
|
||
// 4) 评论官职(按全站评论数)
|
||
if (data.rank_name && !nickEl.querySelector('.atk-rank-badge')) {
|
||
var tip = data.rank_title || data.rank_name;
|
||
if (data.rank_count != null) tip += ' (' + data.rank_count + '条评论)';
|
||
injectBadge(nickEl, 'atk-rank-badge', data.rank_name, tip, data.rank_color || '', data.rank_bg || '');
|
||
}
|
||
|
||
// 5) 拆掉 .atk-badge-wrap 包装层:把 博主/置顶 提成昵称的直接子元素(保持原顺序)
|
||
var wrap = nickEl.querySelector('.atk-badge-wrap');
|
||
if (wrap && wrap.parentNode) {
|
||
var wrapParent = wrap.parentNode;
|
||
while (wrap.firstChild) wrapParent.insertBefore(wrap.firstChild, wrap);
|
||
wrapParent.removeChild(wrap);
|
||
}
|
||
// 6) 所有徽章挂同一个类,几何样式(尺寸/内边距/圆角/字重/对齐)只由
|
||
// .atk-cmt-badge 一处定义,从此不会有"两种徽章尺寸不一致"的问题
|
||
var allBadges = nickEl.querySelectorAll(
|
||
'.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-link-badge, .atk-feed-badge, .atk-rank-badge'
|
||
);
|
||
for (var bi = 0; bi < allBadges.length; bi++) allBadges[bi].classList.add('atk-cmt-badge');
|
||
} catch (e) { /* 徽章注入失败不影响评论 */ }
|
||
|
||
// ── 友链申请入口卡片:评论内容就是约定 URL(https://usj.cc/link-apply)时,
|
||
// 整条评论解析成申请入口卡片(点击打开申请弹窗),其余评论不受影响。
|
||
try {
|
||
var d2 = null;
|
||
if (node && typeof node.getData === 'function') d2 = node.getData();
|
||
else if (node && node.comment && typeof node.comment.getData === 'function') d2 = node.comment.getData();
|
||
else d2 = node;
|
||
var el2 = null;
|
||
if (node && node.$el) el2 = node.$el;
|
||
else if (node && typeof node.getEl === 'function') el2 = node.getEl();
|
||
if (!d2 || !el2) return;
|
||
var content = String(d2.content || '').trim().replace(/\/+$/, '').toLowerCase();
|
||
if (content !== 'https://usj.cc/link-apply') return;
|
||
// 只转换顶层评论:回复里出现同一串 URL 不该被吃掉(也避免出现游离的卡片)
|
||
if (d2.rid) return;
|
||
|
||
// 幂等 + 自愈:class 每次渲染都补一次(CSS 靠它隐藏整条评论),
|
||
// 卡片始终挂在评论列表"上方"(.atk-list-body 的第一个子节点),
|
||
// 这样发新评论/切换排序都不会把卡片夹到评论中间。
|
||
el2.classList.add('atk-fl-apply');
|
||
var listBody = document.querySelector('.atk-list-body') || document.querySelector('.atk-list');
|
||
var oldHolder = document.querySelector('.atk-fl-card-wrap');
|
||
if (oldHolder && listBody && oldHolder.parentNode === listBody && oldHolder.isConnected) return;
|
||
if (oldHolder && oldHolder.parentNode) oldHolder.parentNode.removeChild(oldHolder);
|
||
|
||
el2.style.cursor = 'pointer';
|
||
el2.title = '点击申请友链';
|
||
|
||
// 先取原评论的头像 / 昵称 / 官方徽章(博主),再隐藏常规元素
|
||
var avImg = el2.querySelector('.atk-avatar img');
|
||
var avatarSrc = avImg ? avImg.getAttribute('src') : '';
|
||
var nickEl2 = el2.querySelector('.atk-nick');
|
||
// wrap 已在上面拆掉,博主徽章现在是昵称的直接子元素
|
||
var sysBadge = (nickEl2 || el2).querySelector('.atk-badge') ||
|
||
el2.querySelector('.atk-badge');
|
||
var nickText = String(d2.nick || '博主');
|
||
|
||
// 隐藏评论常规元素(头像列/昵称行/页脚操作),整条变成一张卡片
|
||
var hd = el2.querySelector('.atk-header');
|
||
var ft = el2.querySelector('.atk-footer');
|
||
var av0 = el2.querySelector('.atk-avatar');
|
||
var meta = el2.querySelector('.atk-meta');
|
||
if (hd) hd.style.display = 'none';
|
||
if (ft) ft.style.display = 'none';
|
||
if (av0) av0.style.display = 'none';
|
||
if (meta) meta.style.display = 'none';
|
||
|
||
var main = el2.querySelector('.atk-main') || el2;
|
||
var contentEl = main.querySelector('.atk-content');
|
||
// 卡片挂在 .atk-comment 这一级,而不是塞进 .atk-content 里:
|
||
// .atk-main 被主题锁了 margin-left:34px !important(头像缩进),塞在里面
|
||
// 卡片永远比评论输入框窄一圈;提到 .atk-comment 这一级就能和输入框同宽对齐。
|
||
var commentEl = el2.querySelector('.atk-comment') || main.parentElement || el2;
|
||
if (main && main !== el2) main.style.display = 'none'; // 常规内容整块隐藏
|
||
|
||
var FL_DESC = '想和我交换友链?点击申请,审核结果会邮件通知你';
|
||
var card = document.createElement('div');
|
||
card.className = 'atk-fl-card'; // 独立类名:容器用的是 atk-fl-apply 做标记
|
||
// 跟随评论列表原生观感:白底 + 细边框,只有按钮带主题色
|
||
card.style.cssText = 'display:flex;align-items:center;gap:12px;padding:12px 14px;margin:2px 0;' +
|
||
'background:var(--bg-card,#fff);border:1px solid var(--border-main,#eeeeee);' +
|
||
'border-radius:10px;transition:border-color .2s,background .2s;';
|
||
card.innerHTML =
|
||
'<img class="atk-fl-avatar uk-animation-scale-up" src="' + avatarSrc + '" alt="" referrerpolicy="no-referrer" ' +
|
||
'style="flex:none;width:40px;height:40px;border-radius:6px;object-fit:cover">' +
|
||
'<span class="atk-fl-body" style="flex:1;min-width:0">' +
|
||
'<span class="atk-fl-nick" style="display:flex;align-items:center;gap:0;font-size:14px;font-weight:600;color:var(--text-main,#333)">' +
|
||
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + nickText.replace(/</g, '<') + '</span>' +
|
||
'</span>' +
|
||
'<span class="atk-fl-desc" style="display:block;font-size:13px;margin-top:2px;color:var(--text-desc,#777);line-height:1.5">' +
|
||
'<span class="atk-fl-typed"></span><i class="atk-fl-caret"></i>' +
|
||
'</span>' +
|
||
'</span>' +
|
||
'<span class="atk-fl-btn" style="flex:none;background:var(--theme-main-20,rgba(7,193,96,.2));color:var(--theme-main,#07c160);' +
|
||
'font-size:13px;font-weight:600;padding:6px 14px;border-radius:5px;white-space:nowrap">申请友链</span>';
|
||
// 昵称行追加官方徽章(博主),样式与评论区徽章一致
|
||
if (sysBadge) {
|
||
var bw = sysBadge.cloneNode(true);
|
||
bw.style.marginLeft = '0';
|
||
var nickRow = card.querySelector('span > span');
|
||
nickRow.appendChild(bw);
|
||
}
|
||
// 卡片包一层容器挂到列表上方(宽度与评论区一致,且不受列表排序/新增影响)
|
||
if (contentEl) contentEl.innerHTML = '';
|
||
var holder = document.createElement('div');
|
||
holder.className = 'atk-fl-card-wrap';
|
||
holder.appendChild(card);
|
||
if (listBody) {
|
||
if (listBody.firstChild) listBody.insertBefore(holder, listBody.firstChild);
|
||
else listBody.appendChild(holder);
|
||
} else {
|
||
commentEl.appendChild(holder);
|
||
}
|
||
// 点击卡片即打开申请弹窗(原来挂在 el2 上,现在卡片已移出评论节点)
|
||
holder.addEventListener('click', function () {
|
||
if (window.flApplyOpenModal) window.flApplyOpenModal();
|
||
});
|
||
|
||
// ── 打字机效果(PJAX 安全:卡片被移除/替换时定时器自动停止) ──
|
||
// 注意:不要用 prefers-reduced-motion 跳过打字 —— Windows 关闭"动画效果"
|
||
// 或部分浏览器默认就会报告 reduce,会导致效果完全不出现。这里始终打字。
|
||
try {
|
||
var typedEl = card.querySelector('.atk-fl-typed');
|
||
var caretEl = card.querySelector('.atk-fl-caret');
|
||
var alive = function () { return card.isConnected !== undefined ? card.isConnected : document.body.contains(card); };
|
||
if (typedEl) {
|
||
var ti = 0;
|
||
setTimeout(function () {
|
||
if (!alive()) return;
|
||
var timer = setInterval(function () {
|
||
if (!alive()) { clearInterval(timer); return; } // PJAX 切页后自动停止
|
||
ti += 1;
|
||
typedEl.textContent = FL_DESC.slice(0, ti);
|
||
if (ti >= FL_DESC.length) {
|
||
clearInterval(timer);
|
||
if (caretEl) caretEl.style.display = 'none';
|
||
}
|
||
}, 42);
|
||
}, 320); // 等入场动画播完再打字,观感更自然
|
||
}
|
||
} catch (e) {}
|
||
|
||
el2.addEventListener('click', function () {
|
||
if (window.flApplyOpenModal) window.flApplyOpenModal();
|
||
});
|
||
el2.addEventListener('mouseenter', function () {
|
||
card.style.borderColor = 'var(--theme-main,#07c160)';
|
||
card.style.background = 'var(--bg-card,#fff)';
|
||
});
|
||
el2.addEventListener('mouseleave', function () {
|
||
card.style.borderColor = 'var(--border-main,#eeeeee)';
|
||
});
|
||
} catch (e) { /* 卡片化失败不影响评论 */ }
|
||
});
|
||
|
||
artalk.on('list-loaded', function() {
|
||
// Add staggered animation to the comment list
|
||
var list = document.querySelector('.atk-list');
|
||
if (list) {
|
||
list.setAttribute('uk-scrollspy', 'target: > .atk-comment-wrap; cls: uk-animation-slide-bottom-small; delay: 50');
|
||
}
|
||
|
||
// Apply granular animations to inner elements
|
||
document.querySelectorAll('.atk-comment-wrap').forEach(function(wrap) {
|
||
var avatar = wrap.querySelector('.atk-avatar');
|
||
if (avatar) avatar.setAttribute('uk-scrollspy', 'cls: uk-animation-scale-up; delay: 200');
|
||
|
||
var main = wrap.querySelector('.atk-main');
|
||
if (main) main.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-right-small; delay: 300');
|
||
|
||
var nick = wrap.querySelector('.atk-nick');
|
||
if (nick) nick.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-top-small; delay: 350');
|
||
});
|
||
|
||
// Trigger lazy load update
|
||
if (window.lazyLoadAgain) {
|
||
window.lazyLoadAgain();
|
||
}
|
||
});
|
||
|
||
} catch (e) {
|
||
console.error('Artalk init failed:', e);
|
||
// 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来
|
||
mount.__atkInited = false;
|
||
listSkelSettled = true;
|
||
listSkeletonHide();
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// 投票前的身份提示 —— 别让读者点了「赞同」却毫无反应
|
||
//
|
||
// 服务端(artalk-cf)的投票语义和 Artalk 原生一致:**票必须挂在身份上**,
|
||
// 未登录时要求 body 带 name + email,否则返回 400。而客户端只在
|
||
// `userInfo.name && userInfo.email` 都齐了才会带上身份(见 Artalk.js 的
|
||
// getUserFields)——没填过的读者点下去就是一个静默失败:
|
||
// 请求打到服务端、报错、界面上的数字纹丝不动,读者只会觉得「点坏了」。
|
||
//
|
||
// 这里在 capture 阶段把这类点击拦下来(拦在 Artalk 自己的 handler 之前),
|
||
// 给一句人话提示并把光标送到昵称输入框。填过身份的直接放行,不干扰。
|
||
// ============================================================================
|
||
(function () {
|
||
if (window.__atkVoteGuard) return; // PJAX 会重复执行本文件,只装一次
|
||
window.__atkVoteGuard = true;
|
||
|
||
var USER_KEY = 'ArtalkUser'; // Artalk 的用户存储键(nick / email / link / token)
|
||
|
||
function readUser() {
|
||
try {
|
||
var raw = localStorage.getItem(USER_KEY);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 已登录(有 token)服务端认 Authorization;游客必须昵称 + 邮箱
|
||
function hasIdentity(u) {
|
||
if (!u) return false;
|
||
if (u.token) return true;
|
||
return !!(u.nick && u.email);
|
||
}
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var el = e.target;
|
||
var btn = el && el.closest ? el.closest('.atk-common-action-btn') : null;
|
||
if (!btn) return;
|
||
if (!/赞同|反对|顶|踩/.test(btn.textContent || '')) return;
|
||
if (hasIdentity(readUser())) return;
|
||
|
||
// 拦在 Artalk 之前:不发那个注定 400 的请求,也不让数字假装动一下
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
if (window.showToast) window.showToast('填写昵称和邮箱后才能点赞', 'warning');
|
||
else if (window.Toast && window.Toast.warning) window.Toast.warning('填写昵称和邮箱后才能点赞');
|
||
|
||
// 把光标送到昵称输入框。
|
||
// ⚠️ 不能只 querySelector 第一个:Artalk 的 DOM 里存在**不止一个** nick 输入框
|
||
// (隐藏模板、未激活的回复框也算),第一个匹配到的往往不可焦点,
|
||
// 对它 focus() 会被浏览器静默忽略 —— activeElement 一点不变,很容易误判成"生效了"。
|
||
// 所以逐个候选试,并以「focus 真的落到它身上」为唯一判据。
|
||
function focusFirstUsable(sel) {
|
||
var list = document.querySelectorAll(sel);
|
||
for (var i = 0; i < list.length; i++) {
|
||
var el = list[i];
|
||
if (!(el.offsetWidth || el.offsetHeight || el.getClientRects().length)) continue;
|
||
try { el.focus({ preventScroll: true }); } catch (err) { try { el.focus(); } catch (e2) { continue; } }
|
||
if (document.activeElement === el) return el;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
var nick = focusFirstUsable('.atk-header [name="nick"]') || focusFirstUsable('[name="nick"]');
|
||
if (nick) {
|
||
try { nick.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (err) { /* 老浏览器忽略 */ }
|
||
}
|
||
}, true);
|
||
})();
|
||
|
||
// ============================================================================
|
||
// 评论提交反馈 —— 校验 / 发送中 / 成功 / 失败
|
||
//
|
||
// 为什么需要:评论接口(Cloudflare)在国内常年丢包,读者点完「发送」可能好几秒
|
||
// 没有任何反馈,根本没法判断到底发出去没有;而服务端对「昵称 / 邮箱缺失」是直接
|
||
// 返 400 的,客户端这边却只在编辑框里弹一行小字,很容易被忽略。
|
||
//
|
||
// 四个挂点:
|
||
// 1) config.beforeSubmit → 提交前校验(内容 / 昵称 / 邮箱必填,邮箱格式;
|
||
// 网址随意不校验)。不调 next() 就不发请求,
|
||
// 省掉一次注定失败的跨境往返。
|
||
// 2) artalk.on('editor-submit') → 提交开始:常驻的「评论发送中…」loading toast;
|
||
// 超过 7 秒仍未结束再补一条「网络较慢」。
|
||
// 3) artalk.on('editor-submitted') → 提交成功(Artalk 失败分支直接 return,不会
|
||
// 触发本事件):关掉 loading,按评论 / 回复 /
|
||
// 编辑三种模式弹成功 toast。
|
||
// 4) 拦 fetch 的 POST /api/v2/comments → 失败(HTTP 错误或网络异常)时,把服务端
|
||
// 的 msg / 标记翻译成人话弹 error toast,并删掉
|
||
// Artalk 自己那行重复的小字通知。
|
||
//
|
||
// 注意:Artalk 提交失败时**不会**清空输入框(只有成功才 reset),所以失败提示里
|
||
// 可以放心说「内容还在」。另外所有失败都是「服务端已拒」——loading toast 必须
|
||
// 在同一轮里收干净,不能留一个转圈的 toast 挂在那儿。
|
||
// ============================================================================
|
||
(function () {
|
||
if (window.__atkSubmit) return; // PJAX 会重复执行本文件,只装一次
|
||
var P = (window.__atkSubmit = {});
|
||
|
||
var POST_RE = /\/api\/v2\/comments(\?|$)/;
|
||
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
var loading = false; // loading toast 是否挂着
|
||
var slowTimer = null; // 慢网补充提示的定时器
|
||
var editor = null; // beforeSubmit 拿到的编辑器实例(用来判提交模式)
|
||
var result = null; // 本轮 POST 的结果,由下面的 fetch 拦截写入
|
||
|
||
function toast(type, text) {
|
||
try {
|
||
if (window.Toast && typeof window.Toast[type] === 'function') return window.Toast[type](text);
|
||
if (window.showToast) window.showToast(text, type);
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
|
||
// 一次性收干净:不用维护 Qmsg 实例引用,也顺手清掉「网络较慢」那条。
|
||
// (Qmsg 的 loading 是 autoClose:false,不主动关就会一直转)
|
||
function closeLoading() {
|
||
loading = false;
|
||
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
|
||
try { if (window.Toast && window.Toast.closeAll) window.Toast.closeAll(); } catch (e) {}
|
||
}
|
||
|
||
// ── 校验用的取值 ─────────────────────────────────────────────────────
|
||
function val(el) { return el && typeof el.value === 'string' ? el.value.trim() : ''; }
|
||
|
||
function headerEls() {
|
||
// 首选 Artalk 编辑器自己的访问器:返回的是**这一份编辑器**的输入框。
|
||
// ⚠️ 不能拿 document.querySelector('[name=nick]') 顶替:Artalk 的 DOM 里
|
||
// 存在不止一个 nick 输入框(隐藏模板、未激活的回复框都算),拿错就会
|
||
// 把提示弹在一个看不见的框上,focus 也静默失败。
|
||
try {
|
||
if (editor && typeof editor.getHeaderInputEls === 'function') {
|
||
var h = editor.getHeaderInputEls();
|
||
if (h && (h.nick || h.email)) return h;
|
||
}
|
||
} catch (e) {}
|
||
var ed = document.querySelector('.atk-main-editor');
|
||
if (!ed) return {};
|
||
return {
|
||
nick: ed.querySelector('.atk-header [name="nick"]'),
|
||
email: ed.querySelector('.atk-header [name="email"]'),
|
||
link: ed.querySelector('.atk-header [name="link"]')
|
||
};
|
||
}
|
||
|
||
function focusEl(el) {
|
||
if (!el) return;
|
||
try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} }
|
||
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
|
||
}
|
||
|
||
// ── 1) 提交前校验:返回 false 表示已拦下,不发请求 ─────────────────────
|
||
P.beforeSubmit = function (ed, next) {
|
||
editor = ed || editor;
|
||
var content = '';
|
||
try { content = String(ed.getContentFinal() || '').trim(); } catch (e) {}
|
||
if (!content) { toast('warning', '评论内容不能为空'); return; }
|
||
|
||
// 编辑已有评论(管理员)用登录身份提交,没有昵称/邮箱输入框,
|
||
// 硬校验会把管理员挡在「编辑」外面 —— 直接放行,内容校验已在上面做过
|
||
var st = '';
|
||
try { st = (ed && typeof ed.getState === 'function' && ed.getState()) || ''; } catch (e) {}
|
||
if (st === 'edit') { next(); return; }
|
||
|
||
var els = headerEls();
|
||
var nick = val(els.nick), email = val(els.email);
|
||
if (!nick) { toast('warning', '请先填写昵称'); focusEl(els.nick); return; }
|
||
if (!email) { toast('warning', '请先填写邮箱(有人回复你时才能通知到)'); focusEl(els.email); return; }
|
||
if (!EMAIL_RE.test(email)) { toast('warning', '邮箱格式好像不对,检查一下'); focusEl(els.email); return; }
|
||
// 网址(link)留空或随便填都行 —— 不校验。服务端也只在非空时才查格式
|
||
next();
|
||
};
|
||
|
||
// ── 2) 提交开始 ──────────────────────────────────────────────────────
|
||
P.begin = function () {
|
||
closeLoading(); // 极端情况下上一轮没收干净
|
||
loading = true;
|
||
result = null;
|
||
toast('loading', '评论发送中…');
|
||
// 慢网补充提示:CF 丢包时首包可能要好几秒,别让读者以为按钮坏了
|
||
slowTimer = setTimeout(function () {
|
||
slowTimer = null;
|
||
if (loading) toast('info', '网络较慢,评论仍在提交中…');
|
||
}, 7000);
|
||
};
|
||
|
||
// ── 3) 提交成功 ──────────────────────────────────────────────────────
|
||
P.done = function () {
|
||
var r = result;
|
||
closeLoading();
|
||
if (r && r.pending) { toast('info', '评论已提交,等待审核'); return; }
|
||
var st = '';
|
||
try { st = (editor && typeof editor.getState === 'function' && editor.getState()) || ''; } catch (e) {}
|
||
if (st === 'edit') { toast('success', '修改已保存'); return; }
|
||
if (st === 'reply') { toast('success', '回复发送成功'); return; }
|
||
toast('success', '评论发送成功');
|
||
};
|
||
|
||
// ── 4) 失败:把服务端响应翻译成人话 ──────────────────────────────────
|
||
// 服务端(blog-admin)的文案见 src/routes/comments.ts:
|
||
// 400 'Invalid parameters: name / email / content / page_key are required'
|
||
// 400 'Invalid Email' / 400 'Invalid Link'
|
||
// 429 'Too many requests, please slow down'
|
||
// 403 'Human verification required'(带 need_captcha,被自研人机门禁拦下)
|
||
// 403 'Admin access required'(带 need_login,昵称/邮箱撞上管理员账号)
|
||
// 5xx 'Comment failed' 等
|
||
function humanize(r) {
|
||
r = r || {};
|
||
var d = r.data || {};
|
||
var msg = String(r.msg || '').trim();
|
||
if (r.network) return '网络不稳定,评论没发出去 —— 内容还在,稍后重试';
|
||
if (d.need_login) return '该昵称或邮箱是管理员账号,请先登录后台再评论';
|
||
if (d.need_captcha) return '请先点一下「点击验证」,通过后再发送评论';
|
||
if (r.status === 429 || /too many/i.test(msg)) return '评论太频繁了,歇一会儿再发';
|
||
if (/invalid email/i.test(msg)) return '邮箱格式不对,检查一下再发';
|
||
if (/invalid link/i.test(msg)) return '网址格式不对(也可以留空)';
|
||
if (/parent comment not found/i.test(msg)) return '要回复的那条评论已经不在了';
|
||
if (/required/i.test(msg)) return '昵称、邮箱和内容都要填哦';
|
||
if (r.status >= 500) return '服务器开小差了,评论没发出去,稍后重试';
|
||
return msg || ('评论发送失败(HTTP ' + r.status + '),稍后重试');
|
||
}
|
||
|
||
function failToast(r) {
|
||
closeLoading();
|
||
toast('error', humanize(r));
|
||
// Artalk 还会在自己编辑框里塞一行 "评论失败: ..." 的小字通知,与 toast 重复。
|
||
// 只删文本匹配的那一条,避免误伤「内容已自动恢复」之类其它通知。
|
||
setTimeout(function () {
|
||
var ns = document.querySelectorAll('.atk-notify');
|
||
for (var i = 0; i < ns.length; i++) {
|
||
if (/评论失败|Failed to comment/.test(ns[i].textContent || '')) {
|
||
if (ns[i].parentNode) ns[i].parentNode.removeChild(ns[i]);
|
||
}
|
||
}
|
||
}, 80);
|
||
}
|
||
|
||
// ── fetch 拦截:只认「新建评论」这一个请求 ────────────────────────────
|
||
// 之所以要先 await 读完 body 再放行,是因为 Artalk 在 `await fetch(...)` 之后
|
||
// 才触发 editor-submitted;如果这里用 res.clone().json() 异步解析而不 await,
|
||
// 事件的回调会先跑,读到的是上一轮的 result(成功/审核状态就串了)。
|
||
var origFetch = window.fetch;
|
||
if (typeof origFetch === 'function') {
|
||
window.fetch = function (input, init) {
|
||
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
|
||
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
||
if (method !== 'POST' || !POST_RE.test(url)) return origFetch.apply(this, arguments);
|
||
|
||
// ★ 把「当前页文章的作者 id」塞进评论提交体 —— 服务端凭它决定这封
|
||
// 「有新评论」的通知邮件发给文章作者本人,还是发博主。
|
||
// 读 window.artalkConfig 而不是闭包里的 config:PJAX 切页时 artalkConfig
|
||
// 会被重新赋值,闭包里那份会过期(把上一页的作者带过去就发错人了)。
|
||
var args = arguments;
|
||
var pageAuthorId = Number((window.artalkConfig || {}).pageAuthorId || 0);
|
||
if (pageAuthorId && init && typeof init.body === 'string') {
|
||
try {
|
||
var payload = JSON.parse(init.body);
|
||
if (payload && typeof payload === 'object' && !payload.author_id) {
|
||
payload.author_id = pageAuthorId;
|
||
args = [input, Object.assign({}, init, { body: JSON.stringify(payload) })];
|
||
}
|
||
} catch (e) { /* body 不是 JSON(理论上不会)→ 原样放行,不影响提交 */ }
|
||
}
|
||
|
||
var p = origFetch.apply(this, args);
|
||
return p.then(function (res) {
|
||
if (res && res.ok) {
|
||
// 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是
|
||
// 煮熟的评论对象本体,is_pending 是它的字段)
|
||
return res.clone().json().then(function (d) {
|
||
result = { ok: true, pending: !!(d && d.is_pending) };
|
||
return res;
|
||
}).catch(function () {
|
||
result = { ok: true, pending: false };
|
||
return res;
|
||
});
|
||
}
|
||
// 失败:Artalk 拿到 !res.ok 会自己 throw,这里只负责记因 + 弹 toast
|
||
return res.clone().json().then(function (d) {
|
||
result = { ok: false, status: res.status, msg: (d && d.msg) || '', data: d || {} };
|
||
failToast(result);
|
||
return res;
|
||
}).catch(function () {
|
||
result = { ok: false, status: res.status, msg: 'HTTP ' + res.status, data: {} };
|
||
failToast(result);
|
||
return res;
|
||
});
|
||
}, function (err) {
|
||
result = { ok: false, network: true, msg: (err && err.message) || 'network error' };
|
||
failToast(result);
|
||
throw err; // 原样抛回:Artalk 自己的失败处理(保留输入内容)不受影响
|
||
});
|
||
};
|
||
}
|
||
})();
|