Files
mce/templates/MetaLab-2026/static/js/post-comments.js
Victor_Jay 6c9a4414cb refactor: 全局移除浏览器 alert 弹窗,统一使用页面内 showToast 通知
- 替换全部 11 个文件中 36 处 alert() 为 showToast()
- API 错误/异常使用 showToast(msg, 'error'),3秒自动消失
- 表单校验提示使用 showToast(msg, 'warning'),3秒自动消失
- 成功确认使用 showToast(msg, 'success'),3秒自动消失
- 移除 post-energize.js 中与全局 showToast 冲突的本地实现
- 删除 posts.css 中已失去引用的 .energize-toast 样式
- 保留 showConfirm() 自定义确认弹窗(需用户确认的操作)
2026-06-03 01:25:12 +08:00

543 lines
23 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.

// 评论系统
// 依赖: utils.js (escapeHtml, formatTime, showToast, api), common.js (getCSRFToken)
(function() {
var commentsSection = document.getElementById('comments');
if (!commentsSection) return;
var postId = commentsSection.closest('.container').querySelector('#postFloatBar').dataset.postId;
var commentsList = document.getElementById('commentsList');
var commentsTotal = document.getElementById('commentsTotal');
var loadMoreBtn = document.getElementById('commentsLoadMore');
var commentInput = document.getElementById('commentInput');
var commentSubmitBtn = document.getElementById('commentSubmitBtn');
var mentionDropdown = document.getElementById('mentionDropdown');
var currentPage = 1;
var totalPages = 1;
// 渲染@提及:有效提及渲染为蓝色链接(显示当前用户名),无效提及保持纯文本
// mentions: { original_username: {uid, name} } — name为当前显示名用户改名后自动更新
function renderMentions(body, mentions) {
body = escapeHtml(body);
if (!mentions || Object.keys(mentions).length === 0) {
return body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
}
return body.replace(/@(\S{1,16})/g, function(match, username) {
var info = mentions[username];
if (info && info.uid) {
return '<a class="mention-link" href="/space/' + info.uid + '">@' + escapeHtml(info.name) + '</a>';
}
return match;
});
}
function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || '';
body = renderMentions(body, c.mentions);
var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' +
'<span class="comment-author">' + author + '</span>' +
'<span class="comment-time">' + time + '</span>' +
'</div>' +
'<div class="comment-body">' + body + '</div>' +
'<div class="comment-actions">';
if (isLoggedIn()) {
html += '<button class="comment-action-btn comment-reply-btn" data-id="'+c.id+'">回复</button>';
}
html += '</div>' +
'<div class="comment-replies" id="replies-' + c.id + '"></div>';
if (c.replies_count > 0) {
html += '<button class="comment-toggle-replies" id="toggle-' + c.id + '" data-id="'+c.id+'" data-loaded="false">共 ' + c.replies_count + ' 条回复</button>';
}
html += '<div class="comment-reply-form" id="replyForm-' + c.id + '" style="display:none">' +
'<textarea class="comment-reply-input" id="replyInput-' + c.id + '" rows="2" placeholder="写下回复..." maxlength="5000"></textarea>' +
'<button class="btn btn-primary comment-reply-submit" data-id="' + c.id + '">回复</button>' +
'</div>' +
'</div>';
return html;
}
function buildReplyCard(r) {
var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || '';
body = renderMentions(body, r.mentions);
var prefix = '';
if (r.reply_to_name) {
prefix = '<span class="comment-reply-to">回复 ' + escapeHtml(r.reply_to_name) + ':</span>';
}
return '<div class="comment-reply-card" data-id="' + r.id + '">' +
'<div class="comment-header">' +
'<span class="comment-author" style="font-size:13px">' + author + '</span>' +
'<span class="comment-time" style="font-size:11px">' + time + '</span>' +
'</div>' +
'<div class="comment-reply-body">' + prefix + body + '</div>' +
'</div>';
}
function isLoggedIn() {
return !!document.getElementById('commentInput');
}
// ====== 加载根评论 ======
function loadComments(page) {
var url = '/api/posts/' + postId + '/comments?page=' + page + '&page_size=20';
fetch(url).then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success) return;
totalPages = d.data.total_pages || 1;
commentsTotal.textContent = '(' + d.data.total + ')';
var items = d.data.items || [];
for (var i = 0; i < items.length; i++) {
commentsList.insertAdjacentHTML('beforeend', buildCommentCard(items[i]));
}
if (page < totalPages) {
loadMoreBtn.style.display = 'block';
loadMoreBtn.textContent = '加载更多';
} else {
loadMoreBtn.style.display = 'none';
}
bindEvents();
})
.catch(function() {
commentsList.innerHTML = '<div class="empty-state">评论加载失败</div>';
});
}
// ====== 绑定事件 ======
function bindEvents() {
var replyBtns = commentsList.querySelectorAll('.comment-reply-btn');
for (var i = 0; i < replyBtns.length; i++) {
if (replyBtns[i]._bound) continue;
replyBtns[i]._bound = true;
bindReplyBtn(replyBtns[i]);
}
var toggleBtns = commentsList.querySelectorAll('.comment-toggle-replies');
for (var j = 0; j < toggleBtns.length; j++) {
if (toggleBtns[j]._bound) continue;
toggleBtns[j]._bound = true;
bindToggleBtn(toggleBtns[j]);
}
var submitBtns = commentsList.querySelectorAll('.comment-reply-submit');
for (var k = 0; k < submitBtns.length; k++) {
if (submitBtns[k]._bound) continue;
submitBtns[k]._bound = true;
bindReplySubmit(submitBtns[k]);
}
var replyInputs = commentsList.querySelectorAll('.comment-reply-input');
for (var m = 0; m < replyInputs.length; m++) {
if (replyInputs[m]._mentionBound) continue;
replyInputs[m]._mentionBound = true;
bindMention(replyInputs[m]);
}
}
function bindReplyBtn(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var form = document.getElementById('replyForm-' + id);
if (form) {
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
if (form.style.display !== 'none') {
document.getElementById('replyInput-' + id).focus();
}
}
});
}
function bindToggleBtn(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var loaded = btn.dataset.loaded === 'true';
if (loaded) {
var replies = document.getElementById('replies-' + id);
if (replies) {
replies.style.display = replies.style.display === 'none' ? 'block' : 'none';
btn.textContent = replies.style.display === 'none' ? '共 ' + (btn.dataset.count || replies.children.length) + ' 条回复' : '收起回复';
}
return;
}
btn.dataset.loaded = 'true';
btn.textContent = '加载中...';
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success) return;
var replies = d.data.replies || [];
var container = document.getElementById('replies-' + id);
if (!container) return;
var html = '';
for (var i = 0; i < replies.length; i++) {
html += buildReplyCard(replies[i]);
}
container.innerHTML = html;
btn.dataset.count = replies.length;
btn.textContent = '收起回复';
})
.catch(function() {
btn.textContent = '加载失败';
});
});
}
function bindReplySubmit(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var input = document.getElementById('replyInput-' + id);
if (!input) return;
var body = input.value.trim();
if (!body) { showToast('请输入回复内容', 'warning'); return; }
btn.disabled = true;
api('POST', '/api/comments/' + id + '/reply', { body: body })
.then(function(d) {
btn.disabled = false;
if (d.success) {
input.value = '';
document.getElementById('replyForm-' + id).style.display = 'none';
var toggleBtn = document.getElementById('toggle-' + id);
if (toggleBtn) {
toggleBtn.dataset.loaded = 'false';
if (toggleBtn.dataset.count) {
toggleBtn.dataset.count = parseInt(toggleBtn.dataset.count) + 1;
toggleBtn.textContent = '共 ' + toggleBtn.dataset.count + ' 条回复';
}
toggleBtn.click();
} else {
var replies = document.getElementById('replies-' + id);
if (replies) {
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
.then(function(r) { return r.json(); })
.then(function(d2) {
if (!d2.success) return;
var items = d2.data.replies || [];
var html = '';
for (var i = 0; i < items.length; i++) {
html += buildReplyCard(items[i]);
}
replies.innerHTML = html;
});
}
}
updateTotalCount(1);
} else {
showToast(d.message || '回复失败', 'error');
}
})
.catch(function() {
btn.disabled = false;
showToast('请求失败', 'error');
});
});
}
// ====== @提及自动补全 ======
var mentionTarget = null;
var mentionPos = 0;
var searchTimer = null;
// 计算 textarea 中光标的近似像素位置(相对视口)
function getCursorPixelPos(textarea, cursorIdx) {
var text = textarea.value.substring(0, cursorIdx);
var lines = text.split('\n');
var lineCount = lines.length;
var lastLine = lines[lineCount - 1];
var style = window.getComputedStyle(textarea);
var lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 21;
var paddingTop = parseFloat(style.paddingTop) || 0;
var paddingLeft = parseFloat(style.paddingLeft) || 0;
// 近似字符宽度 (中文字符约 2x 英文字符)
var charWidth = parseFloat(style.fontSize) * 0.6 || 8.4;
var rect = textarea.getBoundingClientRect();
var top = rect.top + paddingTop + (lineCount - 1) * lineHeight + lineHeight;
var left = rect.left + paddingLeft + lastLine.length * charWidth;
return { top: top, left: left, bottom: rect.bottom };
}
function bindMention(input) {
input.addEventListener('input', function() {
var val = input.value;
var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention();
return;
}
var query = val.substring(atPos + 1, cursor);
// 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention();
return;
}
mentionTarget = input;
mentionPos = atPos;
clearTimeout(searchTimer);
// 仅输入 @ 时,显示提示
if (query.length === 0) {
showEmptyHint(input);
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query);
} else {
showMention(input, [], query);
}
})
.catch(function() { hideMention(); });
}, 200);
});
input.addEventListener('blur', function() {
setTimeout(hideMention, 150);
});
}
function showMention(input, users, query) {
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
if (users.length === 0) {
// 无匹配结果:显示提示(仅在有查询时)
if (query.length > 0) {
var emptyItem = document.createElement('div');
emptyItem.className = 'mention-no-result';
emptyItem.textContent = '未搜索到用户';
dropdown.appendChild(emptyItem);
}
} else {
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
var avatarHtml = u.avatar
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
item.innerHTML = avatarHtml +
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
'<span class="mention-level">LV' + u.level + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
})(users[i]);
}
}
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
// 悬浮在光标上方(不遮挡输入内容)
var dropHeight = dropdown.offsetHeight || 60;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function showEmptyHint(input) {
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
var hint = document.createElement('div');
hint.className = 'mention-no-result';
hint.textContent = '输入以查找用户';
dropdown.appendChild(hint);
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
var dropHeight = dropdown.offsetHeight || 36;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function hideMention() {
mentionDropdown.style.display = 'none';
mentionDropdown.innerHTML = '';
mentionTarget = null;
}
function selectMention(user) {
if (!mentionTarget) return;
var val = mentionTarget.value;
var before = val.substring(0, mentionPos);
var after = val.substring(mentionTarget.selectionStart);
var insert = '@' + user.username + ' ';
mentionTarget.value = before + insert + after;
mentionTarget.focus();
var newPos = mentionPos + insert.length;
mentionTarget.setSelectionRange(newPos, newPos);
hideMention();
}
// ====== 发表评论 ======
if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', function() {
var body = commentInput.value.trim();
if (!body) { showToast('请输入评论内容', 'warning'); return; }
commentSubmitBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/comments', { body: body })
.then(function(d) {
commentSubmitBtn.disabled = false;
if (d.success) {
commentInput.value = '';
commentsList.innerHTML = '';
currentPage = 1;
loadComments(currentPage);
updateTotalCount(1);
} else {
showToast(d.message || '发表失败', 'error');
}
})
.catch(function() {
commentSubmitBtn.disabled = false;
showToast('请求失败', 'error');
});
});
bindMention(commentInput);
}
// ====== 加载更多 ======
loadMoreBtn.addEventListener('click', function() {
currentPage++;
loadComments(currentPage);
});
function updateTotalCount(delta) {
var el = commentsTotal;
var current = parseInt(el.textContent.replace(/[()]/g, '')) || 0;
el.textContent = '(' + (current + delta) + ')';
}
// ====== 图片上传 ======
var imageUploadInput = document.getElementById('commentImageInput');
var uploadLabel = document.getElementById('commentUploadLabel');
var uploadPreview = document.getElementById('commentUploadPreview');
if (imageUploadInput && uploadLabel) {
uploadLabel.addEventListener('click', function() {
imageUploadInput.click();
});
imageUploadInput.addEventListener('change', function() {
var file = imageUploadInput.files[0];
if (!file) return;
uploadLabel.style.pointerEvents = 'none';
uploadLabel.style.opacity = '0.5';
uploadLabel.textContent = '上传中...';
var formData = new FormData();
formData.append('file', file);
fetch('/api/comments/upload-image', {
method: 'POST',
headers: { 'X-CSRF-Token': getCSRFToken() },
body: formData
})
.then(function(r) { return r.json(); })
.then(function(d) {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
if (d.success && d.data && d.data.url) {
var imgUrl = '\n![](' + d.data.url + ')\n';
var input = commentInput;
var start = input.selectionStart;
var end = input.selectionEnd;
input.value = input.value.substring(0, start) + imgUrl + input.value.substring(end);
input.focus();
input.selectionStart = input.selectionEnd = start + imgUrl.length;
} else {
showToast(d.message || '上传失败', 'error');
}
})
.catch(function() {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
showToast('上传失败', 'error');
});
imageUploadInput.value = '';
});
}
// ====== 评论高亮 ======
function highlightComment(commentId) {
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
if (card) {
commentsList.insertBefore(card, commentsList.firstChild);
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
return true;
}
return false;
}
var hash = window.location.hash;
var commentMatch = hash.match(/^#comment-(\d+)$/);
if (commentMatch) {
var targetCommentId = commentMatch[1];
var checkInterval = setInterval(function() {
if (highlightComment(targetCommentId)) {
clearInterval(checkInterval);
}
}, 300);
setTimeout(function() {
clearInterval(checkInterval);
if (!commentsList.querySelector('.comment-card[data-id="' + targetCommentId + '"]') &&
!commentsList.querySelector('.comment-reply-card[data-id="' + targetCommentId + '"]')) {
showToast('该评论已不存在');
}
}, 3000);
} else if (hash === '#comments') {
setTimeout(function() {
var section = document.getElementById('comments');
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 400);
}
// ====== 初始化 ======
loadComments(currentPage);
})();