u o
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-2
@@ -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_post2_dsc": "Blog posts will appear here once you start publishing from the admin dashboard.",
|
||||||
"home_post3_tit": "Built with Go",
|
"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_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 page
|
||||||
"login_title": "Sign In",
|
"login_title": "Sign In",
|
||||||
@@ -43,6 +44,7 @@ var translations = map[Lang]map[string]string{
|
|||||||
"login_ph_pass": "Enter your password",
|
"login_ph_pass": "Enter your password",
|
||||||
"login_submit": "Sign In",
|
"login_submit": "Sign In",
|
||||||
"login_error": "Invalid username or password.",
|
"login_error": "Invalid username or password.",
|
||||||
|
"login_required": "Please fill in all fields.",
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
"dash_title": "Dashboard",
|
"dash_title": "Dashboard",
|
||||||
@@ -91,6 +93,26 @@ var translations = map[Lang]map[string]string{
|
|||||||
"crop_uploading": "Uploading...",
|
"crop_uploading": "Uploading...",
|
||||||
"crop_success": "Avatar updated.",
|
"crop_success": "Avatar updated.",
|
||||||
"crop_error": "Failed to upload avatar. Please try again.",
|
"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: {
|
ZH: {
|
||||||
// 导航
|
// 导航
|
||||||
@@ -112,6 +134,7 @@ var translations = map[Lang]map[string]string{
|
|||||||
"home_post2_dsc": "当你从后台管理面板发布文章后,博客文章将显示在这里。",
|
"home_post2_dsc": "当你从后台管理面板发布文章后,博客文章将显示在这里。",
|
||||||
"home_post3_tit": "使用 Go 构建",
|
"home_post3_tit": "使用 Go 构建",
|
||||||
"home_post3_dsc": "此博客引擎使用 Go、Gin Web 框架、GORM 和 Tailwind CSS 构建,提供现代化的体验。",
|
"home_post3_dsc": "此博客引擎使用 Go、Gin Web 框架、GORM 和 Tailwind CSS 构建,提供现代化的体验。",
|
||||||
|
"home_no_posts": "暂无已发布的文章。",
|
||||||
|
|
||||||
// 登录页
|
// 登录页
|
||||||
"login_title": "登录",
|
"login_title": "登录",
|
||||||
@@ -121,6 +144,7 @@ var translations = map[Lang]map[string]string{
|
|||||||
"login_ph_pass": "请输入密码",
|
"login_ph_pass": "请输入密码",
|
||||||
"login_submit": "登录",
|
"login_submit": "登录",
|
||||||
"login_error": "用户名或密码错误。",
|
"login_error": "用户名或密码错误。",
|
||||||
|
"login_required": "请填写所有字段。",
|
||||||
|
|
||||||
// 后台
|
// 后台
|
||||||
"dash_title": "后台管理",
|
"dash_title": "后台管理",
|
||||||
@@ -134,8 +158,6 @@ var translations = map[Lang]map[string]string{
|
|||||||
|
|
||||||
// 页脚
|
// 页脚
|
||||||
"footer_text": "© 2026 Go Blog。由 Go & Gin 驱动。",
|
"footer_text": "© 2026 Go Blog。由 Go & Gin 驱动。",
|
||||||
|
|
||||||
// 页面标题
|
|
||||||
"page_home": "首页",
|
"page_home": "首页",
|
||||||
"page_login": "登录",
|
"page_login": "登录",
|
||||||
|
|
||||||
@@ -169,6 +191,26 @@ var translations = map[Lang]map[string]string{
|
|||||||
"crop_uploading": "上传中...",
|
"crop_uploading": "上传中...",
|
||||||
"crop_success": "头像已更新。",
|
"crop_success": "头像已更新。",
|
||||||
"crop_error": "头像上传失败,请重试。",
|
"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": "正文不能为空。",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func main() {
|
|||||||
admin.Use(middleware.AuthRequired())
|
admin.Use(middleware.AuthRequired())
|
||||||
{
|
{
|
||||||
admin.GET("", handlers.AdminDashboard())
|
admin.GET("", handlers.AdminDashboard())
|
||||||
|
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||||
|
admin.POST("/articles/new", handlers.ArticleCreate(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected profile routes.
|
// Protected profile routes.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+2
-2
@@ -44,8 +44,8 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
|||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate the User table (idempotent).
|
// Auto-migrate tables (idempotent).
|
||||||
if err := db.AutoMigrate(&User{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Article{}); err != nil {
|
||||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type User struct {
|
|||||||
Email string `gorm:"size:255" json:"email"`
|
Email string `gorm:"size:255" json:"email"`
|
||||||
Status int `gorm:"default:1" json:"status"`
|
Status int `gorm:"default:1" json:"status"`
|
||||||
Role string `gorm:"size:32;default:author" json:"role"`
|
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.
|
// SetPassword hashes the plain-text password with bcrypt and stores it.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{define "article_create"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
|
||||||
|
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "article_create_title"}}</h2>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form action="/admin/articles/new" method="post" class="space-y-6">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||||
|
<input type="text" name="title" value="{{.FormTitle}}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||||
|
placeholder="{{index .Tr "article_title"}}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slug -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
|
||||||
|
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||||
|
placeholder="{{index .Tr "article_slug_hint"}}">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
|
||||||
|
<textarea name="summary" rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
|
||||||
|
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content (EasyMDE Markdown Editor) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
|
||||||
|
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
|
||||||
|
<input type="text" name="cover" value="{{.FormCover}}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||||
|
placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IsTop -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox"
|
||||||
|
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||||
|
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" name="status" value="0"
|
||||||
|
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
|
||||||
|
{{index .Tr "article_save_draft"}}
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="status" value="1"
|
||||||
|
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||||
|
{{index .Tr "article_publish"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{template "footer" .}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var easyMDE = new EasyMDE({
|
||||||
|
element: document.getElementById('articleContent'),
|
||||||
|
spellChecker: false,
|
||||||
|
autosave: { enabled: false },
|
||||||
|
placeholder: '{{index .Tr "article_content"}}',
|
||||||
|
toolbar: [
|
||||||
|
'bold', 'italic', 'heading', '|',
|
||||||
|
'quote', 'unordered-list', 'ordered-list', '|',
|
||||||
|
'link', 'image', 'code', 'table', '|',
|
||||||
|
'preview', 'side-by-side', 'fullscreen', '|',
|
||||||
|
'guide'
|
||||||
|
],
|
||||||
|
status: false,
|
||||||
|
minHeight: '300px'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|||||||
Reference in New Issue
Block a user