fix: 修复帖子系统5项缺陷 + 代码块插入位置/language-class/分割线占位
- 作者显示 用户名(UID): 修正Post.AuthorName GORM标签从 -:all 改为 -:migration;<-:false;column:author_name
- 审核流程: 帖子详情页新增"提交审核"按钮(draft/rejected时作者可见)
- 图片上传: 新增 POST /api/posts/upload-image 端点,限制5MB/jpg/png/gif/webp
- 代码块语言标注: 工具栏插入代码块时弹出语言输入框,生成 language-xxx class
- 状态中文显示: 管理后台/帖子详情页状态改为 PostStatusDisplayNames 映射
- 修复代码块插入位置错误: init时调用switchMode同步DOM; 无选区时appendChild; 防<pre>嵌套
- 修复 bluemonday.UGCPolicy() 剥离 code/pre 的 class 属性,显式 AllowAttrs("class")
- 修复分割线工具栏插入多余text占位符: hr/link/image以外不强制填占位文本
This commit is contained in:
@ -49,16 +49,17 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
||||
if nextPage > totalPages { nextPage = totalPages }
|
||||
|
||||
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "内容管理",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"Status": status,
|
||||
"ExtraCSS": "/admin/static/css/posts.css",
|
||||
"Title": "内容管理",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"Status": status,
|
||||
"StatusNames": model.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/admin/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
@ -107,6 +113,7 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||
"Post": post,
|
||||
"PostBodyHTML": template.HTML(post.BodyHTML),
|
||||
"UID": uid,
|
||||
"StatusNames": model.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
@ -319,3 +326,83 @@ func (ctrl *PostController) PreviewAPI(c *gin.Context) {
|
||||
|
||||
common.Ok(c, gin.H{"html": html})
|
||||
}
|
||||
|
||||
// allowedImageExt 允许的图片扩展名
|
||||
var allowedImageExt = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
}
|
||||
|
||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制 5MB
|
||||
maxSize := int64(5 << 20)
|
||||
if header.Size > maxSize {
|
||||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
// 存储路径
|
||||
storageDir := "storage/uploads/posts"
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一文件名:uid_timestamp.ext
|
||||
ts := time.Now().UnixMilli()
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
savePath := filepath.Join(storageDir, filename)
|
||||
|
||||
if err := saveUploadedFile(file, savePath); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
|
||||
url := "/uploads/posts/" + filename
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// saveUploadedFile 将 multipart.File 写入目标路径
|
||||
func saveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// 限制读取 5MB 防止内存放大
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -21,7 +21,9 @@ type Post struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
AuthorName string `gorm:"-:all" json:"author_name,omitempty"`
|
||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
||||
// 从 JOIN 查询的 users.username AS author_name 别名中读取
|
||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||
}
|
||||
|
||||
// 帖子状态常量
|
||||
|
||||
@ -57,5 +57,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
postsAPI.DELETE("/:id", d.postCtrl.Delete)
|
||||
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
||||
postsAPI.POST("/preview", d.postCtrl.PreviewAPI)
|
||||
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,12 @@ import (
|
||||
)
|
||||
|
||||
// ucgPolicy 对 Goldmark 输出的 HTML 做安全消毒
|
||||
// 移除 script / iframe / javscript: / data: 等危险内容
|
||||
var ucgPolicy = bluemonday.UGCPolicy()
|
||||
// UGC 默认策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx
|
||||
var ucgPolicy = func() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowAttrs("class").OnElements("code", "pre")
|
||||
return p
|
||||
}()
|
||||
|
||||
// postStore 帖子服务所需的最小仓储接口(ISP)
|
||||
type postStore interface {
|
||||
|
||||
Reference in New Issue
Block a user