This commit is contained in:
2026-06-21 20:01:20 +08:00
parent 62732ba348
commit 42516257e0
9 changed files with 179 additions and 18 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"permissions": {
"allow": [
"Bash(curl -s -c /tmp/cookies.txt -L -X POST http://localhost:8099/login -d \"username=admin&password=admin\" -o /dev/null -w \"login final HTTP %{http_code}\\\\n\")",
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o /tmp/dash.html -w \"HTTP %{http_code}\\\\n\")",
"Read(//tmp/**)",
"Bash(sed -n 's/.*\\\\\\(text-3xl font-bold text-gray-900 mt-1\">1\\\\n\\\\\\).*//p' /tmp/dash.html)",
"Bash(perl -0777 -ne 'while\\(/dash_posts.*?text-3xl[^>]*>\\(.*?\\)<\\\\/p>/sg\\){print \"POSTS BLOCK: $1\\\\n\"}' /tmp/dash.html)",
"Bash(python -c ' *)",
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o dash.html)",
"Bash(rm -f dash.html)",
"Bash(curl -s http://localhost:8099/article/123 -o detail.html -w \"detail HTTP %{http_code}\\\\n\")",
"Bash(rm -f detail.html)",
"Bash(cp /tmp/cfg_backup.yaml win/etc/blog_go/config.yaml)",
"Bash(pkill -f blogtest.exe)",
"Bash(pkill -f \"blog_go.exe\")",
"Bash(cd c:/Users/wuwen/Documents/project/go_blog && rm -f /tmp/blogtest.exe && git status --short && echo \"--- diff stat ---\" && git diff --stat)"
],
"additionalDirectories": [
"\\tmp"
]
}
}
+12 -1
View File
@@ -4,16 +4,27 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// AdminDashboard renders the protected admin dashboard.
func AdminDashboard() gin.HandlerFunc {
func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
username, _ := c.Get("username")
data := DefaultData(c)
data["Title"] = tr["dash_page_title"]
data["Username"] = username
// Article counts: total and published.
var postCount, publishedCount int64
db.Model(&models.Article{}).Count(&postCount)
db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished).Count(&publishedCount)
data["PostCount"] = postCount
data["PublishedCount"] = publishedCount
c.HTML(http.StatusOK, "dashboard", data)
}
}
+62 -2
View File
@@ -2,16 +2,76 @@ package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// HomePage renders the public home page.
func HomePage() gin.HandlerFunc {
// publishedArticleOrder is the ordering used to list published articles:
// pinned first, then by most recent publish/creation time.
const publishedArticleOrder = "is_top DESC, published_at DESC, created_at DESC"
// HomePage renders the public home page with the latest published articles.
func HomePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
data := DefaultData(c)
data["Title"] = tr["page_home"]
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
Order(publishedArticleOrder).
Limit(10).
Find(&articles)
data["Articles"] = articles
c.HTML(http.StatusOK, "home", data)
}
}
// ArticleDetail renders a single published article by its slug.
func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
slug := c.Param("slug")
var article models.Article
err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).
Preload("Author").
First(&article).Error
if err != nil {
data := DefaultData(c)
data["Title"] = tr["article_not_found"]
c.HTML(http.StatusNotFound, "article_not_found", data)
return
}
// Increment view count; ignore errors so a view never breaks the page.
db.Model(&models.Article{}).Where("id = ?", article.ID).
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
authorName := article.Author.Username
if article.Author.DisplayName != "" {
authorName = article.Author.DisplayName
}
data := DefaultData(c)
data["Title"] = article.Title
data["Article"] = article
data["AuthorName"] = authorName
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
c.HTML(http.StatusOK, "article", data)
}
}
// formatPublishTime returns the publication time as a readable string, falling
// back to the creation time when the publish timestamp is unset.
func formatPublishTime(publishedAt *time.Time) string {
if publishedAt != nil {
return publishedAt.Format("2006-01-02 15:04")
}
return ""
}
+8
View File
@@ -28,6 +28,7 @@ var translations = map[Lang]map[string]string{
"home_sign_in": "Sign In",
"home_to_dash": "Dashboard",
"home_latest": "Latest Posts",
"home_read_more": "Read more",
"home_post1_tit": "Getting Started",
"home_post1_dsc": "Welcome to your new blog! This is a placeholder post. Start writing to see your content here.",
"home_post2_tit": "Hello World",
@@ -52,6 +53,7 @@ var translations = map[Lang]map[string]string{
"dash_posts": "Posts",
"dash_pages": "Pages",
"dash_users": "Users",
"dash_published": "Published",
"dash_mgmt_title": "Blog Management",
"dash_mgmt_desc": "Post management, categories, and settings will appear here in future updates.",
"dash_new_article": "New Article",
@@ -114,6 +116,8 @@ var translations = map[Lang]map[string]string{
"article_new": "New Article",
"article_title_required": "Title is required.",
"article_content_required": "Content is required.",
"article_back_home": "Back to Home",
"article_not_found": "Article not found.",
},
ZH: {
// 导航
@@ -129,6 +133,7 @@ var translations = map[Lang]map[string]string{
"home_sign_in": "登录",
"home_to_dash": "后台管理",
"home_latest": "最新文章",
"home_read_more": "阅读全文",
"home_post1_tit": "入门指南",
"home_post1_dsc": "欢迎来到你的新博客!这是一篇占位文章。开始写作后,你的内容将显示在这里。",
"home_post2_tit": "你好,世界",
@@ -153,6 +158,7 @@ var translations = map[Lang]map[string]string{
"dash_posts": "文章",
"dash_pages": "页面",
"dash_users": "用户",
"dash_published": "已发布",
"dash_mgmt_title": "博客管理",
"dash_mgmt_desc": "文章管理、分类和设置将在后续版本中添加。",
"dash_new_article": "新建文章",
@@ -213,6 +219,8 @@ var translations = map[Lang]map[string]string{
"article_new": "新建文章",
"article_title_required": "标题不能为空。",
"article_content_required": "正文不能为空。",
"article_back_home": "返回首页",
"article_not_found": "文章不存在。",
},
}
+3 -2
View File
@@ -46,16 +46,17 @@ func main() {
router.Use(middleware.SetUserContext(db))
// 8. Register routes.
router.GET("/", handlers.HomePage())
router.GET("/", handlers.HomePage(db))
router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db))
router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db))
// Protected admin routes.
admin := router.Group("/admin")
admin.Use(middleware.AuthRequired())
{
admin.GET("", handlers.AdminDashboard())
admin.GET("", handlers.AdminDashboard(db))
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
admin.POST("/articles/new", handlers.ArticleCreate(db))
}
+3 -1
View File
@@ -20,7 +20,9 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_posts"}}</p>
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{{.PostCount}}
<span class="text-sm font-normal text-gray-400">({{.PublishedCount}} {{index .Tr "dash_published"}})</span>
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_pages"}}</p>
+10
View File
@@ -0,0 +1,10 @@
{{define "article_not_found"}}
{{template "header" .}}
<section class="max-w-3xl mx-auto px-4 py-24 text-center">
<h1 class="text-3xl font-bold text-gray-900 mb-3">{{index .Tr "article_not_found"}}</h1>
<a href="/" class="inline-block mt-4 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "article_back_home"}}
</a>
</section>
{{template "footer" .}}
{{end}}
+39
View File
@@ -0,0 +1,39 @@
{{define "article"}}
{{template "header" .}}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<section class="max-w-3xl mx-auto px-4 py-12">
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors mb-6">
← {{index .Tr "article_back_home"}}
</a>
<article>
<h1 class="text-4xl font-extrabold text-gray-900 mb-3">{{.Article.Title}}</h1>
<div class="flex items-center gap-3 text-sm text-gray-500 mb-8">
{{if .Article.Author.Avatar}}
<img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover">
{{end}}
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
{{if .PublishedAt}}<span>·</span><span>{{.PublishedAt}}</span>{{end}}
</div>
{{if .Article.Cover}}
<div class="rounded-xl overflow-hidden mb-8">
<img src="{{.Article.Cover}}" alt="{{.Article.Title}}" class="w-full max-h-96 object-cover">
</div>
{{end}}
<div id="articleBody" class="prose max-w-none text-gray-800"></div>
</article>
</section>
<script>
// {{.Article.Content}} is emitted by html/template as a JSON-encoded JS
// string, safely handling quotes, newlines, and HTML special characters.
var md = {{.Article.Content}};
document.getElementById('articleBody').innerHTML = marked.parse(md);
</script>
{{template "footer" .}}
{{end}}
+19 -12
View File
@@ -20,18 +20,25 @@
<section class="max-w-5xl mx-auto px-4 py-16">
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post1_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post1_dsc"}}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post2_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post2_dsc"}}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post3_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post3_dsc"}}</p>
</div>
{{range .Articles}}
<a href="/article/{{.Slug}}"
class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
{{if .Cover}}
<div class="aspect-video w-full overflow-hidden bg-gray-100">
<img src="{{.Cover}}" alt="{{.Title}}" class="w-full h-full object-cover">
</div>
{{end}}
<div class="p-6 flex flex-col flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{.Title}}
{{if .IsTop}}<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index $.Tr "article_is_top"}}</span>{{end}}
</h3>
{{if .Summary}}<p class="text-gray-500 text-sm line-clamp-2">{{.Summary}}</p>{{end}}
<span class="mt-4 text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
</div>
</a>
{{else}}
<div class="col-span-full text-center text-gray-500 py-12">{{index .Tr "home_no_posts"}}</div>
{{end}}
</div>
</section>
{{template "footer" .}}