评论分页

This commit is contained in:
2026-09-07 10:33:41 +08:00
parent 7f5bb974fa
commit caa9436e6a
5 changed files with 151 additions and 51 deletions
+48 -15
View File
@@ -17,15 +17,15 @@ func GetComments(c *gin.Context) {
postSlug := c.Query("post_slug") // 前台使用 slug
status := c.Query("status")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "1000")
pageSizeStr := c.DefaultQuery("page_size", "10")
// 解析分页参数
page := 1
pageSize := 1000
pageSize := 10
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 1000 {
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 10 {
pageSize = ps
}
@@ -34,22 +34,20 @@ func GetComments(c *gin.Context) {
return db.Select("id", "title", "slug")
})
// 非管理员只能查看自己文章上的评论(后台)或已审核的评论(前台)
if isAdmin(c) {
// 管理员可以查看所有评论
if status != "" {
db = db.Where("status = ?", status)
}
} else {
// 状态过滤(管理员和普通用户均适用)
if status != "" {
db = db.Where("status = ?", status)
}
// 非管理员只能查看自己文章上的评论
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
// 如果是后台接口调用,限制为自己的文章
// 未指定具体文章时,限制为自己名下的文章
if postID == "" && postSlug == "" {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
}
}
// 按文章 ID 或 slug 过滤
@@ -64,14 +62,16 @@ func GetComments(c *gin.Context) {
db = db.Where("post_id = ?", post.ID)
}
// 统计总数
// 只统计并查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
// Count 必须与列表使用相同过滤条件,否则分页总数会包含子评论而不准确
db = db.Where("parent_id IS NULL")
var total int64
db.Count(&total)
// 只查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
var comments []models.Comment
offset := (page - 1) * pageSize
db.Where("parent_id IS NULL").Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
db.Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
// 预加载子评论
for i := range comments {
@@ -102,6 +102,39 @@ func GetComments(c *gin.Context) {
})
}
// GetCommentPosts 获取所有有评论的文章列表(用于评论管理页的文章筛选下拉框)
// 与评论列表分页无关,始终返回权限范围内的全部文章
func GetCommentPosts(c *gin.Context) {
// 有评论的文章 ID 子查询
postIDsWithComments := database.DB.Model(&models.Comment{}).Select("DISTINCT post_id")
query := database.DB.Model(&models.Post{}).
Select("id", "title", "slug").
Where("id IN (?)", postIDsWithComments)
// 非管理员只能看到自己名下的文章
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
query = query.Where("author_id = ?", userID)
}
}
var posts []models.Post
query.Order("id DESC").Find(&posts)
type postOption struct {
ID uint `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
}
list := make([]postOption, 0, len(posts))
for _, p := range posts {
list = append(list, postOption{ID: p.ID, Title: p.Title, Slug: p.Slug})
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
func loadAndVerifyComment(c *gin.Context) *models.Comment {
id := c.Param("id")
+1
View File
@@ -81,6 +81,7 @@ func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) {
// 评论管理(查看所有评论,需登录)
auth.GET("/comments", backend.GetComments)
auth.GET("/comments/posts", backend.GetCommentPosts) // 有评论的文章列表(筛选下拉框)
auth.PUT("/comments/:id/approve", backend.ApproveComment)
auth.PUT("/comments/:id/spam", backend.MarkSpamComment)
auth.DELETE("/comments/:id", backend.DeleteComment)
+16
View File
@@ -1092,6 +1092,22 @@ a.stat-card:hover {
text-overflow: ellipsis;
}
/* 评论管理页顶部筛选区(文章 + 状态) */
.comment-filters {
display: flex;
align-items: center;
gap: 12px;
}
.comment-status-filter {
width: 140px;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
font-size: 14px;
}
/* 基础设置表单 */
.settings-form {
max-width: 560px;
+72 -31
View File
@@ -272,6 +272,7 @@ function showPage(pageName) {
loadPages();
break;
case 'comments':
loadCommentPostFilter();
loadComments();
break;
case 'users':
@@ -1050,25 +1051,36 @@ async function loadPages() {
}
}
// 加载评论(postId 为空时加载全部)
async function loadComments(postId = '') {
// 评论列表分页与筛选状态(每页 10 条)
const COMMENTS_PAGE_SIZE = 10;
let commentsCurrentPage = 1;
let commentsCurrentPostId = ''; // 当前按文章筛选的 post_id
let commentsCurrentStatus = ''; // 当前按状态筛选的 status
// 加载评论列表(分页 + 文章/状态筛选)
async function loadComments(page = commentsCurrentPage) {
try {
const url = postId ? `${API_BASE}/comments?post_id=${postId}` : `${API_BASE}/comments`;
const res = await fetch(url, { headers: getHeaders() });
const params = new URLSearchParams();
params.set('page', page);
params.set('page_size', COMMENTS_PAGE_SIZE);
if (commentsCurrentPostId) params.set('post_id', commentsCurrentPostId);
if (commentsCurrentStatus) params.set('status', commentsCurrentStatus);
const res = await fetch(`${API_BASE}/comments?${params.toString()}`, { headers: getHeaders() });
const data = await res.json();
// 加载全部评论时刷新文章筛选下拉框
if (!postId) {
populateCommentPostFilter(data.data || []);
}
const list = data.data || [];
const total = data.total || 0;
const totalPages = Math.max(1, data.total_pages || Math.ceil(total / COMMENTS_PAGE_SIZE));
commentsCurrentPage = Math.min(Math.max(1, data.page || page), totalPages);
const tbody = document.getElementById('comments-list');
tbody.innerHTML = data.data?.map(c => `
tbody.innerHTML = list.map(c => `
<tr>
<td>${c.author}</td>
<td>${c.content.substring(0, 50)}${c.content.length > 50 ? '...' : ''}</td>
<td><a href="/post/${encodeURIComponent(c.post_slug || c.post_id)}" target="_blank">${c.post_title || '文章 #' + c.post_id}</a></td>
<td><span class="status-badge status-${c.status}">${c.status}</span></td>
<td><span class="status-badge status-${c.status}">${getStatusText(c.status)}</span></td>
<td>${new Date(c.created_at).toLocaleString()}</td>
<td>
${c.status === 'pending' ? `<button class="btn-success" onclick="approveComment(${c.id})">通过</button>` : ''}
@@ -1076,11 +1088,28 @@ async function loadComments(postId = '') {
</td>
</tr>
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无评论</td></tr>';
renderCommentsPagination(total, totalPages);
} catch (err) {
console.error('加载评论失败:', err);
}
}
// 渲染评论列表分页条
function renderCommentsPagination(total, totalPages) {
const box = document.getElementById('comments-pagination');
if (!box) return;
const buttons = [];
buttons.push(`<button ${commentsCurrentPage <= 1 ? 'disabled' : ''} onclick="loadComments(${commentsCurrentPage - 1})">上一页</button>`);
for (let i = 1; i <= totalPages; i++) {
buttons.push(`<button class="${i === commentsCurrentPage ? 'active' : ''}" onclick="loadComments(${i})">${i}</button>`);
}
buttons.push(`<button ${commentsCurrentPage >= totalPages ? 'disabled' : ''} onclick="loadComments(${commentsCurrentPage + 1})">下一页</button>`);
box.innerHTML = buttons.join('') + `<span class="pagination-info">共 ${total} 条,第 ${commentsCurrentPage}/${totalPages} 页</span>`;
}
// 显示分类表单
function showCategoryForm() {
showModal('category', '新建分类');
@@ -1280,7 +1309,9 @@ function getStatusText(status) {
'published': '已发布',
'draft': '草稿',
'private': '私密',
'pending': '待审核'
'pending': '待审核',
'approved': '已通过',
'spam': '垃圾'
};
return map[status] || status;
}
@@ -1322,33 +1353,43 @@ async function loadUsers() {
}
}
// 填充评论筛选下拉框(所有有评论的文章)
function populateCommentPostFilter(comments) {
// 加载评论筛选下拉框(所有有评论的文章,来自后端接口而非当前页)
async function loadCommentPostFilter() {
const select = document.getElementById('comment-post-filter');
if (!select) return;
try {
const res = await fetch(`${API_BASE}/comments/posts`, { headers: getHeaders() });
const data = await res.json();
// 按文章去重
const posts = new Map();
comments.forEach(c => {
if (!posts.has(c.post_id)) {
posts.set(c.post_id, c.post_title || ('文章 #' + c.post_id));
select.innerHTML = '<option value="">全部文章</option>';
(data.data || []).forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.title;
opt.title = p.title; // 悬停显示完整标题
select.appendChild(opt);
});
// 恢复当前选中的文章;若已不存在则重置为全部
select.value = commentsCurrentPostId;
if (select.value !== commentsCurrentPostId) {
commentsCurrentPostId = '';
select.value = '';
}
});
select.innerHTML = '<option value="">全部文章</option>';
posts.forEach((title, id) => {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = title;
opt.title = title; // 悬停显示完整标题
select.appendChild(opt);
});
select.value = '';
} catch (err) {
console.error('加载文章筛选失败:', err);
}
}
// 按选中文章过滤评论列表
function filterCommentsByPost() {
loadComments(document.getElementById('comment-post-filter').value);
commentsCurrentPostId = document.getElementById('comment-post-filter').value;
loadComments(1);
}
// 按选中状态过滤评论列表
function filterCommentsByStatus() {
commentsCurrentStatus = document.getElementById('comment-status-filter').value;
loadComments(1);
}
// 加载基础设置
+14 -5
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - GoBlog</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/admin.css?v=4">
<link rel="stylesheet" href="/static/css/admin.css?v=5">
</head>
<body>
<div class="admin-header">
@@ -58,7 +58,7 @@
{{template "admin_modal_password" .}}
<script>window.__currentUser = {{.CurrentUser }}</script>
<script src="/static/js/admin.js?v=22"></script>
<script src="/static/js/admin.js?v=23"></script>
</body>
</html>
{{end}}
@@ -353,9 +353,17 @@
<div id="comments-page" class="page hidden">
<div class="page-header">
<h1>评论管理</h1>
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
<option value="">全部文章</option>
</select>
<div class="comment-filters">
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
<option value="">全部文章</option>
</select>
<select id="comment-status-filter" class="comment-status-filter" onchange="filterCommentsByStatus()">
<option value="">全部状态</option>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="spam">垃圾</option>
</select>
</div>
</div>
<table class="data-table">
<thead>
@@ -370,6 +378,7 @@
</thead>
<tbody id="comments-list"></tbody>
</table>
<div id="comments-pagination" class="pagination"></div>
</div>
{{end}}