diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b01e3b0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/goblog.db b/goblog.db index 5fbff75..c13266a 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/goblog.exe b/goblog.exe index 8837c0c..105fa6d 100644 Binary files a/goblog.exe and b/goblog.exe differ diff --git a/goblog.exe~ b/goblog.exe~ new file mode 100644 index 0000000..4ef13c8 Binary files /dev/null and b/goblog.exe~ differ diff --git a/handlers/post.go b/handlers/post.go index 0460ce4..3598532 100644 --- a/handlers/post.go +++ b/handlers/post.go @@ -225,6 +225,11 @@ func UpdatePost(c *gin.Context) { updates["published_at"] = &now } } + // 如果文章是已发布状态但没有发布时间,设置发布时间 + if post.Status == "published" && post.PublishedAt == nil { + now := time.Now() + updates["published_at"] = &now + } updates["is_top"] = req.IsTop // 处理标签 diff --git a/handlers/view.go b/handlers/view.go index 2dfce92..a6ac582 100644 --- a/handlers/view.go +++ b/handlers/view.go @@ -15,7 +15,6 @@ import ( "gorm.io/gorm" ) -// 模板函数 func templateFuncs() template.FuncMap { return template.FuncMap{ "add": func(a, b int) int { @@ -30,12 +29,6 @@ func templateFuncs() template.FuncMap { } } -// 初始化模板 - 返回 gin 可用的 HTMLRender -func InitTemplates(theme string) (*template.Template, error) { - tmpl, err := template.New("").Funcs(templateFuncs()).ParseGlob("templates/" + theme + "/*.html") - return tmpl, err -} - // 首页 type IndexData struct { Title string @@ -61,8 +54,7 @@ func IndexView(c *gin.Context) { tagID, _ := strconv.Atoi(c.Query("tag")) db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author"). - Where("status = ?", "published"). - Where("published_at IS NOT NULL") + Where("status = ?", "published") if categoryID > 0 { db = db.Where("category_id = ?", categoryID) @@ -98,7 +90,7 @@ func IndexView(c *gin.Context) { Year: time.Now().Year(), } - c.HTML(http.StatusOK, "base.html", data) + c.HTML(http.StatusOK, "index", data) } // 文章详情页 @@ -112,6 +104,8 @@ type PostDetailData struct { Comments []models.Comment CommentCount int64 Year int + PrevPost *models.Post // 上一篇 + NextPost *models.Post // 下一篇 } func PostView(c *gin.Context) { @@ -129,7 +123,7 @@ func PostView(c *gin.Context) { First(&post).Error if err != nil { - c.HTML(http.StatusNotFound, "base.html", gin.H{ + c.HTML(http.StatusNotFound, "index", gin.H{ "Title": "404 - 页面不存在", "SiteName": cfg.App.Name, "Year": time.Now().Year(), @@ -148,6 +142,16 @@ func PostView(c *gin.Context) { var commentCount int64 database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount) + // 获取上一篇(发布时间更早的文章) + var prevPost models.Post + prevErr := database.DB.Where("status = ? AND published_at < ?", "published", post.PublishedAt). + Order("published_at DESC").First(&prevPost).Error + + // 获取下一篇(发布时间更晚的文章) + var nextPost models.Post + nextErr := database.DB.Where("status = ? AND published_at > ?", "published", post.PublishedAt). + Order("published_at ASC").First(&nextPost).Error + data := PostDetailData{ Title: post.Title, SiteName: cfg.App.Name, @@ -160,7 +164,15 @@ func PostView(c *gin.Context) { Year: time.Now().Year(), } - c.HTML(http.StatusOK, "base.html", data) + // 只有当查询成功时才赋值 + if prevErr == nil { + data.PrevPost = &prevPost + } + if nextErr == nil { + data.NextPost = &nextPost + } + + c.HTML(http.StatusOK, "post", data) } // 独立页面 @@ -180,9 +192,8 @@ func PageView(c *gin.Context) { var page models.Page err := database.DB.Where("slug = ? AND status = ?", slug, "published").First(&page).Error - if err != nil { - c.HTML(http.StatusNotFound, "base.html", gin.H{ + c.HTML(http.StatusNotFound, "index", gin.H{ "Title": "404 - 页面不存在", "SiteName": cfg.App.Name, "Year": time.Now().Year(), @@ -204,7 +215,7 @@ func PageView(c *gin.Context) { Year: time.Now().Year(), } - c.HTML(http.StatusOK, "base.html", data) + c.HTML(http.StatusOK, "page", data) } // 提交评论(表单提交) @@ -228,14 +239,13 @@ func SubmitComment(c *gin.Context) { Email: email, Website: website, Content: content, - IP: c.ClientIP(), Status: "pending", } if err := database.DB.Create(&comment).Error; err != nil { - c.String(http.StatusInternalServerError, "发表评论失败") + c.String(http.StatusInternalServerError, "提交失败") return } - c.Redirect(http.StatusFound, "/post/"+post.Slug+"#comment-"+strconv.Itoa(int(comment.ID))) + c.Redirect(http.StatusFound, "/post/"+post.Slug) } diff --git a/main.go b/main.go index b5dd2a6..d1492c2 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "goblog/database" "goblog/handlers" "goblog/middleware" + "goblog/utils" "github.com/gin-gonic/gin" ) @@ -32,12 +33,9 @@ func main() { // 创建路由 r := gin.Default() - // 初始化模板 - tmpl, err := handlers.InitTemplates(cfg.App.Theme) - if err != nil { - log.Fatal("模板初始化失败:", err) - } - r.SetHTMLTemplate(tmpl) + // 使用自定义的 TemplateRenderer + htmlRenderer := utils.LoadTemplates(cfg.App.Theme) + r.HTMLRender = htmlRenderer // 静态文件 r.Static("/static", "./static") diff --git a/static/admin/app.js b/static/admin/app.js index d6eeb11..2760d2f 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -175,6 +175,7 @@ async function loadPosts() { ${post.published_at ? new Date(post.published_at).toLocaleDateString() : '-'} + ${post.status !== 'published' ? `` : ``} @@ -272,6 +273,29 @@ async function handlePostSubmit(e) { } } +// 更改文章状态 +async function changePostStatus(id, status) { + const actionText = status === 'published' ? '发布' : '下架'; + if (!confirm(`确定要${actionText}这篇文章吗?`)) return; + + try { + const res = await fetch(`${API_BASE}/admin/posts/${id}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ status }) + }); + + if (res.ok) { + loadPosts(); + } else { + const err = await res.json(); + alert(err.error || '操作失败'); + } + } catch (err) { + alert('网络错误'); + } +} + // 删除文章 async function deletePost(id) { if (!confirm('确定要删除这篇文章吗?')) return; @@ -437,14 +461,17 @@ async function handleModalSubmit(e) { const id = document.getElementById('modal-id').value; const data = { - name: document.getElementById('modal-name').value, slug: document.getElementById('modal-slug').value }; + // 页面使用 title,分类和标签使用 name if (type === 'page') { + data.title = document.getElementById('modal-name').value; data.content = document.getElementById('modal-content').value; data.order = parseInt(document.getElementById('modal-order').value) || 0; data.status = 'published'; + } else { + data.name = document.getElementById('modal-name').value; } try { @@ -499,6 +526,35 @@ function editTag(id, name, slug) { document.getElementById('modal').classList.remove('hidden'); } +// 编辑页面 +async function editPage(id) { + try { + const res = await fetch(`${API_BASE}/pages/${id}`, { headers: getHeaders() }); + const data = await res.json(); + const page = data.data; + + document.getElementById('modal-type').value = 'page'; + document.getElementById('modal-id').value = id; + document.getElementById('modal-title').textContent = '编辑页面'; + document.getElementById('modal-name').value = page.title; + document.getElementById('modal-slug').value = page.slug; + + // 添加内容区域 + document.querySelector('.modal-extra').innerHTML = ` + + + + + `; + document.getElementById('modal-content').value = page.content; + document.getElementById('modal-order').value = page.order; + + document.getElementById('modal').classList.remove('hidden'); + } catch (err) { + alert('加载页面失败'); + } +} + // 删除分类 async function deleteCategory(id) { if (!confirm('确定要删除这个分类吗?')) return; diff --git a/static/admin/index.html b/static/admin/index.html index 703b08d..760b094 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -247,6 +247,6 @@ - + diff --git a/static/css/style.css b/static/css/style.css index d78d958..3cece17 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -354,6 +354,65 @@ a:hover { border-top: 1px solid #eee; } +/* 文章导航 - 上一篇/下一篇 */ +.post-navigation { + display: flex; + justify-content: space-between; + gap: 20px; + margin: 30px 0; + padding: 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.nav-prev, +.nav-next { + flex: 1; +} + +.nav-next { + text-align: right; +} + +.nav-link { + display: flex; + flex-direction: column; + padding: 10px; + border-radius: 6px; + transition: background-color 0.2s; +} + +.nav-link:hover { + background-color: #f5f5f5; +} + +.nav-link.disabled { + color: #999; + cursor: not-allowed; +} + +.nav-link.disabled:hover { + background-color: transparent; +} + +.nav-label { + font-size: 13px; + color: #999; + margin-bottom: 5px; +} + +.nav-title { + font-size: 15px; + font-weight: 500; + color: #333; + line-height: 1.4; +} + +.nav-link:hover .nav-title { + color: #1890ff; +} + /* 响应式 */ @media (max-width: 768px) { .site-title { @@ -373,4 +432,13 @@ a:hover { display: block; margin: 5px 0; } + + .post-navigation { + flex-direction: column; + gap: 15px; + } + + .nav-next { + text-align: left; + } } diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000..c0fbcac Binary files /dev/null and b/static/images/favicon.ico differ diff --git a/templates/default/base.html b/templates/default/base.html index c74b1d8..f6872ca 100644 --- a/templates/default/base.html +++ b/templates/default/base.html @@ -5,6 +5,7 @@ {{.Title}} - {{.SiteName}} + diff --git a/templates/default/index.html b/templates/default/index.html index f6f4367..64c0a17 100644 --- a/templates/default/index.html +++ b/templates/default/index.html @@ -15,9 +15,9 @@ {{if .IsTop}}置顶{{end}}
- {{.PublishedAt.Format "2006-01-02"}} + {{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}} - {{.Category.Name}} + {{if .Category}}{{.Category.Name}}{{else}}未分类{{end}} {{if .Tags}} diff --git a/templates/default/post.html b/templates/default/post.html index 73e3d62..2f2dcd3 100644 --- a/templates/default/post.html +++ b/templates/default/post.html @@ -30,6 +30,35 @@
+ +

