commit 4cab051f9c49fb1fee36580bcae894d07456a006 Author: Zhang Chao Date: Fri Feb 13 10:53:54 2026 +0800 qoder生成项目 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ecd8af1 --- /dev/null +++ b/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + Database DatabaseConfig + Server ServerConfig + App AppConfig +} + +type DatabaseConfig struct { + Driver string + DSN string +} + +type ServerConfig struct { + Port int + Mode string +} + +type AppConfig struct { + Name string + Description string + Author string + Theme string +} + +func Load() *Config { + port, _ := strconv.Atoi(getEnv("SERVER_PORT", "8080")) + + return &Config{ + Database: DatabaseConfig{ + Driver: getEnv("DB_DRIVER", "sqlite"), + DSN: getEnv("DB_DSN", "goblog.db"), + }, + Server: ServerConfig{ + Port: port, + Mode: getEnv("GIN_MODE", "debug"), + }, + App: AppConfig{ + Name: getEnv("APP_NAME", "GoBlog"), + Description: getEnv("APP_DESC", "一个简洁的个人博客"), + Author: getEnv("APP_AUTHOR", "Admin"), + Theme: getEnv("APP_THEME", "default"), + }, + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..cd90498 --- /dev/null +++ b/database/database.go @@ -0,0 +1,63 @@ +package database + +import ( + "goblog/config" + "goblog/models" + "log" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init(cfg *config.DatabaseConfig) error { + var err error + + DB, err = gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return err + } + + // 自动迁移 + err = DB.AutoMigrate( + &models.User{}, + &models.Post{}, + &models.Category{}, + &models.Tag{}, + &models.Comment{}, + &models.Page{}, + &models.Option{}, + ) + if err != nil { + return err + } + + log.Println("数据库连接成功") + return nil +} + +// 创建默认管理员 +func CreateDefaultAdmin() error { + var count int64 + DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count) + + if count == 0 { + admin := &models.User{ + Username: "admin", + Password: "$2a$10$XrrWkDB.DIpuXOYYrMiZdOBpl0gxrtziSCQ4OOnGFP10C8.xF30qq", // admin123 + Nickname: "管理员", + Email: "admin@example.com", + Role: "admin", + Status: 1, + } + if err := DB.Create(admin).Error; err != nil { + return err + } + log.Println("默认管理员创建成功,用户名: admin,密码: admin123") + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..745c719 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module goblog + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.48.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + 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/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + 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/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // 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 + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2250a0b --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +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/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= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +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/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= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +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/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= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +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/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= +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= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +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/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= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/goblog.db b/goblog.db new file mode 100644 index 0000000..5fbff75 Binary files /dev/null and b/goblog.db differ diff --git a/goblog.exe b/goblog.exe new file mode 100644 index 0000000..8837c0c Binary files /dev/null and b/goblog.exe differ diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..5d0ebcf --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,242 @@ +package handlers + +import ( + "net/http" + "time" + + "goblog/database" + "goblog/models" + + "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") + +func JWTSecret() []byte { + return jwtSecret +} + +// 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// 注册请求 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` + Email string `json:"email" binding:"required,email"` +} + +// JWT Claims +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// 登录 +func Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + if user.Status == 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + // 生成 JWT + claims := Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"}) + return + } + + 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, + }, + }) +} + +// 注册 +func Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查用户名是否已存在 + var count int64 + database.DB.Model(&models.User{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"}) + return + } + + // 检查邮箱是否已存在 + database.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被注册"}) + return + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + + user := models.User{ + Username: req.Username, + Password: string(hashedPassword), + Nickname: req.Nickname, + Email: req.Email, + Role: "user", + Status: 1, + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "注册成功", + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "nickname": user.Nickname, + "email": user.Email, + }, + }) +} + +// 获取当前用户信息 +func GetCurrentUser(c *gin.Context) { + userID, _ := c.Get("userID") + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +// 更新用户信息 +func UpdateUser(c *gin.Context) { + userID, _ := c.Get("userID") + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + var req struct { + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.Nickname != "" { + updates["nickname"] = req.Nickname + } + if req.Email != "" { + updates["email"] = req.Email + } + if req.Avatar != "" { + updates["avatar"] = req.Avatar + } + + if err := database.DB.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +// 修改密码 +func ChangePassword(c *gin.Context) { + userID, _ := c.Get("userID") + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + var req struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"}) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if 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 + } + + c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"}) +} diff --git a/handlers/category.go b/handlers/category.go new file mode 100644 index 0000000..f732817 --- /dev/null +++ b/handlers/category.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "net/http" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" +) + +// 创建分类请求 +type CreateCategoryRequest struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug"` + Description string `json:"description"` + ParentID *uint `json:"parent_id"` +} + +// 获取分类列表 +func GetCategories(c *gin.Context) { + var categories []models.Category + database.DB.Order("id ASC").Find(&categories) + c.JSON(http.StatusOK, gin.H{"data": categories}) +} + +// 获取单个分类 +func GetCategory(c *gin.Context) { + id := c.Param("id") + + var category models.Category + if err := database.DB.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +// 创建分类 +func CreateCategory(c *gin.Context) { + var req CreateCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + slug := req.Slug + if slug == "" { + slug = generateSlug(req.Name) + } + + category := models.Category{ + Name: req.Name, + Slug: slug, + Description: req.Description, + ParentID: req.ParentID, + } + + if err := database.DB.Create(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建分类失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": category}) +} + +// 更新分类 +func UpdateCategory(c *gin.Context) { + id := c.Param("id") + + var category models.Category + if err := database.DB.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + var req CreateCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "parent_id": req.ParentID, + } + if req.Slug != "" { + updates["slug"] = req.Slug + } + + if err := database.DB.Model(&category).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新分类失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +// 删除分类 +func DeleteCategory(c *gin.Context) { + id := c.Param("id") + + var category models.Category + if err := database.DB.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + // 检查是否有文章使用此分类 + var count int64 + database.DB.Model(&models.Post{}).Where("category_id = ?", id).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "该分类下还有文章,无法删除"}) + return + } + + if err := database.DB.Delete(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除分类失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/handlers/comment.go b/handlers/comment.go new file mode 100644 index 0000000..e61cf61 --- /dev/null +++ b/handlers/comment.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "net/http" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" +) + +// 创建评论请求 +type CreateCommentRequest struct { + PostID uint `json:"post_id" binding:"required"` + ParentID *uint `json:"parent_id"` + Author string `json:"author" binding:"required"` + Email string `json:"email" binding:"required,email"` + Website string `json:"website"` + Content string `json:"content" binding:"required"` +} + +// 获取评论列表 +func GetComments(c *gin.Context) { + postID := c.Query("post_id") + status := c.Query("status") + + db := database.DB.Model(&models.Comment{}).Preload("User") + + if postID != "" { + db = db.Where("post_id = ?", postID) + } + if status != "" { + db = db.Where("status = ?", status) + } + + var comments []models.Comment + db.Order("created_at DESC").Find(&comments) + + c.JSON(http.StatusOK, gin.H{"data": comments}) +} + +// 创建评论 +func CreateComment(c *gin.Context) { + var req CreateCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查文章是否存在 + var post models.Post + if err := database.DB.First(&post, req.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"}) + return + } + + comment := models.Comment{ + PostID: req.PostID, + ParentID: req.ParentID, + Author: req.Author, + Email: req.Email, + Website: req.Website, + Content: req.Content, + IP: c.ClientIP(), + Status: "pending", // 默认待审核 + } + + // 如果用户已登录 + if userID, exists := c.Get("userID"); exists { + uid := userID.(uint) + comment.UserID = &uid + comment.Status = "approved" // 登录用户评论自动通过 + } + + if err := database.DB.Create(&comment).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "发表评论失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": comment}) +} + +// 审核评论 +func ApproveComment(c *gin.Context) { + id := c.Param("id") + + var comment models.Comment + if err := database.DB.First(&comment, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"}) + return + } + + comment.Status = "approved" + if err := database.DB.Save(&comment).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "审核通过"}) +} + +// 标记为垃圾评论 +func MarkSpamComment(c *gin.Context) { + id := c.Param("id") + + var comment models.Comment + if err := database.DB.First(&comment, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"}) + return + } + + comment.Status = "spam" + if err := database.DB.Save(&comment).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "已标记为垃圾评论"}) +} + +// 删除评论 +func DeleteComment(c *gin.Context) { + id := c.Param("id") + + var comment models.Comment + if err := database.DB.First(&comment, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"}) + return + } + + if err := database.DB.Delete(&comment).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/handlers/page.go b/handlers/page.go new file mode 100644 index 0000000..96bba03 --- /dev/null +++ b/handlers/page.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "net/http" + "strconv" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" +) + +// 获取页面列表 +func GetPages(c *gin.Context) { + var pages []models.Page + database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages) + c.JSON(http.StatusOK, gin.H{"data": pages}) +} + +// 获取单个页面 +func GetPage(c *gin.Context) { + id := c.Param("id") + + var page models.Page + query := database.DB + + if _, err := strconv.Atoi(id); err == nil { + query = query.Where("id = ?", id) + } else { + query = query.Where("slug = ?", id) + } + + // 非管理员只能查看已发布页面 + if !isAdmin(c) { + query = query.Where("status = ?", "published") + } + + if err := query.First(&page).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": page}) +} + +// 创建页面 +func CreatePage(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` + Slug string `json:"slug"` + Content string `json:"content" binding:"required"` + Status string `json:"status"` + Order int `json:"order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, _ := c.Get("userID") + + slug := req.Slug + if slug == "" { + slug = generateSlug(req.Title) + } + + page := models.Page{ + Title: req.Title, + Slug: slug, + Content: req.Content, + AuthorID: userID.(uint), + Status: req.Status, + Order: req.Order, + } + + if err := database.DB.Create(&page).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建页面失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": page}) +} + +// 更新页面 +func UpdatePage(c *gin.Context) { + id := c.Param("id") + + var page models.Page + if err := database.DB.First(&page, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) + return + } + + var req struct { + Title string `json:"title"` + Slug string `json:"slug"` + Content string `json:"content"` + Status string `json:"status"` + Order int `json:"order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.Title != "" { + updates["title"] = req.Title + } + if req.Slug != "" { + updates["slug"] = req.Slug + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.Status != "" { + updates["status"] = req.Status + } + updates["order"] = req.Order + + if err := database.DB.Model(&page).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新页面失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": page}) +} + +// 删除页面 +func DeletePage(c *gin.Context) { + id := c.Param("id") + + var page models.Page + if err := database.DB.First(&page, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) + return + } + + if err := database.DB.Delete(&page).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除页面失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/handlers/post.go b/handlers/post.go new file mode 100644 index 0000000..0460ce4 --- /dev/null +++ b/handlers/post.go @@ -0,0 +1,274 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 文章列表请求参数 +type PostListQuery struct { + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=10"` + CategoryID uint `form:"category_id"` + TagID uint `form:"tag_id"` + Status string `form:"status"` + Keyword string `form:"keyword"` +} + +// 创建文章请求 +type CreatePostRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Summary string `json:"summary"` + Cover string `json:"cover"` + CategoryID uint `json:"category_id" binding:"required"` + Tags []string `json:"tags"` + Status string `json:"status"` + IsTop bool `json:"is_top"` +} + +// 更新文章请求 +type UpdatePostRequest struct { + Title string `json:"title"` + Content string `json:"content"` + Summary string `json:"summary"` + Cover string `json:"cover"` + CategoryID uint `json:"category_id"` + Tags []string `json:"tags"` + Status string `json:"status"` + IsTop bool `json:"is_top"` +} + +// 生成 slug +func generateSlug(title string) string { + slug := strings.ToLower(title) + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") + // 简化处理,实际项目中可能需要更完善的 slug 生成 + return slug +} + +// 获取文章列表 +func GetPosts(c *gin.Context) { + var query PostListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author") + + // 前端只显示已发布的文章 + if !isAdmin(c) { + db = db.Where("status = ?", "published") + } else if query.Status != "" { + db = db.Where("status = ?", query.Status) + } + + if query.CategoryID > 0 { + db = db.Where("category_id = ?", query.CategoryID) + } + + if query.TagID > 0 { + db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id"). + Where("post_tags.tag_id = ?", query.TagID) + } + + if query.Keyword != "" { + db = db.Where("title LIKE ? OR content LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") + } + + var total int64 + db.Count(&total) + + var posts []models.Post + offset := (query.Page - 1) * query.PageSize + db.Order("is_top DESC, published_at DESC, created_at DESC"). + Offset(offset).Limit(query.PageSize).Find(&posts) + + c.JSON(http.StatusOK, gin.H{ + "data": posts, + "total": total, + "page": query.Page, + "size": query.PageSize, + }) +} + +// 获取单篇文章 +func GetPost(c *gin.Context) { + id := c.Param("id") + + var post models.Post + query := database.DB.Preload("Category").Preload("Tags").Preload("Author").Preload("Comments", func(db *gorm.DB) *gorm.DB { + return db.Where("status = ? AND parent_id IS NULL", "approved").Preload("Children", func(db *gorm.DB) *gorm.DB { + return db.Where("status = ?", "approved") + }) + }) + + // 尝试按 ID 或 slug 查找 + if _, err := strconv.Atoi(id); err == nil { + query = query.Where("id = ?", id) + } else { + query = query.Where("slug = ?", id) + } + + // 非管理员只能查看已发布文章 + if !isAdmin(c) { + query = query.Where("status = ?", "published") + } + + if err := query.First(&post).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"}) + return + } + + // 增加浏览量 + database.DB.Model(&post).UpdateColumn("views", gorm.Expr("views + 1")) + + c.JSON(http.StatusOK, gin.H{"data": post}) +} + +// 创建文章 +func CreatePost(c *gin.Context) { + var req CreatePostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, _ := c.Get("userID") + + post := models.Post{ + Title: req.Title, + Slug: generateSlug(req.Title), + Content: req.Content, + Summary: req.Summary, + Cover: req.Cover, + AuthorID: userID.(uint), + CategoryID: req.CategoryID, + Status: req.Status, + IsTop: req.IsTop, + } + + if req.Status == "published" { + now := time.Now() + post.PublishedAt = &now + } + + // 处理标签 + if len(req.Tags) > 0 { + var tags []models.Tag + for _, tagName := range req.Tags { + var tag models.Tag + database.DB.FirstOrCreate(&tag, models.Tag{ + Name: tagName, + Slug: generateSlug(tagName), + }) + tags = append(tags, tag) + } + post.Tags = tags + } + + if err := database.DB.Create(&post).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文章失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": post}) +} + +// 更新文章 +func UpdatePost(c *gin.Context) { + id := c.Param("id") + + var post models.Post + if err := database.DB.First(&post, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"}) + return + } + + var req UpdatePostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.Title != "" { + updates["title"] = req.Title + updates["slug"] = generateSlug(req.Title) + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.Summary != "" { + updates["summary"] = req.Summary + } + if req.Cover != "" { + updates["cover"] = req.Cover + } + if req.CategoryID > 0 { + updates["category_id"] = req.CategoryID + } + if req.Status != "" { + updates["status"] = req.Status + if req.Status == "published" && post.Status != "published" { + now := time.Now() + updates["published_at"] = &now + } + } + updates["is_top"] = req.IsTop + + // 处理标签 + if len(req.Tags) > 0 { + var tags []models.Tag + for _, tagName := range req.Tags { + var tag models.Tag + database.DB.FirstOrCreate(&tag, models.Tag{ + Name: tagName, + Slug: generateSlug(tagName), + }) + tags = append(tags, tag) + } + database.DB.Model(&post).Association("Tags").Replace(tags) + } + + if err := database.DB.Model(&post).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": post}) +} + +// 删除文章 +func DeletePost(c *gin.Context) { + id := c.Param("id") + + var post models.Post + if err := database.DB.First(&post, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"}) + return + } + + if err := database.DB.Delete(&post).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +// 判断是否为管理员 +func isAdmin(c *gin.Context) bool { + role, exists := c.Get("role") + return exists && role == "admin" +} diff --git a/handlers/tag.go b/handlers/tag.go new file mode 100644 index 0000000..e427c4c --- /dev/null +++ b/handlers/tag.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "net/http" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" +) + +// 获取标签列表 +func GetTags(c *gin.Context) { + var tags []models.Tag + database.DB.Order("post_count DESC").Find(&tags) + c.JSON(http.StatusOK, gin.H{"data": tags}) +} + +// 获取单个标签 +func GetTag(c *gin.Context) { + id := c.Param("id") + + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +// 创建标签 +func CreateTag(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + slug := req.Slug + if slug == "" { + slug = generateSlug(req.Name) + } + + tag := models.Tag{ + Name: req.Name, + Slug: slug, + } + + if err := database.DB.Create(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建标签失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": tag}) +} + +// 更新标签 +func UpdateTag(c *gin.Context) { + id := c.Param("id") + + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + var req struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Slug != "" { + updates["slug"] = req.Slug + } + + if err := database.DB.Model(&tag).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +// 删除标签 +func DeleteTag(c *gin.Context) { + id := c.Param("id") + + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + if err := database.DB.Delete(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/handlers/view.go b/handlers/view.go new file mode 100644 index 0000000..2dfce92 --- /dev/null +++ b/handlers/view.go @@ -0,0 +1,241 @@ +package handlers + +import ( + "html/template" + "math" + "net/http" + "strconv" + "time" + + "goblog/config" + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 模板函数 +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "add": func(a, b int) int { + return a + b + }, + "sub": func(a, b int) int { + return a - b + }, + "html": func(s string) template.HTML { + return template.HTML(s) + }, + } +} + +// 初始化模板 - 返回 gin 可用的 HTMLRender +func InitTemplates(theme string) (*template.Template, error) { + tmpl, err := template.New("").Funcs(templateFuncs()).ParseGlob("templates/" + theme + "/*.html") + return tmpl, err +} + +// 首页 +type IndexData struct { + Title string + SiteName string + SiteDesc string + Description string + Posts []models.Post + Pages []models.Page + CurrentPage int + TotalPages int + Year int +} + +func IndexView(c *gin.Context) { + cfg := config.Load() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize := 10 + + categoryID, _ := strconv.Atoi(c.Query("category")) + tagID, _ := strconv.Atoi(c.Query("tag")) + + db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author"). + Where("status = ?", "published"). + Where("published_at IS NOT NULL") + + if categoryID > 0 { + db = db.Where("category_id = ?", categoryID) + } + if tagID > 0 { + db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id"). + Where("post_tags.tag_id = ?", tagID) + } + + var total int64 + db.Count(&total) + + 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) + + // 获取页面 + var pages []models.Page + database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages) + + totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + + data := IndexData{ + Title: "首页", + SiteName: cfg.App.Name, + SiteDesc: cfg.App.Description, + Description: cfg.App.Description, + Posts: posts, + Pages: pages, + CurrentPage: page, + TotalPages: totalPages, + Year: time.Now().Year(), + } + + c.HTML(http.StatusOK, "base.html", data) +} + +// 文章详情页 +type PostDetailData struct { + Title string + SiteName string + SiteDesc string + Description string + Post models.Post + Pages []models.Page + Comments []models.Comment + CommentCount int64 + Year int +} + +func PostView(c *gin.Context) { + cfg := config.Load() + slug := c.Param("slug") + + var post models.Post + err := database.DB.Preload("Category").Preload("Tags").Preload("Author"). + Preload("Comments", func(db *gorm.DB) *gorm.DB { + return db.Where("status = ? AND parent_id IS NULL", "approved").Preload("Children", func(db *gorm.DB) *gorm.DB { + return db.Where("status = ?", "approved") + }) + }). + Where("slug = ? AND status = ?", slug, "published"). + First(&post).Error + + if err != nil { + c.HTML(http.StatusNotFound, "base.html", gin.H{ + "Title": "404 - 页面不存在", + "SiteName": cfg.App.Name, + "Year": time.Now().Year(), + }) + return + } + + // 增加浏览量 + database.DB.Model(&post).UpdateColumn("views", gorm.Expr("views + 1")) + + // 获取页面 + var pages []models.Page + database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages) + + // 统计评论数 + var commentCount int64 + database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount) + + data := PostDetailData{ + Title: post.Title, + SiteName: cfg.App.Name, + SiteDesc: cfg.App.Description, + Description: post.Summary, + Post: post, + Pages: pages, + Comments: post.Comments, + CommentCount: commentCount, + Year: time.Now().Year(), + } + + c.HTML(http.StatusOK, "base.html", data) +} + +// 独立页面 +type PageDetailData struct { + Title string + SiteName string + SiteDesc string + Description string + Page models.Page + Pages []models.Page + Year int +} + +func PageView(c *gin.Context) { + cfg := config.Load() + slug := c.Param("slug") + + var page models.Page + err := database.DB.Where("slug = ? AND status = ?", slug, "published").First(&page).Error + + if err != nil { + c.HTML(http.StatusNotFound, "base.html", gin.H{ + "Title": "404 - 页面不存在", + "SiteName": cfg.App.Name, + "Year": time.Now().Year(), + }) + return + } + + // 获取所有页面 + var pages []models.Page + database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages) + + data := PageDetailData{ + Title: page.Title, + SiteName: cfg.App.Name, + SiteDesc: cfg.App.Description, + Description: "", + Page: page, + Pages: pages, + Year: time.Now().Year(), + } + + c.HTML(http.StatusOK, "base.html", data) +} + +// 提交评论(表单提交) +func SubmitComment(c *gin.Context) { + postID, _ := strconv.Atoi(c.PostForm("post_id")) + author := c.PostForm("author") + email := c.PostForm("email") + website := c.PostForm("website") + content := c.PostForm("content") + + // 检查文章是否存在 + var post models.Post + if err := database.DB.First(&post, postID).Error; err != nil { + c.String(http.StatusBadRequest, "文章不存在") + return + } + + comment := models.Comment{ + PostID: uint(postID), + Author: author, + Email: email, + Website: website, + Content: content, + IP: c.ClientIP(), + Status: "pending", + } + + if err := database.DB.Create(&comment).Error; err != nil { + c.String(http.StatusInternalServerError, "发表评论失败") + return + } + + c.Redirect(http.StatusFound, "/post/"+post.Slug+"#comment-"+strconv.Itoa(int(comment.ID))) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b5dd2a6 --- /dev/null +++ b/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "log" + "strconv" + + "goblog/config" + "goblog/database" + "goblog/handlers" + "goblog/middleware" + + "github.com/gin-gonic/gin" +) + +func main() { + // 加载配置 + cfg := config.Load() + + // 设置 Gin 模式 + gin.SetMode(cfg.Server.Mode) + + // 初始化数据库 + if err := database.Init(&cfg.Database); err != nil { + log.Fatal("数据库初始化失败:", err) + } + + // 创建默认管理员 + if err := database.CreateDefaultAdmin(); err != nil { + log.Fatal("创建默认管理员失败:", err) + } + + // 创建路由 + r := gin.Default() + + // 初始化模板 + tmpl, err := handlers.InitTemplates(cfg.App.Theme) + if err != nil { + log.Fatal("模板初始化失败:", err) + } + r.SetHTMLTemplate(tmpl) + + // 静态文件 + 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) + } + } + + // 启动服务器 + port := ":" + strconv.Itoa(cfg.Server.Port) + log.Printf("服务器启动在 http://localhost%s", port) + if err := r.Run(port); err != nil { + log.Fatal("服务器启动失败:", err) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..14872bd --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,93 @@ +package middleware + +import ( + "net/http" + "strings" + + "goblog/handlers" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// JWT 认证中间件 +func JWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证令牌"}) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.JSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"}) + c.Abort() + return + } + + tokenString := parts[1] + claims := &handlers.Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return handlers.JWTSecret(), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"}) + c.Abort() + return + } + + c.Set("userID", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Next() + } +} + +// 管理员权限中间件 +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) + c.Abort() + return + } + c.Next() + } +} + +// 可选认证中间件(用于某些既支持游客又支持登录用户的接口) +func OptionalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.Next() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.Next() + return + } + + tokenString := parts[1] + claims := &handlers.Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return handlers.JWTSecret(), nil + }) + + if err == nil && token.Valid { + c.Set("userID", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + } + + c.Next() + } +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..2fd2884 --- /dev/null +++ b/models/models.go @@ -0,0 +1,109 @@ +package models + +import ( + "time" + + "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:禁用 +} + +// 文章模型 +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 `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"` +} + +// 分类模型 +type Category struct { + 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"` + Slug string `gorm:"uniqueIndex;size:100" json:"slug"` + Description string `gorm:"size:255" json:"description"` + ParentID *uint `json:"parent_id"` + PostCount int `gorm:"default:0" json:"post_count"` +} + +// 标签模型 +type Tag struct { + 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"` + Slug string `gorm:"uniqueIndex;size:100" json:"slug"` + PostCount int `gorm:"default:0" json:"post_count"` +} + +// 评论模型 +type Comment 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:"-"` + PostID uint `json:"post_id"` + Post Post `gorm:"foreignKey:PostID" json:"-"` + ParentID *uint `json:"parent_id"` + Author string `gorm:"size:100" json:"author"` + Email string `gorm:"size:100" json:"email"` + 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 + UserID *uint `json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Children []Comment `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +// 设置模型 +type Option struct { + ID uint `gorm:"primarykey"` + Key string `gorm:"uniqueIndex;size:100"` + Value string `gorm:"type:text"` +} + +// 页面模型(独立页面) +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"` +} diff --git a/static/admin/app.js b/static/admin/app.js new file mode 100644 index 0000000..d6eeb11 --- /dev/null +++ b/static/admin/app.js @@ -0,0 +1,560 @@ +// API 基础地址 +const API_BASE = '/api'; + +// 当前登录用户 +let currentUser = null; + +// 页面初始化 +document.addEventListener('DOMContentLoaded', () => { + const token = localStorage.getItem('token'); + if (token) { + currentUser = JSON.parse(localStorage.getItem('user') || '{}'); + document.getElementById('username').textContent = currentUser.nickname || currentUser.username; + showSidebar(); + showPage('dashboard'); + } else { + // 未登录状态:隐藏所有页面,只显示登录页 + document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); + const loginPage = document.getElementById('login-page'); + if (loginPage) { + loginPage.classList.remove('hidden'); + } + hideSidebar(); + } + + // 绑定菜单点击事件 + document.querySelectorAll('.menu-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const page = item.dataset.page; + showPage(page); + + document.querySelectorAll('.menu-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + }); + }); + + // 登录表单 + document.getElementById('login-form').addEventListener('submit', handleLogin); + + // 文章表单 + document.getElementById('post-form').addEventListener('submit', handlePostSubmit); + + // 模态框表单 + document.getElementById('modal-form').addEventListener('submit', handleModalSubmit); +}); + +// 显示登录页 +function showLogin() { + hideSidebar(); + document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); + const loginPage = document.getElementById('login-page'); + if (loginPage) { + loginPage.classList.remove('hidden'); + } +} + +// 显示侧边栏 +function showSidebar() { + document.querySelector('.sidebar').style.display = 'flex'; +} + +// 隐藏侧边栏 +function hideSidebar() { + document.querySelector('.sidebar').style.display = 'none'; +} + +// 切换页面 +function showPage(pageName) { + document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); + const targetPage = document.getElementById(pageName + '-page'); + if (targetPage) { + targetPage.classList.remove('hidden'); + } + + // 加载对应数据 + switch(pageName) { + case 'dashboard': + loadDashboard(); + break; + case 'posts': + loadPosts(); + break; + case 'categories': + loadCategories(); + break; + case 'tags': + loadTags(); + break; + case 'pages': + loadPages(); + break; + case 'comments': + loadComments(); + break; + } +} + +// 登录 +async function handleLogin(e) { + e.preventDefault(); + const username = document.getElementById('login-username').value; + const password = document.getElementById('login-password').value; + + try { + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + const data = await res.json(); + if (res.ok) { + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + currentUser = data.user; + document.getElementById('username').textContent = data.user.nickname || data.user.username; + showSidebar(); + showPage('dashboard'); + } else { + alert(data.error || '登录失败'); + } + } catch (err) { + alert('网络错误'); + } +} + +// 退出登录 +function logout() { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + currentUser = null; + showLogin(); +} + +// 获取请求头 +function getHeaders() { + return { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem('token') + }; +} + +// 加载仪表盘 +async function loadDashboard() { + try { + const [posts, categories, tags, comments] = await Promise.all([ + fetch(`${API_BASE}/posts?page_size=1`).then(r => r.json()), + fetch(`${API_BASE}/categories`).then(r => r.json()), + fetch(`${API_BASE}/tags`).then(r => r.json()), + fetch(`${API_BASE}/comments?status=pending`).then(r => r.json()) + ]); + + document.getElementById('stat-posts').textContent = posts.total || 0; + document.getElementById('stat-categories').textContent = categories.data?.length || 0; + document.getElementById('stat-tags').textContent = tags.data?.length || 0; + document.getElementById('stat-comments').textContent = comments.data?.length || 0; + } catch (err) { + console.error('加载仪表盘失败:', err); + } +} + +// 加载文章列表 +async function loadPosts() { + try { + const res = await fetch(`${API_BASE}/posts?page_size=100`, { headers: getHeaders() }); + const data = await res.json(); + + const tbody = document.getElementById('posts-list'); + tbody.innerHTML = data.data?.map(post => ` + + ${post.title} + ${post.category?.name || '-'} + ${getStatusText(post.status)} + ${post.views} + ${post.published_at ? new Date(post.published_at).toLocaleDateString() : '-'} + + + + + + `).join('') || '暂无文章'; + } catch (err) { + console.error('加载文章失败:', err); + } +} + +// 显示文章表单 +async function showPostForm(isEdit = false) { + document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章'; + document.getElementById('post-form').reset(); + document.getElementById('post-id').value = ''; + + // 加载分类选项 + const res = await fetch(`${API_BASE}/categories`); + const data = await res.json(); + const select = document.getElementById('post-category'); + select.innerHTML = data.data?.map(c => ``).join('') || ''; + + showPage('post-form'); +} + +// 编辑文章 +async function editPost(id) { + try { + const res = await fetch(`${API_BASE}/posts/${id}`, { headers: getHeaders() }); + const data = await res.json(); + const post = data.data; + + document.getElementById('post-id').value = post.id; + document.getElementById('post-title').value = post.title; + document.getElementById('post-content').value = post.content; + document.getElementById('post-summary').value = post.summary || ''; + document.getElementById('post-cover').value = post.cover || ''; + document.getElementById('post-status').value = post.status; + document.getElementById('post-istop').checked = post.is_top; + document.getElementById('post-tags').value = post.tags?.map(t => t.name).join(', ') || ''; + + // 加载分类并设置选中 + const catRes = await fetch(`${API_BASE}/categories`); + const catData = await catRes.json(); + const select = document.getElementById('post-category'); + select.innerHTML = catData.data?.map(c => + `` + ).join('') || ''; + + document.getElementById('post-form-title').textContent = '编辑文章'; + showPage('post-form'); + } catch (err) { + alert('加载文章失败'); + } +} + +// 提交文章 +async function handlePostSubmit(e) { + e.preventDefault(); + + const id = document.getElementById('post-id').value; + const tagsStr = document.getElementById('post-tags').value; + const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : []; + + const data = { + title: document.getElementById('post-title').value, + content: document.getElementById('post-content').value, + summary: document.getElementById('post-summary').value, + cover: document.getElementById('post-cover').value, + category_id: parseInt(document.getElementById('post-category').value), + status: document.getElementById('post-status').value, + is_top: document.getElementById('post-istop').checked, + tags: tags + }; + + try { + const url = id ? `${API_BASE}/admin/posts/${id}` : `${API_BASE}/admin/posts`; + const method = id ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: getHeaders(), + body: JSON.stringify(data) + }); + + if (res.ok) { + alert(id ? '更新成功' : '创建成功'); + showPage('posts'); + loadPosts(); + } else { + const err = await res.json(); + alert(err.error || '操作失败'); + } + } catch (err) { + alert('网络错误'); + } +} + +// 删除文章 +async function deletePost(id) { + if (!confirm('确定要删除这篇文章吗?')) return; + + try { + const res = await fetch(`${API_BASE}/admin/posts/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + + if (res.ok) { + loadPosts(); + } else { + alert('删除失败'); + } + } catch (err) { + alert('网络错误'); + } +} + +// 加载分类 +async function loadCategories() { + try { + const res = await fetch(`${API_BASE}/categories`); + const data = await res.json(); + + const tbody = document.getElementById('categories-list'); + tbody.innerHTML = data.data?.map(cat => ` + + ${cat.name} + ${cat.slug} + ${cat.post_count} + + + + + + `).join('') || '暂无分类'; + } catch (err) { + console.error('加载分类失败:', err); + } +} + +// 加载标签 +async function loadTags() { + try { + const res = await fetch(`${API_BASE}/tags`); + const data = await res.json(); + + const tbody = document.getElementById('tags-list'); + tbody.innerHTML = data.data?.map(tag => ` + + ${tag.name} + ${tag.slug} + ${tag.post_count} + + + + + + `).join('') || '暂无标签'; + } catch (err) { + console.error('加载标签失败:', err); + } +} + +// 加载页面 +async function loadPages() { + try { + const res = await fetch(`${API_BASE}/pages`); + const data = await res.json(); + + const tbody = document.getElementById('pages-list'); + tbody.innerHTML = data.data?.map(page => ` + + ${page.title} + ${page.slug} + ${getStatusText(page.status)} + ${page.order} + + + + + + `).join('') || '暂无页面'; + } catch (err) { + console.error('加载页面失败:', err); + } +} + +// 加载评论 +async function loadComments() { + try { + const res = await fetch(`${API_BASE}/comments`, { headers: getHeaders() }); + const data = await res.json(); + + const tbody = document.getElementById('comments-list'); + tbody.innerHTML = data.data?.map(c => ` + + ${c.author} + ${c.content.substring(0, 50)}${c.content.length > 50 ? '...' : ''} + 文章 #${c.post_id} + ${c.status} + ${new Date(c.created_at).toLocaleString()} + + ${c.status === 'pending' ? `` : ''} + + + + `).join('') || '暂无评论'; + } catch (err) { + console.error('加载评论失败:', err); + } +} + +// 显示分类表单 +function showCategoryForm() { + showModal('category', '新建分类'); +} + +// 显示标签表单 +function showTagForm() { + showModal('tag', '新建标签'); +} + +// 显示页面表单 +function showPageForm() { + showModal('page', '新建页面', true); +} + +// 显示模态框 +function showModal(type, title, hasContent = false) { + document.getElementById('modal-type').value = type; + document.getElementById('modal-id').value = ''; + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-name').value = ''; + document.getElementById('modal-slug').value = ''; + + const extra = document.querySelector('.modal-extra'); + if (hasContent) { + extra.innerHTML = ` + + + + + `; + } else { + extra.innerHTML = ''; + } + + document.getElementById('modal').classList.remove('hidden'); +} + +// 隐藏模态框 +function hideModal() { + document.getElementById('modal').classList.add('hidden'); +} + +// 提交模态框表单 +async function handleModalSubmit(e) { + e.preventDefault(); + const type = document.getElementById('modal-type').value; + const id = document.getElementById('modal-id').value; + + const data = { + name: document.getElementById('modal-name').value, + slug: document.getElementById('modal-slug').value + }; + + if (type === 'page') { + data.content = document.getElementById('modal-content').value; + data.order = parseInt(document.getElementById('modal-order').value) || 0; + data.status = 'published'; + } + + try { + // 处理复数形式 + const typePlural = type === 'category' ? 'categories' : + type === 'tag' ? 'tags' : + type === 'page' ? 'pages' : type + 's'; + const url = id ? `${API_BASE}/admin/${typePlural}/${id}` : `${API_BASE}/admin/${typePlural}`; + const method = id ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: getHeaders(), + body: JSON.stringify(data) + }); + + if (res.ok) { + hideModal(); + // 处理复数形式的页面名称 + const pageName = type === 'category' ? 'categories' : + type === 'tag' ? 'tags' : + type === 'page' ? 'pages' : type + 's'; + showPage(pageName); + } else { + const errorData = await res.json().catch(() => ({})); + alert(errorData.error || '操作失败'); + } + } catch (err) { + alert('网络错误'); + } +} + +// 编辑分类 +function editCategory(id, name, slug) { + document.getElementById('modal-type').value = 'category'; + document.getElementById('modal-id').value = id; + document.getElementById('modal-title').textContent = '编辑分类'; + document.getElementById('modal-name').value = name; + document.getElementById('modal-slug').value = slug; + document.querySelector('.modal-extra').innerHTML = ''; + document.getElementById('modal').classList.remove('hidden'); +} + +// 编辑标签 +function editTag(id, name, slug) { + document.getElementById('modal-type').value = 'tag'; + document.getElementById('modal-id').value = id; + document.getElementById('modal-title').textContent = '编辑标签'; + document.getElementById('modal-name').value = name; + document.getElementById('modal-slug').value = slug; + document.querySelector('.modal-extra').innerHTML = ''; + document.getElementById('modal').classList.remove('hidden'); +} + +// 删除分类 +async function deleteCategory(id) { + if (!confirm('确定要删除这个分类吗?')) return; + await fetch(`${API_BASE}/admin/categories/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + loadCategories(); +} + +// 删除标签 +async function deleteTag(id) { + if (!confirm('确定要删除这个标签吗?')) return; + await fetch(`${API_BASE}/admin/tags/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + loadTags(); +} + +// 删除页面 +async function deletePage(id) { + if (!confirm('确定要删除这个页面吗?')) return; + await fetch(`${API_BASE}/admin/pages/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + loadPages(); +} + +// 通过评论 +async function approveComment(id) { + await fetch(`${API_BASE}/admin/comments/${id}/approve`, { + method: 'PUT', + headers: getHeaders() + }); + loadComments(); +} + +// 删除评论 +async function deleteComment(id) { + if (!confirm('确定要删除这条评论吗?')) return; + await fetch(`${API_BASE}/admin/comments/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + loadComments(); +} + +// 获取状态文本 +function getStatusText(status) { + const map = { + 'published': '已发布', + 'draft': '草稿', + 'private': '私密', + 'pending': '待审核' + }; + return map[status] || status; +} diff --git a/static/admin/index.html b/static/admin/index.html new file mode 100644 index 0000000..703b08d --- /dev/null +++ b/static/admin/index.html @@ -0,0 +1,252 @@ + + + + + + 后台管理 - GoBlog + + + +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + diff --git a/static/admin/style.css b/static/admin/style.css new file mode 100644 index 0000000..0be023e --- /dev/null +++ b/static/admin/style.css @@ -0,0 +1,360 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f0f2f5; +} + +#app { + display: flex; + min-height: 100vh; +} + +/* 侧边栏 */ +.sidebar { + width: 220px; + background: #001529; + color: #fff; + display: flex; + flex-direction: column; +} + +.logo { + padding: 20px; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.logo h2 { + font-size: 20px; +} + +.logo p { + font-size: 12px; + color: rgba(255,255,255,0.6); + margin-top: 5px; +} + +.menu { + flex: 1; + padding: 10px 0; +} + +.menu-item { + display: block; + padding: 12px 20px; + color: rgba(255,255,255,0.7); + text-decoration: none; + transition: all 0.3s; +} + +.menu-item:hover, +.menu-item.active { + color: #fff; + background: #1890ff; +} + +.user-info { + padding: 15px 20px; + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-info button { + background: transparent; + border: 1px solid rgba(255,255,255,0.3); + color: #fff; + padding: 5px 12px; + border-radius: 4px; + cursor: pointer; +} + +.user-info button:hover { + background: rgba(255,255,255,0.1); +} + +/* 主内容区 */ +.main-content { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.page.hidden { + display: none; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.page-header h1 { + font-size: 24px; + font-weight: 500; +} + +/* 按钮 */ +.btn-primary { + background: #1890ff; + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.btn-primary:hover { + background: #40a9ff; +} + +.btn-secondary { + background: #fff; + color: #333; + border: 1px solid #d9d9d9; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.btn-secondary:hover { + border-color: #1890ff; + color: #1890ff; +} + +.btn-danger { + background: #ff4d4f; + color: #fff; + border: none; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} + +.btn-success { + background: #52c41a; + color: #fff; + border: none; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-right: 5px; +} + +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; +} + +.stat-card { + background: #fff; + padding: 24px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.stat-card h3 { + font-size: 14px; + color: #666; + font-weight: normal; +} + +.stat-number { + font-size: 32px; + font-weight: 600; + color: #1890ff; + margin-top: 10px; +} + +/* 数据表格 */ +.data-table { + width: 100%; + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #f0f0f0; +} + +.data-table th { + background: #fafafa; + font-weight: 500; + color: #333; +} + +.data-table tr:hover { + background: #fafafa; +} + +/* 表单 */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 14px; + color: #333; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #d9d9d9; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #1890ff; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +/* 登录页 */ +#login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f0f2f5; +} + +#login-page.hidden { + display: none; +} + +.login-box { + background: #fff; + padding: 40px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + width: 360px; +} + +.login-box h2 { + text-align: center; + margin-bottom: 24px; +} + +.login-box button { + width: 100%; + padding: 12px; + font-size: 16px; +} + +/* 弹窗 */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-content { + background: #fff; + padding: 24px; + border-radius: 8px; + width: 400px; + max-width: 90%; +} + +.modal-content h3 { + margin-bottom: 20px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; +} + +/* 状态标签 */ +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; +} + +.status-published { + background: #f6ffed; + color: #52c41a; +} + +.status-draft { + background: #fff7e6; + color: #fa8c16; +} + +.status-pending { + background: #fff2f0; + color: #ff4d4f; +} + +.status-approved { + background: #f6ffed; + color: #52c41a; +} + +/* 响应式 */ +@media (max-width: 768px) { + .sidebar { + width: 60px; + } + + .logo h2, + .logo p, + .menu-item { + display: none; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + grid-template-columns: 1fr; + } +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..d78d958 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,376 @@ +/* 基础样式重置 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +a { + color: #1890ff; + text-decoration: none; +} + +a:hover { + color: #40a9ff; +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 0 20px; +} + +/* 头部样式 */ +.header { + background: #fff; + padding: 30px 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin-bottom: 30px; +} + +.site-title { + font-size: 28px; + font-weight: 600; +} + +.site-title a { + color: #333; +} + +.site-desc { + color: #666; + margin-top: 8px; +} + +.main-nav { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.main-nav a { + margin-right: 20px; + color: #666; + font-size: 15px; +} + +.main-nav a:hover { + color: #1890ff; +} + +/* 主内容区 */ +.main { + min-height: 500px; +} + +/* 文章列表 */ +.post-list { + display: flex; + flex-direction: column; + gap: 25px; +} + +.post-item { + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.post-cover img { + width: 100%; + height: 200px; + object-fit: cover; +} + +.post-content { + padding: 25px; +} + +.post-title { + font-size: 22px; + margin-bottom: 12px; +} + +.post-title a { + color: #333; +} + +.post-title a:hover { + color: #1890ff; +} + +.top-badge { + display: inline-block; + background: #ff4d4f; + color: #fff; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + margin-left: 8px; + vertical-align: middle; +} + +.post-meta { + color: #999; + font-size: 14px; + margin-bottom: 15px; +} + +.post-meta span { + margin-right: 15px; +} + +.post-meta a { + color: #666; +} + +.tag { + display: inline-block; + background: #f0f0f0; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + margin-right: 5px; +} + +.post-summary { + color: #666; + line-height: 1.8; +} + +.read-more { + display: inline-block; + margin-top: 15px; + color: #1890ff; +} + +/* 文章详情 */ +.post-detail { + background: #fff; + border-radius: 8px; + padding: 40px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.post-header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid #eee; +} + +.post-detail .post-title { + font-size: 28px; + margin-bottom: 15px; +} + +.post-body { + line-height: 1.8; + color: #444; +} + +.post-body h2, +.post-body h3, +.post-body h4 { + margin: 30px 0 15px; +} + +.post-body p { + margin-bottom: 15px; +} + +.post-body img { + max-width: 100%; + border-radius: 4px; +} + +.post-body pre { + background: #f6f8fa; + padding: 16px; + border-radius: 6px; + overflow-x: auto; +} + +.post-body code { + background: #f6f8fa; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; +} + +/* 页面 */ +.page-detail { + background: #fff; + border-radius: 8px; + padding: 40px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.page-title { + font-size: 28px; + margin-bottom: 20px; +} + +/* 分页 */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 40px; + padding: 20px; +} + +.pagination a { + padding: 8px 16px; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.page-info { + color: #666; +} + +/* 评论区 */ +.comments-section { + background: #fff; + border-radius: 8px; + padding: 30px; + margin-top: 30px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.comments-section h3 { + margin-bottom: 20px; +} + +.comment-form { + margin-bottom: 30px; + padding-bottom: 30px; + border-bottom: 1px solid #eee; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +.comment-form button { + background: #1890ff; + color: #fff; + border: none; + padding: 10px 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.comment-form button:hover { + background: #40a9ff; +} + +.comments-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.comment-item { + padding: 15px; + background: #f9f9f9; + border-radius: 6px; +} + +.comment-item.child { + margin-left: 30px; + margin-top: 10px; + background: #f0f0f0; +} + +.comment-header { + margin-bottom: 10px; + font-size: 14px; +} + +.comment-author { + font-weight: 600; + color: #333; +} + +.comment-date { + color: #999; + margin-left: 10px; +} + +.comment-content { + color: #555; + line-height: 1.6; +} + +.no-comments { + color: #999; + text-align: center; + padding: 30px; +} + +/* 空状态 */ +.empty { + text-align: center; + padding: 60px; + color: #999; + background: #fff; + border-radius: 8px; +} + +/* 底部 */ +.footer { + background: #fff; + padding: 30px 0; + margin-top: 50px; + text-align: center; + color: #999; + border-top: 1px solid #eee; +} + +/* 响应式 */ +@media (max-width: 768px) { + .site-title { + font-size: 24px; + } + + .post-detail, + .page-detail { + padding: 20px; + } + + .post-detail .post-title { + font-size: 22px; + } + + .post-meta span { + display: block; + margin: 5px 0; + } +} diff --git a/templates/default/base.html b/templates/default/base.html new file mode 100644 index 0000000..c74b1d8 --- /dev/null +++ b/templates/default/base.html @@ -0,0 +1,36 @@ + + + + + + {{.Title}} - {{.SiteName}} + + + + +
+
+

+ {{.SiteName}} +

+

{{.SiteDesc}}

+ +
+
+ +
+ {{template "content" .}} +
+ + + + diff --git a/templates/default/index.html b/templates/default/index.html new file mode 100644 index 0000000..f6f4367 --- /dev/null +++ b/templates/default/index.html @@ -0,0 +1,51 @@ +{{define "content"}} +
+ {{range .Posts}} + + {{else}} +
暂无文章
+ {{end}} +
+ +{{if gt .TotalPages 1}} + +{{end}} +{{end}} diff --git a/templates/default/page.html b/templates/default/page.html new file mode 100644 index 0000000..0729a58 --- /dev/null +++ b/templates/default/page.html @@ -0,0 +1,11 @@ +{{define "content"}} +
+ + +
+ {{.Page.Content | html}} +
+
+{{end}} diff --git a/templates/default/post.html b/templates/default/post.html new file mode 100644 index 0000000..73e3d62 --- /dev/null +++ b/templates/default/post.html @@ -0,0 +1,87 @@ +{{define "content"}} +
+
+

{{.Post.Title}}

+ +
+ + {{if .Post.Cover}} +
+ {{.Post.Title}} +
+ {{end}} + +
+ {{.Post.Content | html}} +
+
+ +
+

评论 ({{.CommentCount}})

+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ {{range .Comments}} +
+
+ + {{if .Website}} + {{.Author}} + {{else}} + {{.Author}} + {{end}} + + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
{{.Content}}
+ + {{if .Children}} +
+ {{range .Children}} +
+
+ {{.Author}} + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
{{.Content}}
+
+ {{end}} +
+ {{end}} +
+ {{else}} +

暂无评论,来发表第一条评论吧!

+ {{end}} +
+
+{{end}}