qoder生成项目
This commit is contained in:
57
config/config.go
Normal file
57
config/config.go
Normal file
@@ -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
|
||||
}
|
||||
63
database/database.go
Normal file
63
database/database.go
Normal file
@@ -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
|
||||
}
|
||||
54
go.mod
Normal file
54
go.mod
Normal file
@@ -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
|
||||
)
|
||||
117
go.sum
Normal file
117
go.sum
Normal file
@@ -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=
|
||||
BIN
goblog.exe
Normal file
BIN
goblog.exe
Normal file
Binary file not shown.
242
handlers/auth.go
Normal file
242
handlers/auth.go
Normal file
@@ -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": "密码修改成功"})
|
||||
}
|
||||
125
handlers/category.go
Normal file
125
handlers/category.go
Normal file
@@ -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": "删除成功"})
|
||||
}
|
||||
137
handlers/comment.go
Normal file
137
handlers/comment.go
Normal file
@@ -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": "删除成功"})
|
||||
}
|
||||
145
handlers/page.go
Normal file
145
handlers/page.go
Normal file
@@ -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": "删除成功"})
|
||||
}
|
||||
274
handlers/post.go
Normal file
274
handlers/post.go
Normal file
@@ -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"
|
||||
}
|
||||
112
handlers/tag.go
Normal file
112
handlers/tag.go
Normal file
@@ -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": "删除成功"})
|
||||
}
|
||||
241
handlers/view.go
Normal file
241
handlers/view.go
Normal file
@@ -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)))
|
||||
}
|
||||
125
main.go
Normal file
125
main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
93
middleware/auth.go
Normal file
93
middleware/auth.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
109
models/models.go
Normal file
109
models/models.go
Normal file
@@ -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"`
|
||||
}
|
||||
560
static/admin/app.js
Normal file
560
static/admin/app.js
Normal file
@@ -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 => `
|
||||
<tr>
|
||||
<td>${post.title}</td>
|
||||
<td>${post.category?.name || '-'}</td>
|
||||
<td><span class="status-badge status-${post.status}">${getStatusText(post.status)}</span></td>
|
||||
<td>${post.views}</td>
|
||||
<td>${post.published_at ? new Date(post.published_at).toLocaleDateString() : '-'}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editPost(${post.id})">编辑</button>
|
||||
<button class="btn-danger" onclick="deletePost(${post.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无文章</td></tr>';
|
||||
} 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 => `<option value="${c.id}">${c.name}</option>`).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 =>
|
||||
`<option value="${c.id}" ${c.id === post.category_id ? 'selected' : ''}>${c.name}</option>`
|
||||
).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 => `
|
||||
<tr>
|
||||
<td>${cat.name}</td>
|
||||
<td>${cat.slug}</td>
|
||||
<td>${cat.post_count}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editCategory(${cat.id}, '${cat.name}', '${cat.slug}')">编辑</button>
|
||||
<button class="btn-danger" onclick="deleteCategory(${cat.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="4" style="text-align:center">暂无分类</td></tr>';
|
||||
} 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 => `
|
||||
<tr>
|
||||
<td>${tag.name}</td>
|
||||
<td>${tag.slug}</td>
|
||||
<td>${tag.post_count}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editTag(${tag.id}, '${tag.name}', '${tag.slug}')">编辑</button>
|
||||
<button class="btn-danger" onclick="deleteTag(${tag.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="4" style="text-align:center">暂无标签</td></tr>';
|
||||
} 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 => `
|
||||
<tr>
|
||||
<td>${page.title}</td>
|
||||
<td>${page.slug}</td>
|
||||
<td><span class="status-badge status-${page.status}">${getStatusText(page.status)}</span></td>
|
||||
<td>${page.order}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editPage(${page.id})">编辑</button>
|
||||
<button class="btn-danger" onclick="deletePage(${page.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">暂无页面</td></tr>';
|
||||
} 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 => `
|
||||
<tr>
|
||||
<td>${c.author}</td>
|
||||
<td>${c.content.substring(0, 50)}${c.content.length > 50 ? '...' : ''}</td>
|
||||
<td>文章 #${c.post_id}</td>
|
||||
<td><span class="status-badge status-${c.status}">${c.status}</span></td>
|
||||
<td>${new Date(c.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
${c.status === 'pending' ? `<button class="btn-success" onclick="approveComment(${c.id})">通过</button>` : ''}
|
||||
<button class="btn-danger" onclick="deleteComment(${c.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无评论</td></tr>';
|
||||
} 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 = `
|
||||
<label>内容</label>
|
||||
<textarea id="modal-content" rows="5"></textarea>
|
||||
<label>排序</label>
|
||||
<input type="number" id="modal-order" value="0">
|
||||
`;
|
||||
} 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;
|
||||
}
|
||||
252
static/admin/index.html
Normal file
252
static/admin/index.html
Normal file
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>后台管理 - GoBlog</title>
|
||||
<link rel="stylesheet" href="/static/admin/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>GoBlog</h2>
|
||||
<p>后台管理</p>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<a href="#dashboard" class="menu-item active" data-page="dashboard">仪表盘</a>
|
||||
<a href="#posts" class="menu-item" data-page="posts">文章管理</a>
|
||||
<a href="#categories" class="menu-item" data-page="categories">分类管理</a>
|
||||
<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>
|
||||
</nav>
|
||||
<div class="user-info">
|
||||
<span id="username">Admin</span>
|
||||
<button onclick="logout()">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- 登录页面 -->
|
||||
<div id="login-page" class="page">
|
||||
<div class="login-box">
|
||||
<h2>管理员登录</h2>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="login-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="login-password" required>
|
||||
</div>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 仪表盘 -->
|
||||
<div id="dashboard-page" class="page hidden">
|
||||
<h1>仪表盘</h1>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>文章总数</h3>
|
||||
<p class="stat-number" id="stat-posts">0</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>分类总数</h3>
|
||||
<p class="stat-number" id="stat-categories">0</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>标签总数</h3>
|
||||
<p class="stat-number" id="stat-tags">0</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>待审核评论</h3>
|
||||
<p class="stat-number" id="stat-comments">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章管理 -->
|
||||
<div id="posts-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>文章管理</h1>
|
||||
<button class="btn-primary" onclick="showPostForm()">新建文章</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>浏览量</th>
|
||||
<th>发布时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="posts-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 文章表单 -->
|
||||
<div id="post-form-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1 id="post-form-title">新建文章</h1>
|
||||
<button class="btn-secondary" onclick="showPage('posts')">返回</button>
|
||||
</div>
|
||||
<form id="post-form">
|
||||
<input type="hidden" id="post-id">
|
||||
<div class="form-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="post-title" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>分类</label>
|
||||
<select id="post-category" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select id="post-status">
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="private">私密</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>标签(用逗号分隔)</label>
|
||||
<input type="text" id="post-tags" placeholder="标签1, 标签2, 标签3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>封面图 URL</label>
|
||||
<input type="text" id="post-cover" placeholder="https://example.com/image.jpg">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>摘要</label>
|
||||
<textarea id="post-summary" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容 (支持 HTML)</label>
|
||||
<textarea id="post-content" rows="15" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="post-istop"> 置顶文章
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 分类管理 -->
|
||||
<div id="categories-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>分类管理</h1>
|
||||
<button class="btn-primary" onclick="showCategoryForm()">新建分类</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>别名</th>
|
||||
<th>文章数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="categories-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 标签管理 -->
|
||||
<div id="tags-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>标签管理</h1>
|
||||
<button class="btn-primary" onclick="showTagForm()">新建标签</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>别名</th>
|
||||
<th>文章数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tags-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 页面管理 -->
|
||||
<div id="pages-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>页面管理</h1>
|
||||
<button class="btn-primary" onclick="showPageForm()">新建页面</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>别名</th>
|
||||
<th>状态</th>
|
||||
<th>排序</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pages-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 评论管理 -->
|
||||
<div id="comments-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>评论管理</h1>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>作者</th>
|
||||
<th>内容</th>
|
||||
<th>文章</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="comments-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 分类/标签/页面 表单弹窗 -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3 id="modal-title">标题</h3>
|
||||
<form id="modal-form">
|
||||
<input type="hidden" id="modal-id">
|
||||
<input type="hidden" id="modal-type">
|
||||
<div class="form-group">
|
||||
<label>名称</label>
|
||||
<input type="text" id="modal-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>别名(可选,用于 URL)</label>
|
||||
<input type="text" id="modal-slug">
|
||||
</div>
|
||||
<div class="form-group modal-extra">
|
||||
<!-- 动态内容 -->
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
360
static/admin/style.css
Normal file
360
static/admin/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
376
static/css/style.css
Normal file
376
static/css/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
36
templates/default/base.html
Normal file
36
templates/default/base.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!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="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>
|
||||
51
templates/default/index.html
Normal file
51
templates/default/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{{define "content"}}
|
||||
<div class="post-list">
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
<div class="post-cover">
|
||||
<a href="/post/{{.Slug}}">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
|
||||
</h2>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{.PublishedAt.Format "2006-01-02"}}</span>
|
||||
<span class="post-category">
|
||||
<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>
|
||||
</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>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="empty">暂无文章</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<div class="pagination">
|
||||
{{if gt .CurrentPage 1}}
|
||||
<a href="/?page={{sub .CurrentPage 1}}" class="prev">← 上一页</a>
|
||||
{{end}}
|
||||
<span class="page-info">{{.CurrentPage}} / {{.TotalPages}}</span>
|
||||
{{if lt .CurrentPage .TotalPages}}
|
||||
<a href="/?page={{add .CurrentPage 1}}" class="next">下一页 →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
11
templates/default/page.html
Normal file
11
templates/default/page.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{{define "content"}}
|
||||
<article class="page-detail">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">{{.Page.Title}}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
{{.Page.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
87
templates/default/post.html
Normal file
87
templates/default/post.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{{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>
|
||||
|
||||
{{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>
|
||||
|
||||
<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}}
|
||||
Reference in New Issue
Block a user