feat: 实现评论系统(后端 + 前端)

- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器
- 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询)
- 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply)
- 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口
- 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移
- 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
This commit is contained in:
2026-06-01 13:24:16 +08:00
parent b44d7ca5c3
commit b7acc91f8c
15 changed files with 1416 additions and 7 deletions

View File

@ -71,6 +71,31 @@
</div>
</div>
{{end}}
<!-- 评论区 -->
{{if .Post.AllowComment}}
<section class="comments-section" id="comments">
<h3 class="comments-title">评论 <span class="comments-count" id="commentsTotal">0</span></h3>
{{if .IsLoggedIn}}
<div class="comment-form">
<textarea class="comment-input" id="commentInput" placeholder="写下你的评论..." rows="3" maxlength="5000"></textarea>
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
</div>
{{else}}
<div class="comment-login-hint">
<a href="/login?redirect=/posts/{{$.Post.ID}}">登录</a> 后参与评论
</div>
{{end}}
<div class="comments-list" id="commentsList"></div>
<div class="comments-loading" id="commentsLoading" style="display:none">加载中...</div>
<button class="btn btn-secondary comments-load-more" id="commentsLoadMore" style="display:none">加载更多</button>
</section>
{{end}}
<!-- @ 提及下拉 -->
<div class="mention-dropdown" id="mentionDropdown" style="display:none"></div>
</div>
{{template "layout/footer.html" .}}
@ -219,5 +244,405 @@
});
})();
</script>
<script>
// ========== 评论系统 ==========
(function() {
var commentsSection = document.getElementById('comments');
if (!commentsSection) return;
var postId = '{{.Post.ID}}';
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 csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
var currentPage = 1;
var totalPages = 1;
// ====== 工具函数 ======
function api(method, url, data) {
var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
if (csrfToken) opts.headers['X-CSRF-Token'] = csrfToken;
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function(r) { return r.json(); });
}
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function formatTime(iso) {
var d = new Date(iso);
var pad = function(n) { return n < 10 ? '0'+n : n; };
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
// ====== 渲染评论 HTML ======
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 = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
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 = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
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) { alert('请输入回复内容'); 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 {
alert(d.message || '回复失败');
}
})
.catch(function() {
btn.disabled = false;
alert('请求失败');
});
});
}
// ====== @提及自动补全 ======
var mentionTarget = null;
var mentionPos = 0;
var searchTimer = null;
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.length === 0 || query.includes(' ')) {
hideMention();
return;
}
mentionTarget = input;
mentionPos = atPos;
clearTimeout(searchTimer);
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 {
hideMention();
}
})
.catch(function() { hideMention(); });
}, 200);
});
input.addEventListener('blur', function() {
setTimeout(hideMention, 150);
});
}
function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.width = Math.max(rect.width, 200) + 'px';
for (var i = 0; i < users.length; i++) {
var u = users[i];
var item = document.createElement('div');
item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
}
}
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) { alert('请输入评论内容'); 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 {
alert(d.message || '发表失败');
}
})
.catch(function() {
commentSubmitBtn.disabled = false;
alert('请求失败');
});
});
// 主输入框 @提及
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) + ')';
}
// ====== 初始化 ======
loadComments(currentPage);
})();
</script>
</body>
</html>

View File

@ -713,3 +713,241 @@
margin-top: 2px;
font-weight: 500;
}
/* ========== 评论区 ========== */
.comments-section {
max-width: 800px;
margin: 32px auto 0;
}
.comments-title {
font-size: 18px;
font-weight: 600;
color: #111;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.comments-count {
color: #6366f1;
}
.comment-form {
margin-bottom: 24px;
}
.comment-input {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
color: #1f2937;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
.comment-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.comment-submit-btn {
margin-top: 10px;
}
.comment-login-hint {
text-align: center;
padding: 24px;
color: #9ca3af;
font-size: 14px;
}
.comment-login-hint a {
color: #6366f1;
text-decoration: none;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 0;
}
/* 单条评论卡片 */
.comment-card {
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
}
.comment-card:last-child {
border-bottom: none;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comment-author {
font-size: 14px;
font-weight: 600;
color: #111;
}
.comment-time {
font-size: 12px;
color: #9ca3af;
}
.comment-body {
font-size: 14px;
line-height: 1.7;
color: #374151;
word-break: break-word;
margin-bottom: 8px;
}
.comment-body .reply-to {
color: #6366f1;
margin-right: 6px;
}
.comment-actions {
display: flex;
gap: 16px;
align-items: center;
}
.comment-action-btn {
background: none;
border: none;
font-size: 12px;
color: #9ca3af;
cursor: pointer;
padding: 2px 0;
}
.comment-action-btn:hover {
color: #6366f1;
}
/* 回复列表(内联) */
.comment-replies {
margin-top: 10px;
padding-left: 36px;
border-left: 2px solid #e5e7eb;
}
.comment-reply-card {
padding: 10px 0 10px 16px;
border-bottom: 1px solid #f9fafb;
}
.comment-reply-card:last-child {
border-bottom: none;
padding-bottom: 0;
}
.comment-reply-body {
font-size: 13px;
line-height: 1.6;
color: #4b5563;
}
.comment-reply-to {
color: #6366f1;
margin-right: 4px;
}
/* 展开回复按钮 */
.comment-toggle-replies {
background: none;
border: none;
color: #6366f1;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
margin-top: 4px;
}
.comment-toggle-replies:hover {
text-decoration: underline;
}
/* 回复输入框 */
.comment-reply-form {
display: flex;
gap: 8px;
margin-top: 10px;
padding-left: 36px;
}
.comment-reply-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.comment-reply-input:focus {
outline: none;
border-color: #6366f1;
}
.comment-reply-submit {
padding: 6px 14px;
font-size: 13px;
flex-shrink: 0;
}
/* 加载更多 */
.comments-load-more {
display: block;
margin: 16px auto 0;
}
/* @提及下拉 */
.mention-dropdown {
position: absolute;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
max-height: 240px;
overflow-y: auto;
z-index: 10000;
min-width: 200px;
}
.mention-item {
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
color: #374151;
}
.mention-item:hover,
.mention-item.active {
background: #f5f3ff;
color: #6366f1;
}
.mention-item .mention-username {
font-weight: 500;
}
.mention-item .mention-uid {
color: #9ca3af;
font-size: 11px;
margin-left: 8px;
}