增加配置文件

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
+124 -8
View File
@@ -1,8 +1,13 @@
package config
import (
"fmt"
"log/slog"
"os"
"strconv"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)
type Config struct {
@@ -28,30 +33,141 @@ type AppConfig struct {
Theme string
}
var globalConfig *Config
// Load 加载配置(支持环境变量和配置文件)
func Load() *Config {
port, _ := strconv.Atoi(getEnv("SERVER_PORT", "8080"))
// 1. 初始化 viper
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./config")
viper.AddConfigPath(".")
// 2. 设置默认值
setDefaults()
// 3. 自动读取环境变量
viper.AutomaticEnv()
viper.SetEnvPrefix("GOBLOG")
// 4. 读取配置文件(如果存在)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
slog.Warn("读取配置文件失败,使用默认配置", "error", err)
}
} else {
slog.Info("配置文件加载成功", "file", viper.ConfigFileUsed())
}
// 5. 解析配置
cfg := parseConfig()
globalConfig = cfg
// 6. 监听配置变更
watchConfig()
return cfg
}
// setDefaults 设置默认配置值
func setDefaults() {
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.mode", "debug")
viper.SetDefault("database.driver", "sqlite")
viper.SetDefault("database.dsn", "goblog.db")
viper.SetDefault("app.name", "GoBlog")
viper.SetDefault("app.description", "一个简洁的个人博客")
viper.SetDefault("app.author", "Admin")
viper.SetDefault("app.theme", "default")
}
// parseConfig 解析配置到结构体
func parseConfig() *Config {
port := viper.GetInt("server.port")
if port == 0 {
port, _ = strconv.Atoi(getEnv("SERVER_PORT", "8080"))
}
return &Config{
Database: DatabaseConfig{
Driver: getEnv("DB_DRIVER", "sqlite"),
DSN: getEnv("DB_DSN", "goblog.db"),
Driver: viper.GetString("database.driver"),
DSN: viper.GetString("database.dsn"),
},
Server: ServerConfig{
Port: port,
Mode: getEnv("GIN_MODE", "debug"),
Mode: viper.GetString("server.mode"),
},
App: AppConfig{
Name: getEnv("APP_NAME", "GoBlog"),
Description: getEnv("APP_DESC", "一个简洁的个人博客"),
Author: getEnv("APP_AUTHOR", "Admin"),
Theme: getEnv("APP_THEME", "default"),
Name: viper.GetString("app.name"),
Description: viper.GetString("app.description"),
Author: viper.GetString("app.author"),
Theme: viper.GetString("app.theme"),
},
}
}
// watchConfig 监听配置文件变更
func watchConfig() {
viper.OnConfigChange(func(e fsnotify.Event) {
slog.Info("配置文件已变更", "file", e.Name, "operation", e.Op.String())
// 重新解析配置
globalConfig = parseConfig()
slog.Info("配置已重新加载",
"theme", globalConfig.App.Theme,
"port", globalConfig.Server.Port,
)
})
viper.WatchConfig()
}
// GetGlobalConfig 获取全局配置(可用于热更新场景)
func GetGlobalConfig() *Config {
return globalConfig
}
// getEnv 获取环境变量
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// CreateDefaultConfig 创建默认配置文件
func CreateDefaultConfig() error {
configDir := "./config"
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
}
configPath := configDir + "/config.yaml"
if _, err := os.Stat(configPath); err == nil {
return nil // 配置文件已存在
}
defaultConfig := `# GoBlog 配置文件
server:
port: 8080
mode: debug # debug 或 release
database:
driver: sqlite # sqlite 或 mysql
dsn: goblog.db # SQLite文件路径或MySQL DSN
app:
name: GoBlog
description: 一个简洁的个人博客
author: Admin
theme: default # 主题名称
`
if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil {
return fmt.Errorf("创建配置文件失败: %w", err)
}
slog.Info("已创建默认配置文件", "file", configPath)
return nil
}
+14
View File
@@ -0,0 +1,14 @@
# GoBlog 配置文件
server:
port: 8080
mode: debug # debug 或 release
database:
driver: sqlite # sqlite 或 mysql
dsn: goblog.db # SQLite文件路径或MySQL DSN
app:
name: GoBlog
description: 一个简洁的个人博客
author: Admin
theme: default # 主题名称
+10
View File
@@ -3,9 +3,11 @@ module goblog
go 1.25.0
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.48.0
gorm.io/gorm v1.31.1
)
@@ -21,6 +23,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.3.0 // indirect
@@ -36,9 +39,16 @@ require (
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
+30
View File
@@ -9,6 +9,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -27,6 +31,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -48,6 +54,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -67,6 +77,20 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -76,12 +100,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -102,6 +130,8 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+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)
}
+45 -90
View File
@@ -1,123 +1,78 @@
package main
import (
"log"
"strconv"
"log/slog"
"os"
"goblog/config"
"goblog/database"
"goblog/handlers"
"goblog/middleware"
"goblog/utils"
"goblog/models"
"goblog/routers"
"github.com/gin-gonic/gin"
)
func main() {
// 创建默认配置文件(如果不存在)
if err := config.CreateDefaultConfig(); err != nil {
slog.Error("创建默认配置文件失败", "error", err)
}
// 加载配置
cfg := config.Load()
// 设置 Gin 模式
gin.SetMode(cfg.Server.Mode)
// 初始化日志系统
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("启动博客系统",
"app", cfg.App.Name,
"theme", cfg.App.Theme,
"db_driver", cfg.Database.Driver,
"port", cfg.Server.Port,
)
// 初始化数据库
if err := database.Init(&cfg.Database); err != nil {
log.Fatal("数据库初始化失败:", err)
slog.Error("数据库初始化失败", "error", err)
os.Exit(1)
}
// 创建默认管理员
if err := database.CreateDefaultAdmin(); err != nil {
log.Fatal("创建默认管理员失败:", err)
slog.Error("创建默认管理员失败", "error", err)
os.Exit(1)
}
// 创建路由
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
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()
// 使用自定义的 TemplateRenderer
htmlRenderer := utils.LoadTemplates(cfg.App.Theme)
r.HTMLRender = htmlRenderer
// 配置模板渲染器
routers.SetupTemplates(r, theme)
// 静态文件
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.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
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)
// 分类管理
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("/admin/comments", handlers.GetComments)
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
admin.DELETE("/comments/:id", handlers.DeleteComment)
}
}
// 配置所有路由
routers.SetupRoutes(r, cfg)
// 启动服务器
port := ":" + strconv.Itoa(cfg.Server.Port)
log.Printf("服务器启动在 http://localhost%s", port)
port := routers.GetServerPort(cfg.Server.Port)
slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode)
if err := r.Run(port); err != nil {
log.Fatal("服务器启动失败:", err)
slog.Error("服务器启动失败", "error", err)
os.Exit(1)
}
}
+213 -57
View File
@@ -1,52 +1,103 @@
package models
import (
"log/slog"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// 用户模型
type User 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:"-"`
Username string `gorm:"uniqueIndex;size:50;not null" json:"username"`
Password string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:100" json:"nickname"`
Email string `gorm:"size:100" json:"email"`
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:禁用
}
const PasswordCryptLevel = 12
// 文章模型
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
// ==================== 用户模型 ====================
type User 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 `json:"author_id"`
Author User `gorm:"foreignKey:AuthorID" json:"author"`
CategoryID uint `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:"size:20;default:'draft'" json:"status"` // published, draft, private
IsTop bool `gorm:"default:false" json:"is_top"`
PublishedAt *time.Time `json:"published_at"`
Comments []Comment `json:"comments,omitempty"`
Username string `gorm:"uniqueIndex;size:50;not null" json:"username"`
Password string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:100" json:"nickname"`
Email string `gorm:"size:100" json:"email"`
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"` // 登录次数统计
}
// 分类模型
// TableName 指定表名
func (User) TableName() string {
return "users"
}
// SetPassword 设置密码加密
func (u *User) SetPassword(password string) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), PasswordCryptLevel)
if err != nil {
return err
}
u.Password = string(hashedPassword)
return nil
}
// CheckPassword 验证密码是否匹配
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
return err == nil
}
// RecordLogin 记录登录信息
func (u *User) RecordLogin(db *gorm.DB) error {
now := time.Now()
u.LastLoginAt = &now
u.LoginCount++
return db.Model(u).Updates(map[string]interface{}{
"last_login_at": now,
"login_count": u.LoginCount,
}).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"`
}
// TableName 指定表名
func (Post) TableName() string {
return "posts"
}
// ==================== 分类模型 ====================
type Category struct {
ID uint `gorm:"primarykey" json:"id"`
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `gorm:"size:100;not null" json:"name"`
@@ -56,9 +107,15 @@ type Category struct {
PostCount int `gorm:"default:0" json:"post_count"`
}
// 标签模型
// TableName 指定表名
func (Category) TableName() string {
return "categories"
}
// ==================== 标签模型 ====================
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `gorm:"size:100;not null" json:"name"`
@@ -66,13 +123,19 @@ type Tag struct {
PostCount int `gorm:"default:0" json:"post_count"`
}
// 评论模型
// TableName 指定表名
func (Tag) TableName() string {
return "tags"
}
// ==================== 评论模型 ====================
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
PostID uint `json:"post_id"`
PostID uint `gorm:"index" json:"post_id"` // 增加索引
Post Post `gorm:"foreignKey:PostID" json:"-"`
ParentID *uint `json:"parent_id"`
Author string `gorm:"size:100" json:"author"`
@@ -80,30 +143,123 @@ type Comment struct {
Website string `gorm:"size:255" json:"website"`
Content string `gorm:"type:text;not null" json:"content"`
IP string `gorm:"size:50" json:"-"`
Status string `gorm:"size:20;default:'pending'" json:"status"` // approved, pending, spam
Status string `gorm:"index;size:20;default:'pending'" json:"status"` // 增加索引
UserID *uint `json:"user_id"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Children []Comment `gorm:"foreignKey:ParentID" json:"children,omitempty"`
// 反垃圾字段
UserAgent string `gorm:"size:255" json:"-"` // 用户代理
IsSpam bool `gorm:"default:false" json:"is_spam"` // 是否为垃圾评论
SpamScore float64 `gorm:"default:0" json:"spam_score"` // 垃圾评分
}
// 设置模型
// TableName 指定表名
func (Comment) TableName() string {
return "comments"
}
// ==================== 设置模型 (优化) ====================
type Option struct {
ID uint `gorm:"primarykey"`
Key string `gorm:"uniqueIndex;size:100"`
Value string `gorm:"type:text"`
Name string `gorm:"column:name;type:varchar(100);primaryKey" json:"name"` // 配置键名
UserID uint `gorm:"column:user_id;type:int;default:0;primaryKey;index" json:"user_id"` // 用户ID(0=全局配置)
Value string `gorm:"column:value;type:text" json:"value"` // 配置值
}
// 页面模型(独立页面)
type Page 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"`
AuthorID uint `json:"author_id"`
Author User `gorm:"foreignKey:AuthorID" json:"author"`
Status string `gorm:"size:20;default:'draft'" json:"status"`
Order int `gorm:"default:0" json:"order"`
// TableName 指定表名
func (Option) TableName() string {
return "options"
}
// GetOptionValue 根据name和userID获取配置值
func GetOptionValue(db *gorm.DB, name string, userID uint) (string, error) {
var option Option
result := db.Where("name = ? AND user_id = ?", name, userID).First(&option)
if result.Error != nil {
return "", result.Error
}
return option.Value, nil
}
// SetOptionValue 根据name和userID设置配置值(存在则更新,不存在则新增)
func SetOptionValue(db *gorm.DB, name string, userID uint, value string) error {
// 先尝试查找
var option Option
result := db.Where("name = ? AND user_id = ?", name, userID).First(&option)
if result.Error == nil {
// 记录存在,更新
return db.Model(&option).Update("value", value).Error
}
// 记录不存在,创建新记录
option = Option{
Name: name,
UserID: userID,
Value: value,
}
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
result := db.Where("user_id = ?", userID).Find(&options)
if result.Error != nil {
return nil, result.Error
}
optionsMap := make(map[string]string)
for _, opt := range options {
optionsMap[opt.Name] = opt.Value
}
return optionsMap, nil
}
// BatchSetOptions 批量设置配置
func BatchSetOptions(db *gorm.DB, userID uint, options map[string]string) error {
return db.Transaction(func(tx *gorm.DB) error {
for name, value := range options {
opt := Option{
Name: name,
UserID: userID,
Value: value,
}
if err := tx.Save(&opt).Error; err != nil {
slog.Error("保存配置失败", "name", name, "error", err)
return err
}
}
return nil
})
}
// ==================== 页面模型(优化) ====================
type Page 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"`
AuthorID uint `gorm:"index" json:"author_id"`
Author User `gorm:"foreignKey:AuthorID" json:"author"`
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
Order int `gorm:"default:0" json:"order"`
Password string `gorm:"size:255" json:"password"` // 访问密码保护
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
ParentID uint `gorm:"default:0" json:"parent_id"` // 父级页面(支持层级页面)
}
// TableName 指定表名
func (Page) TableName() string {
return "pages"
}
+113
View File
@@ -0,0 +1,113 @@
package routers
import (
"strconv"
"goblog/handlers"
"goblog/middleware"
"goblog/utils"
"github.com/gin-gonic/gin"
)
// 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)
// 分类管理
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("/admin/comments", handlers.GetComments)
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
admin.DELETE("/comments/:id", handlers.DeleteComment)
}
}
}
// SetupTemplates 配置模板渲染器
func SetupTemplates(r *gin.Engine, theme string) {
htmlRenderer := utils.LoadTemplates(theme)
utils.SetGlobalRenderer(htmlRenderer)
r.HTMLRender = htmlRenderer
}
// GetServerPort 获取服务器端口
func GetServerPort(port int) string {
return ":" + strconv.Itoa(port)
}
+372
View File
@@ -92,6 +92,12 @@ function showPage(pageName) {
case 'comments':
loadComments();
break;
case 'themes':
loadThemes();
break;
case 'theme-editor':
// 主题编辑器由 editTheme() 函数加载
break;
}
}
@@ -614,3 +620,369 @@ function getStatusText(status) {
};
return map[status] || status;
}
// ==================== 主题管理 ====================
// 加载主题列表
let currentTheme = null;
async function loadThemes() {
try {
// 获取当前主题
const currentRes = await fetch(`${API_BASE}/theme`, {
headers: getHeaders()
});
const currentData = await currentRes.json();
currentTheme = currentData.theme;
// 获取所有可用主题
const themesRes = await fetch(`${API_BASE}/themes`, {
headers: getHeaders()
});
const themesData = await themesRes.json();
// 填充下拉列表
const themeSelect = document.getElementById('theme-select');
themeSelect.innerHTML = '';
themesData.themes.forEach(theme => {
const option = document.createElement('option');
option.value = theme;
option.textContent = theme;
if (theme === currentTheme) {
option.selected = true;
}
themeSelect.appendChild(option);
});
// 显示主题列表
displayThemeList(themesData.themes, currentTheme);
} catch (error) {
console.error('加载主题失败:', error);
showThemeMessage('加载主题失败', 'error');
}
}
// 显示主题列表
function displayThemeList(themes, currentTheme) {
const themeList = document.getElementById('theme-list');
themeList.innerHTML = '';
themes.forEach(theme => {
const themeCard = document.createElement('div');
themeCard.className = `theme-card ${theme === currentTheme ? 'active' : ''}`;
themeCard.innerHTML = `
<div class="theme-card-header">
<h3>${theme}</h3>
${theme === currentTheme ? '<span class="theme-badge">当前使用</span>' : ''}
</div>
<div class="theme-card-body">
<p>主题名称: ${theme}</p>
<p>状态: ${theme === currentTheme ? '使用中' : '未使用'}</p>
</div>
<div class="theme-card-actions">
<button class="btn-small btn-secondary" onclick="editTheme('${theme}')">编辑</button>
${theme !== currentTheme ? `<button class="btn-small btn-primary" onclick="quickSwitch('${theme}')">切换</button>` : ''}
</div>
`;
themeList.appendChild(themeCard);
});
}
// 切换主题
async function switchTheme() {
const themeSelect = document.getElementById('theme-select');
const selectedTheme = themeSelect.value;
if (!selectedTheme) {
showThemeMessage('请选择一个主题', 'error');
return;
}
if (selectedTheme === currentTheme) {
showThemeMessage('当前已经是该主题', 'warning');
return;
}
try {
const response = await fetch(`${API_BASE}/theme`, {
method: 'POST',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: selectedTheme })
});
const data = await response.json();
if (response.ok) {
showThemeMessage(data.message || '主题切换成功', 'success');
currentTheme = selectedTheme;
// 刷新主题列表显示
loadThemes();
} else {
showThemeMessage(data.error || '主题切换失败', 'error');
}
} catch (error) {
console.error('切换主题失败:', error);
showThemeMessage('切换主题失败', 'error');
}
}
// 显示主题消息
function showThemeMessage(message, type = 'info') {
const messageEl = document.getElementById('theme-message');
messageEl.textContent = message;
messageEl.className = `theme-message ${type}`;
messageEl.style.display = 'block';
// 3秒后自动隐藏
setTimeout(() => {
messageEl.style.display = 'none';
}, 3000);
}
// 显示创建主题弹窗
function showCreateThemeModal() {
document.getElementById('theme-modal').classList.remove('hidden');
document.getElementById('new-theme-name').value = '';
document.getElementById('theme-template').value = 'default';
document.getElementById('create-css').checked = true;
}
// 隐藏创建主题弹窗
function hideThemeModal() {
document.getElementById('theme-modal').classList.add('hidden');
}
// 创建主题表单提交
document.addEventListener('DOMContentLoaded', () => {
const createThemeForm = document.getElementById('create-theme-form');
if (createThemeForm) {
createThemeForm.addEventListener('submit', handleCreateTheme);
}
});
async function handleCreateTheme(e) {
e.preventDefault();
const themeName = document.getElementById('new-theme-name').value.trim();
const template = document.getElementById('theme-template').value;
const createCSS = document.getElementById('create-css').checked;
if (!themeName) {
showThemeMessage('请输入主题名称', 'error');
return;
}
try {
const response = await fetch(`${API_BASE}/themes/create`, {
method: 'POST',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: themeName,
template: template,
create_css: createCSS
})
});
const data = await response.json();
if (response.ok) {
showThemeMessage(data.message || '主题创建成功', 'success');
hideThemeModal();
// 刷新主题列表
setTimeout(() => {
loadThemes();
}, 500);
} else {
showThemeMessage(data.error || '主题创建失败', 'error');
}
} catch (error) {
console.error('创建主题失败:', error);
showThemeMessage('创建主题失败', 'error');
}
}
// 快速切换主题
async function quickSwitch(theme) {
try {
const response = await fetch(`${API_BASE}/theme`, {
method: 'POST',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: theme })
});
const data = await response.json();
if (response.ok) {
showThemeMessage(data.message || '主题切换成功', 'success');
currentTheme = theme;
loadThemes();
} else {
showThemeMessage(data.error || '主题切换失败', 'error');
}
} catch (error) {
console.error('切换主题失败:', error);
showThemeMessage('切换主题失败', 'error');
}
}
// ==================== 主题编辑器 ====================
let currentEditingTheme = null;
let currentEditingFile = null;
let themeFiles = [];
// 编辑主题
async function editTheme(themeName) {
currentEditingTheme = themeName;
currentEditingFile = null;
document.getElementById('editor-theme-name').textContent = themeName;
document.getElementById('current-file-name').textContent = '选择文件进行编辑';
document.getElementById('theme-file-editor').value = '';
showPage('theme-editor');
// 加载主题文件列表
await loadThemeFiles(themeName);
}
// 加载主题文件列表
async function loadThemeFiles(themeName) {
try {
const response = await fetch(`${API_BASE}/themes/${themeName}/files`, {
headers: getHeaders()
});
const data = await response.json();
if (response.ok) {
themeFiles = data.files || [];
displayThemeFiles(themeFiles);
} else {
showThemeMessage(data.error || '加载文件列表失败', 'error');
}
} catch (error) {
console.error('加载文件列表失败:', error);
showThemeMessage('加载文件列表失败', 'error');
}
}
// 显示主题文件列表
function displayThemeFiles(files) {
const filesContainer = document.getElementById('theme-files');
filesContainer.innerHTML = '';
if (files.length === 0) {
filesContainer.innerHTML = '<p class="no-files">暂无文件</p>';
return;
}
files.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'theme-file-item';
fileItem.innerHTML = `
<span class="file-icon">${getFileIcon(file)}</span>
<span class="file-name">${file}</span>
`;
fileItem.onclick = () => loadFileContent(file);
filesContainer.appendChild(fileItem);
});
}
// 获取文件图标
function getFileIcon(filename) {
if (filename.endsWith('.html')) return '📄';
if (filename.endsWith('.css')) return '🎨';
if (filename.endsWith('.js')) return '📜';
return '📁';
}
// 加载文件内容
async function loadFileContent(filename) {
try {
currentEditingFile = filename;
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${filename}`, {
headers: getHeaders()
});
const data = await response.json();
if (response.ok) {
document.getElementById('current-file-name').textContent = filename;
document.getElementById('theme-file-editor').value = data.content || '';
// 高亮当前文件
document.querySelectorAll('.theme-file-item').forEach(item => {
item.classList.remove('active');
});
event.currentTarget.classList.add('active');
} else {
showThemeMessage(data.error || '加载文件失败', 'error');
}
} catch (error) {
console.error('加载文件失败:', error);
showThemeMessage('加载文件失败', 'error');
}
}
// 保存当前文件
async function saveCurrentFile() {
if (!currentEditingTheme || !currentEditingFile) {
showThemeMessage('请先选择文件', 'warning');
return;
}
const content = document.getElementById('theme-file-editor').value;
try {
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${currentEditingFile}`, {
method: 'PUT',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (response.ok) {
showThemeMessage(data.message || '文件保存成功', 'success');
// 如果是模板文件,重新加载主题
if (currentEditingFile.endsWith('.html')) {
const renderer = await fetch(`${API_BASE}/theme`, { headers: getHeaders() });
const rendererData = await renderer.json();
if (rendererData.theme === currentEditingTheme) {
// 重新加载当前主题以应用更改
await fetch(`${API_BASE}/theme`, {
method: 'POST',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: currentEditingTheme })
});
}
}
} else {
showThemeMessage(data.error || '保存文件失败', 'error');
}
} catch (error) {
console.error('保存文件失败:', error);
showThemeMessage('保存文件失败', 'error');
}
}
+79 -1
View File
@@ -4,6 +4,7 @@
<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">
</head>
<body>
@@ -20,6 +21,7 @@
<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="#themes" class="menu-item" data-page="themes">主题管理</a>
</nav>
<div class="user-info">
<span id="username">Admin</span>
@@ -218,6 +220,50 @@
<tbody id="comments-list"></tbody>
</table>
</div>
<!-- 主题管理 -->
<div id="themes-page" class="page hidden">
<div class="page-header">
<h1>主题管理</h1>
<button class="btn-primary" onclick="showCreateThemeModal()">新建主题</button>
</div>
<div class="theme-settings">
<div class="form-group">
<label>当前主题</label>
<select id="theme-select" class="theme-selector">
<option value="">加载中...</option>
</select>
</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>
<!-- 主题编辑器 -->
<div id="theme-editor-page" class="page hidden">
<div class="page-header">
<h1>编辑主题: <span id="editor-theme-name"></span></h1>
<div>
<button class="btn-secondary" onclick="showPage('themes')">返回</button>
<button class="btn-primary" onclick="saveCurrentFile()">保存文件</button>
</div>
</div>
<div class="theme-editor-container">
<div class="theme-file-list">
<h3>文件列表</h3>
<div id="theme-files"></div>
</div>
<div class="theme-editor-content">
<div class="editor-header">
<h3 id="current-file-name">选择文件进行编辑</h3>
</div>
<textarea id="theme-file-editor" class="code-editor" placeholder="选择左侧文件进行编辑..."></textarea>
</div>
</div>
</div>
</main>
</div>
@@ -247,6 +293,38 @@
</div>
</div>
<script src="/static/admin/app.js?v=6"></script>
<!-- 创建主题弹窗 -->
<div id="theme-modal" class="modal hidden">
<div class="modal-content modal-large">
<h3>新建主题</h3>
<form id="create-theme-form">
<div class="form-group">
<label>主题名称 *</label>
<input type="text" id="new-theme-name" required placeholder="例如: mytheme">
<small>只能包含字母、数字和下划线</small>
</div>
<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>
</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>
<button type="submit" class="btn-primary">创建主题</button>
</div>
</form>
</div>
</div>
<script src="/static/admin/app.js?v=9"></script>
</body>
</html>
+304
View File
@@ -358,3 +358,307 @@ body {
grid-template-columns: 1fr;
}
}
/* 主题管理 */
.theme-settings {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 24px;
}
.theme-settings .form-group {
margin-bottom: 20px;
}
.theme-settings label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.theme-selector {
width: 100%;
max-width: 400px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: #fff;
cursor: pointer;
}
.theme-selector:focus {
outline: none;
border-color: #1890ff;
}
.theme-settings .btn-primary {
margin-top: 10px;
}
.theme-message {
margin-top: 15px;
padding: 10px 15px;
border-radius: 4px;
display: none;
}
.theme-message.success {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
.theme-message.error {
background: #fff2f0;
border: 1px solid #ffccc7;
color: #ff4d4f;
}
.theme-message.warning {
background: #fffbe6;
border: 1px solid #ffe58f;
color: #faad14;
}
.theme-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.theme-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 2px solid transparent;
transition: all 0.3s;
}
.theme-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.theme-card.active {
border-color: #1890ff;
background: #f0f9ff;
}
.theme-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.theme-card-header h3 {
font-size: 18px;
color: #333;
margin: 0;
}
.theme-badge {
background: #1890ff;
color: #fff;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.theme-card-body p {
margin: 8px 0;
color: #666;
font-size: 14px;
}
@media (max-width: 768px) {
.theme-list {
grid-template-columns: 1fr;
}
.theme-settings {
padding: 16px;
}
.theme-selector {
max-width: 100%;
}
}
/* 大尺寸模态框 */
.modal-large {
max-width: 600px;
}
.modal-large .form-group small {
display: block;
margin-top: 5px;
color: #999;
font-size: 12px;
}
.modal-large input[type="text"] {
width: 100%;
}
.modal-large input[type="checkbox"] {
margin-right: 8px;
}
/* 主题卡片操作按钮 */
.theme-card-actions {
display: flex;
gap: 10px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.btn-small {
padding: 6px 12px;
font-size: 13px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.btn-small.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-small.btn-secondary:hover {
background: #e8e8e8;
}
.btn-small.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-small.btn-primary:hover {
background: #40a9ff;
}
/* 主题编辑器 */
.theme-editor-container {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
height: calc(100vh - 200px);
}
.theme-file-list {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow-y: auto;
}
.theme-file-list h3 {
margin: 0 0 15px;
font-size: 16px;
color: #333;
}
.theme-file-item {
padding: 12px;
margin-bottom: 8px;
background: #f9f9f9;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 10px;
}
.theme-file-item:hover {
background: #e6f7ff;
}
.theme-file-item.active {
background: #1890ff;
color: #fff;
}
.theme-file-item .file-icon {
font-size: 18px;
}
.theme-file-item .file-name {
flex: 1;
font-size: 14px;
}
.theme-editor-content {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
}
.editor-header {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.editor-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.code-editor {
flex: 1;
width: 100%;
padding: 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
background: #f9f9f9;
color: #333;
}
.code-editor:focus {
outline: none;
border-color: #1890ff;
background: #fff;
}
.no-files {
text-align: center;
color: #999;
padding: 40px 20px;
}
@media (max-width: 1024px) {
.theme-editor-container {
grid-template-columns: 1fr;
height: auto;
}
.theme-file-list {
max-height: 300px;
}
.code-editor {
min-height: 500px;
}
}
+542
View File
@@ -0,0 +1,542 @@
/* 极简主题 - Minimal Theme */
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 16px;
line-height: 1.8;
color: #e0e0e0;
background-color: #121212;
}
a {
color: #64b5f6;
text-decoration: none;
transition: color 0.3s ease;
}
a:hover {
color: #90caf9;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 0 25px;
}
.wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 头部样式 */
.header {
background: #1e1e1e;
padding: 40px 0;
border-bottom: 1px solid #333;
margin-bottom: 40px;
}
.site-title {
font-size: 32px;
font-weight: 400;
letter-spacing: 1px;
}
.site-title a {
color: #e0e0e0;
}
.site-title a:hover {
color: #64b5f6;
}
.site-desc {
color: #9e9e9e;
margin-top: 10px;
font-style: italic;
}
.main-nav {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #333;
}
.main-nav a {
margin-right: 25px;
color: #9e9e9e;
font-size: 15px;
font-weight: 300;
}
.main-nav a:hover {
color: #64b5f6;
}
/* 主内容区 */
.main {
flex: 1;
}
/* 文章列表 */
.post-list {
display: flex;
flex-direction: column;
gap: 40px;
}
.post-item {
padding-bottom: 40px;
border-bottom: 1px solid #333;
}
.post-item:last-child {
border-bottom: none;
}
.post-title {
font-size: 26px;
margin-bottom: 15px;
font-weight: 400;
}
.post-title a {
color: #e0e0e0;
}
.post-title a:hover {
color: #64b5f6;
}
.top-badge {
display: inline-block;
background: #d32f2f;
color: #fff;
font-size: 11px;
padding: 3px 10px;
border-radius: 3px;
margin-left: 10px;
vertical-align: middle;
font-weight: 400;
}
.post-meta {
color: #757575;
font-size: 14px;
margin-bottom: 20px;
font-style: italic;
}
.post-meta span {
margin-right: 20px;
}
.post-meta a {
color: #9e9e9e;
}
.tag {
display: inline-block;
background: #2c2c2c;
padding: 3px 10px;
border-radius: 3px;
font-size: 12px;
margin-right: 8px;
color: #9e9e9e;
}
.tag:hover {
background: #383838;
color: #64b5f6;
}
.post-summary {
color: #b0b0b0;
line-height: 1.9;
margin-bottom: 20px;
}
.read-more {
display: inline-block;
color: #64b5f6;
font-weight: 400;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
}
.read-more:hover {
border-bottom-color: #64b5f6;
}
/* 文章详情 */
.post-detail {
background: #1e1e1e;
padding: 45px;
border-radius: 4px;
border: 1px solid #333;
}
.post-header {
margin-bottom: 35px;
padding-bottom: 25px;
border-bottom: 1px solid #333;
}
.post-detail .post-title {
font-size: 32px;
margin-bottom: 20px;
font-weight: 400;
line-height: 1.4;
}
.post-body {
line-height: 1.9;
color: #d0d0d0;
}
.post-body h2,
.post-body h3,
.post-body h4 {
margin: 35px 0 20px;
color: #e0e0e0;
font-weight: 400;
}
.post-body h2 {
font-size: 26px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.post-body h3 {
font-size: 22px;
}
.post-body p {
margin-bottom: 20px;
}
.post-body img {
max-width: 100%;
border-radius: 4px;
border: 1px solid #333;
}
.post-body pre {
background: #252525;
padding: 20px;
border-radius: 4px;
overflow-x: auto;
border: 1px solid #333;
margin: 25px 0;
}
.post-body code {
background: #252525;
padding: 3px 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
border: 1px solid #333;
}
.post-body blockquote {
border-left: 3px solid #64b5f6;
padding-left: 20px;
margin: 25px 0;
color: #9e9e9e;
font-style: italic;
}
.post-body ul,
.post-body ol {
margin: 20px 0;
padding-left: 30px;
}
.post-body li {
margin-bottom: 10px;
}
/* 页面 */
.page-detail {
background: #1e1e1e;
padding: 45px;
border-radius: 4px;
border: 1px solid #333;
}
.page-title {
font-size: 32px;
margin-bottom: 25px;
font-weight: 400;
}
.page-body {
line-height: 1.9;
color: #d0d0d0;
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 25px;
margin-top: 50px;
padding: 25px;
}
.pagination a {
padding: 10px 20px;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 4px;
color: #9e9e9e;
transition: all 0.3s ease;
}
.pagination a:hover {
background: #2c2c2c;
border-color: #64b5f6;
color: #64b5f6;
}
.page-info {
color: #757575;
}
/* 评论区 */
.comments-section {
background: #1e1e1e;
padding: 35px;
margin-top: 40px;
border-radius: 4px;
border: 1px solid #333;
}
.comments-section h3 {
margin-bottom: 25px;
font-weight: 400;
font-size: 22px;
color: #e0e0e0;
}
.comment-form {
margin-bottom: 35px;
padding-bottom: 35px;
border-bottom: 1px solid #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 12px;
background: #252525;
border: 1px solid #333;
border-radius: 4px;
font-size: 14px;
color: #e0e0e0;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #64b5f6;
}
.form-group textarea {
min-height: 120px;
resize: vertical;
}
.comment-form button {
background: #64b5f6;
color: #121212;
border: none;
padding: 12px 30px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.3s ease;
}
.comment-form button:hover {
background: #90caf9;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 25px;
}
.comment-item {
padding: 20px;
background: #252525;
border-radius: 4px;
border: 1px solid #333;
}
.comment-item.child {
margin-left: 35px;
margin-top: 15px;
background: #2a2a2a;
}
.comment-header {
margin-bottom: 12px;
font-size: 14px;
}
.comment-author {
font-weight: 500;
color: #e0e0e0;
}
.comment-author a {
color: #64b5f6;
}
.comment-date {
color: #757575;
margin-left: 12px;
}
.comment-content {
color: #b0b0b0;
line-height: 1.7;
}
.no-comments {
color: #757575;
text-align: center;
padding: 40px;
}
/* 空状态 */
.empty {
text-align: center;
padding: 80px;
color: #757575;
background: #1e1e1e;
border-radius: 4px;
border: 1px solid #333;
}
/* 底部 */
.footer {
background: #1e1e1e;
padding: 35px 0;
margin-top: 60px;
text-align: center;
color: #757575;
border-top: 1px solid #333;
}
/* 文章导航 - 上一篇/下一篇 */
.post-navigation {
display: flex;
justify-content: space-between;
gap: 25px;
margin: 35px 0;
padding: 25px;
background: #1e1e1e;
border-radius: 4px;
border: 1px solid #333;
}
.nav-prev,
.nav-next {
flex: 1;
}
.nav-next {
text-align: right;
}
.nav-link {
display: flex;
flex-direction: column;
padding: 15px;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: #252525;
}
.nav-link.disabled {
color: #757575;
cursor: not-allowed;
}
.nav-link.disabled:hover {
background-color: transparent;
}
.nav-label {
font-size: 13px;
color: #757575;
margin-bottom: 8px;
}
.nav-title {
font-size: 15px;
font-weight: 400;
color: #e0e0e0;
line-height: 1.5;
}
.nav-link:hover .nav-title {
color: #64b5f6;
}
/* 响应式 */
@media (max-width: 768px) {
.site-title {
font-size: 26px;
}
.post-detail,
.page-detail {
padding: 25px;
}
.post-detail .post-title {
font-size: 24px;
}
.post-meta span {
display: block;
margin: 8px 0;
}
.post-navigation {
flex-direction: column;
gap: 20px;
}
.nav-next {
text-align: left;
}
.comment-item.child {
margin-left: 20px;
}
}
+56
View File
@@ -0,0 +1,56 @@
/* mytheme 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;
}
+207 -1
View File
@@ -23,11 +23,208 @@ a:hover {
}
.container {
max-width: 900px;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* 内容区域布局 */
.content-wrapper {
display: flex;
gap: 30px;
align-items: flex-start;
}
.main-content {
flex: 1;
min-width: 0;
}
/* 侧边栏 */
.sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar-widget {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.widget-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #1890ff;
color: #333;
}
/* 博客信息 */
.blog-info {
text-align: center;
}
.blog-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 10px;
}
.blog-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.blog-description {
font-size: 14px;
color: #666;
line-height: 1.6;
}
.blog-stats {
display: flex;
justify-content: space-around;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 20px;
font-weight: 600;
color: #1890ff;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
/* 搜索框 */
.search-form {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #1890ff;
}
.search-btn {
padding: 8px 16px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.search-btn:hover {
background: #40a9ff;
}
/* 分类列表 */
.category-list {
list-style: none;
}
.category-item {
margin-bottom: 8px;
}
.category-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
color: #666;
border-radius: 4px;
transition: all 0.2s;
}
.category-link:hover {
background: #f0f9ff;
color: #1890ff;
}
.category-count {
background: #f0f0f0;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: #999;
}
/* 标签云 */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
display: inline-block;
padding: 4px 12px;
background: #f0f0f0;
color: #666;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s;
}
.tag-item:hover {
background: #1890ff;
color: #fff;
}
/* 系统菜单 */
.system-menu {
list-style: none;
}
.system-menu-item {
margin-bottom: 8px;
}
.system-menu-link {
display: block;
padding: 8px 12px;
color: #666;
border-radius: 4px;
transition: all 0.2s;
}
.system-menu-link:hover {
background: #f0f9ff;
color: #1890ff;
}
/* 头部样式 */
.header {
background: #fff;
@@ -441,4 +638,13 @@ a:hover {
.nav-next {
text-align: left;
}
/* 移动端 sidebar 隐藏或置底 */
.content-wrapper {
flex-direction: column;
}
.sidebar {
width: 100%;
}
}
+6 -1
View File
@@ -25,7 +25,12 @@
</header>
<main class="main container">
{{template "content" .}}
<div class="content-wrapper">
<div class="main-content">
{{template "content" .}}
</div>
{{template "sidebar" .}}
</div>
</main>
<footer class="footer">
+6 -1
View File
@@ -1,5 +1,6 @@
{{define "content"}}
<div class="post-list">
{{if .Posts}}
{{range .Posts}}
<article class="post-item">
{{if .Cover}}
@@ -32,8 +33,12 @@
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
</div>
</article>
{{end}}
{{else}}
<div class="empty">暂无文章</div>
<div class="empty">
<p>暂无文章</p>
<p style="margin-top: 10px; font-size: 14px; color: #999;">文章总数: {{.PostCount}}</p>
</div>
{{end}}
</div>
+77
View File
@@ -0,0 +1,77 @@
{{ define "sidebar" }}
<aside class="sidebar">
<!-- 博客信息 -->
<div class="sidebar-widget blog-info">
<img src="/static/images/avatar.png" alt="头像" class="blog-avatar" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%2280%22><rect fill=%22%231890ff%22 width=%2280%22 height=%2280%22/><text x=%2250%%22 y=%2250%%22 fill=%22white%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2232%22>B</text></svg>'">
<div class="blog-name">{{.SiteName}}</div>
<div class="blog-description">{{.SiteDesc}}</div>
<div class="blog-stats">
<div class="stat-item">
<div class="stat-number">{{.PostCount}}</div>
<div class="stat-label">文章</div>
</div>
<div class="stat-item">
<div class="stat-number">{{.CategoryCount}}</div>
<div class="stat-label">分类</div>
</div>
<div class="stat-item">
<div class="stat-number">{{.TagCount}}</div>
<div class="stat-label">标签</div>
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="sidebar-widget">
<h3 class="widget-title">🔍 搜索</h3>
<form class="search-form" action="/search" method="get">
<input type="text" name="keyword" class="search-input" placeholder="输入关键字搜索..." required>
<button type="submit" class="search-btn">搜索</button>
</form>
</div>
<!-- 分类 -->
{{if .Categories}}
<div class="sidebar-widget">
<h3 class="widget-title">📂 分类</h3>
<ul class="category-list">
{{range .Categories}}
<li class="category-item">
<a href="/?category={{.ID}}" class="category-link">
<span>{{.Name}}</span>
<span class="category-count">{{.PostCount}}</span>
</a>
</li>
{{end}}
</ul>
</div>
{{end}}
<!-- 标签云 -->
{{if .Tags}}
<div class="sidebar-widget">
<h3 class="widget-title">🏷️ 标签云</h3>
<div class="tag-cloud">
{{range .Tags}}
<a href="/?tag={{.ID}}" class="tag-item" title="{{.Name}} ({{.PostCount}}篇文章)">
{{.Name}}
</a>
{{end}}
</div>
</div>
{{end}}
<!-- 系统菜单 -->
<div class="sidebar-widget">
<h3 class="widget-title">⚙️ 系统</h3>
<ul class="system-menu">
<li class="system-menu-item">
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
</li>
<li class="system-menu-item">
<a href="/admin/login" class="system-menu-link">👤 用户登录</a>
</li>
</ul>
</div>
</aside>
{{ end }}
+39
View File
@@ -0,0 +1,39 @@
<!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/minimal.css">
</head>
<body>
<div class="wrapper">
<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>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<div class="post-list">
{{range .Posts}}
<article class="post-item">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
</h2>
<div class="post-meta">
<span class="post-date">{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
<span class="post-category">
{{if .Category}}<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
</span>
{{if .Tags}}
<span class="post-tags">
{{range .Tags}}
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
{{end}}
</span>
{{end}}
<span class="post-views">{{.Views}} 阅读</span>
</div>
<p class="post-summary">{{.Summary}}</p>
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
</article>
{{else}}
<div class="empty">暂无文章</div>
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .CurrentPage 1}}
<a href="/?page={{sub .CurrentPage 1}}" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CurrentPage}} / {{.TotalPages}}</span>
{{if lt .CurrentPage .TotalPages}}
<a href="/?page={{add .CurrentPage 1}}" class="next">下一页 →</a>
{{end}}
</div>
{{end}}
{{end}}
+11
View File
@@ -0,0 +1,11 @@
{{define "content"}}
<article class="page-detail">
<header class="page-header">
<h1 class="page-title">{{.Page.Title}}</h1>
</header>
<div class="page-body">
{{.Page.Content | html}}
</div>
</article>
{{end}}
+110
View File
@@ -0,0 +1,110 @@
{{define "content"}}
<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>
{{end}}
</span>
{{end}}
<span class="post-views">{{.Post.Views}} 阅读</span>
</div>
</header>
<div class="post-body">
{{.Post.Content | html}}
</div>
</article>
<nav class="post-navigation">
<div class="nav-prev">
{{if .PrevPost}}
<a href="/post/{{.PrevPost.Slug}}" class="nav-link">
<span class="nav-label">← 上一篇</span>
<span class="nav-title">{{.PrevPost.Title}}</span>
</a>
{{else}}
<span class="nav-link disabled">
<span class="nav-label">← 上一篇</span>
<span class="nav-title">没有了</span>
</span>
{{end}}
</div>
<div class="nav-next">
{{if .NextPost}}
<a href="/post/{{.NextPost.Slug}}" class="nav-link">
<span class="nav-label">下一篇 →</span>
<span class="nav-title">{{.NextPost.Title}}</span>
</a>
{{else}}
<span class="nav-link disabled">
<span class="nav-label">下一篇 →</span>
<span class="nav-title">没有了</span>
</span>
{{end}}
</div>
</nav>
<section class="comments-section">
<h3>评论 ({{.CommentCount}})</h3>
<form class="comment-form" action="/comment" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
</div>
<div class="form-group">
<input type="email" name="email" placeholder="邮箱" required>
</div>
<div class="form-group">
<input type="url" name="website" placeholder="网站(可选)">
</div>
<div class="form-group">
<textarea name="content" placeholder="写下你的评论..." required></textarea>
</div>
<button type="submit">发表评论</button>
</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}}
</div>
</section>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
<!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/style.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>
+51
View File
@@ -0,0 +1,51 @@
{{define "content"}}
<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}}">
</a>
</div>
{{end}}
<div class="post-content">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
</h2>
<div class="post-meta">
<span class="post-date">{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
<span class="post-category">
{{if .Category}}<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
</span>
{{if .Tags}}
<span class="post-tags">
{{range .Tags}}
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
{{end}}
</span>
{{end}}
<span class="post-views">{{.Views}} 阅读111</span>
</div>
<p class="post-summary">{{.Summary}}</p>
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
</div>
</article>
{{else}}
<div class="empty">暂无文章</div>
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .CurrentPage 1}}
<a href="/?page={{sub .CurrentPage 1}}" class="prev">← 上一页</a>
{{end}}
<span class="page-info">{{.CurrentPage}} / {{.TotalPages}}</span>
{{if lt .CurrentPage .TotalPages}}
<a href="/?page={{add .CurrentPage 1}}" class="next">下一页 →</a>
{{end}}
</div>
{{end}}
{{end}}
+11
View File
@@ -0,0 +1,11 @@
{{define "content"}}
<article class="page-detail">
<header class="page-header">
<h1 class="page-title">{{.Page.Title}}</h1>
</header>
<div class="page-body">
{{.Page.Content | html}}
</div>
</article>
{{end}}
+116
View File
@@ -0,0 +1,116 @@
{{define "content"}}
<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>
{{end}}
</span>
{{end}}
<span class="post-views">{{.Post.Views}} 阅读913</span>
</div>
</header>
{{if .Post.Cover}}
<div class="post-cover">
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
</div>
{{end}}
<div class="post-body">
{{.Post.Content | html}}
</div>
</article>
<nav class="post-navigation">
<div class="nav-prev">
{{if .PrevPost}}
<a href="/post/{{.PrevPost.Slug}}" class="nav-link">
<span class="nav-label">← 上一篇</span>
<span class="nav-title">{{.PrevPost.Title}}</span>
</a>
{{else}}
<span class="nav-link disabled">
<span class="nav-label">← 上一篇</span>
<span class="nav-title">没有了</span>
</span>
{{end}}
</div>
<div class="nav-next">
{{if .NextPost}}
<a href="/post/{{.NextPost.Slug}}" class="nav-link">
<span class="nav-label">下一篇 →</span>
<span class="nav-title">{{.NextPost.Title}}</span>
</a>
{{else}}
<span class="nav-link disabled">
<span class="nav-label">下一篇 →</span>
<span class="nav-title">没有了</span>
</span>
{{end}}
</div>
</nav>
<section class="comments-section">
<h3>评论 ({{.CommentCount}})</h3>
<form class="comment-form" action="/comment" method="POST">
<input type="hidden" name="post_id" value="{{.Post.ID}}">
<div class="form-group">
<input type="text" name="author" placeholder="昵称" required>
</div>
<div class="form-group">
<input type="email" name="email" placeholder="邮箱" required>
</div>
<div class="form-group">
<input type="url" name="website" placeholder="网站(可选)">
</div>
<div class="form-group">
<textarea name="content" placeholder="写下你的评论..." required></textarea>
</div>
<button type="submit">发表评论</button>
</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}}
</div>
</section>
{{end}}
+54 -8
View File
@@ -1,13 +1,15 @@
package utils
import (
"fmt"
"html/template"
"net/http"
"sync"
"github.com/gin-gonic/gin/render"
)
// 模板函数
// templateFuncs 定义模板函数
func templateFuncs() template.FuncMap {
return template.FuncMap{
"add": func(a, b int) int {
@@ -22,15 +24,20 @@ func templateFuncs() template.FuncMap {
}
}
// 自定义 HTMLRender,存储一个 map:页面名 -> *template.Template
// TemplateRenderer 自定义模板渲染器,支持多模板和主题切换
type TemplateRenderer struct {
templates map[string]*template.Template
mu sync.RWMutex // 读写锁,支持运行时重新加载
}
// Lookup 查找指定名称的模板
func (r *TemplateRenderer) Lookup(name string) *template.Template {
r.mu.RLock()
defer r.mu.RUnlock()
return r.templates[name]
}
// Instance 创建渲染实例
func (r *TemplateRenderer) Instance(name string, data any) render.Render {
return &htmlRender{
tmpl: r.Lookup(name),
@@ -39,40 +46,79 @@ func (r *TemplateRenderer) Instance(name string, data any) render.Render {
}
}
// htmlRender HTML渲染器
type htmlRender struct {
tmpl *template.Template
data any
name string
}
// Render 执行模板渲染
func (r *htmlRender) Render(w http.ResponseWriter) error {
r.WriteContentType(w)
return r.tmpl.ExecuteTemplate(w, "base.html", r.data)
}
// WriteContentType 设置内容类型
func (r *htmlRender) WriteContentType(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
// 初始化多模板
// LoadTemplates 加载指定主题的模板
func LoadTemplates(theme string) *TemplateRenderer {
r := &TemplateRenderer{
templates: make(map[string]*template.Template),
}
r.reload(theme)
return r
}
// Reload 重新加载主题模板(线程安全)
func (r *TemplateRenderer) Reload(theme string) {
r.mu.Lock()
defer r.mu.Unlock()
r.reload(theme)
}
// reload 内部重载方法(需要持有锁)
func (r *TemplateRenderer) reload(theme string) {
templateDir := "templates/" + theme
// 为每个页面创建独立的模板组合
pages := []string{"index", "post", "page"}
pages := []string{"index", "post", "page", "sidebar"}
newTemplates := make(map[string]*template.Template)
for _, pageName := range pages {
// 组合 base.html 和具体页面模板
// 只加载 base.html 和对应的页面模板
allFiles := []string{
templateDir + "/base.html",
templateDir + "/" + pageName + ".html",
}
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...))
r.templates[pageName] = tmpl
// 解析模板文件
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
if err != nil {
// 如果解析失败,记录错误但不中断
fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err)
continue
}
newTemplates[pageName] = tmpl
}
return r
// 更新模板映射
r.templates = newTemplates
fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates))
}
// 全局模板渲染器实例
var globalRenderer *TemplateRenderer
// SetGlobalRenderer 设置全局渲染器
func SetGlobalRenderer(r *TemplateRenderer) {
globalRenderer = r
}
// GetGlobalRenderer 获取全局渲染器
func GetGlobalRenderer() *TemplateRenderer {
return globalRenderer
}