diff --git a/config/config.go b/config/config.go
index ecd8af1..49e0371 100644
--- a/config/config.go
+++ b/config/config.go
@@ -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
+}
diff --git a/config/config.yaml b/config/config.yaml
new file mode 100644
index 0000000..d19dbe2
--- /dev/null
+++ b/config/config.yaml
@@ -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 # 主题名称
diff --git a/go.mod b/go.mod
index 745c719..bc1d84f 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 2250a0b..954fa6f 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/goblog.db b/goblog.db
index c13266a..fd3dfac 100644
Binary files a/goblog.db and b/goblog.db differ
diff --git a/goblog.exe b/goblog.exe
index 105fa6d..491724b 100644
Binary files a/goblog.exe and b/goblog.exe differ
diff --git a/goblog.exe~ b/goblog.exe~
deleted file mode 100644
index 4ef13c8..0000000
Binary files a/goblog.exe~ and /dev/null differ
diff --git a/handlers/auth.go b/handlers/auth.go
index 5d0ebcf..e980bf3 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -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
diff --git a/handlers/theme.go b/handlers/theme.go
new file mode 100644
index 0000000..1009230
--- /dev/null
+++ b/handlers/theme.go
@@ -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 := `
+
+
+
+
+ {{.Title}} - {{.SiteName}}
+
+
+
+
+
+
+
+
+ {{template "content" .}}
+
+
+
+
+
+`
+ os.WriteFile(filepath.Join(themePath, "base.html"), []byte(baseHTML), 0644)
+ os.WriteFile(filepath.Join(themePath, "index.html"), []byte(`{{define "content"}}
+
+{{end}}
+`), 0644)
+ os.WriteFile(filepath.Join(themePath, "post.html"), []byte(`{{define "content"}}
+
+ {{.Post.Title}}
+
+ {{.Post.Content | html}}
+
+
+{{end}}
+`), 0644)
+ os.WriteFile(filepath.Join(themePath, "page.html"), []byte(`{{define "content"}}
+
+ {{.Page.Title}}
+
+ {{.Page.Content | html}}
+
+
+{{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": "文件保存成功",
+ })
+}
diff --git a/handlers/view.go b/handlers/view.go
index a6ac582..2c50f70 100644
--- a/handlers/view.go
+++ b/handlers/view.go
@@ -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)
+}
diff --git a/main.go b/main.go
index d1492c2..7287088 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}
diff --git a/models/models.go b/models/models.go
index 2fd2884..6a06e1f 100644
--- a/models/models.go
+++ b/models/models.go
@@ -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"
}
diff --git a/routers/router.go b/routers/router.go
new file mode 100644
index 0000000..a27c64a
--- /dev/null
+++ b/routers/router.go
@@ -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)
+}
diff --git a/static/admin/app.js b/static/admin/app.js
index 2760d2f..63233b0 100644
--- a/static/admin/app.js
+++ b/static/admin/app.js
@@ -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 = `
+
+
+
主题名称: ${theme}
+
状态: ${theme === currentTheme ? '使用中' : '未使用'}
+
+
+
+ ${theme !== currentTheme ? `` : ''}
+
+ `;
+
+ 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 = '暂无文件
';
+ return;
+ }
+
+ files.forEach(file => {
+ const fileItem = document.createElement('div');
+ fileItem.className = 'theme-file-item';
+ fileItem.innerHTML = `
+ ${getFileIcon(file)}
+ ${file}
+ `;
+ 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');
+ }
+}
diff --git a/static/admin/index.html b/static/admin/index.html
index 760b094..fe311e8 100644
--- a/static/admin/index.html
+++ b/static/admin/index.html
@@ -4,6 +4,7 @@
后台管理 - GoBlog
+
@@ -20,6 +21,7 @@
+
Admin
@@ -218,6 +220,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -247,6 +293,38 @@
-
+
+
+
+