- 底栏:≤480px 压缩按钮内边距,SE 等小屏单行放下;换行时按钮组右对齐 - Worker:comments 加 is_featured;PUT /comments/:id 支持切换;新增公开接口 GET /api/v2/featured/comments(排除悄悄话/待审/已删,服务端渲染 markdown) - 前端:评论区注入「精选」按钮(回复右侧,仅管理员可见) - 关于页:走心评论板块(标题寄语通栏 + 卡片瀑布流),排在热力图上方
159 lines
7.2 KiB
JavaScript
159 lines
7.2 KiB
JavaScript
/**
|
||
* 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();
|
||
})();
|