feat: 走心评论(精选评论)+ 移动端评论框底栏布局修复
- 底栏:≤480px 压缩按钮内边距,SE 等小屏单行放下;换行时按钮组右对齐 - Worker:comments 加 is_featured;PUT /comments/:id 支持切换;新增公开接口 GET /api/v2/featured/comments(排除悄悄话/待审/已删,服务端渲染 markdown) - 前端:评论区注入「精选」按钮(回复右侧,仅管理员可见) - 关于页:走心评论板块(标题寄语通栏 + 卡片瀑布流),排在热力图上方
This commit is contained in:
1 parent
3d7d7bd396
commit
1b71fba413
10 files changed
+409
-2
No files matched your search
@@ -4641,6 +4641,23 @@ input[type=submit] {
|
||||
.artalk > .atk-list > .atk-list-footer { bottom: auto !important; }
|
||||
.atk-main { margin-left: 34px !important; }
|
||||
.atk-bottom { padding: 5px 10px 10px 5px !important; }
|
||||
/* 底栏右侧(人机验证 + 发送按钮)换行修正:
|
||||
移动端窄屏时右侧组会 flex-wrap 到第二行,space-between 对单元素行不生效,
|
||||
整组会挤在左边、右侧留大片空白(iPhone SE / 16 Pro 实测)。
|
||||
margin-left:auto 单行时与 space-between 等价(仍靠右),换行时把整组推到右边,
|
||||
与桌面端「按钮在右下」的观感一致。 */
|
||||
.atk-main-editor > .atk-bottom > .atk-item:last-child { margin-left: auto; }
|
||||
/* 小屏(≤480,含 iPhone SE 375):底栏压进一行 —— 图标+悄悄话靠左、验证+发送靠右。
|
||||
各元素收一点内边距/间距(375px 下自然宽度 324 vs 可用 316,只差一点就溢出换行)。
|
||||
真放不下(如人机验证失败的长文案)仍按上面的规则优雅换行右对齐。 */
|
||||
@media (max-width: 480px) {
|
||||
.atk-main-editor > .atk-bottom .atk-plug-btn { padding: 0 8px; }
|
||||
.atk-main-editor > .atk-bottom .atk-plug-btn:not(:last-child) { margin-right: 3px; }
|
||||
.atk-whisper-toggle { margin-left: 6px; }
|
||||
/* 提高特异性压过 human.js 动态注入的 @media(max-width:640px) 规则 */
|
||||
.atk-main-editor .atk-human-tf { padding: 0 6px; gap: 5px; margin-right: 5px; }
|
||||
.atk-main-editor > .atk-bottom .atk-send-btn { padding: 0 10px !important; }
|
||||
}
|
||||
.atk-comment-children > .atk-comment-wrap { margin-top: 6px !important; }
|
||||
.atk-comment-children > .atk-comment-wrap > .atk-comment { padding: 0 !important; margin: auto; }
|
||||
.atk-comment .atk-height-limit:after { margin-bottom: 20px; }
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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();
|
||||
})();
|
||||
Reference in new issue
Block a user