From 8a8d80118701e6c7a1d0d3bb2ae6696d896c3b0d Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Wed, 27 May 2026 19:22:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B8=96=E5=AD=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F5=E9=A1=B9=E7=BC=BA=E9=99=B7=20+=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E6=8F=92=E5=85=A5=E4=BD=8D=E7=BD=AE/language?= =?UTF-8?q?-class/=E5=88=86=E5=89=B2=E7=BA=BF=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 作者显示 用户名(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; 防
嵌套
- 修复 bluemonday.UGCPolicy() 剥离 code/pre 的 class 属性,显式 AllowAttrs("class")
- 修复分割线工具栏插入多余text占位符: hr/link/image以外不强制填占位文本
---
 cmd/server/main.go                            |   1 +
 .../controller/admin/admin_post_controller.go |  21 +--
 internal/controller/post_controller.go        |  87 ++++++++++
 internal/model/post.go                        |   4 +-
 internal/router/api.go                        |   1 +
 internal/service/post_service.go              |   8 +-
 templates/MetaLab-2026/html/posts/index.html  |   2 +-
 templates/MetaLab-2026/html/posts/new.html    |   3 +
 templates/MetaLab-2026/html/posts/show.html   |  30 +++-
 templates/MetaLab-2026/static/js/editor.js    | 148 ++++++++++++++++--
 templates/admin/html/posts/index.html         |   4 +-
 11 files changed, 281 insertions(+), 28 deletions(-)

diff --git a/cmd/server/main.go b/cmd/server/main.go
index 374c9a5..c571ebd 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -63,6 +63,7 @@ func main() {
 	r.Static("/admin/static", "./templates/admin/static")
 	r.Static("/shared/static", "./templates/shared/static")
 	r.Static("/uploads/avatars", "./storage/uploads/avatars")
+	r.Static("/uploads/posts", "./storage/uploads/posts")
 
 	// 站点设置管理器(DB 持久化 + 内存缓存)
 	siteSettings, err := config.NewSiteSettings(db)
diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go
index 22bf7e8..64ae5f9 100644
--- a/internal/controller/admin/admin_post_controller.go
+++ b/internal/controller/admin/admin_post_controller.go
@@ -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",
 	}))
 }
 
diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go
index 1fbd409..21e4f78 100644
--- a/internal/controller/post_controller.go
+++ b/internal/controller/post_controller.go
@@ -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
+}
diff --git a/internal/model/post.go b/internal/model/post.go
index 7c0de38..8960a72 100644
--- a/internal/model/post.go
+++ b/internal/model/post.go
@@ -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"`
 }
 
 // 帖子状态常量
diff --git a/internal/router/api.go b/internal/router/api.go
index 9fa5f90..08a75bb 100644
--- a/internal/router/api.go
+++ b/internal/router/api.go
@@ -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)
 	}
 }
