diff --git a/go.mod b/go.mod index bc1d84f..2b57522 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/spf13/viper v1.21.0 + github.com/yuin/goldmark v1.8.5 golang.org/x/crypto v0.48.0 gorm.io/gorm v1.31.1 ) diff --git a/go.sum b/go.sum index 954fa6f..3034aa4 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/goblog.db b/goblog.db index 6a6d34d..e68b331 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/handlers/post.go b/handlers/post.go index 4ec74a7..e92cb72 100644 --- a/handlers/post.go +++ b/handlers/post.go @@ -8,6 +8,7 @@ import ( "goblog/database" "goblog/models" + "goblog/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -47,6 +48,21 @@ type UpdatePostRequest struct { IsTop bool `json:"is_top"` } +// Markdown 预览请求 +type MarkdownPreviewRequest struct { + Content string `json:"content"` +} + +// PreviewMarkdown 将 Markdown 渲染为 HTML(后台编辑器预览用) +func PreviewMarkdown(c *gin.Context) { + var req MarkdownPreviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"html": string(utils.RenderMarkdown(req.Content))}) +} + // 生成 slug func generateSlug(title string) string { slug := strings.ToLower(title) diff --git a/handlers/view.go b/handlers/view.go index 9f8f7db..6e8c1c1 100644 --- a/handlers/view.go +++ b/handlers/view.go @@ -11,6 +11,7 @@ import ( "goblog/config" "goblog/database" "goblog/models" + "goblog/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -37,6 +38,7 @@ func templateFuncs() template.FuncMap { "html": func(s string) template.HTML { return template.HTML(s) }, + "markdown": utils.RenderMarkdown, } } diff --git a/routers/router.go b/routers/router.go index 925dd4d..cc2fdb4 100644 --- a/routers/router.go +++ b/routers/router.go @@ -76,6 +76,9 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) { admin.PUT("/posts/:id", handlers.UpdatePost) admin.DELETE("/posts/:id", handlers.DeletePost) + // Markdown 预览(文章编辑器用) + admin.POST("/markdown/preview", handlers.PreviewMarkdown) + // 分类管理 admin.POST("/categories", handlers.CreateCategory) admin.PUT("/categories/:id", handlers.UpdateCategory) diff --git a/static/admin/app.js b/static/admin/app.js index b9e8c3c..335c82f 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -212,6 +212,7 @@ async function showPostForm(isEdit = false) { document.getElementById('post-form').reset(); document.getElementById('post-id').value = ''; setCover(''); + mdShowEdit(); // 加载分类选项 const res = await fetch(`${API_BASE}/categories`); @@ -247,6 +248,7 @@ async function editPost(id) { ).join('') || ''; document.getElementById('post-form-title').textContent = '编辑文章'; + mdShowEdit(); showPage('post-form'); } catch (err) { alert('加载文章失败'); @@ -295,6 +297,76 @@ async function handlePostSubmit(e) { } } +// ============ Markdown 编辑器 ============ + +// 在选区两侧插入包裹符号(加粗、斜体、链接等) +function mdWrap(prefix, suffix, placeholder) { + mdShowEdit(); + const ta = document.getElementById('post-content'); + const start = ta.selectionStart, end = ta.selectionEnd; + const selected = ta.value.substring(start, end) || placeholder; + ta.value = ta.value.substring(0, start) + prefix + selected + suffix + ta.value.substring(end); + ta.focus(); + ta.selectionStart = start + prefix.length; + ta.selectionEnd = start + prefix.length + selected.length; +} + +// 在行首插入前缀(标题、引用、列表等) +function mdInsertLine(prefix, placeholder) { + mdShowEdit(); + const ta = document.getElementById('post-content'); + const start = ta.selectionStart, end = ta.selectionEnd; + const selected = ta.value.substring(start, end) || placeholder; + // 不在行首时先换行,保证前缀生效 + const needNewline = start > 0 && ta.value[start - 1] !== '\n'; + const insert = (needNewline ? '\n' : '') + prefix + selected; + ta.value = ta.value.substring(0, start) + insert + ta.value.substring(end); + ta.focus(); + ta.selectionStart = start + insert.length - selected.length; + ta.selectionEnd = start + insert.length; +} + +// 插入表格模板 +function mdInsertTable() { + mdShowEdit(); + const ta = document.getElementById('post-content'); + const table = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 内容 | 内容 | 内容 |\n'; + const start = ta.selectionStart; + ta.value = ta.value.substring(0, start) + table + ta.value.substring(ta.selectionEnd); + ta.focus(); + ta.selectionStart = ta.selectionEnd = start + table.length; +} + +// 切换到编辑模式 +function mdShowEdit() { + document.getElementById('post-content').classList.remove('hidden'); + document.getElementById('md-preview').classList.add('hidden'); + document.getElementById('md-tab-edit').classList.add('active'); + document.getElementById('md-tab-preview').classList.remove('active'); +} + +// 切换到预览模式(调用后端渲染,与前台显示效果一致) +async function mdShowPreview() { + const ta = document.getElementById('post-content'); + const preview = document.getElementById('md-preview'); + document.getElementById('md-tab-edit').classList.remove('active'); + document.getElementById('md-tab-preview').classList.add('active'); + ta.classList.add('hidden'); + preview.classList.remove('hidden'); + preview.innerHTML = '
渲染中...
'; + try { + const res = await fetch(`${API_BASE}/admin/markdown/preview`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ content: ta.value }) + }); + const data = await res.json(); + preview.innerHTML = data.html || '暂无内容
'; + } catch (err) { + preview.innerHTML = '预览失败,请检查网络
'; + } +} + // 更改文章状态 async function changePostStatus(id, status) { const actionText = status === 'published' ? '发布' : '下架'; diff --git a/static/admin/index.html b/static/admin/index.html index 1b2cad9..563b0ff 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -141,8 +141,31 @@