增加配置文件

This commit is contained in:
2026-06-02 13:52:47 +08:00
parent 6117222625
commit aab3aeb5b2
31 changed files with 3326 additions and 198 deletions
+29 -21
View File
@@ -1,6 +1,7 @@
package handlers
import (
"log/slog"
"net/http"
"time"
@@ -9,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var jwtSecret = []byte("your-secret-key-change-in-production")
@@ -54,16 +54,22 @@ func Login(c *gin.Context) {
return
}
if user.Status == 0 {
if user.Status == 0 || !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
// 使用 User 模型的 CheckPassword 方法
if !user.CheckPassword(req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 记录登录信息
if err := user.RecordLogin(database.DB); err != nil {
slog.Warn("记录登录信息失败", "user_id", user.ID, "error", err)
}
// 生成 JWT
claims := Claims{
UserID: user.ID,
@@ -85,12 +91,14 @@ func Login(c *gin.Context) {
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,
"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,
},
})
}
@@ -118,20 +126,20 @@ func Register(c *gin.Context) {
return
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
// 使用 User 模型的 SetPassword 方法
user := models.User{
Username: req.Username,
Password: string(hashedPassword),
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 {
@@ -221,18 +229,18 @@ func ChangePassword(c *gin.Context) {
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
// 使用 User 模型的 CheckPassword 方法
if !user.CheckPassword(req.OldPassword) {
c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
// 使用 User 模型的 SetPassword 方法
if err := user.SetPassword(req.NewPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
user.Password = string(hashedPassword)
if err := database.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"})
return
+435
View File
@@ -0,0 +1,435 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"goblog/database"
"goblog/models"
"goblog/utils"
"github.com/gin-gonic/gin"
)
// 获取可用主题列表
func GetThemes(c *gin.Context) {
themes := []string{}
// 读取 templates 目录
entries, err := os.ReadDir("templates")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "无法读取主题目录",
})
return
}
for _, entry := range entries {
if entry.IsDir() {
// 检查是否是有效主题(包含 base.html)
basePath := filepath.Join("templates", entry.Name(), "base.html")
if _, err := os.Stat(basePath); err == nil {
themes = append(themes, entry.Name())
}
}
}
c.JSON(http.StatusOK, gin.H{
"themes": themes,
})
}
// 获取当前主题
func GetCurrentTheme(c *gin.Context) {
currentTheme := "default"
// 使用新的 Option 查询方法
theme, err := models.GetOptionValue(database.DB, "theme", 0)
if err == nil && theme != "" {
currentTheme = theme
}
c.JSON(http.StatusOK, gin.H{
"theme": currentTheme,
})
}
// 切换主题
func SwitchTheme(c *gin.Context) {
type Request struct {
Theme string `json:"theme" binding:"required"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供主题名称",
})
return
}
// 验证主题是否存在
themePath := filepath.Join("templates", req.Theme)
if _, err := os.Stat(themePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题不存在",
})
return
}
// 验证主题是否有效
basePath := filepath.Join(themePath, "base.html")
if _, err := os.Stat(basePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题文件不完整",
})
return
}
// 更新数据库中的主题设置
err := models.SetOptionValue(database.DB, "theme", 0, req.Theme)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存主题设置失败"})
return
}
// 重新加载模板(立即生效)
renderer := utils.GetGlobalRenderer()
if renderer != nil {
renderer.Reload(req.Theme)
}
c.JSON(http.StatusOK, gin.H{
"message": "主题切换成功",
"theme": req.Theme,
})
}
// 创建新主题
func CreateTheme(c *gin.Context) {
type Request struct {
Name string `json:"name" binding:"required"`
Template string `json:"template"`
CreateCSS bool `json:"create_css"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供主题名称",
})
return
}
// 验证主题名称(只能包含字母、数字和下划线)
validName := true
for _, ch := range req.Name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') {
validName = false
break
}
}
if !validName || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题名称只能包含字母、数字和下划线",
})
return
}
// 检查主题是否已存在
themePath := filepath.Join("templates", req.Name)
if _, err := os.Stat(themePath); err == nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题已存在",
})
return
}
// 创建主题目录
if err := os.MkdirAll(themePath, 0755); 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)
}
// 创建 CSS 文件
if req.CreateCSS {
cssPath := filepath.Join("static", "css", req.Name+".css")
os.MkdirAll(filepath.Dir(cssPath), 0755)
cssContent := `/* ` + req.Name + ` Theme */
/* 在此添加你的样式 */
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)
}
c.JSON(http.StatusOK, gin.H{
"message": "主题创建成功",
"theme": req.Name,
})
}
// 获取主题文件列表
func GetThemeFiles(c *gin.Context) {
themeName := c.Param("theme")
themePath := filepath.Join("templates", themeName)
if _, err := os.Stat(themePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题不存在",
})
return
}
files := []string{}
// 读取模板文件
entries, err := os.ReadDir(themePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "读取文件列表失败",
})
return
}
for _, entry := range entries {
if !entry.IsDir() {
ext := filepath.Ext(entry.Name())
if ext == ".html" || ext == ".css" || ext == ".js" {
files = append(files, entry.Name())
}
}
}
// 检查是否有 CSS 文件
cssPath := filepath.Join("static", "css", themeName+".css")
if _, err := os.Stat(cssPath); err == nil {
files = append(files, themeName+".css")
}
c.JSON(http.StatusOK, gin.H{
"files": files,
})
}
// 获取主题文件内容
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)
}
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "文件不存在",
})
return
}
content, err := os.ReadFile(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "读取文件失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"content": string(content),
})
}
// 保存主题文件
func SaveThemeFile(c *gin.Context) {
themeName := c.Param("theme")
filename := c.Param("file")
type Request struct {
Content string `json:"content"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供文件内容",
})
return
}
var filePath string
// 判断是模板文件还是 CSS 文件
if filename == themeName+".css" {
filePath = filepath.Join("static", "css", filename)
} else {
filePath = filepath.Join("templates", themeName, filename)
}
// 保存文件
if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "保存文件失败",
})
return
}
// 如果是模板文件且是当前使用的主题,重新加载
if filename != themeName+".css" {
currentTheme, err := models.GetOptionValue(database.DB, "theme", 0)
if err == nil && currentTheme == themeName {
renderer := utils.GetGlobalRenderer()
if renderer != nil {
renderer.Reload(themeName)
}
}
}
c.JSON(http.StatusOK, gin.H{
"message": "文件保存成功",
})
}
+193 -10
View File
@@ -2,6 +2,7 @@ package handlers
import (
"html/template"
"log/slog"
"math"
"net/http"
"strconv"
@@ -15,6 +16,16 @@ import (
"gorm.io/gorm"
)
// loadSidebarData 加载侧边栏数据
func loadSidebarData() (categories []models.Category, tags []models.Tag, postCount, categoryCount, tagCount int64) {
database.DB.Order("name ASC").Find(&categories)
database.DB.Order("name ASC").Find(&tags)
database.DB.Model(&models.Post{}).Where("status = ?", "published").Count(&postCount)
database.DB.Model(&models.Category{}).Count(&categoryCount)
database.DB.Model(&models.Tag{}).Count(&tagCount)
return
}
func templateFuncs() template.FuncMap {
return template.FuncMap{
"add": func(a, b int) int {
@@ -40,10 +51,19 @@ type IndexData struct {
CurrentPage int
TotalPages int
Year int
// Sidebar 数据
Categories []models.Category
Tags []models.Tag
PostCount int64
CategoryCount int64
TagCount int64
}
func IndexView(c *gin.Context) {
cfg := config.Load()
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
@@ -67,27 +87,57 @@ func IndexView(c *gin.Context) {
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)
}
// 获取页面
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
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
}
data := IndexData{
Title: "首页",
SiteName: cfg.App.Name,
SiteDesc: cfg.App.Description,
Description: cfg.App.Description,
SiteName: siteName,
SiteDesc: siteDesc,
Description: siteDesc,
Posts: posts,
Pages: pages,
CurrentPage: page,
TotalPages: totalPages,
Year: time.Now().Year(),
// Sidebar 数据
Categories: categories,
Tags: tags,
PostCount: postCount,
CategoryCount: categoryCount,
TagCount: tagCount,
}
c.HTML(http.StatusOK, "index", data)
@@ -106,10 +156,19 @@ type PostDetailData struct {
Year int
PrevPost *models.Post // 上一篇
NextPost *models.Post // 下一篇
// Sidebar 数据
Categories []models.Category
Tags []models.Tag
PostCount int64
CategoryCount int64
TagCount int64
}
func PostView(c *gin.Context) {
cfg := config.Load()
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
slug := c.Param("slug")
var post models.Post
@@ -152,16 +211,37 @@ 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
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
}
// 加载侧边栏数据
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
data := PostDetailData{
Title: post.Title,
SiteName: cfg.App.Name,
SiteDesc: cfg.App.Description,
SiteName: siteName,
SiteDesc: siteDesc,
Description: post.Summary,
Post: post,
Pages: pages,
Comments: post.Comments,
CommentCount: commentCount,
Year: time.Now().Year(),
// Sidebar 数据
Categories: categories,
Tags: tags,
PostCount: postCount,
CategoryCount: categoryCount,
TagCount: tagCount,
}
// 只有当查询成功时才赋值
@@ -184,10 +264,19 @@ type PageDetailData struct {
Page models.Page
Pages []models.Page
Year int
// Sidebar 数据
Categories []models.Category
Tags []models.Tag
PostCount int64
CategoryCount int64
TagCount int64
}
func PageView(c *gin.Context) {
cfg := config.Load()
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
slug := c.Param("slug")
var page models.Page
@@ -205,14 +294,35 @@ func PageView(c *gin.Context) {
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
// 从数据库读取站点设置
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
}
// 加载侧边栏数据
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
data := PageDetailData{
Title: page.Title,
SiteName: cfg.App.Name,
SiteDesc: cfg.App.Description,
SiteName: siteName,
SiteDesc: siteDesc,
Description: "",
Page: page,
Pages: pages,
Year: time.Now().Year(),
// Sidebar 数据
Categories: categories,
Tags: tags,
PostCount: postCount,
CategoryCount: categoryCount,
TagCount: tagCount,
}
c.HTML(http.StatusOK, "page", data)
@@ -249,3 +359,76 @@ func SubmitComment(c *gin.Context) {
c.Redirect(http.StatusFound, "/post/"+post.Slug)
}
// 搜索结果页
func SearchView(c *gin.Context) {
cfg := config.GetGlobalConfig()
if cfg == nil {
cfg = config.Load()
}
keyword := c.Query("keyword")
if keyword == "" {
c.Redirect(http.StatusFound, "/")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 10
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
Where("status = ?", "published").
Where("title LIKE ? OR content LIKE ? OR summary LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
var total int64
db.Count(&total)
var posts []models.Post
offset := (page - 1) * pageSize
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)
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
}
data := IndexData{
Title: "搜索: " + keyword,
SiteName: siteName,
SiteDesc: siteDesc,
Description: "搜索关键词: " + keyword,
Posts: posts,
Pages: pages,
CurrentPage: page,
TotalPages: totalPages,
Year: time.Now().Year(),
Categories: categories,
Tags: tags,
PostCount: postCount,
CategoryCount: categoryCount,
TagCount: tagCount,
}
c.HTML(http.StatusOK, "index", data)
}