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:
2026-05-27 19:22:47 +08:00
parent 1830f10e24
commit 8a8d801187
11 changed files with 281 additions and 28 deletions

View File

@ -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
}