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:
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '' }; 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 = '';
|
||||
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');
|
||||
})();
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user