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 @@
- - + +
+
+
+ + + + + + + + + + + + +
+
+ + +
+
+ + +
- + diff --git a/static/admin/style.css b/static/admin/style.css index f03c71f..dbd55e7 100644 --- a/static/admin/style.css +++ b/static/admin/style.css @@ -770,3 +770,173 @@ body { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } } + +/* Markdown 编辑器 */ +.md-editor { + border: 1px solid #d9d9d9; + border-radius: 4px; + overflow: hidden; +} + +.md-editor:focus-within { + border-color: #1890ff; +} + +.md-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 6px 8px; + background: #fafafa; + border-bottom: 1px solid #e8e8e8; +} + +.md-tools { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.md-tools button { + min-width: 30px; + height: 28px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #555; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.md-tools button:hover { + background: #e6f7ff; + border-color: #91d5ff; + color: #1890ff; +} + +.md-tabs { + display: flex; + gap: 4px; +} + +.md-tabs button { + height: 28px; + padding: 0 14px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fff; + color: #555; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.md-tabs button.active { + background: #1890ff; + border-color: #1890ff; + color: #fff; +} + +.md-editor textarea { + display: block; + border: none !important; + border-radius: 0 !important; + resize: vertical; + font-family: Consolas, Monaco, 'Courier New', monospace; + line-height: 1.6; +} + +.md-editor textarea:focus { + outline: none; +} + +.md-preview { + min-height: 320px; + max-height: 600px; + overflow-y: auto; + padding: 16px; + background: #fff; + font-size: 14px; + line-height: 1.8; + color: #333; +} + +.md-preview-tip { + color: #999; + text-align: center; + padding: 40px 0; +} + +/* 预览区内容排版 */ +.md-preview h1, +.md-preview h2, +.md-preview h3, +.md-preview h4 { + margin: 16px 0 8px; + font-weight: 600; +} + +.md-preview p { + margin: 8px 0; +} + +.md-preview blockquote { + margin: 12px 0; + padding: 8px 16px; + border-left: 4px solid #1890ff; + background: #f5f9ff; + color: #666; +} + +.md-preview pre { + margin: 12px 0; + padding: 12px 16px; + background: #f6f8fa; + border-radius: 6px; + overflow-x: auto; +} + +.md-preview code { + font-family: Consolas, Monaco, 'Courier New', monospace; + font-size: 13px; + background: #f0f0f0; + padding: 2px 5px; + border-radius: 3px; +} + +.md-preview pre code { + background: none; + padding: 0; +} + +.md-preview table { + border-collapse: collapse; + margin: 12px 0; + width: 100%; +} + +.md-preview th, +.md-preview td { + border: 1px solid #d9d9d9; + padding: 8px 12px; + text-align: left; +} + +.md-preview th { + background: #fafafa; +} + +.md-preview img { + max-width: 100%; +} + +.md-preview ul, +.md-preview ol { + margin: 8px 0; + padding-left: 24px; +} + diff --git a/templates/default/post.html b/templates/default/post.html index 2f2dcd3..ca957cf 100644 --- a/templates/default/post.html +++ b/templates/default/post.html @@ -26,7 +26,7 @@ {{end}}
- {{.Post.Content | html}} + {{.Post.Content | markdown}}
diff --git a/templates/minimal/post.html b/templates/minimal/post.html index 3871cdd..e09c821 100644 --- a/templates/minimal/post.html +++ b/templates/minimal/post.html @@ -20,7 +20,7 @@
- {{.Post.Content | html}} + {{.Post.Content | markdown}}
diff --git a/templates/mytheme/post.html b/templates/mytheme/post.html index 769daa4..0f7bf3b 100644 --- a/templates/mytheme/post.html +++ b/templates/mytheme/post.html @@ -26,7 +26,7 @@ {{end}}
- {{.Post.Content | html}} + {{.Post.Content | markdown}}
diff --git a/utils/markdown.go b/utils/markdown.go new file mode 100644 index 0000000..8a5cd50 --- /dev/null +++ b/utils/markdown.go @@ -0,0 +1,31 @@ +package utils + +import ( + "bytes" + "html/template" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" +) + +// markdownEngine 全局 Markdown 渲染引擎 +// 启用 GFM 扩展(表格、删除线、任务列表、自动链接等) +// 启用 WithUnsafe 允许原始 HTML 透传,兼容旧文章中的 HTML 内容 +var markdownEngine = goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + goldmarkhtml.WithHardWraps(), + ), +) + +// RenderMarkdown 将 Markdown 文本渲染为 HTML +func RenderMarkdown(source string) template.HTML { + var buf bytes.Buffer + if err := markdownEngine.Convert([]byte(source), &buf); err != nil { + // 渲染失败时原样返回,避免页面空白 + return template.HTML(source) + } + return template.HTML(buf.String()) +} diff --git a/utils/markdown_test.go b/utils/markdown_test.go new file mode 100644 index 0000000..b8aa621 --- /dev/null +++ b/utils/markdown_test.go @@ -0,0 +1,26 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestRenderMarkdown(t *testing.T) { + // Markdown 语法渲染 + out := string(RenderMarkdown("# 标题\n\n**加粗** 和 `代码`")) + if !strings.Contains(out, "

标题

") || !strings.Contains(out, "加粗") || !strings.Contains(out, "代码") { + t.Errorf("Markdown 渲染结果不符合预期: %s", out) + } + + // GFM 表格 + out = string(RenderMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |")) + if !strings.Contains(out, "") { + t.Errorf("GFM 表格未渲染: %s", out) + } + + // 兼容旧文章的 HTML 内容 + out = string(RenderMarkdown("

旧的HTML内容

")) + if !strings.Contains(out, "
") { + t.Errorf("HTML 内容未透传: %s", out) + } +} diff --git a/utils/templateRenderer.go b/utils/templateRenderer.go index d497c08..30fdbb6 100644 --- a/utils/templateRenderer.go +++ b/utils/templateRenderer.go @@ -22,6 +22,7 @@ func templateFuncs() template.FuncMap { "html": func(s string) template.HTML { return template.HTML(s) }, + "markdown": RenderMarkdown, } }