评论变为ajax加载 和提交

This commit is contained in:
2026-08-20 18:04:42 +08:00
parent d4d009f8b2
commit 6e88700f96
8 changed files with 345 additions and 217 deletions
+56 -57
View File
@@ -2,6 +2,7 @@ package backend
import (
"net/http"
"strconv"
"goblog/database"
"goblog/models"
@@ -10,43 +11,72 @@ import (
"gorm.io/gorm"
)
// 创建评论请求
type CreateCommentRequest struct {
PostID uint `json:"post_id" binding:"required"`
ParentID *uint `json:"parent_id"`
Author string `json:"author" binding:"required"`
Email string `json:"email" binding:"required,email"`
Website string `json:"website"`
Content string `json:"content" binding:"required"`
}
// 获取评论列表
func GetComments(c *gin.Context) {
postID := c.Query("post_id")
postSlug := c.Query("post_slug") // 前台使用 slug
status := c.Query("status")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "10")
// 解析分页参数
page := 1
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 <= 100 {
pageSize = ps
}
db := database.DB.Model(&models.Comment{}).Preload("User").
Preload("Post", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "title", "slug")
})
// 非管理员只能查看自己文章上的评论
// 非管理员只能查看自己文章上的评论(后台)或已审核的评论(前台)
if !isAdmin(c) {
// 前台访问:只显示已审核的评论
db = db.Where("status = ?", "approved")
if userID, exists := c.Get("userID"); exists {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
// 如果是后台接口调用,限制为自己的文章
if postID == "" && postSlug == "" {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
}
} else {
// 管理员可以查看所有状态的评论(通过 status 参数过滤)
if status != "" {
db = db.Where("status = ?", status)
}
}
// 按文章 ID 或 slug 过滤
if postID != "" {
db = db.Where("post_id = ?", postID)
}
if status != "" {
db = db.Where("status = ?", status)
} else if postSlug != "" {
var post models.Post
if err := database.DB.Where("slug = ?", postSlug).First(&post).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
db = db.Where("post_id = ?", post.ID)
}
// 统计总数
var total int64
db.Count(&total)
// 只查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
var comments []models.Comment
db.Order("created_at DESC").Find(&comments)
offset := (page - 1) * pageSize
db.Where("parent_id IS NULL").Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
// 预加载子评论
for i := range comments {
database.DB.Model(&comments[i]).Association("Children").Find(&comments[i].Children)
}
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
type commentWithPost struct {
@@ -59,48 +89,17 @@ func GetComments(c *gin.Context) {
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// 创建评论
func CreateComment(c *gin.Context) {
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
if totalPages < 1 {
totalPages = 1
}
// 检查文章是否存在
var post models.Post
if err := database.DB.First(&post, req.PostID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
comment := models.Comment{
PostID: req.PostID,
ParentID: req.ParentID,
Author: req.Author,
Email: req.Email,
Website: req.Website,
Content: req.Content,
IP: c.ClientIP(),
Status: "pending", // 默认待审核
}
// 如果用户已登录
if userID, exists := c.Get("userID"); exists {
uid := userID.(uint)
comment.UserID = &uid
comment.Status = "approved" // 登录用户评论自动通过
}
if err := database.DB.Create(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发表评论失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": comment})
c.JSON(http.StatusOK, gin.H{
"data": list,
"page": page,
"total_pages": totalPages,
"total": total,
})
}
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
+93 -70
View File
@@ -206,47 +206,6 @@ func PostView(c *gin.Context) {
pages := loadPublishedPages()
// 统计评论数
var commentCount int64
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
// 读取评论展示设置
showComments := true
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
showComments = v != "0"
}
commentPageSize := 10
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
commentPageSize = n
}
}
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
if commentPage < 1 {
commentPage = 1
}
var comments []models.Comment
commentTotalPages := 0
if showComments {
var topCount int64
database.DB.Model(&models.Comment{}).
Where("post_id = ? AND status = ? AND parent_id IS NULL", post.ID, "approved").
Count(&topCount)
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
if commentTotalPages > 0 && commentPage > commentTotalPages {
commentPage = commentTotalPages
}
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", post.ID, "approved").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "approved")
}).
Order("created_at ASC").
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
Find(&comments)
}
// 获取上一篇(发布时间更早的文章)
var prevPost models.Post
prevErr := database.DB.Where("status = ? AND published_at < ?", "published", post.PublishedAt).
@@ -261,19 +220,13 @@ func PostView(c *gin.Context) {
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
data := PostDetailData{
Title: post.Title,
SiteName: siteName,
SiteDesc: siteDesc,
Description: post.Summary,
Post: post,
Pages: pages,
Comments: comments,
CommentCount: commentCount,
Year: time.Now().Year(),
// 评论展示设置与分页
ShowComments: showComments,
CommentPage: commentPage,
CommentTotalPages: commentTotalPages,
Title: post.Title,
SiteName: siteName,
SiteDesc: siteDesc,
Description: post.Summary,
Post: post,
Pages: pages,
Year: time.Now().Year(),
// Sidebar 数据
Categories: categories,
Tags: tags,
@@ -353,36 +306,106 @@ func PageView(c *gin.Context) {
c.HTML(http.StatusOK, "page", data)
}
// 提交评论(表单提交)
func ListComments(c *gin.Context) {
// 统计评论数
var commentCount int64
postID := c.Param("postID")
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", postID, "approved").Count(&commentCount)
// 读取评论展示设置
showComments := true
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
showComments = v != "0"
}
commentPageSize := 10
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
commentPageSize = n
}
}
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
if commentPage < 1 {
commentPage = 1
}
var comments []models.Comment
commentTotalPages := 0
if showComments {
var topCount int64
database.DB.Model(&models.Comment{}).
Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
Count(&topCount)
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
if commentTotalPages > 0 && commentPage > commentTotalPages {
commentPage = commentTotalPages
}
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "approved")
}).
Order("created_at ASC").
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
Find(&comments)
}
c.JSON(http.StatusOK, gin.H{
"data": comments,
"page": commentPage,
"total_pages": commentTotalPages,
"total": commentCount,
})
}
// 创建评论请求
type CreateCommentRequest struct {
PostID uint `json:"post_id" binding:"required"`
ParentID *uint `json:"parent_id"`
Author string `json:"author" binding:"required"`
Email string `json:"email" binding:"required,email"`
Website string `json:"website"`
Content string `json:"content" binding:"required"`
}
// 提交评论
func SubmitComment(c *gin.Context) {
postID, _ := strconv.Atoi(c.PostForm("post_id"))
author := c.PostForm("author")
email := c.PostForm("email")
website := c.PostForm("website")
content := c.PostForm("content")
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查文章是否存在
// 检查文章是否存在
var post models.Post
if err := database.DB.First(&post, postID).Error; err != nil {
c.String(http.StatusBadRequest, "文章不存在")
if err := database.DB.First(&post, req.PostID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
comment := models.Comment{
PostID: uint(postID),
Author: author,
Email: email,
Website: website,
Content: content,
Status: "pending",
PostID: req.PostID,
ParentID: req.ParentID,
Author: req.Author,
Email: req.Email,
Website: req.Website,
Content: req.Content,
IP: c.ClientIP(),
Status: "pending", // 默认待审核
}
// 如果用户已登录
if userID, exists := c.Get("userID"); exists {
uid := userID.(uint)
comment.UserID = &uid
comment.Status = "approved" // 登录用户评论自动通过
}
if err := database.DB.Create(&comment).Error; err != nil {
c.String(http.StatusInternalServerError, "提交失败")
return
}
c.Redirect(http.StatusFound, "/post/"+post.Slug)
c.JSON(http.StatusCreated, gin.H{"message": "评论提交成功"})
}
// 搜索结果页
+2 -4
View File
@@ -42,7 +42,8 @@ func setupFrontendRoutes(r *gin.Engine) {
r.GET("/page/:slug", middleware.OptionalAuth(), frontend.PageView)
r.GET("/search", middleware.OptionalAuth(), frontend.SearchView)
r.POST("/comment", frontend.SubmitComment)
r.GET("/posts/:postID/comments", frontend.ListComments)
r.POST("/posts/:postID/comment", frontend.SubmitComment)
r.GET("/logout", backend.WebLogout)
}
@@ -60,9 +61,6 @@ func setupPublicAPIRoutes(api *gin.RouterGroup) {
// 登录
api.POST("/auth/login", backend.Login)
// 创建评论(可选认证,登录后关联用户)
api.POST("/comments", middleware.OptionalAuth(), backend.CreateComment)
// 仪表盘公开统计数据(可选认证,登录后返回更多数据)
api.GET("/dashboard/stats", middleware.OptionalAuth(), backend.GetDashboardStats)
+182
View File
@@ -0,0 +1,182 @@
/**
* 前台评论 AJAX 功能
*/
// 评论分页加载
async function loadComments(postSlug, page = 1) {
try {
const url = `/posts/${postSlug}/comments?page=${page}`;
const res = await fetch(url);
if (!res.ok) throw new Error('加载评论失败');
const data = await res.json();
renderComments(data.data || [], postSlug);
renderCommentPagination(data.page, data.total_pages, postSlug);
} catch (err) {
console.error('加载评论失败:', err);
document.querySelector('.comments-list').innerHTML = '<p class="no-comments">评论加载失败</p>';
}
}
// 渲染评论列表
function renderComments(comments, postSlug) {
const listEl = document.querySelector('.comments-list');
if (!listEl) return;
if (!comments || comments.length === 0) {
listEl.innerHTML = '<p class="no-comments">暂无评论,来发表第一条评论吧!</p>';
return;
}
listEl.innerHTML = comments.map(c => buildCommentHTML(c)).join('');
}
// 构建单条评论 HTML
function buildCommentHTML(comment) {
let html = `
<div class="comment-item" id="comment-${comment.id}">
<div class="comment-header">
<span class="comment-author">
${comment.website ? `<a href="${escapeHtml(comment.website)}" target="_blank">${escapeHtml(comment.author)}</a>` : escapeHtml(comment.author)}
</span>
<span class="comment-date">${formatDate(comment.created_at)}</span>
</div>
<div class="comment-content">${escapeHtml(comment.content)}</div>
`;
// 子评论
if (comment.children && comment.children.length > 0) {
html += '<div class="comment-children">';
comment.children.forEach(child => {
html += `
<div class="comment-item child" id="comment-${child.id}">
<div class="comment-header">
<span class="comment-author">${escapeHtml(child.author)}</span>
<span class="comment-date">${formatDate(child.created_at)}</span>
</div>
<div class="comment-content">${escapeHtml(child.content)}</div>
</div>
`;
});
html += '</div>';
}
html += '</div>';
return html;
}
// 渲染评论分页
function renderCommentPagination(currentPage, totalPages, postSlug) {
const paginationEl = document.querySelector('.comment-pagination');
if (!paginationEl) return;
if (totalPages <= 1) {
paginationEl.style.display = 'none';
return;
}
paginationEl.style.display = 'block';
let html = '';
if (currentPage > 1) {
html += `<a href="javascript:void(0)" onclick="loadComments('${postSlug}', ${currentPage - 1})" class="prev">← 上一页</a>`;
}
html += `<span class="page-info">${currentPage} / ${totalPages}</span>`;
if (currentPage < totalPages) {
html += `<a href="javascript:void(0)" onclick="loadComments('${postSlug}', ${currentPage + 1})" class="next">下一页 →</a>`;
}
paginationEl.innerHTML = html;
}
// 提交评论(AJAX)
async function submitComment(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
data.post_id = Number(data.post_id);
// 简单验证
if (!data.author || !data.email || !data.content) {
alert('请填写必填字段');
return;
}
// 邮箱格式验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
alert('请输入有效的邮箱地址');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const res = await fetch(`/posts/${data.post_id}/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.ok) {
alert('评论提交成功,等待审核');
form.reset();
// 重新加载评论(回到第一页)
const postSlug = window.location.pathname.split('/').pop();
loadComments(postSlug, 1);
} else {
alert(result.error || '提交失败');
}
} catch (err) {
console.error('提交评论失败:', err);
alert('网络错误,请稍后重试');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '发表评论';
}
}
// 工具函数:转义 HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 工具函数:格式化日期
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
// 绑定评论表单提交事件
const commentForm = document.querySelector('.comment-form');
if (commentForm) {
commentForm.addEventListener('submit', submitComment);
}
// 如果当前是文章详情页,自动加载评论
const postSlug = window.location.pathname.match(/\/post\/([^\/]+)/);
if (postSlug && postSlug[1]) {
// 从 URL 获取初始页码
const urlParams = new URLSearchParams(window.location.search);
const cpage = parseInt(urlParams.get('cpage')) || 1;
loadComments(postSlug[1], cpage);
}
});
+1
View File
@@ -8,6 +8,7 @@
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/layui.css">
<script src="/static/js/frontend.js"></script>
</head>
<body>
+5 -43
View File
@@ -60,9 +60,9 @@
{{if .ShowComments}}
<section class="comments-section" id="comments">
<h3>评论 ({{.CommentCount}})</h3>
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
<form class="comment-form" action="/comment" method="POST">
<form class="comment-form" action="/api/comments" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
@@ -80,50 +80,12 @@
</form>
<div class="comments-list">
{{range .Comments}}
<div class="comment-item" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">
{{if .Website}}
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
{{else}}
{{.Author}}
{{end}}
</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
{{if .Children}}
<div class="comment-children">
{{range .Children}}
<div class="comment-item child" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">{{.Author}}</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
{{end}}
<!-- 评论由 AJAX 动态加载 -->
</div>
{{if gt .CommentTotalPages 1}}
<div class="pagination comment-pagination">
{{if gt .CommentPage 1}}
<a href="/post/{{.Post.Slug}}?cpage={{sub .CommentPage 1}}#comments" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CommentPage}} / {{.CommentTotalPages}}</span>
{{if lt .CommentPage .CommentTotalPages}}
<a href="/post/{{.Post.Slug}}?cpage={{add .CommentPage 1}}#comments" class="next">下一页 →</a>
{{end}}
<div class="pagination comment-pagination" style="display:none">
<!-- 分页由 AJAX 动态渲染 -->
</div>
{{end}}
</section>
{{end}}
{{end}}
+1
View File
@@ -7,6 +7,7 @@
<meta name="description" content="{{.Description}}">
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/frontend.js"></script>
</head>
<body>
<header class="header">
+5 -43
View File
@@ -59,9 +59,9 @@
{{if .ShowComments}}
<section class="comments-section" id="comments">
<h3>评论 ({{.CommentCount}})</h3>
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
<form class="comment-form" action="/comment" method="POST">
<form class="comment-form" action="/api/comments" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
@@ -79,50 +79,12 @@
</form>
<div class="comments-list">
{{range .Comments}}
<div class="comment-item" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">
{{if .Website}}
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
{{else}}
{{.Author}}
{{end}}
</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
{{if .Children}}
<div class="comment-children">
{{range .Children}}
<div class="comment-item child" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">{{.Author}}</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
{{end}}
<!-- 评论由 AJAX 动态加载 -->
</div>
{{if gt .CommentTotalPages 1}}
<div class="pagination comment-pagination">
{{if gt .CommentPage 1}}
<a href="/post/{{.Post.Slug}}?cpage={{sub .CommentPage 1}}#comments" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CommentPage}} / {{.CommentTotalPages}}</span>
{{if lt .CommentPage .CommentTotalPages}}
<a href="/post/{{.Post.Slug}}?cpage={{add .CommentPage 1}}#comments" class="next">下一页 →</a>
{{end}}
<div class="pagination comment-pagination" style="display:none">
<!-- 分页由 AJAX 动态渲染 -->
</div>
{{end}}
</section>
{{end}}
{{end}}