diff --git a/internal/service/post_service.go b/internal/service/post_service.go
index fc0929f..13c6bb9 100644
--- a/internal/service/post_service.go
+++ b/internal/service/post_service.go
@@ -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 {
diff --git a/templates/MetaLab-2026/html/posts/index.html b/templates/MetaLab-2026/html/posts/index.html
index 6842b66..ffe1c5d 100644
--- a/templates/MetaLab-2026/html/posts/index.html
+++ b/templates/MetaLab-2026/html/posts/index.html
@@ -29,7 +29,7 @@
                 {{.Title}}
             
             
- + {{.CreatedAt.Format "2006-01-02 15:04"}}

{{printf "%.200s" .Body}}

diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index 554a0bf..cffc210 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -77,6 +77,9 @@ + + {{/* 隐藏的图片上传控件 */}} + {{template "layout/footer.html" .}} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 557184b..885200a 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -8,10 +8,10 @@

{{.Post.Title}}

- + {{.Post.CreatedAt.Format "2006-01-02 15:04"}} {{if ne .Post.Status "approved"}} - {{printf "%s" .Post.Status}} + {{index $.StatusNames .Post.Status}} {{end}}
@@ -36,6 +36,9 @@ {{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (ne $.Post.Status "locked")}} 编辑 {{end}} + {{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}} + + {{end}} {{if $.CanAccessAdmin}} 管理 {{end}} @@ -46,5 +49,28 @@ {{template "layout/footer.html" .}} + diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index a813e64..28befb1 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -15,6 +15,7 @@ var currentMode = 'wysiwyg'; var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea) var syncTimer = null; + var imageInput = document.getElementById('imageFileInput'); // ===================== HELPERS ===================== @@ -235,8 +236,8 @@ if (url) document.execCommand('createLink', false, url); break; case 'image': - var imgUrl = prompt('输入图片链接:', 'https://'); - if (imgUrl) document.execCommand('insertImage', false, imgUrl); + imagePickerTarget = 'wysiwyg'; + imageInput.click(); break; case 'quote': document.execCommand('formatBlock', false, 'blockquote'); @@ -245,7 +246,9 @@ wrapSelectionWysiwyg('`', '`'); break; case 'pre': - document.execCommand('formatBlock', false, 'pre'); + var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', ''); + lang = lang ? lang.trim() : 'text'; + insertCodeBlockWysiwyg(lang); break; case 'ul': document.execCommand('insertUnorderedList', false, null); @@ -285,17 +288,31 @@ case 'h2': wrap = { b: '## ', a: '' }; break; case 'h3': wrap = { b: '### ',a: '' }; break; case 'link': wrap = { b: '[', a: '](https://)' }; break; - case 'image': wrap = { b: '![', a: '](https://)' }; break; + case 'image': + // 源码/分屏模式下图片使用上传 + imagePickerTarget = currentMode; + imageInput.click(); + return; case 'quote': wrap = { b: '> ', a: '' }; break; case 'code': wrap = { b: '`', a: '`' }; break; - case 'pre': wrap = { b: '\n```\n', a: '\n```\n' }; break; + case 'pre': + var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', ''); + lang = lang ? lang.trim() : 'text'; + wrap = { b: '\n```' + lang + '\n', a: '\n```\n' }; + break; case 'ul': wrap = { b: '- ', a: '' }; break; case 'ol': wrap = { b: '1. ', a: '' }; break; case 'hr': wrap = { b: '\n---\n', a: '' }; break; } if (wrap.b !== undefined) { - var replacement = wrap.b + (text || (action === 'link' ? 'link' : action === 'image' ? 'image' : 'text')) + wrap.a; + var placeholder = ''; + if (!text) { + if (action === 'link') placeholder = 'link'; + else if (action === 'image') placeholder = 'image'; + // hr 和其他无内容操作不加占位文本 + } + var replacement = wrap.b + placeholder + wrap.a; bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end); bodyEl.focus(); var cursor = start + wrap.b.length + (text ? text.length : 0); @@ -306,6 +323,119 @@ } } + // WYSIWYG 中插入带语言标注的代码块 + function insertCodeBlockWysiwyg(lang) { + if (wysiwygEl.style.display === 'none') { + // WYSIWYG 不可见时直接向 textarea 插入 markdown 代码块 + var start = bodyEl.selectionStart; + var end = bodyEl.selectionEnd; + var text = bodyEl.value.substring(start, end); + var b = '\n```' + lang + '\n'; + var a = '\n```\n'; + bodyEl.value = bodyEl.value.substring(0, start) + b + (text || '') + a + bodyEl.value.substring(end); + bodyEl.focus(); + var cursor = start + b.length + (text ? text.length : 0); + bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : a.length)); + if (currentMode === 'split') updateSplitPreview(); + return; + } + wysiwygEl.focus(); + var sel = window.getSelection(); + + var pre = document.createElement('pre'); + var code = document.createElement('code'); + if (lang && lang !== 'text') code.className = 'language-' + lang; + code.textContent = '\n'; + pre.appendChild(code); + + if (!sel.rangeCount) { + // 无选区:追加到编辑器末尾 + wysiwygEl.appendChild(pre); + } else { + var range = sel.getRangeAt(0); + // 防止在已有 pre 内部嵌套插入:把插入点移到 pre 之后 + var ancestor = range.commonAncestorContainer; + while (ancestor && ancestor !== wysiwygEl) { + if (ancestor.nodeType === 1 && ancestor.tagName === 'PRE') { + range.setStartAfter(ancestor); + range.setEndAfter(ancestor); + break; + } + ancestor = ancestor.parentNode; + } + // 确保插入点落在 WYSIWYG 内部 + if (!wysiwygEl.contains(range.commonAncestorContainer)) { + wysiwygEl.appendChild(pre); + } else { + range.deleteContents(); + range.insertNode(pre); + } + } + + // 在代码块后加换行,方便继续输入 + var br = document.createElement('br'); + pre.parentNode.insertBefore(br, pre.nextSibling); + + // 光标移到 code 内 + var newRange = document.createRange(); + newRange.selectNodeContents(code); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + + wysiwygDirty = true; + } + + // ==================== IMAGE UPLOAD ==================== + var imagePickerTarget = null; + + if (imageInput) { + imageInput.addEventListener('change', function() { + var file = imageInput.files[0]; + if (!file) { imagePickerTarget = null; return; } + uploadImage(file, function(url) { + if (!url) { imagePickerTarget = null; return; } + if (imagePickerTarget === 'wysiwyg') { + wysiwygEl.focus(); + document.execCommand('insertImage', false, url); + wysiwygDirty = true; + } else { + var start = bodyEl.selectionStart; + var end = bodyEl.selectionEnd; + var alt = bodyEl.value.substring(start, end) || 'image'; + var md = '![' + alt + '](' + url + ')'; + bodyEl.value = bodyEl.value.substring(0, start) + md + bodyEl.value.substring(end); + bodyEl.focus(); + bodyEl.setSelectionRange(start + md.length, start + md.length); + if (currentMode === 'split') updateSplitPreview(); + } + imagePickerTarget = null; + }); + imageInput.value = ''; + }); + } + + function uploadImage(file, callback) { + var fd = new FormData(); + fd.append('file', file); + + fetch('/api/posts/upload-image', { + method: 'POST', + headers: { 'X-CSRF-Token': getCSRFToken() }, + body: fd + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success && d.data && d.data.url) { + callback(d.data.url); + } else { + alert(d.message || '图片上传失败'); + callback(null); + } + }) + .catch(function() { alert('图片上传请求失败'); callback(null); }); + } + // ==================== FORM SUBMISSION ==================== form.addEventListener('submit', function(e) { @@ -353,8 +483,6 @@ }); // ==================== INIT ==================== - if (bodyEl.value.trim()) { - // 如果已有内容(编辑帖子),渲染到 WYSIWYG 但不写入 textarea - syncMarkdownToWysiwyg(); - } + // 同步 DOM 状态与默认 mode(textarea 隐藏、WYSIWYG 可见),并渲染已有内容 + switchMode('wysiwyg'); })(); diff --git a/templates/admin/html/posts/index.html b/templates/admin/html/posts/index.html index c82e597..c8b30af 100644 --- a/templates/admin/html/posts/index.html +++ b/templates/admin/html/posts/index.html @@ -42,8 +42,8 @@ {{.ID}} {{.Title}} - {{.AuthorName}} - {{.Status}} + {{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}} + {{index $.StatusNames .Status}} {{.CreatedAt.Format "2006-01-02 15:04"}}