qoder生成项目

This commit is contained in:
2026-02-13 10:53:54 +08:00
commit 4cab051f9c
24 changed files with 3627 additions and 0 deletions

57
config/config.go Normal file
View 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
View 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
View 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
View 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.db Normal file

Binary file not shown.

BIN
goblog.exe Normal file

Binary file not shown.

242
handlers/auth.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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>&copy; {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
</div>
</footer>
</body>
</html>

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

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

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