增加markdown支持。

This commit is contained in:
2026-07-28 15:51:54 +08:00
parent 98161a2fe8
commit 21be62fa7c
15 changed files with 353 additions and 6 deletions
+1
View File
@@ -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
)
+2
View File
@@ -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=
BIN
View File
Binary file not shown.
+16
View File
@@ -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)
+2
View File
@@ -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,
}
}
+3
View File
@@ -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)
+72
View File
@@ -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 = '<p class="md-preview-tip">渲染中...</p>';
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 || '<p class="md-preview-tip">暂无内容</p>';
} catch (err) {
preview.innerHTML = '<p class="md-preview-tip">预览失败,请检查网络</p>';
}
}
// 更改文章状态
async function changePostStatus(id, status) {
const actionText = status === 'published' ? '发布' : '下架';
+26 -3
View File
@@ -141,8 +141,31 @@
<textarea id="post-summary" rows="3"></textarea>
</div>
<div class="form-group">
<label>内容 (支持 HTML)</label>
<textarea id="post-content" rows="15" required></textarea>
<label>内容 (支持 Markdown 语法)</label>
<div class="md-editor">
<div class="md-toolbar">
<div class="md-tools" id="md-tools">
<button type="button" title="加粗" onclick="mdWrap('**','**','粗体文字')"><b>B</b></button>
<button type="button" title="斜体" onclick="mdWrap('*','*','斜体文字')"><i>I</i></button>
<button type="button" title="删除线" onclick="mdWrap('~~','~~','删除线文字')"><s>S</s></button>
<button type="button" title="标题" onclick="mdInsertLine('## ','标题')">H</button>
<button type="button" title="链接" onclick="mdWrap('[','](https://)','链接文字')">🔗</button>
<button type="button" title="图片" onclick="mdWrap('![','](https://)','图片描述')">🖼</button>
<button type="button" title="行内代码" onclick="mdWrap('`','`','代码')">&lt;/&gt;</button>
<button type="button" title="代码块" onclick="mdWrap('\n```\n','\n```\n','代码块')">{ }</button>
<button type="button" title="引用" onclick="mdInsertLine('> ','引用内容')">”</button>
<button type="button" title="无序列表" onclick="mdInsertLine('- ','列表项')">•</button>
<button type="button" title="有序列表" onclick="mdInsertLine('1. ','列表项')">1.</button>
<button type="button" title="表格" onclick="mdInsertTable()">⊞</button>
</div>
<div class="md-tabs">
<button type="button" id="md-tab-edit" class="active" onclick="mdShowEdit()">编辑</button>
<button type="button" id="md-tab-preview" onclick="mdShowPreview()">预览</button>
</div>
</div>
<textarea id="post-content" rows="15" required placeholder="支持 Markdown 语法,也兼容 HTML 内容"></textarea>
<div id="md-preview" class="md-preview hidden"></div>
</div>
</div>
<div class="form-group">
<label>
@@ -348,6 +371,6 @@
</div>
</div>
<script src="/static/admin/app.js?v=10"></script>
<script src="/static/admin/app.js?v=11"></script>
</body>
</html>
+170
View File
@@ -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;
}
+1 -1
View File
@@ -26,7 +26,7 @@
{{end}}
<div class="post-body">
{{.Post.Content | html}}
{{.Post.Content | markdown}}
</div>
</article>
+1 -1
View File
@@ -20,7 +20,7 @@
</header>
<div class="post-body">
{{.Post.Content | html}}
{{.Post.Content | markdown}}
</div>
</article>
+1 -1
View File
@@ -26,7 +26,7 @@
{{end}}
<div class="post-body">
{{.Post.Content | html}}
{{.Post.Content | markdown}}
</div>
</article>
+31
View File
@@ -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())
}
+26
View File
@@ -0,0 +1,26 @@
package utils
import (
"strings"
"testing"
)
func TestRenderMarkdown(t *testing.T) {
// Markdown 语法渲染
out := string(RenderMarkdown("# 标题\n\n**加粗** 和 `代码`"))
if !strings.Contains(out, "<h1>标题</h1>") || !strings.Contains(out, "<strong>加粗</strong>") || !strings.Contains(out, "<code>代码</code>") {
t.Errorf("Markdown 渲染结果不符合预期: %s", out)
}
// GFM 表格
out = string(RenderMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |"))
if !strings.Contains(out, "<table>") {
t.Errorf("GFM 表格未渲染: %s", out)
}
// 兼容旧文章的 HTML 内容
out = string(RenderMarkdown("<div class=\"old\"><p>旧的HTML内容</p></div>"))
if !strings.Contains(out, "<div class=\"old\">") {
t.Errorf("HTML 内容未透传: %s", out)
}
}
+1
View File
@@ -22,6 +22,7 @@ func templateFuncs() template.FuncMap {
"html": func(s string) template.HTML {
return template.HTML(s)
},
"markdown": RenderMarkdown,
}
}