feat: 评论系统补充功能完成——图片上传、后台管理、通知跳转高亮

- 评论图片上传(LV4+,经验≥1500),JPEG/PNG 自动转 WebP,二进制搜索质量优化
- 后台评论管理页面,支持关键词搜索、分页、删除,侧边栏新增入口
- 通知跳转高亮:评论通知 RelatedID 改为 postID,消息页链接到 #comments,前端自动滚动
- 新增 adminCommentUseCase/commentStore 接口(遵循 ISP),AdminCommentRow 移至 model 层
- 前端 @mention 联想/图片上传光标插入/评论展开收起/内联回复交互完善
This commit is contained in:
2026-06-01 13:35:41 +08:00
parent b7acc91f8c
commit 36c571b6bb
17 changed files with 532 additions and 11 deletions

View File

@ -41,6 +41,8 @@
{{$link := ""}}
{{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}}
{{$link = printf "/posts/%d" (derefUint $m.RelatedID)}}
{{else if or (eq $m.NotifyType "comment") (eq $m.NotifyType "comment_reply")}}
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
{{$link = "/settings"}}
{{end}}

View File

@ -80,7 +80,15 @@
{{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 class="comment-form-actions">
<label class="comment-upload-btn" id="commentUploadLabel">
<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>
图片
</label>
<input type="file" id="commentImageInput" accept="image/*" style="display:none" />
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
</div>
<div class="comment-upload-preview" id="commentUploadPreview"></div>
</div>
{{else}}
<div class="comment-login-hint">
@ -640,6 +648,71 @@
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': csrfToken },
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) {
// 插入图片 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 {
alert(d.message || '上传失败');
}
})
.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> 图片';
alert('上传失败');
});
imageUploadInput.value = '';
});
}
// ====== Hash 定位:从消息通知跳转时滚动到评论区 ======
if (window.location.hash === '#comments') {
setTimeout(function() {
var section = document.getElementById('comments');
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 400);
}
// ====== 初始化 ======
loadComments(currentPage);
})();

View File

@ -756,10 +756,43 @@
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.comment-submit-btn {
.comment-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.comment-upload-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
background: #fff;
transition: border-color 0.2s, color 0.2s;
}
.comment-upload-btn:hover {
border-color: #6366f1;
color: #6366f1;
}
.comment-upload-preview {
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.comment-submit-btn {
margin-top: 0;
}
.comment-login-hint {
text-align: center;
padding: 24px;

View File

@ -0,0 +1,33 @@
{{template "admin/layout/base.html" .}}
<div class="admin-page-header">
<h2>评论管理</h2>
</div>
<div class="admin-toolbar">
<input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." />
<button id="commentSearchBtn" class="btn btn-secondary">搜索</button>
<button id="commentClearBtn" class="btn btn-secondary" style="display:none">清除</button>
</div>
<div class="admin-table-wrap" id="commentTableWrap">
<table class="admin-table">
<thead>
<tr>
<th style="width:60px">ID</th>
<th style="width:100px">作者</th>
<th>评论内容</th>
<th style="width:160px">所属文章</th>
<th style="width:140px">时间</th>
<th style="width:100px">操作</th>
</tr>
</thead>
<tbody id="commentTableBody"></tbody>
</table>
</div>
<div class="admin-pagination" id="commentPagination"></div>
<div class="admin-loading" id="commentLoading" style="display:none">加载中...</div>
{{template "admin/layout/footer.html" .}}
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}"></script>

View File

@ -51,6 +51,10 @@
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
域能日志
</a>
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
评论管理
</a>
{{if .CanManageSettings}}
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>

View File

@ -0,0 +1,132 @@
(function() {
'use strict';
var currentPage = 1;
var currentKeyword = '';
var totalPages = 1;
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
var searchInput = document.getElementById('commentSearch');
var searchBtn = document.getElementById('commentSearchBtn');
var clearBtn = document.getElementById('commentClearBtn');
var tbody = document.getElementById('commentTableBody');
var pagination = document.getElementById('commentPagination');
var loading = document.getElementById('commentLoading');
function api(url, opts) {
opts = opts || {};
return fetch(url, opts).then(function(r) { return r.json(); });
}
function escapeHtml(text) {
if (!text) return '';
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());
}
function loadComments(page) {
loading.style.display = 'block';
var url = '/api/admin/comments?page=' + page + '&page_size=20';
if (currentKeyword) url += '&keyword=' + encodeURIComponent(currentKeyword);
api(url).then(function(d) {
loading.style.display = 'none';
if (!d.success) return;
totalPages = d.data.total_pages || 1;
tbody.innerHTML = '';
var items = d.data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#9ca3af">暂无评论</td></tr>';
pagination.innerHTML = '';
return;
}
for (var i = 0; i < items.length; i++) {
var c = items[i];
var row = document.createElement('tr');
var body = escapeHtml(c.body).substring(0, 80) + (c.body && c.body.length > 80 ? '...' : '');
row.innerHTML =
'<td>' + c.id + '</td>' +
'<td>' + escapeHtml(c.author_name) + '</td>' +
'<td class="comment-body-cell">' + body + '</td>' +
'<td><a href="/posts/' + c.post_id + '" target="_blank">' + escapeHtml(c.post_title || '文章#' + c.post_id) + '</a></td>' +
'<td>' + formatTime(c.created_at) + '</td>' +
'<td><button class="btn btn-danger btn-sm comment-delete-btn" data-id="' + c.id + '">删除</button></td>';
tbody.appendChild(row);
}
renderPagination(page);
bindDeleteBtns();
}).catch(function() {
loading.style.display = 'none';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#dc2626">加载失败</td></tr>';
});
}
function bindDeleteBtns() {
var btns = tbody.querySelectorAll('.comment-delete-btn');
for (var i = 0; i < btns.length; i++) {
if (btns[i]._bound) continue;
btns[i]._bound = true;
btns[i].addEventListener('click', function() {
var id = this.dataset.id;
if (!confirm('确认删除此评论?')) return;
fetch('/api/admin/comments/' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
loadComments(currentPage);
} else {
alert(d.message || '删除失败');
}
}).catch(function() { alert('请求失败'); });
});
}
}
function renderPagination(page) {
if (totalPages <= 1) { pagination.innerHTML = ''; return; }
var html = '';
if (page > 1) html += '<a href="javascript:void(0)" class="admin-page-link" data-page="' + (page-1) + '">上一页</a>';
html += '<span class="admin-page-info">' + page + ' / ' + totalPages + '</span>';
if (page < totalPages) html += '<a href="javascript:void(0)" class="admin-page-link" data-page="' + (page+1) + '">下一页</a>';
pagination.innerHTML = html;
var links = pagination.querySelectorAll('.admin-page-link');
for (var j = 0; j < links.length; j++) {
links[j].addEventListener('click', function() {
currentPage = parseInt(this.dataset.page);
loadComments(currentPage);
});
}
}
searchBtn.addEventListener('click', function() {
currentKeyword = searchInput.value.trim();
currentPage = 1;
if (currentKeyword) { clearBtn.style.display = 'inline-block'; }
else { clearBtn.style.display = 'none'; }
loadComments(currentPage);
});
clearBtn.addEventListener('click', function() {
currentKeyword = '';
currentPage = 1;
searchInput.value = '';
clearBtn.style.display = 'none';
loadComments(currentPage);
});
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') searchBtn.click();
});
loadComments(currentPage);
})();