From a84982e1f5e4f58bcd34d9a5bc02b58feea6e3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Sun, 21 Jun 2026 19:29:42 +0800 Subject: [PATCH] u o --- handlers/article.go | 135 ++++++++++++++++++++++++++++ i18n/i18n.go | 50 ++++++++++- main.go | 2 + models/article.go | 38 ++++++++ models/db.go | 4 +- models/user.go | 1 + templates/admin/article_create.html | 94 +++++++++++++++++++ templates/layouts/base.html | 2 + 8 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 handlers/article.go create mode 100644 models/article.go create mode 100644 templates/admin/article_create.html diff --git a/handlers/article.go b/handlers/article.go new file mode 100644 index 0000000..28b2d22 --- /dev/null +++ b/handlers/article.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "net/http" + "regexp" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "go_blog/models" + + "gorm.io/gorm" +) + +// slugRe matches characters that are not URL-safe. +var slugRe = regexp.MustCompile(`[^a-z0-9\-]+`) + +// generateSlug converts a title into a URL-friendly slug. +func generateSlug(title string) string { + s := strings.ToLower(title) + s = strings.ReplaceAll(s, " ", "-") + s = slugRe.ReplaceAllString(s, "") + s = strings.Trim(s, "-") + // Collapse multiple dashes. + s = regexp.MustCompile(`-{2,}`).ReplaceAllString(s, "-") + return s +} + +// ArticleCreatePage renders the article creation form. +func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + data := DefaultData(c) + data["Title"] = tr["article_create_title"] + c.HTML(http.StatusOK, "article_create", data) + } +} + +// ArticleCreate handles the POST request to create a new article. +func ArticleCreate(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + + title := strings.TrimSpace(c.PostForm("title")) + content := strings.TrimSpace(c.PostForm("content")) + slug := strings.TrimSpace(c.PostForm("slug")) + summary := strings.TrimSpace(c.PostForm("summary")) + cover := strings.TrimSpace(c.PostForm("cover")) + statusStr := c.PostForm("status") + isTop := c.PostForm("is_top") == "1" + + // Validate required fields. + if title == "" { + data := DefaultData(c) + data["Title"] = tr["article_create_title"] + data["Error"] = tr["article_title_required"] + data["FormTitle"] = title + data["FormSlug"] = slug + data["FormSummary"] = summary + data["FormContent"] = content + data["FormCover"] = cover + data["FormStatus"] = statusStr + c.HTML(http.StatusOK, "article_create", data) + return + } + if content == "" { + data := DefaultData(c) + data["Title"] = tr["article_create_title"] + data["Error"] = tr["article_content_required"] + data["FormTitle"] = title + data["FormSlug"] = slug + data["FormSummary"] = summary + data["FormContent"] = content + data["FormCover"] = cover + data["FormStatus"] = statusStr + c.HTML(http.StatusOK, "article_create", data) + return + } + + // Auto-generate slug if empty. + if slug == "" { + slug = generateSlug(title) + } + + // Parse status: default to draft (0). + status := models.ArticleDraft + if statusStr == "1" { + status = models.ArticlePublished + } + + // Get author ID from session. + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + c.Redirect(http.StatusFound, "/login") + return + } + + var publishedAt *time.Time + if status == models.ArticlePublished { + now := time.Now() + publishedAt = &now + } + + article := models.Article{ + AuthorID: uint(userID.(int)), + Title: title, + Slug: slug, + Summary: summary, + Content: content, + Cover: cover, + Status: status, + IsTop: isTop, + PublishedAt: publishedAt, + } + + if err := db.Create(&article).Error; err != nil { + data := DefaultData(c) + data["Title"] = tr["article_create_title"] + data["Error"] = tr["article_error"] + data["FormTitle"] = title + data["FormSlug"] = slug + data["FormSummary"] = summary + data["FormContent"] = content + data["FormCover"] = cover + data["FormStatus"] = statusStr + c.HTML(http.StatusOK, "article_create", data) + return + } + + // Success: redirect to dashboard. + c.Redirect(http.StatusFound, "/admin") + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 6a82226..0fd153b 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -34,6 +34,7 @@ var translations = map[Lang]map[string]string{ "home_post2_dsc": "Blog posts will appear here once you start publishing from the admin dashboard.", "home_post3_tit": "Built with Go", "home_post3_dsc": "This blog engine uses Go, Gin web framework, GORM, and Tailwind CSS for a modern experience.", + "home_no_posts": "No published posts yet.", // Login page "login_title": "Sign In", @@ -43,6 +44,7 @@ var translations = map[Lang]map[string]string{ "login_ph_pass": "Enter your password", "login_submit": "Sign In", "login_error": "Invalid username or password.", + "login_required": "Please fill in all fields.", // Dashboard "dash_title": "Dashboard", @@ -91,6 +93,26 @@ var translations = map[Lang]map[string]string{ "crop_uploading": "Uploading...", "crop_success": "Avatar updated.", "crop_error": "Failed to upload avatar. Please try again.", + + // Article creation + "article_create_title": "Create Article", + "article_title": "Title", + "article_slug": "Slug", + "article_slug_hint": "Auto-generated from title if empty", + "article_summary": "Summary", + "article_content": "Content", + "article_cover": "Cover Image URL", + "article_status": "Status", + "article_draft": "Draft", + "article_published": "Published", + "article_is_top": "Pin to top", + "article_save_draft": "Save Draft", + "article_publish": "Publish", + "article_created": "Article created successfully.", + "article_error": "Failed to create article.", + "article_new": "New Article", + "article_title_required": "Title is required.", + "article_content_required": "Content is required.", }, ZH: { // 导航 @@ -112,6 +134,7 @@ var translations = map[Lang]map[string]string{ "home_post2_dsc": "当你从后台管理面板发布文章后,博客文章将显示在这里。", "home_post3_tit": "使用 Go 构建", "home_post3_dsc": "此博客引擎使用 Go、Gin Web 框架、GORM 和 Tailwind CSS 构建,提供现代化的体验。", + "home_no_posts": "暂无已发布的文章。", // 登录页 "login_title": "登录", @@ -121,6 +144,7 @@ var translations = map[Lang]map[string]string{ "login_ph_pass": "请输入密码", "login_submit": "登录", "login_error": "用户名或密码错误。", + "login_required": "请填写所有字段。", // 后台 "dash_title": "后台管理", @@ -134,10 +158,8 @@ var translations = map[Lang]map[string]string{ // 页脚 "footer_text": "© 2026 Go Blog。由 Go & Gin 驱动。", - - // 页面标题 - "page_home": "首页", - "page_login": "登录", + "page_home": "首页", + "page_login": "登录", // 语言切换 "lang_switch": "English", @@ -169,6 +191,26 @@ var translations = map[Lang]map[string]string{ "crop_uploading": "上传中...", "crop_success": "头像已更新。", "crop_error": "头像上传失败,请重试。", + + // 文章创建 + "article_create_title": "新建文章", + "article_title": "标题", + "article_slug": "Slug", + "article_slug_hint": "留空则从标题自动生成", + "article_summary": "摘要", + "article_content": "正文", + "article_cover": "封面图 URL", + "article_status": "状态", + "article_draft": "草稿", + "article_published": "已发布", + "article_is_top": "置顶", + "article_save_draft": "保存草稿", + "article_publish": "发布", + "article_created": "文章创建成功。", + "article_error": "创建文章失败。", + "article_new": "新建文章", + "article_title_required": "标题不能为空。", + "article_content_required": "正文不能为空。", }, } diff --git a/main.go b/main.go index e7ae4c4..b5323f0 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,8 @@ func main() { admin.Use(middleware.AuthRequired()) { admin.GET("", handlers.AdminDashboard()) + admin.GET("/articles/new", handlers.ArticleCreatePage(db)) + admin.POST("/articles/new", handlers.ArticleCreate(db)) } // Protected profile routes. diff --git a/models/article.go b/models/article.go new file mode 100644 index 0000000..926c40c --- /dev/null +++ b/models/article.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Article status constants. +const ( + ArticleDraft = 0 // 草稿 + ArticlePublished = 1 // 已发布 + ArticleArchived = 2 // 已归档 +) + +// Article represents a blog post. +type Article struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"` + AuthorID uint `gorm:"not null;index" json:"author_id"` + Title string `gorm:"not null;size:255" json:"title"` + Summary string `gorm:"size:512" json:"summary"` + Content string `gorm:"type:text;not null" json:"content"` + Cover string `gorm:"size:512" json:"cover"` + Status int `gorm:"default:0;index" json:"status"` + IsTop bool `gorm:"default:false" json:"is_top"` + ViewCount int `gorm:"default:0" json:"view_count"` + Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"` + PublishedAt *time.Time `gorm:"index" json:"published_at"` + Author User `gorm:"foreignKey:AuthorID" json:"-"` +} + +// TableName overrides the default GORM table name. +func (Article) TableName() string { + return "articles" +} diff --git a/models/db.go b/models/db.go index fd725fc..a733b87 100644 --- a/models/db.go +++ b/models/db.go @@ -44,8 +44,8 @@ func InitDB(cfg *config.Config) *gorm.DB { log.Fatalf("Failed to connect to database: %v", err) } - // Auto-migrate the User table (idempotent). - if err := db.AutoMigrate(&User{}); err != nil { + // Auto-migrate tables (idempotent). + if err := db.AutoMigrate(&User{}, &Article{}); err != nil { log.Fatalf("Failed to auto-migrate database: %v", err) } diff --git a/models/user.go b/models/user.go index 367399f..61160ea 100644 --- a/models/user.go +++ b/models/user.go @@ -33,6 +33,7 @@ type User struct { Email string `gorm:"size:255" json:"email"` Status int `gorm:"default:1" json:"status"` Role string `gorm:"size:32;default:author" json:"role"` + Articles []Article `gorm:"foreignKey:AuthorID" json:"-"` } // SetPassword hashes the plain-text password with bcrypt and stores it. diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html new file mode 100644 index 0000000..f2dc481 --- /dev/null +++ b/templates/admin/article_create.html @@ -0,0 +1,94 @@ +{{define "article_create"}} +{{template "header" .}} + +
+

{{index .Tr "article_create_title"}}

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+ +
+ + +
+ + +
+ + +

{{index .Tr "article_slug_hint"}}

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ +{{template "footer" .}} + + + +{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index f0dd9b7..7703705 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -8,6 +8,8 @@ + +