Files
blog/themes/Ying/assets/js/modules/comment-featured.js
T
zqlit 8a6c291a8a feat: 走心评论(精选评论)+ 移动端评论框底栏布局修复
- 底栏:≤480px 压缩按钮内边距,SE 等小屏单行放下;换行时按钮组右对齐
- Worker:comments 加 is_featured;PUT /comments/:id 支持切换;新增公开接口
  GET /api/v2/featured/comments(排除悄悄话/待审/已删,服务端渲染 markdown)
- 前端:评论区注入「精选」按钮(回复右侧,仅管理员可见)
- 关于页:走心评论板块(标题寄语通栏 + 卡片瀑布流),排在热力图上方
2026-10-05 12:08:33 +08:00

159 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* comment-featured —— 「走心评论」精选按钮(仅管理员可见)
*
* 管理员在任意评论页,操作条(赞同 / 回复 / 折叠…)的「回复」右边多一个
* 「精选」按钮:点了把该评论标记为精选(PUT /comments/:id {is_featured}),
* 关于页「走心评论」板块会展示它。已精选的显示「取消精选」,再点一次取消。
*
* 实现要点:
* - 按钮插在「回复」span(纯 <span>回复</span>,无 class)右边,顺序与
* 用户要求一致:赞同 → 回复 → 精选 → 折叠/已审/置顶/编辑/删除。
* - 只对管理员可见:挂官方同款 atk-only-admin-show 属性,Artalk 内核在
* 登录态变化时统一 add/remove .atk-hide,不用自己判断登录态。
* - 精选状态从列表响应里收集:fetch 拦截读 GET /comments 的 JSON,
* 把 is_featured 的评论 id 记进 Set(单条 PUT 的响应也顺手更新)。
* - 编辑器会被 Artalk 反复重建(PJAX / 回复模式),用 MutationObserver 补挂,
* 已挂过的评论跳过(data-feat 标记)。
*/
(function () {
if (window.__atkFeatReady) return; // PJAX 重复执行防抖
window.__atkFeatReady = true;
var featured = new Set(); // 已精选的评论 id
function server() {
return String((window.artalkConfig && window.artalkConfig.server) || '').replace(/\/+$/, '');
}
// ── 精选状态收集:拦截 fetch,读评论列表 / 单条响应 ────────────────────
// 注意只「读取」,请求原样放行;与 comment-anonymous 的注入拦截互不干扰。
var origFetch = window.fetch;
if (typeof origFetch === 'function' && !window.__atkFeatFetchHooked) {
window.__atkFeatFetchHooked = true;
window.fetch = function (input, init) {
var p = origFetch.apply(this, [input, init]);
try {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
if (/\/api\/v2\/comments(\?|$)/.test(url) && !/lookup/.test(url)) {
p.then(function (res) {
try {
if (!res || !res.ok) return;
res.clone().json().then(function (data) {
var list = (data && data.comments) || [];
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].is_featured !== undefined) {
if (list[i].is_featured) featured.add(Number(list[i].id));
else featured.delete(Number(list[i].id));
}
}
refreshButtons(); // 按钮可能先于响应解析渲染,文案要修正
}).catch(function () {});
} catch (e) {}
}).catch(function () {});
}
} catch (e) {}
return p;
};
}
function getToken() {
try {
var u = JSON.parse(localStorage.getItem('ArtalkUser') || '{}');
return u.token || '';
} catch (e) { return ''; }
}
function commentIdOf(actions) {
var wrap = actions.closest('.atk-comment-wrap');
if (!wrap || !wrap.id) return 0;
var m = wrap.id.match(/^atk-comment-(\d+)$/);
return m ? Number(m[1]) : 0;
}
function toggle(commentId, btn) {
if (!commentId) return;
var next = !featured.has(commentId);
var token = getToken();
if (!token) { window.showToast && showToast('请先以管理员身份登录', 'warning'); return; }
btn.style.opacity = '0.5';
fetch(server() + '/api/v2/comments/' + commentId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ is_featured: next }),
}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
if (next) featured.add(commentId); else featured.delete(commentId);
btn.textContent = next ? '取消精选' : '精选';
window.showToast && showToast(next ? '已加入走心评论' : '已取消精选', 'success');
}).catch(function () {
window.showToast && showToast('操作失败,请重试', 'error');
}).then(function () { btn.style.opacity = ''; });
}
// 在 .atk-actions 里找到「回复」span:Artalk 渲染的纯 <span>回复</span>
function replySpanOf(actions) {
var spans = actions.querySelectorAll(':scope > span');
for (var i = 0; i < spans.length; i++) {
if (spans[i].textContent.trim() === '回复' && !spans[i].classList.contains('atk-common-action-btn')) {
return spans[i];
}
}
return null;
}
function refreshButtons() {
try {
var btns = document.querySelectorAll('.atk-feat-btn');
for (var i = 0; i < btns.length; i++) {
var id = commentIdOf(btns[i].closest('.atk-actions'));
if (id) btns[i].textContent = featured.has(id) ? '取消精选' : '精选';
}
} catch (e) {}
}
function inject(actions) {
if (actions.__atkFeat) return;
var id = commentIdOf(actions);
if (!id) return; // 编辑器回复预览等没有 id 的区域不动
var replySpan = replySpanOf(actions);
if (!replySpan) return; // 关闭回复的评论没有「回复」按钮,下轮再说
actions.__atkFeat = true;
var btn = document.createElement('span');
btn.className = 'atk-common-action-btn atk-feat-btn';
btn.setAttribute('atk-only-admin-show', '');
btn.textContent = featured.has(id) ? '取消精选' : '精选';
// 初始显隐对齐官方 admin 按钮:官方的 [atk-only-admin-show] 显隐同步
// 在列表渲染时就跑完了,我们是后注入的赶不上那趟车 —— 抄当前时刻
// 官方按钮(如「折叠」)的 atk-hide 状态;之后登录/登出的再同步由
// Artalk 内核统一处理(我们挂着同款属性,会被一起切换)。
var refAdminBtn = actions.querySelector('[atk-only-admin-show]');
if (!refAdminBtn || refAdminBtn.classList.contains('atk-hide')) {
btn.classList.add('atk-hide');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
toggle(id, btn);
});
replySpan.after(btn);
}
function scan() {
try {
var list = document.querySelectorAll('.atk-actions');
for (var i = 0; i < list.length; i++) inject(list[i]);
} catch (e) {}
}
var timer = null;
function scheduleScan() {
if (timer) return;
timer = setTimeout(function () { timer = null; scan(); }, 150);
}
try {
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
} catch (e) {}
scan();
})();