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}} + + + + + +
+
+

+ {{.SiteName}} +

+

{{.SiteDesc}}

+ +
+
+ +
+ {{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}

+

状态: ${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 @@ - + + + + diff --git a/static/admin/style.css b/static/admin/style.css index 0be023e..5f78c5b 100644 --- a/static/admin/style.css +++ b/static/admin/style.css @@ -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; + } +} diff --git a/static/css/minimal.css b/static/css/minimal.css new file mode 100644 index 0000000..a0dd1c4 --- /dev/null +++ b/static/css/minimal.css @@ -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; + } +} diff --git a/static/css/mytheme.css b/static/css/mytheme.css new file mode 100644 index 0000000..b18ca54 --- /dev/null +++ b/static/css/mytheme.css @@ -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; +} diff --git a/static/css/style.css b/static/css/style.css index 3cece17..6423f6d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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%; + } } diff --git a/templates/default/base.html b/templates/default/base.html index f6872ca..1fa5e0a 100644 --- a/templates/default/base.html +++ b/templates/default/base.html @@ -25,7 +25,12 @@
- {{template "content" .}} +
+
+ {{template "content" .}} +
+ {{template "sidebar" .}} +