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

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

View File

@ -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",
}))
}

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
}

View File

@ -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"`
}
// 帖子状态常量

View File

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

View File

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

View File

@ -29,7 +29,7 @@
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h2>
<div class="post-card-meta">
<span class="post-author">{{.AuthorName}}</span>
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span>
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<p class="post-card-summary">{{printf "%.200s" .Body}}</p>

View File

@ -77,6 +77,9 @@
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div>
</form>
{{/* 隐藏的图片上传控件 */}}
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;">
</div>
{{template "layout/footer.html" .}}

View File

@ -8,10 +8,10 @@
<header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta">
<span class="post-author">{{.Post.AuthorName}}</span>
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}({{.Post.UserID}}){{else}}UID{{.Post.UserID}}{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{printf "%s" .Post.Status}}</span>
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
{{end}}
</div>
</header>
@ -36,6 +36,9 @@
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (ne $.Post.Status "locked")}}
<a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a>
{{end}}
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}}
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
{{end}}
{{if $.CanAccessAdmin}}
<a href="/admin/posts" class="btn btn-secondary">管理</a>
{{end}}
@ -46,5 +49,28 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
<script>
(function() {
var btn = document.getElementById('submitAuditBtn');
if (!btn) return;
btn.addEventListener('click', function() {
if (!confirm('确认提交审核?审核通过后将公开发布。')) return;
var csrf = document.querySelector('meta[name="csrf-token"]');
fetch('/api/posts/' + btn.dataset.id + '/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf.getAttribute('content') : ''
}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { window.location.reload(); }
else { alert(d.message || '提交失败'); }
})
.catch(function() { alert('请求失败'); });
});
})();
</script>
</body>
</html>

View File

@ -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 状态与默认 modetextarea 隐藏、WYSIWYG 可见),并渲染已有内容
switchMode('wysiwyg');
})();

View File

@ -42,8 +42,8 @@
<tr class="post-row">
<td>{{.ID}}</td>
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a></td>
<td>{{.AuthorName}}</td>
<td><span class="status-badge status-{{.Status}}">{{.Status}}</span></td>
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="actions-cell">
<div class="action-btns">