评论 ({{.CommentCount}})

diff --git a/utils/templateRenderer.go b/utils/templateRenderer.go new file mode 100644 index 0000000..7cd3692 --- /dev/null +++ b/utils/templateRenderer.go @@ -0,0 +1,78 @@ +package utils + +import ( + "html/template" + "net/http" + + "github.com/gin-gonic/gin/render" +) + +// 模板函数 +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "add": func(a, b int) int { + return a + b + }, + "sub": func(a, b int) int { + return a - b + }, + "html": func(s string) template.HTML { + return template.HTML(s) + }, + } +} + +// 自定义 HTMLRender,存储一个 map:页面名 -> *template.Template +type TemplateRenderer struct { + templates map[string]*template.Template +} + +func (r *TemplateRenderer) Lookup(name string) *template.Template { + return r.templates[name] +} + +func (r *TemplateRenderer) Instance(name string, data any) render.Render { + return &htmlRender{ + tmpl: r.Lookup(name), + data: data, + name: name, + } +} + +type htmlRender struct { + tmpl *template.Template + data any + name string +} + +func (r *htmlRender) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + return r.tmpl.ExecuteTemplate(w, "base.html", r.data) +} + +func (r *htmlRender) WriteContentType(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") +} + +// 初始化多模板 +func LoadTemplates(theme string) *TemplateRenderer { + r := &TemplateRenderer{ + templates: make(map[string]*template.Template), + } + + templateDir := "templates/" + theme + + // 为每个页面创建独立的模板组合 + pages := []string{"index", "post", "page"} + for _, pageName := range pages { + // 组合 base.html 和具体页面模板 + allFiles := []string{ + templateDir + "/base.html", + templateDir + "/" + pageName + ".html", + } + tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)) + r.templates[pageName] = tmpl + } + + return r +}