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}}
- {{.AuthorName}} + {{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}} {{.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"}}