567 lines
24 KiB
JavaScript
567 lines
24 KiB
JavaScript
// 评论系统
|
||
// 依赖: 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 (let i = 0; i < users.length; i++) {
|
||
(function(u) {
|
||
let 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\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, persistent) {
|
||
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' });
|
||
if (persistent) {
|
||
card.classList.add('highlight-mention');
|
||
} else {
|
||
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 mentionMatch = hash.match(/^#mention-(\d+)$/);
|
||
if (mentionMatch) {
|
||
var mentionCommentId = mentionMatch[1];
|
||
var mentionInterval = setInterval(function() {
|
||
if (highlightComment(mentionCommentId, true)) {
|
||
clearInterval(mentionInterval);
|
||
}
|
||
}, 300);
|
||
setTimeout(function() {
|
||
clearInterval(mentionInterval);
|
||
if (!commentsList.querySelector('.comment-card[data-id="' + mentionCommentId + '"]') &&
|
||
!commentsList.querySelector('.comment-reply-card[data-id="' + mentionCommentId + '"]')) {
|
||
showToast('该评论已不存在');
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
// 普通评论/回复链接:短暂高亮
|
||
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);
|
||
})();
|