完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。

同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 04:11:38 +08:00
parent 1d273066b0
commit b451703642
73 changed files with 1943 additions and 752 deletions

View File

@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"path/filepath"
"strconv"
@@ -53,6 +54,11 @@ func (h *Handlers) APIStats(c *gin.Context) {
})
}
// APIForumLimits 前台可见的论坛限制配置
func (h *Handlers) APIForumLimits(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.PublicLimits())
}
// APIAdminCreateBoard 管理员创建板块JSON
func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
var req struct {
@@ -125,13 +131,13 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
posts, total, err := h.Post.List(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if posts == nil {
posts = []model.Post{}
posts = []service.PostListItem{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
@@ -286,39 +292,73 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
forum := h.Settings.ForumSettings()
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"post_edit_window_hours": forum["post_edit_window_hours"],
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateLimits(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"limits": h.Settings.Limits(),
})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"content": content,
"word_count": service.CountFilterWords(content),
"path": h.Cfg.FilterWordsPath(),
})
}
// APIAdminUpdateFilterWords 更新敏感词配置
func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
var req struct {
PostEditWindowHours int `json:"post_edit_window_hours"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.SetPostEditWindowHours(req.PostEditWindowHours); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"post_edit_window_hours": req.PostEditWindowHours,
"message": "敏感词已保存并生效",
"word_count": service.CountFilterWords(req.Content),
})
}
// APIPosts 帖子列表(分页)
func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
keyword := c.Query("keyword")
@@ -331,6 +371,10 @@ func (h *Handlers) APIPosts(c *gin.Context) {
}
items, total, err := h.Post.ListItems(q)
if err != nil {
if isClientLimitError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -349,11 +393,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
// APIPostDetail 帖子详情
func (h *Handlers) APIPostDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if c.Query("skip_view") != "1" {
h.Post.RecordView(uint(id))
}
uid := h.currentUserID(c)
if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content)
@@ -381,7 +428,7 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
// APIPostComments 楼层列表
func (h *Handlers) APIPostComments(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -463,7 +510,7 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
func (h *Handlers) APIPostRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -486,7 +533,7 @@ func (h *Handlers) APIPostRevisions(c *gin.Context) {
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -508,3 +555,8 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c)
}
func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong)
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
@@ -136,7 +137,7 @@ func (h *Handlers) PostNewPage(c *gin.Context) {
func (h *Handlers) PostEditPage(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) {
c.Redirect(http.StatusFound, "/")
return
@@ -238,6 +239,11 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
return
}
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
return
}
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -246,6 +252,26 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url})
}
func (h *Handlers) APIUploadPostImage(c *gin.Context) {
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
uid := h.currentUserID(c)
url, err := service.SaveUploadedImage(
file,
h.Cfg.PostImageUploadDir(),
"/uploads/posts",
fmt.Sprintf("%d", uid),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url})
}
func (h *Handlers) APICreatePost(c *gin.Context) {
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
title := c.PostForm("title")