Compare commits

...
28 Commits
Author SHA1 Message Date
zhangchao 337cb82756 GenerateSearchableText由正则改为goldmark的AST 2026-09-30 17:12:13 +08:00
zhangchao 4b0d545f47 消除unicod字符 2026-09-11 16:19:13 +08:00
zhangchao f376d94a49 摘要为空取前120字符,删除tag同步删除关联 2026-09-08 17:48:40 +08:00
zhangchao caa9436e6a 评论分页 2026-09-07 10:33:41 +08:00
zhangchao 7f5bb974fa 优化后台文章页的标签表现 2026-09-04 17:21:45 +08:00
zhangchao 533f31e868 整理router 2026-09-02 17:56:36 +08:00
zhangchao 2bcfa1da93 slug生成采用既有工具。 2026-08-31 17:50:58 +08:00
zhangchao f216ef4d1b 增加内容可搜索字段,屏蔽各种标记。 2026-08-29 01:40:52 +08:00
zhangchao cfe5cf53aa 修复后台评论加载问题,非admin也可加载对自己文章的未审核评论。 2026-08-26 17:59:03 +08:00
zhangchao 20395a79a2 增加修改个人信息和修改密码菜单。 2026-08-25 23:54:42 +08:00
zhangchao 16ba817ecf 整理router,删除多余中单件和路由。退出按键放最上方。 2026-08-25 17:54:54 +08:00
zhangchao 052803679a 删除部分多余内容。 2026-08-20 22:15:21 +08:00
zhangchao 6e88700f96 评论变为ajax加载 和提交 2026-08-20 18:04:42 +08:00
zhangchao d4d009f8b2 调整后台模板和js、css文件位置 2026-08-20 00:00:54 +08:00
zhangchao abee7e86aa 整理router 2026-08-19 17:49:58 +08:00
zhangchao 4a9ac54963 文章和文件建立关联 2026-08-19 17:39:23 +08:00
zhangchao f1a6c5abaa 文件管理增加数据库表。 2026-08-18 17:29:06 +08:00
zhangchao f1780de632 优化router、main等文件。 2026-08-14 17:59:20 +08:00
zhangchao 0f0850357d post.html加上标签。 2026-08-12 23:09:11 +08:00
zhangchao 42eab167ef post和index一样加入标签符号 2026-08-12 22:36:58 +08:00
zhangchao 42c6f2450d handlers区分backend和frontend,需要确认。 2026-08-12 17:57:31 +08:00
zhangchao 60869ec214 ai优化,删除未使用代码,提取重复代码。 2026-08-12 11:12:23 +08:00
zhangchao 043bf8773b 优化登录逻辑。 2026-08-12 10:57:37 +08:00
zhangchao a1d57d6b44 后台独立登录页面,并加入权限控制。 2026-08-11 18:04:02 +08:00
zhangchao 808f2e7523 创建主题修改成从现有主题作为模板。 2026-08-07 01:32:43 +08:00
zhangchao ec1a19f469 优化一些逻辑。 2026-08-06 17:45:06 +08:00
zhangchao ea293f7866 去掉注册接口。 2026-08-06 14:48:42 +08:00
zhangchao 0b27aafdc4 后台菜单变成折叠 2026-08-05 00:26:40 +08:00
41 changed files with 3217 additions and 1550 deletions
+6
View File
@@ -44,3 +44,9 @@ go.work.sum
# vendor 目录(如使用 go mod 则无需提交)
vendor/
# 命令行工具
cmd/
.opencode/.npmrc
.opencode/opencode.jsonc
.opencode/openwork.json
+10 -15
View File
@@ -2,8 +2,6 @@ package config
import (
"log/slog"
"os"
"strconv"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
@@ -21,8 +19,9 @@ type DatabaseConfig struct {
}
type ServerConfig struct {
Port int
Mode string
Port int
Mode string
LogLevel string // 日志级别:debug/info/warn/error,默认 info
}
type AppConfig struct {
@@ -72,6 +71,7 @@ func Load() *Config {
func setDefaults() {
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.mode", "debug")
viper.SetDefault("server.log_level", "info")
viper.SetDefault("database.driver", "sqlite")
viper.SetDefault("database.dsn", "goblog.db")
viper.SetDefault("app.name", "GoBlog")
@@ -82,18 +82,13 @@ func setDefaults() {
// parseConfig 解析配置到结构体
func parseConfig() *Config {
port := viper.GetInt("server.port")
if port == 0 {
port, _ = strconv.Atoi(getEnv("SERVER_PORT", "8080"))
}
return &Config{
Database: DatabaseConfig{
Driver: viper.GetString("database.driver"),
DSN: viper.GetString("database.dsn"),
},
Server: ServerConfig{
Port: port,
Port: viper.GetInt("server.port"),
Mode: viper.GetString("server.mode"),
},
App: AppConfig{
@@ -125,10 +120,10 @@ func GetGlobalConfig() *Config {
return globalConfig
}
// getEnv 获取环境变量
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
// getConfig 获取全局配置(启动时已初始化,直接返回)
func GetConfig() *Config {
if cfg := GetGlobalConfig(); cfg != nil {
return cfg
}
return defaultValue
return Load()
}
+1
View File
@@ -2,6 +2,7 @@
server:
port: 8080
mode: debug # debug 或 release
log_level: info # info, warn, error
database:
driver: sqlite # sqlite 或 mysql
+1
View File
@@ -33,6 +33,7 @@ func Init(cfg *config.DatabaseConfig) error {
&models.Comment{},
&models.Page{},
&models.Option{},
&models.File{},
)
if err != nil {
return err
+2
View File
@@ -8,6 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gosimple/slug v1.15.0
github.com/spf13/viper v1.21.0
github.com/yuin/goldmark v1.8.5
golang.org/x/crypto v0.53.0
@@ -30,6 +31,7 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+4
View File
@@ -50,6 +50,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+108
View File
@@ -0,0 +1,108 @@
package backend
import (
"encoding/json"
"html/template"
"log/slog"
"net/http"
"sync"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
const adminTemplatePath = "./templates/admin/index.html"
// AdminPageData 后台页面模板数据
type AdminPageData struct {
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
DisplayName string // 侧边栏显示的用户名
CurrentUser template.JS // JSON 序列化的当前用户信息(id/username/nickname/role),注入前端
}
var (
adminTmplOnce sync.Once
adminTmpl *template.Template
adminTmplErr error
)
// getAdminTemplate 懒加载后台模板(仅解析一次)
func getAdminTemplate() (*template.Template, error) {
adminTmplOnce.Do(func() {
adminTmpl, adminTmplErr = template.ParseFiles(adminTemplatePath)
if adminTmplErr != nil {
slog.Error("解析后台模板失败", "path", adminTemplatePath, "error", adminTmplErr)
}
})
return adminTmpl, adminTmplErr
}
// menuPageSet 从菜单提取页面标识集合
func menuPageSet(menus []AdminMenuGroup) map[string]bool {
set := make(map[string]bool)
for _, g := range menus {
for _, item := range g.Items {
set[item.Key] = true
}
}
return set
}
// AdminLoginView 独立登录页
func AdminLoginView(c *gin.Context) {
c.File("./templates/admin/login.html")
}
// AdminView 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
// 依赖 JWTAuth 中间件注入 userID/role 等上下文
func AdminView(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.Redirect(http.StatusFound, "/admin/login")
return
}
// 校验用户当前状态(禁用账号不允许进入后台)
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil || user.Status == 0 {
c.Redirect(http.StatusFound, "/admin/login")
return
}
tmpl, tmplErr := getAdminTemplate()
if tmplErr != nil {
c.String(http.StatusInternalServerError, "后台模板加载失败")
return
}
// 菜单与登录接口保持同一来源,页面框架只渲染菜单内的页面
menus := GetMenusByRole(user.Role)
displayName := user.Nickname
if displayName == "" {
displayName = user.Username
}
// 将用户基本信息注入前端,避免依赖 localStorage
currentUserJSON, _ := json.Marshal(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"role": user.Role,
})
data := AdminPageData{
Menus: menus,
PageSet: menuPageSet(menus),
DisplayName: displayName,
CurrentUser: template.JS(currentUserJSON),
}
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "admin_index", data); err != nil {
slog.Error("渲染后台页面失败", "error", err)
}
}
+77 -84
View File
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"log/slog"
@@ -18,20 +18,61 @@ func JWTSecret() []byte {
return jwtSecret
}
// AdminMenuItem 后台菜单项
type AdminMenuItem struct {
Key string `json:"key"` // 页面标识,与页面 div 的 data-page 对应
Name string `json:"name"` // 菜单显示名称
}
// AdminMenuGroup 后台菜单分组(Group 为空表示顶级菜单)
type AdminMenuGroup struct {
Group string `json:"group"`
Items []AdminMenuItem `json:"items"`
}
var adminMenus = []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
}},
{Group: "内容管理", Items: []AdminMenuItem{
{Key: "posts", Name: "文章管理"},
{Key: "categories", Name: "分类管理"},
{Key: "tags", Name: "标签管理"},
{Key: "files", Name: "文件管理"},
{Key: "pages", Name: "页面管理"},
{Key: "comments", Name: "评论管理"},
}},
{Group: "系统管理", Items: []AdminMenuItem{
{Key: "users", Name: "用户管理"},
{Key: "themes", Name: "主题管理"},
{Key: "settings", Name: "基础设置"},
}},
}
var userMenus = []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
{Key: "posts", Name: "文章管理"},
{Key: "files", Name: "文件管理"},
{Key: "comments", Name: "评论管理"},
}},
}
// GetMenusByRole 根据角色返回后台菜单
func GetMenusByRole(role string) []AdminMenuGroup {
switch role {
case "admin":
return adminMenus
default:
return userMenus
}
}
// 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Email string `json:"email" binding:"required,email"`
}
// JWT Claims
type Claims struct {
UserID uint `json:"user_id"`
@@ -54,7 +95,7 @@ func Login(c *gin.Context) {
return
}
if user.Status == 0 || !user.IsActive {
if user.Status == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
return
}
@@ -88,74 +129,22 @@ func Login(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"email": user.Email,
"role": user.Role,
"avatar": user.Avatar,
"last_login_at": user.LastLoginAt,
"login_count": user.LoginCount,
},
})
// 写入 Cookie,供服务端渲染后台页面时识别登录态
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "登录成功"})
}
// 注册
func Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 退出登录(清除服务端 Cookie)
func Logout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
}
// 检查用户名是否已存在
var count int64
database.DB.Model(&models.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
return
}
// 检查邮箱是否已存在
database.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被注册"})
return
}
// 使用 User 模型的 SetPassword 方法
user := models.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Role: "user",
Status: 1,
IsActive: true,
}
// 加密密码
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
if err := database.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "注册成功",
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"email": user.Email,
},
})
// WebLogout 前台页面退出登录(清除 Cookie 后回首页)
func WebLogout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/")
}
// 获取当前用户信息
@@ -191,21 +180,19 @@ func UpdateUser(c *gin.Context) {
return
}
updates := map[string]interface{}{}
if req.Nickname != "" {
updates["nickname"] = req.Nickname
}
if req.Email != "" {
updates["email"] = req.Email
}
if req.Avatar != "" {
updates["avatar"] = req.Avatar
updates := map[string]interface{}{
"nickname": req.Nickname,
"email": req.Email,
"avatar": req.Avatar,
}
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
user.Nickname = req.Nickname
user.Email = req.Email
user.Avatar = req.Avatar
c.JSON(http.StatusOK, gin.H{"data": user})
}
@@ -248,3 +235,9 @@ func ChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
// 判断是否为管理员
func isAdmin(c *gin.Context) bool {
role, exists := c.Get("role")
return exists && role == "admin"
}
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
+216
View File
@@ -0,0 +1,216 @@
package backend
import (
"net/http"
"strconv"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 获取评论列表
func GetComments(c *gin.Context) {
postID := c.Query("post_id")
postSlug := c.Query("post_slug") // 前台使用 slug
status := c.Query("status")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "10")
// 解析分页参数
page := 1
pageSize := 10
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 10 {
pageSize = ps
}
db := database.DB.Model(&models.Comment{}).Preload("User").
Preload("Post", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "title", "slug")
})
// 状态过滤(管理员和普通用户均适用)
if status != "" {
db = db.Where("status = ?", status)
}
// 非管理员只能查看自己文章上的评论
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
// 未指定具体文章时,限制为自己名下的文章
if postID == "" && postSlug == "" {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
}
}
// 按文章 ID 或 slug 过滤
if postID != "" {
db = db.Where("post_id = ?", postID)
} else if postSlug != "" {
var post models.Post
if err := database.DB.Where("slug = ?", postSlug).First(&post).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
db = db.Where("post_id = ?", post.ID)
}
// 只统计并查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
// Count 必须与列表使用相同过滤条件,否则分页总数会包含子评论而不准确
db = db.Where("parent_id IS NULL")
var total int64
db.Count(&total)
var comments []models.Comment
offset := (page - 1) * pageSize
db.Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
// 预加载子评论
for i := range comments {
database.DB.Model(&comments[i]).Association("Children").Find(&comments[i].Children)
}
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
type commentWithPost struct {
models.Comment
PostTitle string `json:"post_title"`
PostSlug string `json:"post_slug"`
}
list := make([]commentWithPost, 0, len(comments))
for _, cm := range comments {
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{
"data": list,
"page": page,
"total_pages": totalPages,
"total": total,
})
}
// GetCommentPosts 获取所有有评论的文章列表(用于评论管理页的文章筛选下拉框)
// 与评论列表分页无关,始终返回权限范围内的全部文章
func GetCommentPosts(c *gin.Context) {
// 有评论的文章 ID 子查询
postIDsWithComments := database.DB.Model(&models.Comment{}).Select("DISTINCT post_id")
query := database.DB.Model(&models.Post{}).
Select("id", "title", "slug").
Where("id IN (?)", postIDsWithComments)
// 非管理员只能看到自己名下的文章
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
query = query.Where("author_id = ?", userID)
}
}
var posts []models.Post
query.Order("id DESC").Find(&posts)
type postOption struct {
ID uint `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
}
list := make([]postOption, 0, len(posts))
for _, p := range posts {
list = append(list, postOption{ID: p.ID, Title: p.Title, Slug: p.Slug})
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
func loadAndVerifyComment(c *gin.Context) *models.Comment {
id := c.Param("id")
var comment models.Comment
if err := database.DB.First(&comment, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return nil
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return nil
}
return &comment
}
// canManageComment 检查当前用户是否有权管理该评论(管理员始终可以,普通用户只能管理自己文章上的评论)
func canManageComment(c *gin.Context, comment *models.Comment) bool {
if isAdmin(c) {
return true
}
if userID, exists := c.Get("userID"); exists {
var post models.Post
if err := database.DB.Select("author_id").First(&post, comment.PostID).Error; err != nil {
return false
}
return post.AuthorID == userID.(uint)
}
return false
}
// 审核评论
func ApproveComment(c *gin.Context) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
comment.Status = "approved"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "审核通过"})
}
// 标记为垃圾评论
func MarkSpamComment(c *gin.Context) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
comment.Status = "spam"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已标记为垃圾评论"})
}
// 删除评论
func DeleteComment(c *gin.Context) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
if err := database.DB.Delete(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+43
View File
@@ -0,0 +1,43 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// GetDashboardStats 返回仪表盘统计数字(按角色过滤:管理员统计全部,普通用户统计自己的)
func GetDashboardStats(c *gin.Context) {
// 文章数:管理员统计全部,普通用户统计自己的(与文章管理页可见范围一致)
postQuery := database.DB.Model(&models.Post{})
if !isAdmin(c) {
userID, _ := c.Get("userID")
postQuery = postQuery.Where("author_id = ?", userID)
}
var postCount int64
postQuery.Count(&postCount)
var categoryCount, tagCount int64
database.DB.Model(&models.Category{}).Count(&categoryCount)
database.DB.Model(&models.Tag{}).Count(&tagCount)
// 待审核评论数:管理员统计全部,普通用户统计自己文章上的
commentQuery := database.DB.Model(&models.Comment{}).Where("status = ?", "pending")
if !isAdmin(c) {
userID, _ := c.Get("userID")
commentQuery = commentQuery.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
var pendingCommentCount int64
commentQuery.Count(&pendingCommentCount)
c.JSON(http.StatusOK, gin.H{
"posts": postCount,
"categories": categoryCount,
"tags": tagCount,
"pending_comments": pendingCommentCount,
})
}
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
+134 -57
View File
@@ -1,9 +1,10 @@
package handlers
package backend
import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"goblog/database"
@@ -11,6 +12,7 @@ import (
"goblog/utils"
"github.com/gin-gonic/gin"
"github.com/gosimple/slug"
"gorm.io/gorm"
)
@@ -34,6 +36,7 @@ type CreatePostRequest struct {
Tags []string `json:"tags"`
Status string `json:"status"`
IsTop bool `json:"is_top"`
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
}
// 更新文章请求
@@ -46,6 +49,7 @@ type UpdatePostRequest struct {
Tags []string `json:"tags"`
Status string `json:"status"`
IsTop bool `json:"is_top"`
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
}
// Markdown 预览请求
@@ -65,13 +69,24 @@ func PreviewMarkdown(c *gin.Context) {
// 生成 slug
func generateSlug(title string) string {
slug := strings.ToLower(title)
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
// 简化处理,实际项目中可能需要更完善的 slug 生成
slug := slug.Make(title)
return slug
}
// findOrCreateTags 根据名称列表查找或创建标签
func findOrCreateTags(tagNames []string) []models.Tag {
var tags []models.Tag
for _, tagName := range tagNames {
var tag models.Tag
database.DB.FirstOrCreate(&tag, models.Tag{
Name: tagName,
Slug: generateSlug(tagName),
})
tags = append(tags, tag)
}
return tags
}
// 获取文章列表
func GetPosts(c *gin.Context) {
var query PostListQuery
@@ -80,24 +95,33 @@ func GetPosts(c *gin.Context) {
return
}
// 分页参数校验
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 10
}
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author")
// 前端只显示已发布的文章
if !isAdmin(c) {
// 管理员可查看全部;登录用户可看到自己的全部文章(含草稿);未登录只能看已发布
if isAdmin(c) {
if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
} else if userId, exists := c.Get("userID"); exists {
// 登录用户:只看自己的,不限状态(后台管理需要看到草稿)
db = db.Where("author_id = ?", userId)
} else {
// 未登录:只看已发布
db = db.Where("status = ?", "published")
} else if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
if query.CategoryID > 0 {
db = db.Where("category_id = ?", query.CategoryID)
}
userId, exists := c.Get("userID")
if exists {
db = db.Where("author_id = ?", userId)
}
if query.TagID > 0 {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", query.TagID)
@@ -141,9 +165,13 @@ func GetPost(c *gin.Context) {
query = query.Where("slug = ?", id)
}
// 非管理员只能查看已发布文章
// 非管理员只能查看已发布文章,但可以查看自己的草稿(编辑用)
if !isAdmin(c) {
query = query.Where("status = ?", "published")
if userId, exists := c.Get("userID"); exists {
query = query.Where("status = ? OR author_id = ?", "published", userId)
} else {
query = query.Where("status = ?", "published")
}
}
if err := query.First(&post).Error; err != nil {
@@ -168,15 +196,16 @@ func CreatePost(c *gin.Context) {
userID, _ := c.Get("userID")
post := models.Post{
Title: req.Title,
Slug: generateSlug(req.Title),
Content: req.Content,
Summary: req.Summary,
Cover: req.Cover,
AuthorID: userID.(uint),
CategoryID: req.CategoryID,
Status: req.Status,
IsTop: req.IsTop,
Title: req.Title,
Slug: generateSlug(req.Title),
Content: req.Content,
SearchableText: string(utils.GenerateSearchableText(req.Content)),
Summary: req.Summary,
Cover: req.Cover,
AuthorID: userID.(uint),
CategoryID: req.CategoryID,
Status: req.Status,
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
}
if req.Status == "published" {
@@ -186,16 +215,7 @@ func CreatePost(c *gin.Context) {
// 处理标签
if len(req.Tags) > 0 {
var tags []models.Tag
for _, tagName := range req.Tags {
var tag models.Tag
database.DB.FirstOrCreate(&tag, models.Tag{
Name: tagName,
Slug: generateSlug(tagName),
})
tags = append(tags, tag)
}
post.Tags = tags
post.Tags = findOrCreateTags(req.Tags)
}
if err := database.DB.Create(&post).Error; err != nil {
@@ -203,6 +223,15 @@ func CreatePost(c *gin.Context) {
return
}
// 处理文件关联(包含封面)
if len(req.FileIDs) > 0 {
var files []models.File
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
if len(files) > 0 {
database.DB.Model(&post).Association("Files").Replace(files)
}
}
c.JSON(http.StatusCreated, gin.H{"data": post})
}
@@ -216,6 +245,15 @@ func UpdatePost(c *gin.Context) {
return
}
// 非管理员只能编辑自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权编辑他人的文章"})
return
}
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -229,13 +267,13 @@ func UpdatePost(c *gin.Context) {
}
if req.Content != "" {
updates["content"] = req.Content
updates["searchable_text"] = string(utils.GenerateSearchableText(req.Content))
}
if req.Summary != "" {
updates["summary"] = req.Summary
}
if req.Cover != "" {
updates["cover"] = req.Cover
}
// 封面始终更新(空字符串表示移除封面)
updates["cover"] = req.Cover
if req.CategoryID > 0 {
updates["category_id"] = req.CategoryID
}
@@ -251,20 +289,22 @@ func UpdatePost(c *gin.Context) {
now := time.Now()
updates["published_at"] = &now
}
updates["is_top"] = req.IsTop
// 非管理员不允许置顶
if isAdmin(c) {
updates["is_top"] = req.IsTop
}
// 处理标签
if len(req.Tags) > 0 {
var tags []models.Tag
for _, tagName := range req.Tags {
var tag models.Tag
database.DB.FirstOrCreate(&tag, models.Tag{
Name: tagName,
Slug: generateSlug(tagName),
})
tags = append(tags, tag)
// 处理标签(nil 表示本次请求不涉及标签;空数组表示移除全部标签)
if req.Tags != nil {
if len(req.Tags) > 0 {
tags := findOrCreateTags(req.Tags)
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
return
}
} else {
database.DB.Model(&post).Association("Tags").Clear()
}
database.DB.Model(&post).Association("Tags").Replace(tags)
}
if err := database.DB.Model(&post).Updates(updates).Error; err != nil {
@@ -272,6 +312,20 @@ func UpdatePost(c *gin.Context) {
return
}
// 处理文件关联(包含封面)
if req.FileIDs != nil {
if len(req.FileIDs) > 0 {
var files []models.File
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
if len(files) > 0 {
database.DB.Model(&post).Association("Files").Replace(files)
}
} else {
// 空数组,清除所有关联
database.DB.Model(&post).Association("Files").Clear()
}
}
c.JSON(http.StatusOK, gin.H{"data": post})
}
@@ -285,6 +339,35 @@ func DeletePost(c *gin.Context) {
return
}
// 非管理员只能删除自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除他人的文章"})
return
}
}
// 获取关联文件,删除物理文件和数据库记录
var files []models.File
database.DB.Model(&post).Association("Files").Find(&files)
for _, f := range files {
// 检查文件是否还被其他文章使用
var count int64
database.DB.Table("post_files").Where("file_id = ? AND post_id != ?", f.ID, post.ID).Count(&count)
if count == 0 {
// 没有其他文章使用,删除物理文件
physPath := filepath.Join("./static/uploads", f.FilePath)
if _, err := os.Stat(physPath); err == nil {
os.Remove(physPath)
}
// 删除 files 表记录
database.DB.Delete(&f)
}
}
// 清除关联关系
database.DB.Model(&post).Association("Files").Clear()
if err := database.DB.Delete(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"})
return
@@ -292,9 +375,3 @@ func DeletePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// 判断是否为管理员
func isAdmin(c *gin.Context) bool {
role, exists := c.Get("role")
return exists && role == "admin"
}
@@ -1,30 +1,18 @@
package handlers
package backend
import (
"net/http"
"strconv"
"goblog/config"
"goblog/database"
"goblog/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// 站点基础设置键名
const (
OptionSiteName = "site_name"
OptionSiteDesc = "site_desc"
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
)
// GetSettings 获取站点基础设置(管理员)
func GetSettings(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
cfg := config.GetConfig()
opts, err := models.GetOptionsByUser(database.DB, 0)
if err != nil {
@@ -34,10 +22,10 @@ func GetSettings(c *gin.Context) {
// 默认值:站点信息取配置文件,评论默认显示、每页 10 条
settings := map[string]string{
OptionSiteName: cfg.App.Name,
OptionSiteDesc: cfg.App.Description,
OptionShowComments: "1",
OptionCommentPageSize: "10",
models.OptionSiteName: cfg.App.Name,
models.OptionSiteDesc: cfg.App.Description,
models.OptionShowComments: "1",
models.OptionCommentPageSize: "10",
}
for name := range settings {
if v, ok := opts[name]; ok && v != "" {
@@ -69,10 +57,10 @@ func UpdateSettings(c *gin.Context) {
showComments = "1"
}
options := map[string]string{
OptionSiteName: req.SiteName,
OptionSiteDesc: req.SiteDesc,
OptionShowComments: showComments,
OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
models.OptionSiteName: req.SiteName,
models.OptionSiteDesc: req.SiteDesc,
models.OptionShowComments: showComments,
models.OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
}
if err := models.BatchSetOptions(database.DB, 0, options); err != nil {
+9 -2
View File
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -7,6 +7,7 @@ import (
"goblog/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 获取标签列表
@@ -103,7 +104,13 @@ func DeleteTag(c *gin.Context) {
return
}
if err := database.DB.Delete(&tag).Error; err != nil {
// 使用事务:先清除 post_tags 中该标签的关联记录,再删除标签本身,保证数据一致性
if err := database.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM post_tags WHERE tag_id = ?", tag.ID).Error; err != nil {
return err
}
return tx.Delete(&tag).Error
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"})
return
}
+44 -164
View File
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -109,9 +109,8 @@ func SwitchTheme(c *gin.Context) {
// 创建新主题
func CreateTheme(c *gin.Context) {
type Request struct {
Name string `json:"name" binding:"required"`
Template string `json:"template"`
CreateCSS bool `json:"create_css"`
Name string `json:"name" binding:"required"`
Template string `json:"template"`
}
var req Request
@@ -147,158 +146,46 @@ func CreateTheme(c *gin.Context) {
}
// 创建主题目录
if err := os.MkdirAll(themePath, 0755); err != nil {
if err := os.MkdirAll(themePath, 0700); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "创建主题目录失败",
})
return
}
// 如果指定了模板,复制模板文件
if req.Template != "" && req.Template != "blank" {
templateSrc := filepath.Join("templates", req.Template)
if _, err := os.Stat(templateSrc); err == nil {
// 复制模板文件
files := []string{"base.html", "index.html", "post.html", "page.html"}
for _, file := range files {
srcFile := filepath.Join(templateSrc, file)
dstFile := filepath.Join(themePath, file)
if data, err := os.ReadFile(srcFile); err == nil {
os.WriteFile(dstFile, data, 0644)
}
}
}
} else {
// 创建空白模板
baseHTML := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - {{.SiteName}}</title>
<meta name="description" content="{{.Description}}">
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/{{.Name}}.css">
</head>
<body>
<header class="header">
<div class="container">
<h1 class="site-title">
<a href="/">{{.SiteName}}</a>
</h1>
<p class="site-desc">{{.SiteDesc}}</p>
<nav class="main-nav">
<a href="/">首页</a>
{{range .Pages}}
<a href="/page/{{.Slug}}">{{.Title}}</a>
{{end}}
</nav>
</div>
</header>
<main class="main container">
{{template "content" .}}
</main>
<footer class="footer">
<div class="container">
<p>&copy; {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
</div>
</footer>
</body>
</html>
`
os.WriteFile(filepath.Join(themePath, "base.html"), []byte(baseHTML), 0644)
os.WriteFile(filepath.Join(themePath, "index.html"), []byte(`{{define "content"}}
<div class="post-list">
<h2>欢迎来到我的博客</h2>
<p>这是一个新主题</p>
</div>
{{end}}
`), 0644)
os.WriteFile(filepath.Join(themePath, "post.html"), []byte(`{{define "content"}}
<article class="post-detail">
<h1>{{.Post.Title}}</h1>
<div class="post-body">
{{.Post.Content | html}}
</div>
</article>
{{end}}
`), 0644)
os.WriteFile(filepath.Join(themePath, "page.html"), []byte(`{{define "content"}}
<article class="page-detail">
<h1>{{.Page.Title}}</h1>
<div class="page-body">
{{.Page.Content | html}}
</div>
</article>
{{end}}
`), 0644)
// 创建新主题时必须选择已有主题作为模板
if req.Template == "" || req.Template == "blank" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "必须选择一个已有主题作为模板",
})
return
}
// 创建 CSS 文件
if req.CreateCSS {
cssPath := filepath.Join("static", "css", req.Name+".css")
os.MkdirAll(filepath.Dir(cssPath), 0755)
// 验证所选模板是否存在并复制模板文件
templateSrc := filepath.Join("templates", req.Template)
if _, err := os.Stat(templateSrc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "所选模板不存在",
})
return
}
cssContent := `/* ` + req.Name + ` Theme */
/* 在此添加你的样式 */
// 复制模板文件
files := []string{"base.html", "index.html", "post.html", "page.html"}
for _, file := range files {
srcFile := filepath.Join(templateSrc, file)
dstFile := filepath.Join(themePath, file)
if data, err := os.ReadFile(srcFile); err == nil {
os.WriteFile(dstFile, data, 0644)
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.header {
background: #fff;
padding: 20px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.site-title {
margin: 0;
}
.site-title a {
color: #333;
text-decoration: none;
}
.site-desc {
color: #666;
margin: 10px 0 0;
}
.main-nav {
margin-top: 15px;
}
.main-nav a {
margin-right: 15px;
color: #666;
text-decoration: none;
}
.main {
padding: 30px 0;
min-height: 500px;
}
.footer {
background: #f5f5f5;
padding: 20px 0;
text-align: center;
color: #999;
}
`
os.WriteFile(cssPath, []byte(cssContent), 0644)
// 复制模板的 CSS 文件
templateCSS := filepath.Join("static", "css", req.Template+".css")
if data, err := os.ReadFile(templateCSS); err == nil {
dstCSS := filepath.Join("static", "css", req.Name+".css")
os.MkdirAll(filepath.Dir(dstCSS), 0755)
os.WriteFile(dstCSS, data, 0644)
}
c.JSON(http.StatusOK, gin.H{
@@ -350,19 +237,19 @@ func GetThemeFiles(c *gin.Context) {
})
}
// resolveThemeFilePath 根据主题名和文件名解析实际文件路径
func resolveThemeFilePath(themeName, filename string) string {
if filename == themeName+".css" {
return filepath.Join("static", "css", filename)
}
return filepath.Join("templates", themeName, filename)
}
// 获取主题文件内容
func GetThemeFile(c *gin.Context) {
themeName := c.Param("theme")
filename := c.Param("file")
var filePath string
// 判断是模板文件还是 CSS 文件
if filename == themeName+".css" {
filePath = filepath.Join("static", "css", filename)
} else {
filePath = filepath.Join("templates", themeName, filename)
}
filePath := resolveThemeFilePath(themeName, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
@@ -401,17 +288,10 @@ func SaveThemeFile(c *gin.Context) {
return
}
var filePath string
// 判断是模板文件还是 CSS 文件
if filename == themeName+".css" {
filePath = filepath.Join("static", "css", filename)
} else {
filePath = filepath.Join("templates", themeName, filename)
}
filePath := resolveThemeFilePath(themeName, filename)
// 保存文件
if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
if err := os.WriteFile(filePath, []byte(req.Content), 0600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "保存文件失败",
})
+288
View File
@@ -0,0 +1,288 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
const (
// uploadDir 上传文件保存根目录
uploadDir = "./static/uploads"
// uploadURLPrefix 上传文件对外访问的 URL 前缀
uploadURLPrefix = "/static/uploads/"
// maxUploadSize 单个文件最大大小(5MB)
maxUploadSize = 5 << 20
)
// allowedImageExts 允许上传的图片扩展名
var allowedImageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// allowedImageMIMEs 允许上传的图片真实 MIME 类型
var allowedImageMIMEs = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// sanitizeBaseName 清理文件名(去除扩展名和不安全字符)
func sanitizeBaseName(filename string) string {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
base = strings.ReplaceAll(base, " ", "-")
var b strings.Builder
for _, r := range base {
if r == '-' || r == '_' ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
res := b.String()
if res == "" {
res = "image"
}
if len(res) > 40 {
res = res[:40]
}
return res
}
// UploadImage 上传图片(校验格式与大小,写入 files 表,按年月目录存储)
func UploadImage(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
// 校验文件大小
if file.Size > maxUploadSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20),
})
return
}
// 校验扩展名
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"})
return
}
// 打开文件并检测真实 MIME 类型(防止伪造扩展名)
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
defer src.Close()
head := make([]byte, 512)
n, _ := src.Read(head)
contentType := http.DetectContentType(head[:n])
if !allowedImageMIMEs[contentType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"})
return
}
// 重置读取位置,准备写入
if _, err := src.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
// 按年月生成子目录:uploads/202608/
now := time.Now()
yearMonth := now.Format("200601")
subDir := filepath.Join(uploadDir, yearMonth)
if err := os.MkdirAll(subDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
return
}
// 生成唯一文件名:时间戳_原始名.扩展名
filename := fmt.Sprintf("%d_%s%s", now.UnixNano(), sanitizeBaseName(file.Filename), ext)
dstPath := filepath.Join(subDir, filename)
dst, err := os.Create(dstPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
// 获取当前用户 ID
userID, _ := c.Get("userID")
// 相对路径(用于 URL 拼接和数据库存储)
relPath := filepath.Join(yearMonth, filename)
// 写入 files 表
fileRecord := models.File{
UserID: userID.(uint),
FileName: filename,
OrigName: file.Filename,
FilePath: relPath,
FileSize: file.Size,
MimeType: contentType,
}
if err := database.DB.Create(&fileRecord).Error; err != nil {
// 文件已保存但数据库写入失败,记录日志但不阻断返回
fmt.Printf("写入文件记录失败: %v\n", err)
}
c.JSON(http.StatusOK, gin.H{
"id": fileRecord.ID,
"url": uploadURLPrefix + strings.ReplaceAll(relPath, "\\", "/"),
"name": filename,
"size": file.Size,
})
}
// ListUploads 列出已上传的图片(从数据库读取,管理员看全部,普通用户看自己的)
func ListUploads(c *gin.Context) {
userID, _ := c.Get("userID")
role, _ := c.Get("role")
var files []models.File
query := database.DB.Order("created_at DESC")
if role != "admin" {
query = query.Where("user_id = ?", userID)
}
if err := query.Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败"})
return
}
// 转换为前端需要的格式
type FileItem struct {
ID uint `json:"id"`
Name string `json:"name"`
OrigName string `json:"orig_name"`
URL string `json:"url"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
UserID uint `json:"user_id"`
CreatedAt string `json:"created_at"`
PostTitle string `json:"post_title"` // 关联文章标题,为空表示未关联
PostID uint `json:"post_id"` // 关联文章ID
}
// 批量查询文件关联信息
fileIDs := make([]uint, 0, len(files))
for _, f := range files {
fileIDs = append(fileIDs, f.ID)
}
// 查询关联关系(带文章标题)
type PostFileResult struct {
FileID uint `json:"file_id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
}
var postFileResults []PostFileResult
database.DB.Table("post_files").
Select("post_files.file_id, post_files.post_id, posts.title as post_title").
Joins("LEFT JOIN posts ON posts.id = post_files.post_id").
Where("post_files.file_id IN ?", fileIDs).
Find(&postFileResults)
// 构建 fileID -> PostFileResult 映射
postFileMap := make(map[uint]PostFileResult)
for _, pf := range postFileResults {
postFileMap[pf.FileID] = pf
}
items := make([]FileItem, 0, len(files))
for _, f := range files {
item := FileItem{
ID: f.ID,
Name: f.FileName,
OrigName: f.OrigName,
URL: uploadURLPrefix + strings.ReplaceAll(f.FilePath, "\\", "/"),
Size: f.FileSize,
MimeType: f.MimeType,
UserID: f.UserID,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
}
// 填充关联信息
if pf, ok := postFileMap[f.ID]; ok {
item.PostTitle = pf.PostTitle
item.PostID = pf.PostID
}
items = append(items, item)
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
// DeleteUpload 删除已上传的图片(同时删除数据库记录和物理文件)
func DeleteUpload(c *gin.Context) {
fileID := c.Param("id")
if fileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件ID"})
return
}
var file models.File
if err := database.DB.First(&file, fileID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件记录不存在"})
return
}
// 权限校验:管理员可删所有,普通用户只能删自己的
userID, _ := c.Get("userID")
role, _ := c.Get("role")
if role != "admin" && file.UserID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件"})
return
}
// 检查文件是否被文章使用
var count int64
database.DB.Table("post_files").Where("file_id = ?", fileID).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "该文件已被文章使用,无法删除"})
return
}
// 删除物理文件
physPath := filepath.Join(uploadDir, file.FilePath)
if _, err := os.Stat(physPath); err == nil {
os.Remove(physPath)
}
// 删除数据库记录
if err := database.DB.Delete(&file).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -92,11 +92,6 @@ func CreateUser(c *gin.Context) {
return
}
role := req.Role
if role != "admin" {
role = "user"
}
status := 1
if req.Status != nil {
status = *req.Status
@@ -106,9 +101,8 @@ func CreateUser(c *gin.Context) {
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Role: role,
Role: req.Role,
Status: status,
IsActive: true,
}
if err := user.SetPassword(req.Password); err != nil {
-152
View File
@@ -1,152 +0,0 @@
package handlers
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 创建评论请求
type CreateCommentRequest struct {
PostID uint `json:"post_id" binding:"required"`
ParentID *uint `json:"parent_id"`
Author string `json:"author" binding:"required"`
Email string `json:"email" binding:"required,email"`
Website string `json:"website"`
Content string `json:"content" binding:"required"`
}
// 获取评论列表
func GetComments(c *gin.Context) {
postID := c.Query("post_id")
status := c.Query("status")
db := database.DB.Model(&models.Comment{}).Preload("User").
Preload("Post", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "title", "slug")
})
if postID != "" {
db = db.Where("post_id = ?", postID)
}
if status != "" {
db = db.Where("status = ?", status)
}
var comments []models.Comment
db.Order("created_at DESC").Find(&comments)
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
type commentWithPost struct {
models.Comment
PostTitle string `json:"post_title"`
PostSlug string `json:"post_slug"`
}
list := make([]commentWithPost, 0, len(comments))
for _, cm := range comments {
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// 创建评论
func CreateComment(c *gin.Context) {
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查文章是否存在
var post models.Post
if err := database.DB.First(&post, req.PostID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
comment := models.Comment{
PostID: req.PostID,
ParentID: req.ParentID,
Author: req.Author,
Email: req.Email,
Website: req.Website,
Content: req.Content,
IP: c.ClientIP(),
Status: "pending", // 默认待审核
}
// 如果用户已登录
if userID, exists := c.Get("userID"); exists {
uid := userID.(uint)
comment.UserID = &uid
comment.Status = "approved" // 登录用户评论自动通过
}
if err := database.DB.Create(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发表评论失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": comment})
}
// 审核评论
func ApproveComment(c *gin.Context) {
id := c.Param("id")
var comment models.Comment
if err := database.DB.First(&comment, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
comment.Status = "approved"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "审核通过"})
}
// 标记为垃圾评论
func MarkSpamComment(c *gin.Context) {
id := c.Param("id")
var comment models.Comment
if err := database.DB.First(&comment, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
comment.Status = "spam"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已标记为垃圾评论"})
}
// 删除评论
func DeleteComment(c *gin.Context) {
id := c.Param("id")
var comment models.Comment
if err := database.DB.First(&comment, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
if err := database.DB.Delete(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+182 -175
View File
@@ -1,8 +1,6 @@
package handlers
package frontend
import (
"html/template"
"log/slog"
"math"
"net/http"
"strconv"
@@ -10,13 +8,42 @@ import (
"goblog/config"
"goblog/database"
"goblog/handlers/backend"
"goblog/models"
"goblog/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
// getConfig 获取全局配置(启动时已初始化,直接返回)
func getConfig() *config.Config {
if cfg := config.GetGlobalConfig(); cfg != nil {
return cfg
}
return config.Load()
}
// loadSiteInfo 加载站点名称和描述(优先数据库,回退配置文件)
func loadSiteInfo(cfg *config.Config) (siteName, siteDesc string) {
siteName = cfg.App.Name
siteDesc = cfg.App.Description
if v, err := models.GetOptionValue(database.DB, models.OptionSiteName, 0); err == nil && v != "" {
siteName = v
}
if v, err := models.GetOptionValue(database.DB, models.OptionSiteDesc, 0); err == nil && v != "" {
siteDesc = v
}
return
}
// loadPublishedPages 加载所有已发布页面(按排序)
func loadPublishedPages() []models.Page {
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
return pages
}
// loadSidebarData 加载侧边栏数据
func loadSidebarData() (categories []models.Category, tags []models.Tag, postCount, categoryCount, tagCount int64) {
database.DB.Order("name ASC").Find(&categories)
@@ -27,22 +54,25 @@ func loadSidebarData() (categories []models.Category, tags []models.Tag, postCou
return
}
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)
},
"markdown": utils.RenderMarkdown,
// loginInfo 从上下文提取登录态信息(供侧边栏系统菜单区分已登录/未登录)
func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) {
tokenString, _ := c.Cookie("token")
if tokenString == "" {
return false, ""
}
claims := &backend.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return backend.JWTSecret(), nil
})
if err == nil && token.Valid {
return true, claims.Username
}
return false, ""
}
// 首页
// 首页数据结构
type IndexData struct {
Title string
SiteName string
@@ -59,31 +89,27 @@ type IndexData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
// 首页(供模板使用)
func IndexView(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
cfg := getConfig()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 10
pageSize := 5
categoryID, _ := strconv.Atoi(c.Query("category"))
tagID, _ := strconv.Atoi(c.Query("tag"))
authorID, _ := strconv.Atoi(c.Query("author"))
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
Where("status = ?", "published")
userId, exists := c.Get("userID")
userId, exists = 1, true
if exists {
db = db.Or("author_id = ?", userId)
}
if categoryID > 0 {
db = db.Where("category_id = ?", categoryID)
}
@@ -91,43 +117,31 @@ func IndexView(c *gin.Context) {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", tagID)
}
if authorID > 0 {
db = db.Where("author_id = ?", authorID)
}
var total int64
db.Count(&total)
// 调试日志
slog.Info("首页查询", "total", total, "page", page, "categoryID", categoryID, "tagID", tagID)
var posts []models.Post
offset := (page - 1) * pageSize
db.Order("is_top DESC, published_at DESC, created_at DESC").
Offset(offset).Limit(pageSize).Find(&posts)
// 调试日志
slog.Info("查询结果", "postCount", len(posts))
for i, p := range posts {
slog.Info("文章", "index", i, "id", p.ID, "title", p.Title, "status", p.Status, "publishedAt", p.PublishedAt, "views", p.Views, "commentsCount", len(p.Comments))
// 如果摘要为空,则从内容中提取摘要
for i := range posts {
p := &posts[i]
if p.Summary == "" && p.SearchableText != "" {
runes := []rune(p.SearchableText)
p.Summary = string(runes[:min(len(runes), 120)])
}
}
// 获取页面
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
// 加载侧边栏数据
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
siteName := cfg.App.Name
siteDesc := cfg.App.Description
if v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
siteName = v
}
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
siteDesc = v
}
siteName, siteDesc := loadSiteInfo(cfg)
pages := loadPublishedPages()
data := IndexData{
Title: "首页",
@@ -146,6 +160,7 @@ func IndexView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}
@@ -158,28 +173,25 @@ type PostDetailData struct {
Description string
Post models.Post
Pages []models.Page
Comments []models.Comment
CommentCount int64
Year int
PrevPost *models.Post // 上一篇
NextPost *models.Post // 下一篇
// 评论展示设置与分页
ShowComments bool
CommentPage int
CommentTotalPages int
ShowComments bool
CommentCount int64
// Sidebar 数据
Categories []models.Category
Tags []models.Tag
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
// 文章详情页(供模板使用)
func PostView(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
cfg := getConfig()
slug := c.Param("slug")
var post models.Post
@@ -207,50 +219,20 @@ func PostView(c *gin.Context) {
// 增加浏览量
database.DB.Model(&post).UpdateColumn("views", gorm.Expr("views + 1"))
// 获取页面
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
pages := loadPublishedPages()
// 统计评论数
var commentCount int64
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
// 读取评论展示设置
showComments := true
if v, optErr := models.GetOptionValue(database.DB, OptionShowComments, 0); optErr == nil {
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
showComments = v != "0"
}
commentPageSize := 10
if v, optErr := models.GetOptionValue(database.DB, OptionCommentPageSize, 0); optErr == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
commentPageSize = n
}
}
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
if commentPage < 1 {
commentPage = 1
}
var comments []models.Comment
commentTotalPages := 0
// 统计评论数
if showComments {
var topCount int64
database.DB.Model(&models.Comment{}).
Where("post_id = ? AND status = ? AND parent_id IS NULL", post.ID, "approved").
Count(&topCount)
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
if commentTotalPages > 0 && commentPage > commentTotalPages {
commentPage = commentTotalPages
}
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", post.ID, "approved").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "approved")
}).
Order("created_at ASC").
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
Find(&comments)
var commentCount int64
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
}
var commentCount int64
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
// 获取上一篇(发布时间更早的文章)
var prevPost models.Post
@@ -262,18 +244,7 @@ func PostView(c *gin.Context) {
nextErr := database.DB.Where("status = ? AND published_at > ?", "published", post.PublishedAt).
Order("published_at ASC").First(&nextPost).Error
// 从数据库读取站点设置
siteName := cfg.App.Name
siteDesc := cfg.App.Description
if v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
siteName = v
}
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
siteDesc = v
}
// 加载侧边栏数据
siteName, siteDesc := loadSiteInfo(cfg)
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
data := PostDetailData{
@@ -283,13 +254,9 @@ func PostView(c *gin.Context) {
Description: post.Summary,
Post: post,
Pages: pages,
Comments: comments,
CommentCount: commentCount,
Year: time.Now().Year(),
// 评论展示设置与分页
ShowComments: showComments,
CommentPage: commentPage,
CommentTotalPages: commentTotalPages,
ShowComments: showComments,
CommentCount: commentCount,
// Sidebar 数据
Categories: categories,
Tags: tags,
@@ -297,6 +264,7 @@ func PostView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
// 只有当查询成功时才赋值
if prevErr == nil {
@@ -324,13 +292,13 @@ type PageDetailData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func PageView(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
cfg := getConfig()
slug := c.Param("slug")
var page models.Page
@@ -344,21 +312,8 @@ func PageView(c *gin.Context) {
return
}
// 获取所有页面
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
// 从数据库读取站点设置
siteName := cfg.App.Name
siteDesc := cfg.App.Description
if v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
siteName = v
}
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
siteDesc = v
}
// 加载侧边栏数据
pages := loadPublishedPages()
siteName, siteDesc := loadSiteInfo(cfg)
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
data := PageDetailData{
@@ -376,48 +331,115 @@ func PageView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "page", data)
}
// 提交评论(表单提交)
func ListComments(c *gin.Context) {
// 统计评论数
var commentCount int64
postID := c.Param("postID")
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", postID, "approved").Count(&commentCount)
// 读取评论展示设置
showComments := true
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
showComments = v != "0"
}
commentPageSize := 10
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
commentPageSize = n
}
}
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
if commentPage < 1 {
commentPage = 1
}
var comments []models.Comment
commentTotalPages := 0
if showComments {
var topCount int64
database.DB.Model(&models.Comment{}).
Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
Count(&topCount)
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
if commentTotalPages > 0 && commentPage > commentTotalPages {
commentPage = commentTotalPages
}
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "approved")
}).
Order("created_at ASC").
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
Find(&comments)
}
c.JSON(http.StatusOK, gin.H{
"data": comments,
"page": commentPage,
"total_pages": commentTotalPages,
"total": commentCount,
})
}
// 创建评论请求
type CreateCommentRequest struct {
PostID uint `json:"post_id" binding:"required"`
ParentID *uint `json:"parent_id"`
Author string `json:"author" binding:"required"`
Email string `json:"email" binding:"required,email"`
Website string `json:"website"`
Content string `json:"content" binding:"required"`
}
// 提交评论
func SubmitComment(c *gin.Context) {
postID, _ := strconv.Atoi(c.PostForm("post_id"))
author := c.PostForm("author")
email := c.PostForm("email")
website := c.PostForm("website")
content := c.PostForm("content")
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查文章是否存在
var post models.Post
if err := database.DB.First(&post, postID).Error; err != nil {
c.String(http.StatusBadRequest, "文章不存在")
if err := database.DB.First(&post, req.PostID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
comment := models.Comment{
PostID: uint(postID),
Author: author,
Email: email,
Website: website,
Content: content,
Status: "pending",
PostID: req.PostID,
ParentID: req.ParentID,
Author: req.Author,
Email: req.Email,
Website: req.Website,
Content: req.Content,
IP: c.ClientIP(),
Status: "pending", // 默认待审核
}
// 如果用户已登录
if userID, exists := c.Get("userID"); exists {
uid := userID.(uint)
comment.UserID = &uid
comment.Status = "approved" // 登录用户评论自动通过
}
if err := database.DB.Create(&comment).Error; err != nil {
c.String(http.StatusInternalServerError, "提交失败")
return
}
c.Redirect(http.StatusFound, "/post/"+post.Slug)
c.JSON(http.StatusCreated, gin.H{"message": "评论提交成功"})
}
// 搜索结果页
func SearchView(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
cfg := getConfig()
keyword := c.Query("keyword")
if keyword == "" {
@@ -433,7 +455,7 @@ func SearchView(c *gin.Context) {
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
Where("status = ?", "published").
Where("title LIKE ? OR content LIKE ? OR summary LIKE ?",
Where("title LIKE ? OR searchable_text LIKE ? OR summary LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
var total int64
@@ -444,26 +466,10 @@ func SearchView(c *gin.Context) {
db.Order("published_at DESC").
Offset(offset).Limit(pageSize).Find(&posts)
// 获取页面
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
pages := loadPublishedPages()
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
// 加载侧边栏数据
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
// 从数据库读取主题设置
siteName := cfg.App.Name
siteDesc := cfg.App.Description
var option models.Option
if err := database.DB.Where("name = ? AND user_id = ?", "site_name", 0).First(&option).Error; err == nil {
siteName = option.Value
}
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
siteDesc = option.Value
}
siteName, siteDesc := loadSiteInfo(cfg)
data := IndexData{
Title: "搜索: " + keyword,
@@ -481,6 +487,7 @@ func SearchView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}
-217
View File
@@ -1,217 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
// uploadDir 上传文件保存目录
uploadDir = "./static/uploads"
// uploadURLPrefix 上传文件对外访问的 URL 前缀
uploadURLPrefix = "/static/uploads/"
// maxUploadSize 单个文件最大大小(5MB)
maxUploadSize = 5 << 20
)
// allowedImageExts 允许上传的图片扩展名
var allowedImageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// allowedImageMIMEs 允许上传的图片真实 MIME 类型
var allowedImageMIMEs = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// UploadedFile 上传文件信息
type UploadedFile struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModTime string `json:"mod_time"`
}
// sanitizeBaseName 清理文件名(去除扩展名和不安全字符)
func sanitizeBaseName(filename string) string {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
base = strings.ReplaceAll(base, " ", "-")
var b strings.Builder
for _, r := range base {
if r == '-' || r == '_' ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
res := b.String()
if res == "" {
res = "image"
}
if len(res) > 40 {
res = res[:40]
}
return res
}
// UploadImage 上传图片(校验格式与大小)
func UploadImage(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
// 校验文件大小
if file.Size > maxUploadSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20),
})
return
}
// 校验扩展名
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"})
return
}
// 打开文件并检测真实 MIME 类型(防止伪造扩展名)
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
defer src.Close()
head := make([]byte, 512)
n, _ := src.Read(head)
contentType := http.DetectContentType(head[:n])
if !allowedImageMIMEs[contentType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"})
return
}
// 重置读取位置,准备写入
if _, err := src.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
return
}
// 生成唯一文件名:时间戳_原始名.扩展名
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext)
dstPath := filepath.Join(uploadDir, filename)
dst, err := os.Create(dstPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"url": uploadURLPrefix + filename,
"name": filename,
"size": file.Size,
})
}
// ListUploads 列出已上传的图片
func ListUploads(c *gin.Context) {
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
return
}
entries, err := os.ReadDir(uploadDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
return
}
files := make([]UploadedFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !allowedImageExts[ext] {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, UploadedFile{
Name: e.Name(),
URL: uploadURLPrefix + e.Name(),
Size: info.Size(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
})
}
// 按修改时间倒序(最新的在前)
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime > files[j].ModTime
})
c.JSON(http.StatusOK, gin.H{"data": files})
}
// DeleteUpload 删除已上传的图片
func DeleteUpload(c *gin.Context) {
name := c.Param("name")
// 防止路径穿越
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"})
return
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedImageExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"})
return
}
path := filepath.Join(uploadDir, name)
if _, err := os.Stat(path); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
if err := os.Remove(path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+112 -28
View File
@@ -1,15 +1,24 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"goblog/config"
"goblog/database"
"goblog/models"
"goblog/routers"
"goblog/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
@@ -19,10 +28,8 @@ func main() {
// 设置 Gin 模式
gin.SetMode(cfg.Server.Mode)
// 初始化日志系统
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// 初始化日志系统(使用 Server.LogLevel)
logger := newLogger(cfg.Server.LogLevel)
slog.SetDefault(logger)
slog.Info("启动博客系统",
@@ -30,6 +37,8 @@ func main() {
"theme", cfg.App.Theme,
"db_driver", cfg.Database.Driver,
"port", cfg.Server.Port,
"mode", cfg.Server.Mode,
"log_level", cfg.Server.LogLevel,
)
// 初始化数据库
@@ -37,31 +46,106 @@ func main() {
slog.Error("数据库初始化失败", "error", err)
os.Exit(1)
}
// defer database.Close() // 如果提供
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
theme := cfg.App.Theme
var option models.Option
if err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error; err == nil {
theme = option.Value
slog.Info("从数据库加载主题", "theme", theme)
} else {
slog.Info("使用配置文件中的主题", "theme", theme)
}
// 创建路由引擎
r := gin.Default()
// 配置模板渲染器
routers.SetupTemplates(r, theme)
// 配置所有路由
routers.SetupRoutes(r, cfg)
// 启动服务器
port := routers.GetServerPort(cfg.Server.Port)
slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode)
if err := r.Run(port); err != nil {
slog.Error("服务器启动失败", "error", err)
// 从数据库加载主题(若不存在则使用配置值)
theme, err := loadTheme(cfg.App.Theme)
if err != nil {
slog.Error("加载主题失败", "error", err)
os.Exit(1)
}
// 创建路由引擎(使用自定义 slog 中间件)
r := gin.New()
r.Use(gin.Recovery())
r.Use(slogMiddleware(logger))
r.Use(gin.Logger())
// 配置模板渲染器(现在 LoadTemplates 返回 error)
htmlRenderer, err := utils.LoadTemplates(theme)
if err != nil {
slog.Error("加载模板失败", "error", err, "theme", theme)
os.Exit(1)
}
utils.SetGlobalRenderer(htmlRenderer)
r.HTMLRender = htmlRenderer
// 配置所有路由
routers.SetupRoutes(r)
// 启动服务器(优雅关闭)
runServer(r, cfg.Server.Port)
}
// newLogger 根据配置字符串创建 slog.Logger
func newLogger(level string) *slog.Logger {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
lvl = slog.LevelInfo // 默认 info
}
opts := &slog.HandlerOptions{Level: lvl}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
// loadTheme 从数据库读取系统主题,若不存在则使用 fallback
func loadTheme(fallback string) (string, error) {
var option models.Option
err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error
if err == nil {
slog.Info("从数据库加载主题", "theme", option.Value)
return option.Value, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.Info("使用配置文件中的主题", "theme", fallback)
return fallback, nil
}
return "", err // 真实错误必须返回
}
// slogMiddleware 自定义请求日志中间件
func slogMiddleware(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
logger.Info("HTTP请求",
"method", c.Request.Method,
"path", path,
"status", c.Writer.Status(),
"duration", time.Since(start),
"client_ip", c.ClientIP(),
)
}
}
// runServer 启动 HTTP 服务并支持优雅关闭
func runServer(r *gin.Engine, port int) {
addr := ":" + strconv.Itoa(port)
srv := &http.Server{
Addr: addr,
Handler: r,
}
go func() {
slog.Info("服务器启动成功", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("服务器启动失败", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("正在关闭服务器...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("服务器强制关闭", "error", err)
} else {
slog.Info("服务器已优雅关闭")
}
}
+21 -46
View File
@@ -1,37 +1,44 @@
package middleware
import (
"goblog/handlers/backend"
"net/http"
"strings"
"goblog/handlers"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// tokenFromContext 统一从 Cookie 或 Authorization 头提取 JWT token
// 优先读 Cookie(服务端渲染 + 同域 AJAX 自动携带),回退到 Authorization 头
func tokenFromContext(c *gin.Context) string {
if tokenString, err := c.Cookie("token"); err == nil && tokenString != "" {
return tokenString
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
return parts[1]
}
}
return ""
}
// JWT 认证中间件
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
tokenString := tokenFromContext(c)
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证令牌"})
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"})
c.Abort()
return
}
tokenString := parts[1]
claims := &handlers.Claims{}
claims := &backend.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return handlers.JWTSecret(), nil
return backend.JWTSecret(), nil
})
if err != nil || !token.Valid {
@@ -59,35 +66,3 @@ func AdminRequired() gin.HandlerFunc {
c.Next()
}
}
// 可选认证中间件(用于某些既支持游客又支持登录用户的接口)
func OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Next()
return
}
tokenString := parts[1]
claims := &handlers.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return handlers.JWTSecret(), nil
})
if err == nil && token.Valid {
c.Set("userID", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
}
c.Next()
}
}
+55 -30
View File
@@ -10,6 +10,14 @@ import (
const PasswordCryptLevel = 12
// 站点基础设置键名
const (
OptionSiteName = "site_name"
OptionSiteDesc = "site_desc"
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
)
// ==================== 用户模型 ====================
type User struct {
@@ -24,7 +32,6 @@ type User struct {
Avatar string `gorm:"size:255" json:"avatar"`
Role string `gorm:"size:20;default:'user'" json:"role"` // admin, user
Status int `gorm:"default:1" json:"status"` // 1:启用 0:禁用
IsActive bool `gorm:"default:true" json:"is_active"` // 账号激活状态
LastLoginAt *time.Time `json:"last_login_at"` // 最后登录时间
LoginCount int `gorm:"default:0" json:"login_count"` // 登录次数统计
}
@@ -64,29 +71,31 @@ func (u *User) RecordLogin(db *gorm.DB) error {
// ==================== 文章模型 ====================
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Title string `gorm:"size:200;not null" json:"title"`
Slug string `gorm:"uniqueIndex;size:200" json:"slug"`
Content string `gorm:"type:text" json:"content"`
Summary string `gorm:"size:500" json:"summary"`
Cover string `gorm:"size:255" json:"cover"`
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
Author User `gorm:"foreignKey:AuthorID" json:"author"`
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
Views int `gorm:"default:0" json:"views"`
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
IsTop bool `gorm:"default:false" json:"is_top"`
PublishedAt *time.Time `json:"published_at"`
Password string `gorm:"size:255" json:"password"` // 访问密码保护
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
Comments []Comment `json:"comments,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Title string `gorm:"size:200;not null" json:"title"`
Slug string `gorm:"uniqueIndex;size:200" json:"slug"`
Content string `gorm:"type:text" json:"content"`
SearchableText string `gorm:"size:255" json:"searchable_text"`
Summary string `gorm:"size:500" json:"summary"`
Cover string `gorm:"size:255" json:"cover"`
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
Author User `gorm:"foreignKey:AuthorID" json:"author"`
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
Files []File `gorm:"many2many:post_files;" json:"files,omitempty"` // 关联文件
Views int `gorm:"default:0" json:"views"`
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
IsTop bool `gorm:"default:false" json:"is_top"`
PublishedAt *time.Time `json:"published_at"`
Password string `gorm:"size:255" json:"password"` // 访问密码保护
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
Comments []Comment `json:"comments,omitempty"`
}
// TableName 指定表名
@@ -201,12 +210,6 @@ func SetOptionValue(db *gorm.DB, name string, userID uint, value string) error {
return db.Create(&option).Error
}
// DeleteOption 删除配置项
func DeleteOption(db *gorm.DB, name string, userID uint) error {
result := db.Where("name = ? AND user_id = ?", name, userID).Delete(&Option{})
return result.Error
}
// GetOptionsByUser 获取用户的所有配置
func GetOptionsByUser(db *gorm.DB, userID uint) (map[string]string, error) {
var options []Option
@@ -260,3 +263,25 @@ type Page struct {
func (Page) TableName() string {
return "pages"
}
// ==================== 文件模型 ====================
// File 上传文件记录
type File struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UserID uint `gorm:"index;not null" json:"user_id"` // 上传者
User User `gorm:"foreignKey:UserID" json:"user"` // 上传者信息
FileName string `gorm:"size:255;not null" json:"file_name"` // 存储文件名
OrigName string `gorm:"size:255" json:"orig_name"` // 原始文件名
FilePath string `gorm:"size:500;not null" json:"file_path"` // 相对存储路径(含年月目录)
FileSize int64 `gorm:"default:0" json:"file_size"` // 文件大小(字节)
MimeType string `gorm:"size:100" json:"mime_type"` // MIME 类型
}
// TableName 指定表名
func (File) TableName() string {
return "files"
}
+129 -118
View File
@@ -1,132 +1,143 @@
package routers
import (
"strconv"
"goblog/handlers"
"goblog/handlers/backend"
"goblog/handlers/frontend"
"goblog/middleware"
"goblog/utils"
"github.com/gin-gonic/gin"
)
const (
staticPath = "/static"
adminPath = "/admin"
apiPrefix = "/api"
)
// SetupRoutes 配置所有路由
func SetupRoutes(r *gin.Engine, cfg interface{}) {
// 静态文件
r.Static("/static", "./static")
// 后台管理(放在前面避免冲突)
r.Static("/admin/static", "./static/admin")
r.GET("/admin", func(c *gin.Context) {
c.File("./static/admin/index.html")
})
// 前端页面路由
r.GET("/", handlers.IndexView)
r.GET("/post/:slug", handlers.PostView)
r.GET("/page/:slug", handlers.PageView)
r.GET("/search", handlers.SearchView)
r.POST("/comment", handlers.SubmitComment)
// API 路由组
api := r.Group("/api")
{
// 公开 API(支持可选认证,以便管理员查看所有文章)
api.GET("/posts", middleware.OptionalAuth(), handlers.GetPosts)
api.GET("/posts/:id", middleware.OptionalAuth(), handlers.GetPost)
api.GET("/categories", handlers.GetCategories)
api.GET("/categories/:id", handlers.GetCategory)
api.GET("/tags", handlers.GetTags)
api.GET("/tags/:id", handlers.GetTag)
api.GET("/pages", handlers.GetPages)
api.GET("/pages/:id", handlers.GetPage)
api.GET("/comments", handlers.GetComments)
// 需要认证的 API
api.POST("/comments", middleware.OptionalAuth(), handlers.CreateComment)
// 用户认证
api.POST("/auth/login", handlers.Login)
api.POST("/auth/register", handlers.Register)
// 需要登录的 API
auth := api.Group("/")
auth.Use(middleware.JWTAuth())
{
auth.GET("/auth/me", handlers.GetCurrentUser)
auth.PUT("/auth/me", handlers.UpdateUser)
auth.PUT("/auth/password", handlers.ChangePassword)
}
// 主题管理(可选认证)
api.GET("/themes", handlers.GetThemes)
api.GET("/theme", handlers.GetCurrentTheme)
api.POST("/theme", middleware.JWTAuth(), handlers.SwitchTheme)
api.POST("/themes/create", middleware.JWTAuth(), handlers.CreateTheme)
api.GET("/themes/:theme/files", middleware.JWTAuth(), handlers.GetThemeFiles)
api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.GetThemeFile)
api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.SaveThemeFile)
// 管理员 API
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
{
// 文章管理
admin.POST("/posts", handlers.CreatePost)
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)
admin.DELETE("/categories/:id", handlers.DeleteCategory)
// 标签管理
admin.POST("/tags", handlers.CreateTag)
admin.PUT("/tags/:id", handlers.UpdateTag)
admin.DELETE("/tags/:id", handlers.DeleteTag)
// 页面管理
admin.POST("/pages", handlers.CreatePage)
admin.PUT("/pages/:id", handlers.UpdatePage)
admin.DELETE("/pages/:id", handlers.DeletePage)
// 评论管理
admin.GET("/comments", handlers.GetComments)
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
admin.DELETE("/comments/:id", handlers.DeleteComment)
// 用户管理
admin.GET("/users", handlers.GetUsers)
admin.POST("/users", handlers.CreateUser)
admin.PUT("/users/:id", handlers.AdminUpdateUser)
admin.PUT("/users/:id/password", handlers.AdminResetPassword)
admin.DELETE("/users/:id", handlers.DeleteUser)
// 基础设置
admin.GET("/settings", handlers.GetSettings)
admin.PUT("/settings", handlers.UpdateSettings)
// 文件管理(图片上传)
admin.POST("/upload", handlers.UploadImage)
admin.GET("/uploads", handlers.ListUploads)
admin.DELETE("/uploads/:name", handlers.DeleteUpload)
}
}
func SetupRoutes(r *gin.Engine) {
// 静态资源路由
r.Static(staticPath, "./static")
// 前台页面
setupFrontendRoutes(r)
// 后台管理页面
setupAdminPageRoutes(r)
}
// SetupTemplates 配置模板渲染器
func SetupTemplates(r *gin.Engine, theme string) {
htmlRenderer := utils.LoadTemplates(theme)
utils.SetGlobalRenderer(htmlRenderer)
r.HTMLRender = htmlRenderer
// setupFrontendRoutes 前台页面(服务端渲染)
func setupFrontendRoutes(r *gin.Engine) {
r.GET("/", frontend.IndexView)
r.GET("/post/:slug", frontend.PostView)
r.GET("/page/:slug", frontend.PageView)
r.GET("/search", frontend.SearchView)
// 评论管理
r.GET("/posts/:postID/comments", frontend.ListComments)
r.POST("/posts/:postID/comment", frontend.SubmitComment)
r.GET("/logout", backend.WebLogout)
}
// GetServerPort 获取服务器端口
func GetServerPort(port int) string {
return ":" + strconv.Itoa(port)
// setupAdminPageRoutes 后台管理页面(服务端渲染)
func setupAdminPageRoutes(r *gin.Engine) {
r.GET(adminPath+"/login", backend.AdminLoginView)
r.GET(adminPath, middleware.JWTAuth(), backend.AdminView)
// API 路由
setupAPIRoutes(r)
}
// setupAPIRoutes API 路由分组
func setupAPIRoutes(r *gin.Engine) {
api := r.Group(apiPrefix)
// 登录
api.POST("/auth/login", backend.Login)
setupAuthenticatedAPIRoutes(api) // 需要 JWT 的接口
setupAdminAPIRoutes(api) // 管理员专属接口
}
// setupAuthenticatedAPIRoutes 需要 JWT 的接口(所有登录用户)
func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) {
auth := api.Group("/")
auth.Use(middleware.JWTAuth())
// 仪表盘公开统计数据
auth.GET("/dashboard/stats", backend.GetDashboardStats)
// 当前用户信息
auth.GET("/auth/me", backend.GetCurrentUser)
auth.PUT("/auth/me", backend.UpdateUser)
auth.PUT("/auth/password", backend.ChangePassword)
auth.POST("/auth/logout", backend.Logout)
// 文章管理(读取 + 写入)
auth.GET("/posts", backend.GetPosts)
auth.GET("/posts/:id", backend.GetPost)
auth.POST("/posts", backend.CreatePost)
auth.PUT("/posts/:id", backend.UpdatePost)
auth.DELETE("/posts/:id", backend.DeletePost)
// 分类读取
auth.GET("/categories", backend.GetCategories)
// 标签读取(文章表单的标签候选,所有登录用户可用)
auth.GET("/tags", backend.GetTags)
// 评论管理(查看所有评论,需登录)
auth.GET("/comments", backend.GetComments)
auth.GET("/comments/posts", backend.GetCommentPosts) // 有评论的文章列表(筛选下拉框)
auth.PUT("/comments/:id/approve", backend.ApproveComment)
auth.PUT("/comments/:id/spam", backend.MarkSpamComment)
auth.DELETE("/comments/:id", backend.DeleteComment)
// Markdown 预览
auth.POST("/markdown/preview", backend.PreviewMarkdown)
// 文件上传
auth.POST("/upload", backend.UploadImage)
auth.GET("/uploads", backend.ListUploads)
auth.DELETE("/uploads/:id", backend.DeleteUpload)
}
// setupAdminAPIRoutes 管理员专属接口(JWT + AdminRequired)
func setupAdminAPIRoutes(api *gin.RouterGroup) {
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
// 分类管理(写入)
admin.POST("/categories", backend.CreateCategory)
admin.GET("/categories/:id", backend.GetCategory)
admin.PUT("/categories/:id", backend.UpdateCategory)
admin.DELETE("/categories/:id", backend.DeleteCategory)
// 标签管理(写入)
admin.GET("/tags", backend.GetTags)
admin.POST("/tags", backend.CreateTag)
admin.GET("/tags/:id", backend.GetTag)
admin.PUT("/tags/:id", backend.UpdateTag)
admin.DELETE("/tags/:id", backend.DeleteTag)
// 页面管理(写入)
admin.GET("/pages", backend.GetPages)
admin.POST("/pages", backend.CreatePage)
admin.GET("/pages/:id", backend.GetPage)
admin.PUT("/pages/:id", backend.UpdatePage)
admin.DELETE("/pages/:id", backend.DeletePage)
// 用户管理
admin.GET("/users", backend.GetUsers)
admin.POST("/users", backend.CreateUser)
admin.PUT("/users/:id", backend.AdminUpdateUser)
admin.PUT("/users/:id/password", backend.AdminResetPassword)
admin.DELETE("/users/:id", backend.DeleteUser)
// 基础设置
admin.GET("/settings", backend.GetSettings)
admin.PUT("/settings", backend.UpdateSettings)
// 主题管理(登录用户可切换主题、创建主题、编辑文件)
admin.GET("/themes", backend.GetThemes)
admin.GET("/theme", backend.GetCurrentTheme)
admin.POST("/theme", backend.SwitchTheme)
admin.POST("/themes/create", backend.CreateTheme)
admin.GET("/themes/:theme/files", backend.GetThemeFiles)
admin.GET("/themes/:theme/files/:file", backend.GetThemeFile)
admin.PUT("/themes/:theme/files/:file", backend.SaveThemeFile)
}
+321 -35
View File
@@ -22,20 +22,66 @@ body {
display: flex;
flex-direction: column;
}
.logo {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
.admin-header {
padding: 15px 20px;
display: flex;
justify-content: space-between;
background-color: #001529;
color: #fff;
align-items: center;
}
.admin-header #username {
margin-left: auto;
}
.user-menu-wrapper {
position: relative;
margin-left: auto;
}
.user-menu-wrapper #username {
margin-left: 0;
}
.user-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 1100;
display: none;
min-width: 140px;
padding: 4px 0;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 4px;
box-shadow: 0 3px 12px rgba(0,0,0,0.16);
}
.user-menu-wrapper:hover .user-menu,
.user-menu-wrapper:focus-within .user-menu {
display: block;
}
.user-menu button {
display: block;
width: 100%;
padding: 9px 14px;
border: 0;
background: transparent;
color: #333;
text-align: left;
cursor: pointer;
font-size: 13px;
}
.user-menu button:hover {
background: #f5f5f5;
color: #1890ff;
}
.admin-header span {
background: transparent;
padding: 5px 12px;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.logo h2 {
font-size: 20px;
}
.logo p {
font-size: 12px;
color: rgba(255,255,255,0.6);
margin-top: 5px;
.admin-header span:hover {
background: rgba(255,255,255,0.1);
}
.menu {
@@ -43,12 +89,52 @@ body {
padding: 10px 0;
}
.menu-group {
margin: 4px 12px 8px;
border-radius: 6px;
overflow: hidden;
background: rgba(255,255,255,0.04);
}
.menu-group-header {
width: 100%;
border: none;
background: transparent;
color: #fff;
text-align: left;
padding: 12px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.5px;
}
.menu-group-header::after {
content: '▾';
float: right;
color: rgba(255,255,255,0.6);
}
.menu-group.collapsed .menu-group-header::after {
content: '▸';
}
.menu-sub {
display: block;
padding-bottom: 4px;
}
.menu-group.collapsed .menu-sub {
display: none;
}
.menu-item {
display: block;
padding: 12px 20px;
color: rgba(255,255,255,0.7);
padding: 10px 20px 10px 32px;
color: rgba(255,255,255,0.75);
text-decoration: none;
transition: all 0.3s;
font-size: 14px;
}
.menu-item:hover,
@@ -57,27 +143,6 @@ body {
background: #1890ff;
}
.user-info {
padding: 15px 20px;
border-top: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info button {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
}
.user-info button:hover {
background: rgba(255,255,255,0.1);
}
/* 主内容区 */
.main-content {
flex: 1;
@@ -159,6 +224,16 @@ body {
gap: 24px;
}
a.stat-card {
text-decoration: none;
color: inherit;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s;
}
a.stat-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.13);
transform: translateY(-2px);
}
.stat-card {
background: #fff;
padding: 24px;
@@ -273,6 +348,60 @@ body {
font-size: 16px;
}
.login-box button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
color: #e74c3c;
background: #fdecea;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 16px;
font-size: 14px;
}
/* 列表分页条 */
.pagination {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.pagination button:hover:not(:disabled):not(.active) {
border-color: #3498db;
color: #3498db;
}
.pagination button.active {
background: #3498db;
border-color: #3498db;
color: #fff;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .pagination-info {
margin-left: auto;
color: #888;
font-size: 13px;
}
/* 弹窗 */
.modal {
position: fixed;
@@ -963,6 +1092,22 @@ body {
text-overflow: ellipsis;
}
/* 评论管理页顶部筛选区(文章 + 状态) */
.comment-filters {
display: flex;
align-items: center;
gap: 12px;
}
.comment-status-filter {
width: 140px;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
font-size: 14px;
}
/* 基础设置表单 */
.settings-form {
max-width: 560px;
@@ -1000,3 +1145,144 @@ body {
margin: 0;
}
/* ==================== 文章标签选择器 ==================== */
.tag-select {
position: relative;
}
/* 外观与普通输入框一致,内部容纳标签胶囊与输入框 */
.tag-select-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 38px;
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: text;
}
.tag-select-box:focus-within {
border-color: #1890ff;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 200px;
padding: 2px 4px 2px 10px;
border: 1px solid #91caff;
border-radius: 12px;
background: #e6f4ff;
color: #0958d9;
font-size: 13px;
line-height: 20px;
}
.tag-chip-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 标签右上角删除按钮 */
.tag-chip-remove {
flex-shrink: 0;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: #0958d9;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.tag-chip-remove:hover {
background: #0958d9;
color: #fff;
}
/* 输入框:覆盖 .form-group input 的默认样式,融入胶囊容器 */
.tag-select-box .tag-input {
flex: 1 1 140px;
min-width: 140px;
width: auto;
padding: 4px 0;
border: none;
border-radius: 0;
background: transparent;
font-size: 14px;
}
.tag-select-box .tag-input:focus {
outline: none;
border: none;
}
/* 候选下拉列表 */
.tag-suggest {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
max-height: 240px;
overflow-y: auto;
padding: 4px 0;
border: 1px solid #e5e5e5;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.tag-suggest-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
color: #333;
font-size: 14px;
cursor: pointer;
}
.tag-suggest-item.active {
background: #f0f7ff;
}
.tag-suggest-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.tag-suggest-count {
flex-shrink: 0;
color: #999;
font-size: 12px;
}
/* 输入内容不存在时,作为新建标签的候选 */
.tag-suggest-new .tag-suggest-name {
color: #1890ff;
}
.tag-hint {
display: block;
margin-top: 6px;
color: #999;
font-size: 12px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
/**
* 前台评论 AJAX 功能
*/
// 评论分页加载
async function loadComments(postID, page = 1) {
try {
const url = `/posts/${postID}/comments?cpage=${page}`;
const res = await fetch(url);
if (!res.ok) throw new Error('加载评论失败');
const data = await res.json();
renderComments(data.data || []);
renderCommentPagination(data.page, data.total_pages, postID);
} catch (err) {
console.error('加载评论失败:', err);
document.querySelector('.comments-list').innerHTML = '<p class="no-comments">评论加载失败</p>';
}
}
// 渲染评论列表
function renderComments(comments) {
const listEl = document.querySelector('.comments-list');
if (!listEl) return;
if (!comments || comments.length === 0) {
listEl.innerHTML = '<p class="no-comments">暂无评论,来发表第一条评论吧!</p>';
return;
}
listEl.innerHTML = comments.map(c => buildCommentHTML(c)).join('');
}
// 构建单条评论 HTML
function buildCommentHTML(comment) {
let html = `
<div class="comment-item" id="comment-${comment.id}">
<div class="comment-header">
<span class="comment-author">
${comment.website ? `<a href="${escapeHtml(comment.website)}" target="_blank">${escapeHtml(comment.author)}</a>` : escapeHtml(comment.author)}
</span>
<span class="comment-date">${formatDate(comment.created_at)}</span>
</div>
<div class="comment-content">${escapeHtml(comment.content)}</div>
`;
// 子评论
if (comment.children && comment.children.length > 0) {
html += '<div class="comment-children">';
comment.children.forEach(child => {
html += `
<div class="comment-item child" id="comment-${child.id}">
<div class="comment-header">
<span class="comment-author">${escapeHtml(child.author)}</span>
<span class="comment-date">${formatDate(child.created_at)}</span>
</div>
<div class="comment-content">${escapeHtml(child.content)}</div>
</div>
`;
});
html += '</div>';
}
html += '</div>';
return html;
}
// 渲染评论分页
function renderCommentPagination(currentPage, totalPages, postID) {
const paginationEl = document.querySelector('.comment-pagination');
if (!paginationEl) return;
if (totalPages <= 1) {
paginationEl.style.display = 'none';
return;
}
paginationEl.style.display = 'block';
let html = '';
if (currentPage > 1) {
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage - 1})" class="prev">← 上一页</a>`;
}
html += `<span class="page-info">${currentPage} / ${totalPages}</span>`;
if (currentPage < totalPages) {
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage + 1})" class="next">下一页 →</a>`;
}
paginationEl.innerHTML = html;
}
// 提交评论(AJAX)
async function submitComment(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
data.post_id = Number(data.post_id);
// 简单验证
if (!data.author || !data.email || !data.content) {
alert('请填写必填字段');
return;
}
// 邮箱格式验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
alert('请输入有效的邮箱地址');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const res = await fetch(`/posts/${data.post_id}/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.ok) {
alert('评论提交成功,等待审核');
form.reset();
// 重新加载评论(回到第一页)
const postID = form.querySelector('input[name="post_id"]').value;
loadComments(postID, 1);
} else {
alert(result.error || '提交失败');
}
} catch (err) {
console.error('提交评论失败:', err);
alert('网络错误,请稍后重试');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '发表评论';
}
}
// 工具函数:转义 HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 工具函数:格式化日期
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
// 绑定评论表单提交事件
const commentForm = document.querySelector('.comment-form');
if (commentForm) {
commentForm.addEventListener('submit', submitComment);
}
// 如果当前是文章详情页,使用表单中的数字 postID 加载评论
const postIDInput = commentForm && commentForm.querySelector('input[name="post_id"]');
if (postIDInput && postIDInput.value) {
// 从 URL 获取初始页码
const urlParams = new URLSearchParams(window.location.search);
const cpage = parseInt(urlParams.get('cpage')) || 1;
loadComments(postIDInput.value, cpage);
}
});
@@ -1,79 +1,146 @@
<!DOCTYPE html>
{{define "admin_index"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - GoBlog</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/admin/style.css?v=2">
<link rel="stylesheet" href="/static/css/admin.css?v=5">
</head>
<body>
<div class="admin-header">
<h2>GoBlog</h2>
<div class="user-menu-wrapper">
<span id="username" tabindex="0" aria-haspopup="true">{{.DisplayName}}</span>
<div class="user-menu" role="menu">
<button type="button" onclick="showProfileModal()">修改个人信息</button>
<button type="button" onclick="showPasswordModal()">修改密码</button>
</div>
</div>
<span id="logout-btn">退出</span>
</div>
<div id="app">
<aside class="sidebar">
<div class="logo">
<h2>GoBlog</h2>
<p>后台管理</p>
</div>
<nav class="menu">
<a href="#dashboard" class="menu-item active" data-page="dashboard">仪表盘</a>
<a href="#posts" class="menu-item" data-page="posts">文章管理</a>
<a href="#categories" class="menu-item" data-page="categories">分类管理</a>
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
<a href="#users" class="menu-item" data-page="users">用户管理</a>
<a href="#files" class="menu-item" data-page="files">文件管理</a>
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
<a href="#settings" class="menu-item" data-page="settings">基础设置</a>
{{range .Menus}}{{if eq .Group ""}}{{range $i, $item := .Items}}<a href="#{{$item.Key}}" class="menu-item{{if eq $item.Key "dashboard"}} active{{end}}" data-page="{{$item.Key}}">{{$item.Name}}</a>
{{end}}{{else}}
<div class="menu-group open">
<button class="menu-group-header" type="button">{{.Group}}</button>
<div class="menu-sub">
{{range .Items}}<a href="#{{.Key}}" class="menu-item" data-page="{{.Key}}">{{.Name}}</a>
{{end}}
</div>
</div>
{{end}}{{end}}
</nav>
<div class="user-info">
<span id="username">Admin</span>
<button onclick="logout()">退出</button>
</div>
</aside>
<main class="main-content">
<!-- 登录页面 -->
<div id="login-page" class="page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required>
</div>
<button type="submit">登录</button>
</form>
</div>
</div>
{{if index .PageSet "dashboard"}}{{template "admin_page_dashboard" .}}{{end}}
{{if index .PageSet "posts"}}{{template "admin_page_posts" .}}{{template "admin_page_post_form" .}}{{end}}
{{if index .PageSet "categories"}}{{template "admin_page_categories" .}}{{end}}
{{if index .PageSet "tags"}}{{template "admin_page_tags" .}}{{end}}
{{if index .PageSet "pages"}}{{template "admin_page_pages" .}}{{end}}
{{if index .PageSet "comments"}}{{template "admin_page_comments" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_page_users" .}}{{end}}
{{if index .PageSet "settings"}}{{template "admin_page_settings" .}}{{end}}
{{if index .PageSet "files"}}{{template "admin_page_files" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_page_themes" .}}{{template "admin_page_theme_editor" .}}{{end}}
</main>
</div>
{{if or (index .PageSet "categories") (index .PageSet "tags") (index .PageSet "pages")}}{{template "admin_modal_common" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
{{template "admin_modal_profile" .}}
{{template "admin_modal_password" .}}
<script>window.__currentUser = {{.CurrentUser }}</script>
<script src="/static/js/admin.js?v=23"></script>
</body>
</html>
{{end}}
{{define "admin_modal_profile"}}
<div id="profile-modal" class="modal hidden">
<div class="modal-content">
<h3>修改个人信息</h3>
<form id="profile-form">
<div class="form-group">
<label for="profile-nickname">昵称</label>
<input type="text" id="profile-nickname" maxlength="100">
</div>
<div class="form-group">
<label for="profile-email">邮箱</label>
<input type="email" id="profile-email" maxlength="100" required>
</div>
<div class="form-group">
<label for="profile-avatar">头像地址</label>
<input type="url" id="profile-avatar" maxlength="255" placeholder="https://">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideProfileModal()">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
{{end}}
{{define "admin_modal_password"}}
<div id="password-modal" class="modal hidden">
<div class="modal-content">
<h3>修改密码</h3>
<form id="password-form">
<div class="form-group">
<label for="old-password">旧密码</label>
<input type="password" id="old-password" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="new-password">新密码</label>
<input type="password" id="new-password" required minlength="6" autocomplete="new-password">
</div>
<div class="form-group">
<label for="confirm-password">确认新密码</label>
<input type="password" id="confirm-password" required minlength="6" autocomplete="new-password">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hidePasswordModal()">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
{{end}}
{{define "admin_page_dashboard"}}
<!-- 仪表盘 -->
<div id="dashboard-page" class="page hidden">
<div id="dashboard-page" class="page">
<h1>仪表盘</h1>
<div class="stats-grid">
<div class="stat-card">
<a href="#posts" class="stat-card" data-page="posts" onclick="showPage('posts')">
<h3>文章总数</h3>
<p class="stat-number" id="stat-posts">0</p>
</div>
<div class="stat-card">
</a>
<a href="#categories" class="stat-card" data-page="categories" onclick="showPage('categories')">
<h3>分类总数</h3>
<p class="stat-number" id="stat-categories">0</p>
</div>
<div class="stat-card">
</a>
<a href="#tags" class="stat-card" data-page="tags" onclick="showPage('tags')">
<h3>标签总数</h3>
<p class="stat-number" id="stat-tags">0</p>
</div>
<div class="stat-card">
</a>
<a href="#comments" class="stat-card" data-page="comments" onclick="showPage('comments')">
<h3>待审核评论</h3>
<p class="stat-number" id="stat-comments">0</p>
</div>
</a>
</div>
</div>
{{end}}
{{define "admin_page_posts"}}
<!-- 文章管理 -->
<div id="posts-page" class="page hidden">
<div class="page-header">
@@ -84,6 +151,7 @@
<thead>
<tr>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>浏览量</th>
@@ -93,8 +161,11 @@
</thead>
<tbody id="posts-list"></tbody>
</table>
<div id="posts-pagination" class="pagination"></div>
</div>
{{end}}
{{define "admin_page_post_form"}}
<!-- 文章表单 -->
<div id="post-form-page" class="page hidden">
<div class="page-header">
@@ -122,8 +193,15 @@
</div>
</div>
<div class="form-group">
<label>标签(用逗号分隔)</label>
<input type="text" id="post-tags" placeholder="标签1, 标签2, 标签3">
<label>标签</label>
<div class="tag-select" id="post-tag-select">
<div class="tag-select-box" id="post-tag-box">
<span class="tag-chips" id="post-tags-selected"></span>
<input type="text" id="post-tags-input" class="tag-input" placeholder="输入标签名,回车添加" autocomplete="off">
</div>
<div class="tag-suggest hidden" id="post-tags-suggest"></div>
</div>
<small class="tag-hint">点击标签右上角 × 可移除;输入的内容若不存在,将作为新标签在保存时创建</small>
</div>
<div class="form-group">
<label>封面图</label>
@@ -152,7 +230,7 @@
<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="mdInsertImage()">🖼</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>
@@ -180,6 +258,34 @@
</form>
</div>
<!-- 图片插入对话框 -->
<div id="image-modal" class="modal hidden">
<div class="modal-content" style="max-width:420px">
<h3>插入图片</h3>
<div class="form-group">
<label>图片描述</label>
<input type="text" id="img-desc" placeholder="输入图片描述(alt 文本)">
</div>
<div class="form-group">
<label>选择图片</label>
<div style="display:flex;gap:8px;align-items:center">
<button type="button" class="btn-secondary" onclick="document.getElementById('img-file-input').click()">选择文件</button>
<input type="file" id="img-file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" onchange="onImageFileSelected(this)">
<span id="img-file-name" style="color:#888;font-size:13px">未选择</span>
</div>
<div id="img-preview-box" style="margin-top:8px;display:none">
<img id="img-preview-thumb" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #ddd">
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideImageModal()">取消</button>
<button type="button" class="btn-primary" id="img-upload-btn" onclick="confirmInsertImage()" disabled>上传并插入</button>
</div>
</div>
</div>
{{end}}
{{define "admin_page_categories"}}
<!-- 分类管理 -->
<div id="categories-page" class="page hidden">
<div class="page-header">
@@ -198,12 +304,13 @@
<tbody id="categories-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_tags"}}
<!-- 标签管理 -->
<div id="tags-page" class="page hidden">
<div class="page-header">
<h1>标签管理</h1>
<button class="btn-primary" onclick="showTagForm()">新建标签</button>
</div>
<table class="data-table">
<thead>
@@ -217,7 +324,9 @@
<tbody id="tags-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_pages"}}
<!-- 页面管理 -->
<div id="pages-page" class="page hidden">
<div class="page-header">
@@ -237,14 +346,24 @@
<tbody id="pages-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_comments"}}
<!-- 评论管理 -->
<div id="comments-page" class="page hidden">
<div class="page-header">
<h1>评论管理</h1>
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
<option value="">全部文章</option>
</select>
<div class="comment-filters">
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
<option value="">全部文章</option>
</select>
<select id="comment-status-filter" class="comment-status-filter" onchange="filterCommentsByStatus()">
<option value="">全部状态</option>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="spam">垃圾</option>
</select>
</div>
</div>
<table class="data-table">
<thead>
@@ -259,8 +378,11 @@
</thead>
<tbody id="comments-list"></tbody>
</table>
<div id="comments-pagination" class="pagination"></div>
</div>
{{end}}
{{define "admin_page_users"}}
<!-- 用户管理 -->
<div id="users-page" class="page hidden">
<div class="page-header">
@@ -282,9 +404,11 @@
<tbody id="users-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_settings"}}
<!-- 基础设置 -->
<div id="settings-page" class="page hidden">
<div id="settings-page" class="page hidden">
<div class="page-header">
<h1>基础设置</h1>
</div>
@@ -310,7 +434,9 @@
<button type="submit" class="btn-primary">保存设置</button>
</form>
</div>
{{end}}
{{define "admin_page_files"}}
<!-- 文件管理 -->
<div id="files-page" class="page hidden">
<div class="page-header">
@@ -323,7 +449,9 @@
<p class="upload-hint">支持 jpg/png/gif/webp 格式,单个文件最大 5MB</p>
<div class="file-grid" id="file-grid"></div>
</div>
{{end}}
{{define "admin_page_themes"}}
<!-- 主题管理 -->
<div id="themes-page" class="page hidden">
<div class="page-header">
@@ -336,15 +464,17 @@
<select id="theme-select" class="theme-selector">
<option value="">加载中...</option>
</select>
<button class="btn-primary" onclick="switchTheme()">切换主题</button>
</div>
<button class="btn-primary" onclick="switchTheme()">切换主题</button>
<div id="theme-message" class="theme-message"></div>
</div>
<div class="theme-list" id="theme-list">
<!-- 主题列表将动态加载 -->
</div>
</div>
{{end}}
{{define "admin_page_theme_editor"}}
<!-- 主题编辑器 -->
<div id="theme-editor-page" class="page hidden">
<div class="page-header">
@@ -367,9 +497,9 @@
</div>
</div>
</div>
</main>
</div>
{{end}}
{{define "admin_modal_common"}}
<!-- 分类/标签/页面 表单弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-content">
@@ -395,7 +525,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_user"}}
<!-- 用户表单弹窗 -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
@@ -441,7 +573,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_theme"}}
<!-- 创建主题弹窗 -->
<div id="theme-modal" class="modal hidden">
<div class="modal-content modal-large">
@@ -455,16 +589,8 @@
<div class="form-group">
<label>基于模板创建</label>
<select id="theme-template">
<option value="default">default(默认主题)</option>
<option value="minimal">minimal(极简主题)</option>
<option value="blank">blank(空白主题)</option>
<option value="">加载中...</option>
</select>
<small>选择新主题的初始模板</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="create-css" checked> 创建样式文件
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideThemeModal()">取消</button>
@@ -473,7 +599,4 @@
</form>
</div>
</div>
<script src="/static/admin/app.js?v=16"></script>
</body>
</html>
{{end}}
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - GoBlog 后台管理</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/admin.css?v=4">
</head>
<body>
<div id="login-page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required autocomplete="current-password">
</div>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-submit">登录</button>
</form>
</div>
</div>
<script>
// 已登录则直接进入后台(通过 Cookie 自动认证,调用接口验证有效性)
(async function () {
try {
var res = await fetch('/api/auth/me');
if (res.ok) {
location.replace('/admin');
}
} catch (e) { /* 网络异常继续显示登录页 */ }
})();
function showError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
el.classList.remove('hidden');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-submit');
btn.disabled = true;
btn.textContent = '登录中...';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
// 服务端已通过 Set-Cookie 写入 JWT,直接跳转即可
location.href = '/admin';
} else {
showError(data.error || '登录失败');
btn.disabled = false;
btn.textContent = '登录';
}
} catch (err) {
showError('网络错误,请稍后重试');
btn.disabled = false;
btn.textContent = '登录';
}
});
</script>
</body>
</html>
+1
View File
@@ -8,6 +8,7 @@
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/layui.css">
<script src="/static/js/frontend.js"></script>
</head>
<body>
+3 -4
View File
@@ -3,13 +3,11 @@
{{if .Posts}}
{{range .Posts}}
<article class="post-item">
{{if .Cover}}
<div class="post-cover">
<a href="/post/{{.Slug}}">
<img src="{{.Cover}}" alt="{{.Title}}">
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
</a>
</div>
{{end}}
<div class="post-content">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
@@ -32,7 +30,8 @@
<span class="post-views"><i class="layui-icon">&#xe63a;</i>{{.Views}} 阅读</span>
<span class="post-comments"><i class="layui-icon">&#xe611;</i>{{len .Comments}} 评论</span>
</div>
<p class="post-summary">{{.Summary}}</p>
<a href="/post/{{.Slug}}">
<p class="post-summary">{{.Summary}}</p></a>
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
</div>
</article>
+21 -60
View File
@@ -2,28 +2,27 @@
<article class="post-detail">
<header class="post-header">
<h1 class="post-title">{{.Post.Title}}</h1>
<div class="post-meta">
<span class="post-date">{{.Post.PublishedAt.Format "2006-01-02 15:04"}}</span>
<span class="post-author">{{.Post.Author.Nickname}}</span>
<span class="post-category">
<a href="/?category={{.Post.CategoryID}}">{{.Post.Category.Name}}</a>
</span>
{{if .Post.Tags}}
<span class="post-tags">
{{range .Post.Tags}}
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
<div class="post-meta">
<span><i class="layui-icon">&#xe66f;</i><a href="/?author={{.Post.Author.ID}}">{{.Post.Author.Username}}</a></span>
<span class="post-date">{{if .Post.PublishedAt}}<i class="layui-icon">&#xe60e;</i>{{.Post.PublishedAt.Format "2006-01-02 15:04"}}{{else}}未发布{{end}}</span>
<span class="post-category">
{{if .Post.Category}}<i class="layui-icon">&#xe705;</i><a href="/?category={{.Post.CategoryID}}">{{.Post.Category.Name}}</a>{{else}}未分类{{end}}
</span>
{{if .Post.Tags}}
<span class="post-tags">
{{range .Post.Tags}}
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
{{end}}
</span>
{{end}}
</span>
{{end}}
<span class="post-views">{{.Post.Views}} 阅读</span>
</div>
<span class="post-views"><i class="layui-icon">&#xe63a;</i>{{.Post.Views}} 阅读</span>
<span class="post-comments"><i class="layui-icon">&#xe611;</i>{{len .Post.Comments}} 评论</span>
</div>
</header>
{{if .Post.Cover}}
<div class="post-cover">
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
</div>
{{end}}
<div class="post-body">
{{.Post.Content | markdown}}
@@ -61,9 +60,9 @@
{{if .ShowComments}}
<section class="comments-section" id="comments">
<h3>评论 ({{.CommentCount}})</h3>
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
<form class="comment-form" action="/comment" method="POST">
<form class="comment-form" action="/api/comments" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
@@ -81,50 +80,12 @@
</form>
<div class="comments-list">
{{range .Comments}}
<div class="comment-item" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">
{{if .Website}}
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
{{else}}
{{.Author}}
{{end}}
</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
{{if .Children}}
<div class="comment-children">
{{range .Children}}
<div class="comment-item child" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">{{.Author}}</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
{{end}}
<!-- 评论由 AJAX 动态加载 -->
</div>
{{if gt .CommentTotalPages 1}}
<div class="pagination comment-pagination">
{{if gt .CommentPage 1}}
<a href="/post/{{.Post.Slug}}?cpage={{sub .CommentPage 1}}#comments" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CommentPage}} / {{.CommentTotalPages}}</span>
{{if lt .CommentPage .CommentTotalPages}}
<a href="/post/{{.Post.Slug}}?cpage={{add .CommentPage 1}}#comments" class="next">下一页 →</a>
{{end}}
<div class="pagination comment-pagination" style="display:none">
<!-- 分页由 AJAX 动态渲染 -->
</div>
{{end}}
</section>
{{end}}
{{end}}
+8 -2
View File
@@ -61,16 +61,22 @@
</div>
{{end}}
<!-- 系统菜单 -->
<!-- 系统菜单(根据登录态区分:已登录显示进入后台/退出登录,未登录显示用户登录) -->
<div class="sidebar-widget">
<h3 class="widget-title">⚙️ 系统</h3>
<ul class="system-menu">
{{if .IsLoggedIn}}
<li class="system-menu-item">
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
<a href="/admin" class="system-menu-link">🔐 进入后台^_^</a>
</li>
<li class="system-menu-item">
<a href="/logout" class="system-menu-link">🚪 退出登录{{if .LoginName}}({{.LoginName}}){{end}}</a>
</li>
{{else}}
<li class="system-menu-item">
<a href="/admin/login" class="system-menu-link">👤 用户登录</a>
</li>
{{end}}
</ul>
</div>
</aside>
+1
View File
@@ -7,6 +7,7 @@
<meta name="description" content="{{.Description}}">
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/frontend.js"></script>
</head>
<body>
<header class="header">
+1 -3
View File
@@ -2,13 +2,11 @@
<div class="post-list">
{{range .Posts}}
<article class="post-item">
{{if .Cover}}
<div class="post-cover">
<a href="/post/{{.Slug}}">
<img src="{{.Cover}}" alt="{{.Title}}">
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
</a>
</div>
{{end}}
<div class="post-content">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
+6 -46
View File
@@ -19,11 +19,9 @@
</div>
</header>
{{if .Post.Cover}}
<div class="post-cover">
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
</div>
{{end}}
<div class="post-body">
{{.Post.Content | markdown}}
@@ -61,9 +59,9 @@
{{if .ShowComments}}
<section class="comments-section" id="comments">
<h3>评论 ({{.CommentCount}})</h3>
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
<form class="comment-form" action="/comment" method="POST">
<form class="comment-form" action="/api/comments" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
@@ -81,50 +79,12 @@
</form>
<div class="comments-list">
{{range .Comments}}
<div class="comment-item" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">
{{if .Website}}
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
{{else}}
{{.Author}}
{{end}}
</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
{{if .Children}}
<div class="comment-children">
{{range .Children}}
<div class="comment-item child" id="comment-{{.ID}}">
<div class="comment-header">
<span class="comment-author">{{.Author}}</span>
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<div class="comment-content">{{.Content}}</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
{{end}}
<!-- 评论由 AJAX 动态加载 -->
</div>
{{if gt .CommentTotalPages 1}}
<div class="pagination comment-pagination">
{{if gt .CommentPage 1}}
<a href="/post/{{.Post.Slug}}?cpage={{sub .CommentPage 1}}#comments" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CommentPage}} / {{.CommentTotalPages}}</span>
{{if lt .CommentPage .CommentTotalPages}}
<a href="/post/{{.Post.Slug}}?cpage={{add .CommentPage 1}}#comments" class="next">下一页 →</a>
{{end}}
<div class="pagination comment-pagination" style="display:none">
<!-- 分页由 AJAX 动态渲染 -->
</div>
{{end}}
</section>
{{end}}
{{end}}
+81
View File
@@ -3,10 +3,13 @@ package utils
import (
"bytes"
"html/template"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
)
// markdownEngine 全局 Markdown 渲染引擎
@@ -20,6 +23,10 @@ var markdownEngine = goldmark.New(
),
)
// searchableTextParser 复用同一套扩展配置,但只做解析,不渲染。
// 与 markdownEngine 共享 goldmark 实例的 Parser,避免重复构造解析器开销。
var searchableTextParser = markdownEngine.Parser()
// RenderMarkdown 将 Markdown 文本渲染为 HTML
func RenderMarkdown(source string) template.HTML {
var buf bytes.Buffer
@@ -29,3 +36,77 @@ func RenderMarkdown(source string) template.HTML {
}
return template.HTML(buf.String())
}
// GenerateSearchableText 提取 Markdown 中的可见纯文本(基于 goldmark AST)
func GenerateSearchableText(raw string) string {
if raw == "" {
return ""
}
// 全角空格转半角
raw = strings.ReplaceAll(raw, "\u3000", " ")
source := []byte(raw)
doc := searchableTextParser.Parse(text.NewReader(source))
var sb strings.Builder
extractSearchableText(doc, source, &sb)
// 压缩空白(等价于原 reMultiSpace + TrimSpace)
return strings.Join(strings.Fields(sb.String()), " ")
}
// extractSearchableText 深度优先遍历 AST,把可见文本写入 sb
func extractSearchableText(node ast.Node, source []byte, sb *strings.Builder) {
switch n := node.(type) {
case *ast.Image:
// 图片:整棵子树跳过(含 alt 文本),对应原 reImage / reRefImage
return
case *ast.HTMLBlock, *ast.RawHTML:
// 原始 HTML:跳过,对应原 reHTMLTag
return
case *ast.AutoLink:
// 自动链接 <url> / <email>:保留 URL / 邮箱
// n.URL(source) 返回链接目标;邮箱自动链接会带 mailto: 前缀,这里去掉
url := string(n.URL(source))
url = strings.TrimPrefix(url, "mailto:")
sb.WriteString(url)
sb.WriteByte(' ')
return
case *ast.FencedCodeBlock, *ast.CodeBlock:
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
seg := lines.At(i)
sb.Write(seg.Value(source))
}
sb.WriteByte(' ')
return
case *ast.Text:
sb.Write(n.Segment.Value(source))
if n.SoftLineBreak() || n.HardLineBreak() {
sb.WriteByte(' ')
}
return
case *ast.String:
// 代码块 / 部分扩展产生的字符串节点
sb.Write(n.Value)
return
}
// 递归子节点
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
extractSearchableText(child, source, sb)
}
// 块级元素结束后补一个空格,避免相邻段落/列表项文本粘连
switch node.Kind() {
case ast.KindParagraph, ast.KindHeading, ast.KindListItem,
ast.KindBlockquote, ast.KindList:
sb.WriteByte(' ')
}
}
+35 -33
View File
@@ -67,12 +67,14 @@ func (r *htmlRender) WriteContentType(w http.ResponseWriter) {
}
// LoadTemplates 加载指定主题的模板
func LoadTemplates(theme string) *TemplateRenderer {
func LoadTemplates(theme string) (*TemplateRenderer, error) {
r := &TemplateRenderer{
templates: make(map[string]*template.Template),
}
r.reload(theme)
return r
if err := r.reload(theme); err != nil {
return nil, err
}
return r, nil
}
// Reload 重新加载主题模板(线程安全)
@@ -83,34 +85,35 @@ func (r *TemplateRenderer) Reload(theme string) {
}
// reload 内部重载方法(需要持有锁)
func (r *TemplateRenderer) reload(theme string) {
func (r *TemplateRenderer) reload(theme string) error {
templateDir := "templates/" + theme
// 需要独立渲染的页面模板
pages := []string{"index", "post", "page"}
// 收集主题目录下的公共局部模板(例如 sidebar/header/footer),
// 这些模板通过 {{template "xxx" .}} 被 base.html 引用,
// 必须与页面模板一起解析,否则渲染时会因模板未定义而失败(白屏)。
// 收集主题目录下的公共局部模板
partials := []string{}
if matches, err := filepath.Glob(templateDir + "/*.html"); err == nil {
for _, f := range matches {
base := filepath.Base(f)
if base == "base.html" {
continue
}
name := base[:len(base)-len(".html")]
isPage := false
for _, p := range pages {
if p == name {
isPage = true
break
}
}
if !isPage {
partials = append(partials, f)
matches, err := filepath.Glob(templateDir + "/*.html")
if err != nil {
return fmt.Errorf("扫描主题目录失败: %w", err)
}
for _, f := range matches {
base := filepath.Base(f)
if base == "base.html" {
continue
}
name := base[:len(base)-len(".html")]
isPage := false
for _, p := range pages {
if p == name {
isPage = true
break
}
}
if !isPage {
partials = append(partials, f)
}
}
// 为每个页面创建独立的模板组合
@@ -118,25 +121,24 @@ func (r *TemplateRenderer) reload(theme string) {
for _, pageName := range pages {
// 加载 base.html、对应页面模板以及所有公共局部模板
allFiles := []string{
templateDir + "/base.html",
templateDir + "/" + pageName + ".html",
}
allFiles := make([]string, 0, 2+len(partials))
allFiles = append(allFiles,
templateDir+"/base.html",
templateDir+"/"+pageName+".html",
)
allFiles = append(allFiles, partials...)
// 解析模板文件
// 解析模板文件,遇到错误立即返回
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
if err != nil {
// 如果解析失败,记录错误但不中断
fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err)
continue
return fmt.Errorf("解析页面模板 %s 失败: %w", pageName, err)
}
newTemplates[pageName] = tmpl
}
// 更新模板映射
// 所有页面模板解析成功后才更新映射
r.templates = newTemplates
fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates))
return nil
}
// 全局模板渲染器实例