/** * comment-featured —— 「走心评论」精选按钮(仅管理员可见) * * 管理员在任意评论页,操作条(赞同 / 回复 / 折叠…)的「回复」右边多一个 * 「精选」按钮:点了把该评论标记为精选(PUT /comments/:id {is_featured}), * 关于页「走心评论」板块会展示它。已精选的显示「取消精选」,再点一次取消。 * * 实现要点: * - 按钮插在「回复」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 渲染的纯 回复 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(); })();