From 21be62fa7c969d4d9759d0fb827fd22ce1c996ba Mon Sep 17 00:00:00 2001 From: Zhang Chao Date: Tue, 28 Jul 2026 15:51:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0markdown=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 2 + goblog.db | Bin 143360 -> 143360 bytes handlers/post.go | 16 ++++ handlers/view.go | 2 + routers/router.go | 3 + static/admin/app.js | 72 +++++++++++++++ static/admin/index.html | 29 +++++- static/admin/style.css | 170 ++++++++++++++++++++++++++++++++++++ templates/default/post.html | 2 +- templates/minimal/post.html | 2 +- templates/mytheme/post.html | 2 +- utils/markdown.go | 31 +++++++ utils/markdown_test.go | 26 ++++++ utils/templateRenderer.go | 1 + 15 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 utils/markdown.go create mode 100644 utils/markdown_test.go 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 6a6d34d6717451fb630d3fb0e68170ddc361b1c8..e68b331c57bbf4a34585f13586f95a283a354772 100644 GIT binary patch delta 232 zcmZp8z|ru4V}dl}--$BLjDH&wwk9wx&F5vh#lRoV?a!me+sAZkv!K8UCcz0sY@!Ul z{tWi^^ISuDIXM}p#}_ji^P5_k8dw>c>seTsnOc~bPhU{XIIUh;S>f4?^-m`(1Cmd6 zG(20d{rRkxrxO=FZfJV4qxbpzr7s&NKb_je^V--kZ z(Y&W!3!dz1d$yqc<)Rty_w)b_d$MQN^Eurh#*Xf1Q#+o`n*-9u1rpoU1{Us`eyp4^ UVEg0}#>s4qEZfzp8Et$0r`PowO|3IZw&)Z01sCSPYfmvjss4!5fD5B5s!Wb s6#z|506#yRVO0$Y2?>|$ase}!J9_~Xmosw#khe8-0q_R5CVK(ew3+uC8vp 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, } }