增加配置文件
This commit is contained in:
+124
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 # 主题名称
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -27,6 +31,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -48,6 +54,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -67,6 +77,20 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -76,12 +100,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
@@ -102,6 +130,8 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+29
-21
@@ -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
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 获取可用主题列表
|
||||
func GetThemes(c *gin.Context) {
|
||||
themes := []string{}
|
||||
|
||||
// 读取 templates 目录
|
||||
entries, err := os.ReadDir("templates")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "无法读取主题目录",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// 检查是否是有效主题(包含 base.html)
|
||||
basePath := filepath.Join("templates", entry.Name(), "base.html")
|
||||
if _, err := os.Stat(basePath); err == nil {
|
||||
themes = append(themes, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"themes": themes,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前主题
|
||||
func GetCurrentTheme(c *gin.Context) {
|
||||
currentTheme := "default"
|
||||
|
||||
// 使用新的 Option 查询方法
|
||||
theme, err := models.GetOptionValue(database.DB, "theme", 0)
|
||||
if err == nil && theme != "" {
|
||||
currentTheme = theme
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"theme": currentTheme,
|
||||
})
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
func SwitchTheme(c *gin.Context) {
|
||||
type Request struct {
|
||||
Theme string `json:"theme" binding:"required"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请提供主题名称",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证主题是否存在
|
||||
themePath := filepath.Join("templates", req.Theme)
|
||||
if _, err := os.Stat(themePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "主题不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证主题是否有效
|
||||
basePath := filepath.Join(themePath, "base.html")
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "主题文件不完整",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新数据库中的主题设置
|
||||
err := models.SetOptionValue(database.DB, "theme", 0, req.Theme)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存主题设置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载模板(立即生效)
|
||||
renderer := utils.GetGlobalRenderer()
|
||||
if renderer != nil {
|
||||
renderer.Reload(req.Theme)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "主题切换成功",
|
||||
"theme": req.Theme,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新主题
|
||||
func CreateTheme(c *gin.Context) {
|
||||
type Request struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Template string `json:"template"`
|
||||
CreateCSS bool `json:"create_css"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请提供主题名称",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证主题名称(只能包含字母、数字和下划线)
|
||||
validName := true
|
||||
for _, ch := range req.Name {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') {
|
||||
validName = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validName || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "主题名称只能包含字母、数字和下划线",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主题是否已存在
|
||||
themePath := filepath.Join("templates", req.Name)
|
||||
if _, err := os.Stat(themePath); err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "主题已存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建主题目录
|
||||
if err := os.MkdirAll(themePath, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建主题目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果指定了模板,复制模板文件
|
||||
if req.Template != "" && req.Template != "blank" {
|
||||
templateSrc := filepath.Join("templates", req.Template)
|
||||
if _, err := os.Stat(templateSrc); err == nil {
|
||||
// 复制模板文件
|
||||
files := []string{"base.html", "index.html", "post.html", "page.html"}
|
||||
for _, file := range files {
|
||||
srcFile := filepath.Join(templateSrc, file)
|
||||
dstFile := filepath.Join(themePath, file)
|
||||
if data, err := os.ReadFile(srcFile); err == nil {
|
||||
os.WriteFile(dstFile, data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 创建空白模板
|
||||
baseHTML := `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{.SiteName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/{{.Name}}.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<h1 class="site-title">
|
||||
<a href="/">{{.SiteName}}</a>
|
||||
</h1>
|
||||
<p class="site-desc">{{.SiteDesc}}</p>
|
||||
<nav class="main-nav">
|
||||
<a href="/">首页</a>
|
||||
{{range .Pages}}
|
||||
<a href="/page/{{.Slug}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main container">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
os.WriteFile(filepath.Join(themePath, "base.html"), []byte(baseHTML), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "index.html"), []byte(`{{define "content"}}
|
||||
<div class="post-list">
|
||||
<h2>欢迎来到我的博客</h2>
|
||||
<p>这是一个新主题</p>
|
||||
</div>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "post.html"), []byte(`{{define "content"}}
|
||||
<article class="post-detail">
|
||||
<h1>{{.Post.Title}}</h1>
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "page.html"), []byte(`{{define "content"}}
|
||||
<article class="page-detail">
|
||||
<h1>{{.Page.Title}}</h1>
|
||||
<div class="page-body">
|
||||
{{.Page.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
}
|
||||
|
||||
// 创建 CSS 文件
|
||||
if req.CreateCSS {
|
||||
cssPath := filepath.Join("static", "css", req.Name+".css")
|
||||
os.MkdirAll(filepath.Dir(cssPath), 0755)
|
||||
|
||||
cssContent := `/* ` + req.Name + ` Theme */
|
||||
/* 在此添加你的样式 */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-title a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-desc {
|
||||
color: #666;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
margin-right: 15px;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 30px 0;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f5f5f5;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
`
|
||||
os.WriteFile(cssPath, []byte(cssContent), 0644)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "主题创建成功",
|
||||
"theme": req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取主题文件列表
|
||||
func GetThemeFiles(c *gin.Context) {
|
||||
themeName := c.Param("theme")
|
||||
|
||||
themePath := filepath.Join("templates", themeName)
|
||||
if _, err := os.Stat(themePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "主题不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
files := []string{}
|
||||
|
||||
// 读取模板文件
|
||||
entries, err := os.ReadDir(themePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取文件列表失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
ext := filepath.Ext(entry.Name())
|
||||
if ext == ".html" || ext == ".css" || ext == ".js" {
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 CSS 文件
|
||||
cssPath := filepath.Join("static", "css", themeName+".css")
|
||||
if _, err := os.Stat(cssPath); err == nil {
|
||||
files = append(files, themeName+".css")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取主题文件内容
|
||||
func GetThemeFile(c *gin.Context) {
|
||||
themeName := c.Param("theme")
|
||||
filename := c.Param("file")
|
||||
|
||||
var filePath string
|
||||
|
||||
// 判断是模板文件还是 CSS 文件
|
||||
if filename == themeName+".css" {
|
||||
filePath = filepath.Join("static", "css", filename)
|
||||
} else {
|
||||
filePath = filepath.Join("templates", themeName, filename)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "文件不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": string(content),
|
||||
})
|
||||
}
|
||||
|
||||
// 保存主题文件
|
||||
func SaveThemeFile(c *gin.Context) {
|
||||
themeName := c.Param("theme")
|
||||
filename := c.Param("file")
|
||||
|
||||
type Request struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请提供文件内容",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var filePath string
|
||||
|
||||
// 判断是模板文件还是 CSS 文件
|
||||
if filename == themeName+".css" {
|
||||
filePath = filepath.Join("static", "css", filename)
|
||||
} else {
|
||||
filePath = filepath.Join("templates", themeName, filename)
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "保存文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是模板文件且是当前使用的主题,重新加载
|
||||
if filename != themeName+".css" {
|
||||
currentTheme, err := models.GetOptionValue(database.DB, "theme", 0)
|
||||
if err == nil && currentTheme == themeName {
|
||||
renderer := utils.GetGlobalRenderer()
|
||||
if renderer != nil {
|
||||
renderer.Reload(themeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "文件保存成功",
|
||||
})
|
||||
}
|
||||
+193
-10
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,123 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/handlers"
|
||||
"goblog/middleware"
|
||||
"goblog/utils"
|
||||
"goblog/models"
|
||||
"goblog/routers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建默认配置文件(如果不存在)
|
||||
if err := config.CreateDefaultConfig(); err != nil {
|
||||
slog.Error("创建默认配置文件失败", "error", err)
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
// 设置 Gin 模式
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 初始化日志系统
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("启动博客系统",
|
||||
"app", cfg.App.Name,
|
||||
"theme", cfg.App.Theme,
|
||||
"db_driver", cfg.Database.Driver,
|
||||
"port", cfg.Server.Port,
|
||||
)
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.Init(&cfg.Database); err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
slog.Error("数据库初始化失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 创建默认管理员
|
||||
if err := database.CreateDefaultAdmin(); err != nil {
|
||||
log.Fatal("创建默认管理员失败:", err)
|
||||
slog.Error("创建默认管理员失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
|
||||
theme := cfg.App.Theme
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error; err == nil {
|
||||
theme = option.Value
|
||||
slog.Info("从数据库加载主题", "theme", theme)
|
||||
} else {
|
||||
slog.Info("使用配置文件中的主题", "theme", theme)
|
||||
}
|
||||
|
||||
// 创建路由引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 使用自定义的 TemplateRenderer
|
||||
htmlRenderer := utils.LoadTemplates(cfg.App.Theme)
|
||||
r.HTMLRender = htmlRenderer
|
||||
// 配置模板渲染器
|
||||
routers.SetupTemplates(r, theme)
|
||||
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// 后台管理(放在前面避免冲突)
|
||||
r.Static("/admin/static", "./static/admin")
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.File("./static/admin/index.html")
|
||||
})
|
||||
|
||||
// 前端页面路由
|
||||
r.GET("/", handlers.IndexView)
|
||||
r.GET("/post/:slug", handlers.PostView)
|
||||
r.GET("/page/:slug", handlers.PageView)
|
||||
r.POST("/comment", handlers.SubmitComment)
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 公开 API(支持可选认证,以便管理员查看所有文章)
|
||||
api.GET("/posts", middleware.OptionalAuth(), handlers.GetPosts)
|
||||
api.GET("/posts/:id", middleware.OptionalAuth(), handlers.GetPost)
|
||||
api.GET("/categories", handlers.GetCategories)
|
||||
api.GET("/categories/:id", handlers.GetCategory)
|
||||
api.GET("/tags", handlers.GetTags)
|
||||
api.GET("/tags/:id", handlers.GetTag)
|
||||
api.GET("/pages", handlers.GetPages)
|
||||
api.GET("/pages/:id", handlers.GetPage)
|
||||
api.GET("/comments", handlers.GetComments)
|
||||
|
||||
// 需要认证的 API
|
||||
api.POST("/comments", middleware.OptionalAuth(), handlers.CreateComment)
|
||||
|
||||
// 用户认证
|
||||
api.POST("/auth/login", handlers.Login)
|
||||
api.POST("/auth/register", handlers.Register)
|
||||
|
||||
// 需要登录的 API
|
||||
auth := api.Group("/")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
{
|
||||
auth.GET("/auth/me", handlers.GetCurrentUser)
|
||||
auth.PUT("/auth/me", handlers.UpdateUser)
|
||||
auth.PUT("/auth/password", handlers.ChangePassword)
|
||||
}
|
||||
|
||||
// 管理员 API
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
|
||||
{
|
||||
// 文章管理
|
||||
admin.POST("/posts", handlers.CreatePost)
|
||||
admin.PUT("/posts/:id", handlers.UpdatePost)
|
||||
admin.DELETE("/posts/:id", handlers.DeletePost)
|
||||
|
||||
// 分类管理
|
||||
admin.POST("/categories", handlers.CreateCategory)
|
||||
admin.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
|
||||
// 标签管理
|
||||
admin.POST("/tags", handlers.CreateTag)
|
||||
admin.PUT("/tags/:id", handlers.UpdateTag)
|
||||
admin.DELETE("/tags/:id", handlers.DeleteTag)
|
||||
|
||||
// 页面管理
|
||||
admin.POST("/pages", handlers.CreatePage)
|
||||
admin.PUT("/pages/:id", handlers.UpdatePage)
|
||||
admin.DELETE("/pages/:id", handlers.DeletePage)
|
||||
|
||||
// 评论管理
|
||||
admin.GET("/admin/comments", handlers.GetComments)
|
||||
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
|
||||
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
|
||||
admin.DELETE("/comments/:id", handlers.DeleteComment)
|
||||
}
|
||||
}
|
||||
// 配置所有路由
|
||||
routers.SetupRoutes(r, cfg)
|
||||
|
||||
// 启动服务器
|
||||
port := ":" + strconv.Itoa(cfg.Server.Port)
|
||||
log.Printf("服务器启动在 http://localhost%s", port)
|
||||
port := routers.GetServerPort(cfg.Server.Port)
|
||||
slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode)
|
||||
if err := r.Run(port); err != nil {
|
||||
log.Fatal("服务器启动失败:", err)
|
||||
slog.Error("服务器启动失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
+213
-57
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -92,6 +92,12 @@ function showPage(pageName) {
|
||||
case 'comments':
|
||||
loadComments();
|
||||
break;
|
||||
case 'themes':
|
||||
loadThemes();
|
||||
break;
|
||||
case 'theme-editor':
|
||||
// 主题编辑器由 editTheme() 函数加载
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,3 +620,369 @@ function getStatusText(status) {
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
|
||||
// 加载主题列表
|
||||
let currentTheme = null;
|
||||
async function loadThemes() {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentRes = await fetch(`${API_BASE}/theme`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const currentData = await currentRes.json();
|
||||
currentTheme = currentData.theme;
|
||||
|
||||
// 获取所有可用主题
|
||||
const themesRes = await fetch(`${API_BASE}/themes`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const themesData = await themesRes.json();
|
||||
|
||||
// 填充下拉列表
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
themeSelect.innerHTML = '';
|
||||
|
||||
themesData.themes.forEach(theme => {
|
||||
const option = document.createElement('option');
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
if (theme === currentTheme) {
|
||||
option.selected = true;
|
||||
}
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 显示主题列表
|
||||
displayThemeList(themesData.themes, currentTheme);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载主题失败:', error);
|
||||
showThemeMessage('加载主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题列表
|
||||
function displayThemeList(themes, currentTheme) {
|
||||
const themeList = document.getElementById('theme-list');
|
||||
themeList.innerHTML = '';
|
||||
|
||||
themes.forEach(theme => {
|
||||
const themeCard = document.createElement('div');
|
||||
themeCard.className = `theme-card ${theme === currentTheme ? 'active' : ''}`;
|
||||
|
||||
themeCard.innerHTML = `
|
||||
<div class="theme-card-header">
|
||||
<h3>${theme}</h3>
|
||||
${theme === currentTheme ? '<span class="theme-badge">当前使用</span>' : ''}
|
||||
</div>
|
||||
<div class="theme-card-body">
|
||||
<p>主题名称: ${theme}</p>
|
||||
<p>状态: ${theme === currentTheme ? '使用中' : '未使用'}</p>
|
||||
</div>
|
||||
<div class="theme-card-actions">
|
||||
<button class="btn-small btn-secondary" onclick="editTheme('${theme}')">编辑</button>
|
||||
${theme !== currentTheme ? `<button class="btn-small btn-primary" onclick="quickSwitch('${theme}')">切换</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
themeList.appendChild(themeCard);
|
||||
});
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
async function switchTheme() {
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
const selectedTheme = themeSelect.value;
|
||||
|
||||
if (!selectedTheme) {
|
||||
showThemeMessage('请选择一个主题', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTheme === currentTheme) {
|
||||
showThemeMessage('当前已经是该主题', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: selectedTheme })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题切换成功', 'success');
|
||||
currentTheme = selectedTheme;
|
||||
|
||||
// 刷新主题列表显示
|
||||
loadThemes();
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题切换失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换主题失败:', error);
|
||||
showThemeMessage('切换主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题消息
|
||||
function showThemeMessage(message, type = 'info') {
|
||||
const messageEl = document.getElementById('theme-message');
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `theme-message ${type}`;
|
||||
messageEl.style.display = 'block';
|
||||
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 显示创建主题弹窗
|
||||
function showCreateThemeModal() {
|
||||
document.getElementById('theme-modal').classList.remove('hidden');
|
||||
document.getElementById('new-theme-name').value = '';
|
||||
document.getElementById('theme-template').value = 'default';
|
||||
document.getElementById('create-css').checked = true;
|
||||
}
|
||||
|
||||
// 隐藏创建主题弹窗
|
||||
function hideThemeModal() {
|
||||
document.getElementById('theme-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 创建主题表单提交
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const createThemeForm = document.getElementById('create-theme-form');
|
||||
if (createThemeForm) {
|
||||
createThemeForm.addEventListener('submit', handleCreateTheme);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreateTheme(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const themeName = document.getElementById('new-theme-name').value.trim();
|
||||
const template = document.getElementById('theme-template').value;
|
||||
const createCSS = document.getElementById('create-css').checked;
|
||||
|
||||
if (!themeName) {
|
||||
showThemeMessage('请输入主题名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: themeName,
|
||||
template: template,
|
||||
create_css: createCSS
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题创建成功', 'success');
|
||||
hideThemeModal();
|
||||
|
||||
// 刷新主题列表
|
||||
setTimeout(() => {
|
||||
loadThemes();
|
||||
}, 500);
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建主题失败:', error);
|
||||
showThemeMessage('创建主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 快速切换主题
|
||||
async function quickSwitch(theme) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: theme })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题切换成功', 'success');
|
||||
currentTheme = theme;
|
||||
loadThemes();
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题切换失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换主题失败:', error);
|
||||
showThemeMessage('切换主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主题编辑器 ====================
|
||||
|
||||
let currentEditingTheme = null;
|
||||
let currentEditingFile = null;
|
||||
let themeFiles = [];
|
||||
|
||||
// 编辑主题
|
||||
async function editTheme(themeName) {
|
||||
currentEditingTheme = themeName;
|
||||
currentEditingFile = null;
|
||||
|
||||
document.getElementById('editor-theme-name').textContent = themeName;
|
||||
document.getElementById('current-file-name').textContent = '选择文件进行编辑';
|
||||
document.getElementById('theme-file-editor').value = '';
|
||||
|
||||
showPage('theme-editor');
|
||||
|
||||
// 加载主题文件列表
|
||||
await loadThemeFiles(themeName);
|
||||
}
|
||||
|
||||
// 加载主题文件列表
|
||||
async function loadThemeFiles(themeName) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${themeName}/files`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
themeFiles = data.files || [];
|
||||
displayThemeFiles(themeFiles);
|
||||
} else {
|
||||
showThemeMessage(data.error || '加载文件列表失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error);
|
||||
showThemeMessage('加载文件列表失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题文件列表
|
||||
function displayThemeFiles(files) {
|
||||
const filesContainer = document.getElementById('theme-files');
|
||||
filesContainer.innerHTML = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
filesContainer.innerHTML = '<p class="no-files">暂无文件</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'theme-file-item';
|
||||
fileItem.innerHTML = `
|
||||
<span class="file-icon">${getFileIcon(file)}</span>
|
||||
<span class="file-name">${file}</span>
|
||||
`;
|
||||
fileItem.onclick = () => loadFileContent(file);
|
||||
filesContainer.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文件图标
|
||||
function getFileIcon(filename) {
|
||||
if (filename.endsWith('.html')) return '📄';
|
||||
if (filename.endsWith('.css')) return '🎨';
|
||||
if (filename.endsWith('.js')) return '📜';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
// 加载文件内容
|
||||
async function loadFileContent(filename) {
|
||||
try {
|
||||
currentEditingFile = filename;
|
||||
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${filename}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('current-file-name').textContent = filename;
|
||||
document.getElementById('theme-file-editor').value = data.content || '';
|
||||
|
||||
// 高亮当前文件
|
||||
document.querySelectorAll('.theme-file-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
event.currentTarget.classList.add('active');
|
||||
} else {
|
||||
showThemeMessage(data.error || '加载文件失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件失败:', error);
|
||||
showThemeMessage('加载文件失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前文件
|
||||
async function saveCurrentFile() {
|
||||
if (!currentEditingTheme || !currentEditingFile) {
|
||||
showThemeMessage('请先选择文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = document.getElementById('theme-file-editor').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${currentEditingFile}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '文件保存成功', 'success');
|
||||
|
||||
// 如果是模板文件,重新加载主题
|
||||
if (currentEditingFile.endsWith('.html')) {
|
||||
const renderer = await fetch(`${API_BASE}/theme`, { headers: getHeaders() });
|
||||
const rendererData = await renderer.json();
|
||||
if (rendererData.theme === currentEditingTheme) {
|
||||
// 重新加载当前主题以应用更改
|
||||
await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: currentEditingTheme })
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showThemeMessage(data.error || '保存文件失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存文件失败:', error);
|
||||
showThemeMessage('保存文件失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
+79
-1
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>后台管理 - GoBlog</title>
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/admin/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,6 +21,7 @@
|
||||
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
|
||||
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
|
||||
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
|
||||
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
|
||||
</nav>
|
||||
<div class="user-info">
|
||||
<span id="username">Admin</span>
|
||||
@@ -218,6 +220,50 @@
|
||||
<tbody id="comments-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 主题管理 -->
|
||||
<div id="themes-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>主题管理</h1>
|
||||
<button class="btn-primary" onclick="showCreateThemeModal()">新建主题</button>
|
||||
</div>
|
||||
<div class="theme-settings">
|
||||
<div class="form-group">
|
||||
<label>当前主题</label>
|
||||
<select id="theme-select" class="theme-selector">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="switchTheme()">切换主题</button>
|
||||
<div id="theme-message" class="theme-message"></div>
|
||||
</div>
|
||||
<div class="theme-list" id="theme-list">
|
||||
<!-- 主题列表将动态加载 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题编辑器 -->
|
||||
<div id="theme-editor-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>编辑主题: <span id="editor-theme-name"></span></h1>
|
||||
<div>
|
||||
<button class="btn-secondary" onclick="showPage('themes')">返回</button>
|
||||
<button class="btn-primary" onclick="saveCurrentFile()">保存文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-editor-container">
|
||||
<div class="theme-file-list">
|
||||
<h3>文件列表</h3>
|
||||
<div id="theme-files"></div>
|
||||
</div>
|
||||
<div class="theme-editor-content">
|
||||
<div class="editor-header">
|
||||
<h3 id="current-file-name">选择文件进行编辑</h3>
|
||||
</div>
|
||||
<textarea id="theme-file-editor" class="code-editor" placeholder="选择左侧文件进行编辑..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -247,6 +293,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=6"></script>
|
||||
<!-- 创建主题弹窗 -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div class="modal-content modal-large">
|
||||
<h3>新建主题</h3>
|
||||
<form id="create-theme-form">
|
||||
<div class="form-group">
|
||||
<label>主题名称 *</label>
|
||||
<input type="text" id="new-theme-name" required placeholder="例如: mytheme">
|
||||
<small>只能包含字母、数字和下划线</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>基于模板创建</label>
|
||||
<select id="theme-template">
|
||||
<option value="default">default(默认主题)</option>
|
||||
<option value="minimal">minimal(极简主题)</option>
|
||||
<option value="blank">blank(空白主题)</option>
|
||||
</select>
|
||||
<small>选择新主题的初始模板</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="create-css" checked> 创建样式文件
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideThemeModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">创建主题</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/* mytheme Theme */
|
||||
/* 在此添加你的样式 */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-title a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-desc {
|
||||
color: #666;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
margin-right: 15px;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 30px 0;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f5f5f5;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
+207
-1
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@
|
||||
</header>
|
||||
|
||||
<main class="main container">
|
||||
{{template "content" .}}
|
||||
<div class="content-wrapper">
|
||||
<div class="main-content">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
{{template "sidebar" .}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{define "content"}}
|
||||
<div class="post-list">
|
||||
{{if .Posts}}
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
@@ -32,8 +33,12 @@
|
||||
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">暂无文章</div>
|
||||
<div class="empty">
|
||||
<p>暂无文章</p>
|
||||
<p style="margin-top: 10px; font-size: 14px; color: #999;">文章总数: {{.PostCount}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{{ define "sidebar" }}
|
||||
<aside class="sidebar">
|
||||
<!-- 博客信息 -->
|
||||
<div class="sidebar-widget blog-info">
|
||||
<img src="/static/images/avatar.png" alt="头像" class="blog-avatar" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%2280%22><rect fill=%22%231890ff%22 width=%2280%22 height=%2280%22/><text x=%2250%%22 y=%2250%%22 fill=%22white%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2232%22>B</text></svg>'">
|
||||
<div class="blog-name">{{.SiteName}}</div>
|
||||
<div class="blog-description">{{.SiteDesc}}</div>
|
||||
<div class="blog-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{.PostCount}}</div>
|
||||
<div class="stat-label">文章</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{.CategoryCount}}</div>
|
||||
<div class="stat-label">分类</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{.TagCount}}</div>
|
||||
<div class="stat-label">标签</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="sidebar-widget">
|
||||
<h3 class="widget-title">🔍 搜索</h3>
|
||||
<form class="search-form" action="/search" method="get">
|
||||
<input type="text" name="keyword" class="search-input" placeholder="输入关键字搜索..." required>
|
||||
<button type="submit" class="search-btn">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 分类 -->
|
||||
{{if .Categories}}
|
||||
<div class="sidebar-widget">
|
||||
<h3 class="widget-title">📂 分类</h3>
|
||||
<ul class="category-list">
|
||||
{{range .Categories}}
|
||||
<li class="category-item">
|
||||
<a href="/?category={{.ID}}" class="category-link">
|
||||
<span>{{.Name}}</span>
|
||||
<span class="category-count">{{.PostCount}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 标签云 -->
|
||||
{{if .Tags}}
|
||||
<div class="sidebar-widget">
|
||||
<h3 class="widget-title">🏷️ 标签云</h3>
|
||||
<div class="tag-cloud">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag-item" title="{{.Name}} ({{.PostCount}}篇文章)">
|
||||
{{.Name}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<div class="sidebar-widget">
|
||||
<h3 class="widget-title">⚙️ 系统</h3>
|
||||
<ul class="system-menu">
|
||||
<li class="system-menu-item">
|
||||
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
|
||||
</li>
|
||||
<li class="system-menu-item">
|
||||
<a href="/admin/login" class="system-menu-link">👤 用户登录</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{.SiteName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/minimal.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<h1 class="site-title">
|
||||
<a href="/">{{.SiteName}}</a>
|
||||
</h1>
|
||||
<p class="site-desc">{{.SiteDesc}}</p>
|
||||
<nav class="main-nav">
|
||||
<a href="/">首页</a>
|
||||
{{range .Pages}}
|
||||
<a href="/page/{{.Slug}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main container">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<div class="post-list">
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
|
||||
</h2>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
|
||||
<span class="post-category">
|
||||
{{if .Category}}<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
|
||||
</span>
|
||||
{{if .Tags}}
|
||||
<span class="post-tags">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Views}} 阅读</span>
|
||||
</div>
|
||||
<p class="post-summary">{{.Summary}}</p>
|
||||
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="empty">暂无文章</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .CurrentPage 1}}
|
||||
<a href="/?page={{sub .CurrentPage 1}}" class="prev">← 上一页</a>
|
||||
{{end}}
|
||||
<span class="page-info">{{.CurrentPage}} / {{.TotalPages}}</span>
|
||||
{{if lt .CurrentPage .TotalPages}}
|
||||
<a href="/?page={{add .CurrentPage 1}}" class="next">下一页 →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "content"}}
|
||||
<article class="page-detail">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">{{.Page.Title}}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
{{.Page.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -0,0 +1,110 @@
|
||||
{{define "content"}}
|
||||
<article class="post-detail">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">{{.Post.Title}}</h1>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{.Post.PublishedAt.Format "2006-01-02 15:04"}}</span>
|
||||
<span class="post-author">{{.Post.Author.Nickname}}</span>
|
||||
<span class="post-category">
|
||||
<a href="/?category={{.Post.CategoryID}}">{{.Post.Category.Name}}</a>
|
||||
</span>
|
||||
{{if .Post.Tags}}
|
||||
<span class="post-tags">
|
||||
{{range .Post.Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Post.Views}} 阅读</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<nav class="post-navigation">
|
||||
<div class="nav-prev">
|
||||
{{if .PrevPost}}
|
||||
<a href="/post/{{.PrevPost.Slug}}" class="nav-link">
|
||||
<span class="nav-label">← 上一篇</span>
|
||||
<span class="nav-title">{{.PrevPost.Title}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="nav-link disabled">
|
||||
<span class="nav-label">← 上一篇</span>
|
||||
<span class="nav-title">没有了</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="nav-next">
|
||||
{{if .NextPost}}
|
||||
<a href="/post/{{.NextPost.Slug}}" class="nav-link">
|
||||
<span class="nav-label">下一篇 →</span>
|
||||
<span class="nav-title">{{.NextPost.Title}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="nav-link disabled">
|
||||
<span class="nav-label">下一篇 →</span>
|
||||
<span class="nav-title">没有了</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="email" name="email" placeholder="邮箱" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="url" name="website" placeholder="网站(可选)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea name="content" placeholder="写下你的评论..." required></textarea>
|
||||
</div>
|
||||
<button type="submit">发表评论</button>
|
||||
</form>
|
||||
|
||||
<div class="comments-list">
|
||||
{{range .Comments}}
|
||||
<div class="comment-item" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
{{if .Website}}
|
||||
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
|
||||
{{else}}
|
||||
{{.Author}}
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
|
||||
{{if .Children}}
|
||||
<div class="comment-children">
|
||||
{{range .Children}}
|
||||
<div class="comment-item child" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{.Author}}</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{.SiteName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<h1 class="site-title">
|
||||
<a href="/">{{.SiteName}}</a>
|
||||
</h1>
|
||||
<p class="site-desc">{{.SiteDesc}}</p>
|
||||
<nav class="main-nav">
|
||||
<a href="/">首页</a>
|
||||
{{range .Pages}}
|
||||
<a href="/page/{{.Slug}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main container">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "content"}}
|
||||
<div class="post-list">
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
<div class="post-cover">
|
||||
<a href="/post/{{.Slug}}">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
|
||||
</h2>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
|
||||
<span class="post-category">
|
||||
{{if .Category}}<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
|
||||
</span>
|
||||
{{if .Tags}}
|
||||
<span class="post-tags">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Views}} 阅读111</span>
|
||||
</div>
|
||||
<p class="post-summary">{{.Summary}}</p>
|
||||
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="empty">暂无文章</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .CurrentPage 1}}
|
||||
<a href="/?page={{sub .CurrentPage 1}}" class="prev">← 上一页</a>
|
||||
{{end}}
|
||||
<span class="page-info">{{.CurrentPage}} / {{.TotalPages}}</span>
|
||||
{{if lt .CurrentPage .TotalPages}}
|
||||
<a href="/?page={{add .CurrentPage 1}}" class="next">下一页 →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "content"}}
|
||||
<article class="page-detail">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">{{.Page.Title}}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
{{.Page.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -0,0 +1,116 @@
|
||||
{{define "content"}}
|
||||
<article class="post-detail">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">{{.Post.Title}}</h1>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{.Post.PublishedAt.Format "2006-01-02 15:04"}}</span>
|
||||
<span class="post-author">{{.Post.Author.Nickname}}</span>
|
||||
<span class="post-category">
|
||||
<a href="/?category={{.Post.CategoryID}}">{{.Post.Category.Name}}</a>
|
||||
</span>
|
||||
{{if .Post.Tags}}
|
||||
<span class="post-tags">
|
||||
{{range .Post.Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Post.Views}} 阅读913</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Post.Cover}}
|
||||
<div class="post-cover">
|
||||
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<nav class="post-navigation">
|
||||
<div class="nav-prev">
|
||||
{{if .PrevPost}}
|
||||
<a href="/post/{{.PrevPost.Slug}}" class="nav-link">
|
||||
<span class="nav-label">← 上一篇</span>
|
||||
<span class="nav-title">{{.PrevPost.Title}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="nav-link disabled">
|
||||
<span class="nav-label">← 上一篇</span>
|
||||
<span class="nav-title">没有了</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="nav-next">
|
||||
{{if .NextPost}}
|
||||
<a href="/post/{{.NextPost.Slug}}" class="nav-link">
|
||||
<span class="nav-label">下一篇 →</span>
|
||||
<span class="nav-title">{{.NextPost.Title}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="nav-link disabled">
|
||||
<span class="nav-label">下一篇 →</span>
|
||||
<span class="nav-title">没有了</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="email" name="email" placeholder="邮箱" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="url" name="website" placeholder="网站(可选)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea name="content" placeholder="写下你的评论..." required></textarea>
|
||||
</div>
|
||||
<button type="submit">发表评论</button>
|
||||
</form>
|
||||
|
||||
<div class="comments-list">
|
||||
{{range .Comments}}
|
||||
<div class="comment-item" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
{{if .Website}}
|
||||
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
|
||||
{{else}}
|
||||
{{.Author}}
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
|
||||
{{if .Children}}
|
||||
<div class="comment-children">
|
||||
{{range .Children}}
|
||||
<div class="comment-item child" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{.Author}}</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,13 +1,15 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin/render"
|
||||
)
|
||||
|
||||
// 模板函数
|
||||
// templateFuncs 定义模板函数
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"add": func(a, b int) int {
|
||||
@@ -22,15 +24,20 @@ func templateFuncs() template.FuncMap {
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 HTMLRender,存储一个 map:页面名 -> *template.Template
|
||||
// TemplateRenderer 自定义模板渲染器,支持多模板和主题切换
|
||||
type TemplateRenderer struct {
|
||||
templates map[string]*template.Template
|
||||
mu sync.RWMutex // 读写锁,支持运行时重新加载
|
||||
}
|
||||
|
||||
// Lookup 查找指定名称的模板
|
||||
func (r *TemplateRenderer) Lookup(name string) *template.Template {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.templates[name]
|
||||
}
|
||||
|
||||
// Instance 创建渲染实例
|
||||
func (r *TemplateRenderer) Instance(name string, data any) render.Render {
|
||||
return &htmlRender{
|
||||
tmpl: r.Lookup(name),
|
||||
@@ -39,40 +46,79 @@ func (r *TemplateRenderer) Instance(name string, data any) render.Render {
|
||||
}
|
||||
}
|
||||
|
||||
// htmlRender HTML渲染器
|
||||
type htmlRender struct {
|
||||
tmpl *template.Template
|
||||
data any
|
||||
name string
|
||||
}
|
||||
|
||||
// Render 执行模板渲染
|
||||
func (r *htmlRender) Render(w http.ResponseWriter) error {
|
||||
r.WriteContentType(w)
|
||||
return r.tmpl.ExecuteTemplate(w, "base.html", r.data)
|
||||
}
|
||||
|
||||
// WriteContentType 设置内容类型
|
||||
func (r *htmlRender) WriteContentType(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
// 初始化多模板
|
||||
// LoadTemplates 加载指定主题的模板
|
||||
func LoadTemplates(theme string) *TemplateRenderer {
|
||||
r := &TemplateRenderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
r.reload(theme)
|
||||
return r
|
||||
}
|
||||
|
||||
// Reload 重新加载主题模板(线程安全)
|
||||
func (r *TemplateRenderer) Reload(theme string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.reload(theme)
|
||||
}
|
||||
|
||||
// reload 内部重载方法(需要持有锁)
|
||||
func (r *TemplateRenderer) reload(theme string) {
|
||||
templateDir := "templates/" + theme
|
||||
|
||||
// 为每个页面创建独立的模板组合
|
||||
pages := []string{"index", "post", "page"}
|
||||
pages := []string{"index", "post", "page", "sidebar"}
|
||||
newTemplates := make(map[string]*template.Template)
|
||||
|
||||
for _, pageName := range pages {
|
||||
// 组合 base.html 和具体页面模板
|
||||
// 只加载 base.html 和对应的页面模板
|
||||
allFiles := []string{
|
||||
templateDir + "/base.html",
|
||||
templateDir + "/" + pageName + ".html",
|
||||
}
|
||||
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...))
|
||||
r.templates[pageName] = tmpl
|
||||
|
||||
// 解析模板文件
|
||||
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
|
||||
if err != nil {
|
||||
// 如果解析失败,记录错误但不中断
|
||||
fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err)
|
||||
continue
|
||||
}
|
||||
newTemplates[pageName] = tmpl
|
||||
}
|
||||
|
||||
return r
|
||||
// 更新模板映射
|
||||
r.templates = newTemplates
|
||||
fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates))
|
||||
}
|
||||
|
||||
// 全局模板渲染器实例
|
||||
var globalRenderer *TemplateRenderer
|
||||
|
||||
// SetGlobalRenderer 设置全局渲染器
|
||||
func SetGlobalRenderer(r *TemplateRenderer) {
|
||||
globalRenderer = r
|
||||
}
|
||||
|
||||
// GetGlobalRenderer 获取全局渲染器
|
||||
func GetGlobalRenderer() *TemplateRenderer {
|
||||
return globalRenderer
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user