Compare commits
38
Commits
aab3aeb5b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337cb82756 | ||
|
|
4b0d545f47 | ||
|
|
f376d94a49 | ||
|
|
caa9436e6a | ||
|
|
7f5bb974fa | ||
|
|
533f31e868 | ||
|
|
2bcfa1da93 | ||
|
|
f216ef4d1b | ||
|
|
cfe5cf53aa | ||
|
|
20395a79a2 | ||
|
|
16ba817ecf | ||
|
|
052803679a | ||
|
|
6e88700f96 | ||
|
|
d4d009f8b2 | ||
|
|
abee7e86aa | ||
|
|
4a9ac54963 | ||
|
|
f1a6c5abaa | ||
|
|
f1780de632 | ||
|
|
0f0850357d | ||
|
|
42eab167ef | ||
|
|
42c6f2450d | ||
|
|
60869ec214 | ||
|
|
043bf8773b | ||
|
|
a1d57d6b44 | ||
|
|
808f2e7523 | ||
|
|
ec1a19f469 | ||
|
|
ea293f7866 | ||
|
|
0b27aafdc4 | ||
|
|
ed5a6c043e | ||
|
|
156b78d45e | ||
|
|
9854b4f195 | ||
|
|
503510de2c | ||
|
|
a9a890a849 | ||
|
|
0b22d4f126 | ||
|
|
21be62fa7c | ||
|
|
98161a2fe8 | ||
|
|
786f11a2ca | ||
|
|
8d0b75e484 |
+52
@@ -0,0 +1,52 @@
|
||||
# 编译产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
goblog
|
||||
|
||||
# 调试文件
|
||||
__debug_bin*
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# SQLite 数据库文件
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# 用户上传的文件
|
||||
static/uploads/*
|
||||
!static/uploads/.gitkeep
|
||||
|
||||
# IDE / 编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Qoder
|
||||
.qoder/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量与本地配置
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Go 工作区文件
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# vendor 目录(如使用 go mod 则无需提交)
|
||||
vendor/
|
||||
|
||||
# 命令行工具
|
||||
cmd/
|
||||
.opencode/.npmrc
|
||||
.opencode/opencode.jsonc
|
||||
.opencode/openwork.json
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// 一次性迁移工具:将远程 Typecho 数据库 typecho_contents 表中的文章(post)和页面(page)
|
||||
// 迁移到本地 goblog.db 的 posts / pages 表。slug 相同的记录执行覆盖更新。
|
||||
// 用法: go run ./cmd/migrate_typecho -host 47.93.160.42 -port 3630 -user blog -pass 'xxx'
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// typechoContent 对应 typecho_contents 表的一行
|
||||
type typechoContent struct {
|
||||
CID int64
|
||||
Title sql.NullString
|
||||
Slug sql.NullString
|
||||
Created int64
|
||||
Modified int64
|
||||
Text sql.NullString
|
||||
Order int64
|
||||
Template sql.NullString
|
||||
Type string
|
||||
Status string
|
||||
Password sql.NullString
|
||||
AllowComment string
|
||||
AllowPing string
|
||||
}
|
||||
|
||||
func main() {
|
||||
host := flag.String("host", "47.93.160.42", "MySQL主机")
|
||||
port := flag.Int("port", 3630, "MySQL端口")
|
||||
user := flag.String("user", "blog", "MySQL用户名")
|
||||
pass := flag.String("pass", "qI7=bL4@iJ", "MySQL密码")
|
||||
dbName := flag.String("db", "typecho", "typecho数据库名(留空自动探测)")
|
||||
sqlitePath := flag.String("sqlite", "goblog.db", "本地SQLite文件路径")
|
||||
dryRun := flag.Bool("dry-run", false, "只对比统计,不写入")
|
||||
flag.Parse()
|
||||
|
||||
// ---------- 连接远程 MySQL ----------
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.User = *user
|
||||
cfg.Passwd = *pass
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = fmt.Sprintf("%s:%d", *host, *port)
|
||||
cfg.DBName = *dbName
|
||||
cfg.Timeout = 10 * time.Second
|
||||
cfg.ReadTimeout = 30 * time.Second
|
||||
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||
|
||||
mysqlDB, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("MySQL DSN错误: %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
if err = mysqlDB.Ping(); err != nil {
|
||||
log.Fatalf("MySQL连接失败: %v", err)
|
||||
}
|
||||
log.Printf("MySQL连接成功: %s", cfg.Addr)
|
||||
|
||||
// 自动探测包含 typecho_contents 的数据库
|
||||
schema := *dbName
|
||||
if schema == "" {
|
||||
err = mysqlDB.QueryRow(
|
||||
"SELECT table_schema FROM information_schema.tables WHERE table_name = 'typecho_contents' LIMIT 1",
|
||||
).Scan(&schema)
|
||||
if err != nil {
|
||||
log.Fatalf("未找到 typecho_contents 表: %v", err)
|
||||
}
|
||||
log.Printf("探测到typecho数据库: %s", schema)
|
||||
}
|
||||
|
||||
// ---------- 连接本地 SQLite ----------
|
||||
localDB, err := gorm.Open(sqlite.Open(*sqlitePath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("SQLite连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 迁移文章归属到本地第一个管理员账号
|
||||
var author models.User
|
||||
if err = localDB.Where("role = ?", "admin").First(&author).Error; err != nil {
|
||||
if err = localDB.First(&author).Error; err != nil {
|
||||
log.Fatalf("本地无任何用户,无法确定文章作者: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("迁移内容将归属本地用户: %s (ID=%d)", author.Username, author.ID)
|
||||
|
||||
// ---------- 读取远程内容 ----------
|
||||
rows, err := mysqlDB.Query(fmt.Sprintf(
|
||||
"SELECT cid, title, slug, created, modified, text, `order`, template, type, status, password, allowComment, allowPing "+
|
||||
"FROM `%s`.typecho_contents WHERE type IN ('post', 'page') ORDER BY cid", schema))
|
||||
if err != nil {
|
||||
log.Fatalf("查询 typecho_contents 失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contents []typechoContent
|
||||
for rows.Next() {
|
||||
var c typechoContent
|
||||
if err = rows.Scan(&c.CID, &c.Title, &c.Slug, &c.Created, &c.Modified, &c.Text,
|
||||
&c.Order, &c.Template, &c.Type, &c.Status, &c.Password, &c.AllowComment, &c.AllowPing); err != nil {
|
||||
log.Fatalf("读取行失败: %v", err)
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Fatalf("遍历结果失败: %v", err)
|
||||
}
|
||||
log.Printf("远程共读取 %d 条内容(post+page)", len(contents))
|
||||
|
||||
// ---------- 写入本地 ----------
|
||||
var postNew, postUpd, pageNew, pageUpd, skipped int
|
||||
for _, c := range contents {
|
||||
title := strings.TrimSpace(c.Title.String)
|
||||
if title == "" {
|
||||
log.Printf("跳过 cid=%d: 标题为空", c.CID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSpace(c.Slug.String)
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("typecho-%d", c.CID)
|
||||
}
|
||||
content := cleanContent(c.Text.String)
|
||||
createdAt := time.Unix(c.Created, 0)
|
||||
updatedAt := time.Unix(c.Modified, 0)
|
||||
if c.Modified == 0 {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
status := "draft"
|
||||
if c.Status == "publish" {
|
||||
status = "published"
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
log.Printf("[dry-run] %s cid=%d slug=%s title=%s status=%s", c.Type, c.CID, slug, title, status)
|
||||
continue
|
||||
}
|
||||
|
||||
switch c.Type {
|
||||
case "post":
|
||||
post := models.Post{
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Content: content,
|
||||
Summary: extractSummary(content),
|
||||
AuthorID: author.ID,
|
||||
Status: status,
|
||||
Password: c.Password.String,
|
||||
AllowComment: c.AllowComment == "1",
|
||||
AllowPing: c.AllowPing == "1",
|
||||
Template: c.Template.String,
|
||||
}
|
||||
if status == "published" {
|
||||
post.PublishedAt = &createdAt
|
||||
}
|
||||
|
||||
var existing models.Post
|
||||
err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error
|
||||
if err == nil {
|
||||
// 覆盖更新已存在的文章
|
||||
post.ID = existing.ID
|
||||
post.CreatedAt = createdAt
|
||||
post.UpdatedAt = updatedAt
|
||||
post.Views = existing.Views
|
||||
post.CategoryID = existing.CategoryID
|
||||
post.IsTop = existing.IsTop
|
||||
if err = localDB.Unscoped().Model(&models.Post{}).Where("id = ?", existing.ID).
|
||||
Select("Title", "Content", "Summary", "AuthorID", "Status", "Password",
|
||||
"AllowComment", "AllowPing", "Template", "PublishedAt", "CreatedAt", "UpdatedAt", "DeletedAt").
|
||||
Updates(map[string]interface{}{
|
||||
"title": post.Title, "content": post.Content, "summary": post.Summary,
|
||||
"author_id": post.AuthorID, "status": post.Status, "password": post.Password,
|
||||
"allow_comment": post.AllowComment, "allow_ping": post.AllowPing,
|
||||
"template": post.Template, "published_at": post.PublishedAt,
|
||||
"created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
log.Fatalf("更新文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
postUpd++
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
post.CreatedAt = createdAt
|
||||
post.UpdatedAt = updatedAt
|
||||
if err = localDB.Create(&post).Error; err != nil {
|
||||
log.Fatalf("插入文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
postNew++
|
||||
} else {
|
||||
log.Fatalf("查询本地文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
|
||||
case "page":
|
||||
var existing models.Page
|
||||
err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error
|
||||
if err == nil {
|
||||
if err = localDB.Unscoped().Model(&models.Page{}).Where("id = ?", existing.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "author_id": author.ID,
|
||||
"status": status, "order": c.Order, "password": c.Password.String,
|
||||
"allow_comment": c.AllowComment == "1",
|
||||
"created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
log.Fatalf("更新页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
pageUpd++
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
page := models.Page{
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Content: content,
|
||||
AuthorID: author.ID,
|
||||
Status: status,
|
||||
Order: int(c.Order),
|
||||
Password: c.Password.String,
|
||||
AllowComment: c.AllowComment == "1",
|
||||
}
|
||||
page.CreatedAt = createdAt
|
||||
page.UpdatedAt = updatedAt
|
||||
if err = localDB.Create(&page).Error; err != nil {
|
||||
log.Fatalf("插入页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
pageNew++
|
||||
} else {
|
||||
log.Fatalf("查询本地页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("迁移完成: 文章新增 %d 篇 / 覆盖 %d 篇, 页面新增 %d 个 / 覆盖 %d 个, 跳过 %d 条",
|
||||
postNew, postUpd, pageNew, pageUpd, skipped)
|
||||
}
|
||||
|
||||
// cleanContent 清理typecho内容: 去掉 <!--markdown--> 标记前缀
|
||||
func cleanContent(text string) string {
|
||||
return strings.TrimPrefix(text, "<!--markdown-->")
|
||||
}
|
||||
|
||||
// extractSummary 提取摘要: 优先取 <!--more--> 之前的部分, 否则取前200个字符
|
||||
func extractSummary(content string) string {
|
||||
if idx := strings.Index(content, "<!--more-->"); idx >= 0 {
|
||||
content = content[:idx]
|
||||
}
|
||||
// 去掉常见markdown标记后截断
|
||||
plain := strings.NewReplacer("#", "", "*", "", "`", "", ">", "", "\r", "").Replace(content)
|
||||
plain = strings.TrimSpace(plain)
|
||||
runes := []rune(plain)
|
||||
if len(runes) > 200 {
|
||||
return string(runes[:200])
|
||||
}
|
||||
return plain
|
||||
}
|
||||
+10
-54
@@ -1,10 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
@@ -22,8 +19,9 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
Mode string
|
||||
Port int
|
||||
Mode string
|
||||
LogLevel string // 日志级别:debug/info/warn/error,默认 info
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -73,6 +71,7 @@ func Load() *Config {
|
||||
func setDefaults() {
|
||||
viper.SetDefault("server.port", 8080)
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.log_level", "info")
|
||||
viper.SetDefault("database.driver", "sqlite")
|
||||
viper.SetDefault("database.dsn", "goblog.db")
|
||||
viper.SetDefault("app.name", "GoBlog")
|
||||
@@ -83,18 +82,13 @@ func setDefaults() {
|
||||
|
||||
// parseConfig 解析配置到结构体
|
||||
func parseConfig() *Config {
|
||||
port := viper.GetInt("server.port")
|
||||
if port == 0 {
|
||||
port, _ = strconv.Atoi(getEnv("SERVER_PORT", "8080"))
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Database: DatabaseConfig{
|
||||
Driver: viper.GetString("database.driver"),
|
||||
DSN: viper.GetString("database.dsn"),
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: port,
|
||||
Port: viper.GetInt("server.port"),
|
||||
Mode: viper.GetString("server.mode"),
|
||||
},
|
||||
App: AppConfig{
|
||||
@@ -126,48 +120,10 @@ func GetGlobalConfig() *Config {
|
||||
return globalConfig
|
||||
}
|
||||
|
||||
// getEnv 获取环境变量
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
// getConfig 获取全局配置(启动时已初始化,直接返回)
|
||||
func GetConfig() *Config {
|
||||
if cfg := GetGlobalConfig(); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// CreateDefaultConfig 创建默认配置文件
|
||||
func CreateDefaultConfig() error {
|
||||
configDir := "./config"
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建配置目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
configPath := configDir + "/config.yaml"
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return nil // 配置文件已存在
|
||||
}
|
||||
|
||||
defaultConfig := `# GoBlog 配置文件
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug 或 release
|
||||
|
||||
database:
|
||||
driver: sqlite # sqlite 或 mysql
|
||||
dsn: goblog.db # SQLite文件路径或MySQL DSN
|
||||
|
||||
app:
|
||||
name: GoBlog
|
||||
description: 一个简洁的个人博客
|
||||
author: Admin
|
||||
theme: default # 主题名称
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil {
|
||||
return fmt.Errorf("创建配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("已创建默认配置文件", "file", configPath)
|
||||
return nil
|
||||
return Load()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug 或 release
|
||||
log_level: info # info, warn, error
|
||||
|
||||
database:
|
||||
driver: sqlite # sqlite 或 mysql
|
||||
|
||||
+5
-24
@@ -3,9 +3,10 @@ package database
|
||||
import (
|
||||
"goblog/config"
|
||||
"goblog/models"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ func Init(cfg *config.DatabaseConfig) error {
|
||||
DB, err = gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -31,33 +33,12 @@ func Init(cfg *config.DatabaseConfig) error {
|
||||
&models.Comment{},
|
||||
&models.Page{},
|
||||
&models.Option{},
|
||||
&models.File{},
|
||||
)
|
||||
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")
|
||||
}
|
||||
slog.Info("数据库连接成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/gorm v1.31.1
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
golang.org/x/crypto v0.53.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
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
|
||||
@@ -26,7 +30,8 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // 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
|
||||
@@ -35,6 +40,7 @@ require (
|
||||
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/ncruces/go-strftime v1.0.0 // 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
|
||||
@@ -50,15 +56,15 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
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
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/tools v0.47.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
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.54.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
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=
|
||||
@@ -31,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
@@ -42,10 +46,16 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
|
||||
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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=
|
||||
@@ -62,10 +72,14 @@ 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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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=
|
||||
@@ -74,7 +88,6 @@ 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
@@ -106,27 +119,29 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
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=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
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=
|
||||
@@ -135,13 +150,35 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
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=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
|
||||
const adminTemplatePath = "./templates/admin/index.html"
|
||||
|
||||
// AdminPageData 后台页面模板数据
|
||||
type AdminPageData struct {
|
||||
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
|
||||
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
|
||||
DisplayName string // 侧边栏显示的用户名
|
||||
CurrentUser template.JS // JSON 序列化的当前用户信息(id/username/nickname/role),注入前端
|
||||
}
|
||||
|
||||
var (
|
||||
adminTmplOnce sync.Once
|
||||
adminTmpl *template.Template
|
||||
adminTmplErr error
|
||||
)
|
||||
|
||||
// getAdminTemplate 懒加载后台模板(仅解析一次)
|
||||
func getAdminTemplate() (*template.Template, error) {
|
||||
adminTmplOnce.Do(func() {
|
||||
adminTmpl, adminTmplErr = template.ParseFiles(adminTemplatePath)
|
||||
if adminTmplErr != nil {
|
||||
slog.Error("解析后台模板失败", "path", adminTemplatePath, "error", adminTmplErr)
|
||||
}
|
||||
})
|
||||
return adminTmpl, adminTmplErr
|
||||
}
|
||||
|
||||
// menuPageSet 从菜单提取页面标识集合
|
||||
func menuPageSet(menus []AdminMenuGroup) map[string]bool {
|
||||
set := make(map[string]bool)
|
||||
for _, g := range menus {
|
||||
for _, item := range g.Items {
|
||||
set[item.Key] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// AdminLoginView 独立登录页
|
||||
func AdminLoginView(c *gin.Context) {
|
||||
c.File("./templates/admin/login.html")
|
||||
}
|
||||
|
||||
// AdminView 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
|
||||
// 依赖 JWTAuth 中间件注入 userID/role 等上下文
|
||||
func AdminView(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验用户当前状态(禁用账号不允许进入后台)
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil || user.Status == 0 {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, tmplErr := getAdminTemplate()
|
||||
if tmplErr != nil {
|
||||
c.String(http.StatusInternalServerError, "后台模板加载失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 菜单与登录接口保持同一来源,页面框架只渲染菜单内的页面
|
||||
menus := GetMenusByRole(user.Role)
|
||||
displayName := user.Nickname
|
||||
if displayName == "" {
|
||||
displayName = user.Username
|
||||
}
|
||||
|
||||
// 将用户基本信息注入前端,避免依赖 localStorage
|
||||
currentUserJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"role": user.Role,
|
||||
})
|
||||
|
||||
data := AdminPageData{
|
||||
Menus: menus,
|
||||
PageSet: menuPageSet(menus),
|
||||
DisplayName: displayName,
|
||||
CurrentUser: template.JS(currentUserJSON),
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "admin_index", data); err != nil {
|
||||
slog.Error("渲染后台页面失败", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -18,20 +18,61 @@ func JWTSecret() []byte {
|
||||
return jwtSecret
|
||||
}
|
||||
|
||||
// AdminMenuItem 后台菜单项
|
||||
type AdminMenuItem struct {
|
||||
Key string `json:"key"` // 页面标识,与页面 div 的 data-page 对应
|
||||
Name string `json:"name"` // 菜单显示名称
|
||||
}
|
||||
|
||||
// AdminMenuGroup 后台菜单分组(Group 为空表示顶级菜单)
|
||||
type AdminMenuGroup struct {
|
||||
Group string `json:"group"`
|
||||
Items []AdminMenuItem `json:"items"`
|
||||
}
|
||||
|
||||
var adminMenus = []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
}},
|
||||
{Group: "内容管理", Items: []AdminMenuItem{
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "categories", Name: "分类管理"},
|
||||
{Key: "tags", Name: "标签管理"},
|
||||
{Key: "files", Name: "文件管理"},
|
||||
{Key: "pages", Name: "页面管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
{Group: "系统管理", Items: []AdminMenuItem{
|
||||
{Key: "users", Name: "用户管理"},
|
||||
{Key: "themes", Name: "主题管理"},
|
||||
{Key: "settings", Name: "基础设置"},
|
||||
}},
|
||||
}
|
||||
var userMenus = []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "files", Name: "文件管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
}
|
||||
|
||||
// GetMenusByRole 根据角色返回后台菜单
|
||||
func GetMenusByRole(role string) []AdminMenuGroup {
|
||||
switch role {
|
||||
case "admin":
|
||||
return adminMenus
|
||||
default:
|
||||
return userMenus
|
||||
}
|
||||
}
|
||||
|
||||
// 登录请求
|
||||
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"`
|
||||
@@ -54,7 +95,7 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status == 0 || !user.IsActive {
|
||||
if user.Status == 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
|
||||
return
|
||||
}
|
||||
@@ -88,74 +129,22 @@ func Login(c *gin.Context) {
|
||||
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,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"login_count": user.LoginCount,
|
||||
},
|
||||
})
|
||||
// 写入 Cookie,供服务端渲染后台页面时识别登录态
|
||||
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "登录成功"})
|
||||
}
|
||||
|
||||
// 注册
|
||||
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
|
||||
}
|
||||
// 退出登录(清除服务端 Cookie)
|
||||
func Logout(c *gin.Context) {
|
||||
c.SetCookie("token", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
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
|
||||
}
|
||||
|
||||
// 使用 User 模型的 SetPassword 方法
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
Role: "user",
|
||||
Status: 1,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
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,
|
||||
},
|
||||
})
|
||||
// WebLogout 前台页面退出登录(清除 Cookie 后回首页)
|
||||
func WebLogout(c *gin.Context) {
|
||||
c.SetCookie("token", "", -1, "/", "", false, true)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
@@ -191,21 +180,19 @@ func UpdateUser(c *gin.Context) {
|
||||
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
|
||||
updates := map[string]interface{}{
|
||||
"nickname": req.Nickname,
|
||||
"email": req.Email,
|
||||
"avatar": req.Avatar,
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||
return
|
||||
}
|
||||
user.Nickname = req.Nickname
|
||||
user.Email = req.Email
|
||||
user.Avatar = req.Avatar
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": user})
|
||||
}
|
||||
@@ -248,3 +235,9 @@ func ChangePassword(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
||||
}
|
||||
|
||||
// 判断是否为管理员
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, exists := c.Get("role")
|
||||
return exists && role == "admin"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -0,0 +1,216 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 获取评论列表
|
||||
func GetComments(c *gin.Context) {
|
||||
postID := c.Query("post_id")
|
||||
postSlug := c.Query("post_slug") // 前台使用 slug
|
||||
status := c.Query("status")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "10")
|
||||
|
||||
// 解析分页参数
|
||||
page := 1
|
||||
pageSize := 10
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 10 {
|
||||
pageSize = ps
|
||||
}
|
||||
|
||||
db := database.DB.Model(&models.Comment{}).Preload("User").
|
||||
Preload("Post", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "title", "slug")
|
||||
})
|
||||
|
||||
// 状态过滤(管理员和普通用户均适用)
|
||||
if status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 非管理员只能查看自己文章上的评论
|
||||
if !isAdmin(c) {
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
// 未指定具体文章时,限制为自己名下的文章
|
||||
if postID == "" && postSlug == "" {
|
||||
db = db.Where("post_id IN (?)",
|
||||
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按文章 ID 或 slug 过滤
|
||||
if postID != "" {
|
||||
db = db.Where("post_id = ?", postID)
|
||||
} else if postSlug != "" {
|
||||
var post models.Post
|
||||
if err := database.DB.Where("slug = ?", postSlug).First(&post).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
|
||||
return
|
||||
}
|
||||
db = db.Where("post_id = ?", post.ID)
|
||||
}
|
||||
|
||||
// 只统计并查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
|
||||
// Count 必须与列表使用相同过滤条件,否则分页总数会包含子评论而不准确
|
||||
db = db.Where("parent_id IS NULL")
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var comments []models.Comment
|
||||
offset := (page - 1) * pageSize
|
||||
db.Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
|
||||
|
||||
// 预加载子评论
|
||||
for i := range comments {
|
||||
database.DB.Model(&comments[i]).Association("Children").Find(&comments[i].Children)
|
||||
}
|
||||
|
||||
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
|
||||
type commentWithPost struct {
|
||||
models.Comment
|
||||
PostTitle string `json:"post_title"`
|
||||
PostSlug string `json:"post_slug"`
|
||||
}
|
||||
list := make([]commentWithPost, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": list,
|
||||
"page": page,
|
||||
"total_pages": totalPages,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommentPosts 获取所有有评论的文章列表(用于评论管理页的文章筛选下拉框)
|
||||
// 与评论列表分页无关,始终返回权限范围内的全部文章
|
||||
func GetCommentPosts(c *gin.Context) {
|
||||
// 有评论的文章 ID 子查询
|
||||
postIDsWithComments := database.DB.Model(&models.Comment{}).Select("DISTINCT post_id")
|
||||
|
||||
query := database.DB.Model(&models.Post{}).
|
||||
Select("id", "title", "slug").
|
||||
Where("id IN (?)", postIDsWithComments)
|
||||
|
||||
// 非管理员只能看到自己名下的文章
|
||||
if !isAdmin(c) {
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
query = query.Where("author_id = ?", userID)
|
||||
}
|
||||
}
|
||||
|
||||
var posts []models.Post
|
||||
query.Order("id DESC").Find(&posts)
|
||||
|
||||
type postOption struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
list := make([]postOption, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
list = append(list, postOption{ID: p.ID, Title: p.Title, Slug: p.Slug})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||
}
|
||||
|
||||
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
|
||||
func loadAndVerifyComment(c *gin.Context) *models.Comment {
|
||||
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 nil
|
||||
}
|
||||
|
||||
if !canManageComment(c, &comment) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
|
||||
return nil
|
||||
}
|
||||
|
||||
return &comment
|
||||
}
|
||||
|
||||
// canManageComment 检查当前用户是否有权管理该评论(管理员始终可以,普通用户只能管理自己文章上的评论)
|
||||
func canManageComment(c *gin.Context, comment *models.Comment) bool {
|
||||
if isAdmin(c) {
|
||||
return true
|
||||
}
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
var post models.Post
|
||||
if err := database.DB.Select("author_id").First(&post, comment.PostID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return post.AuthorID == userID.(uint)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 审核评论
|
||||
func ApproveComment(c *gin.Context) {
|
||||
comment := loadAndVerifyComment(c)
|
||||
if comment == nil {
|
||||
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) {
|
||||
comment := loadAndVerifyComment(c)
|
||||
if comment == nil {
|
||||
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) {
|
||||
comment := loadAndVerifyComment(c)
|
||||
if comment == nil {
|
||||
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": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDashboardStats 返回仪表盘统计数字(按角色过滤:管理员统计全部,普通用户统计自己的)
|
||||
func GetDashboardStats(c *gin.Context) {
|
||||
// 文章数:管理员统计全部,普通用户统计自己的(与文章管理页可见范围一致)
|
||||
postQuery := database.DB.Model(&models.Post{})
|
||||
if !isAdmin(c) {
|
||||
userID, _ := c.Get("userID")
|
||||
postQuery = postQuery.Where("author_id = ?", userID)
|
||||
}
|
||||
var postCount int64
|
||||
postQuery.Count(&postCount)
|
||||
|
||||
var categoryCount, tagCount int64
|
||||
database.DB.Model(&models.Category{}).Count(&categoryCount)
|
||||
database.DB.Model(&models.Tag{}).Count(&tagCount)
|
||||
|
||||
// 待审核评论数:管理员统计全部,普通用户统计自己文章上的
|
||||
commentQuery := database.DB.Model(&models.Comment{}).Where("status = ?", "pending")
|
||||
if !isAdmin(c) {
|
||||
userID, _ := c.Get("userID")
|
||||
commentQuery = commentQuery.Where("post_id IN (?)",
|
||||
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
|
||||
}
|
||||
var pendingCommentCount int64
|
||||
commentQuery.Count(&pendingCommentCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": postCount,
|
||||
"categories": categoryCount,
|
||||
"tags": tagCount,
|
||||
"pending_comments": pendingCommentCount,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,15 +1,18 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gosimple/slug"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -33,6 +36,7 @@ type CreatePostRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
IsTop bool `json:"is_top"`
|
||||
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
|
||||
}
|
||||
|
||||
// 更新文章请求
|
||||
@@ -45,17 +49,44 @@ type UpdatePostRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
IsTop bool `json:"is_top"`
|
||||
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
|
||||
}
|
||||
|
||||
// Markdown 预览请求
|
||||
type MarkdownPreviewRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// PreviewMarkdown 将 Markdown 渲染为 HTML(后台编辑器预览用)
|
||||
func PreviewMarkdown(c *gin.Context) {
|
||||
var req MarkdownPreviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"html": string(utils.RenderMarkdown(req.Content))})
|
||||
}
|
||||
|
||||
// 生成 slug
|
||||
func generateSlug(title string) string {
|
||||
slug := strings.ToLower(title)
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
// 简化处理,实际项目中可能需要更完善的 slug 生成
|
||||
slug := slug.Make(title)
|
||||
return slug
|
||||
}
|
||||
|
||||
// findOrCreateTags 根据名称列表查找或创建标签
|
||||
func findOrCreateTags(tagNames []string) []models.Tag {
|
||||
var tags []models.Tag
|
||||
for _, tagName := range tagNames {
|
||||
var tag models.Tag
|
||||
database.DB.FirstOrCreate(&tag, models.Tag{
|
||||
Name: tagName,
|
||||
Slug: generateSlug(tagName),
|
||||
})
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
func GetPosts(c *gin.Context) {
|
||||
var query PostListQuery
|
||||
@@ -64,13 +95,27 @@ func GetPosts(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数校验
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 || query.PageSize > 100 {
|
||||
query.PageSize = 10
|
||||
}
|
||||
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author")
|
||||
|
||||
// 前端只显示已发布的文章
|
||||
if !isAdmin(c) {
|
||||
// 管理员可查看全部;登录用户可看到自己的全部文章(含草稿);未登录只能看已发布
|
||||
if isAdmin(c) {
|
||||
if query.Status != "" {
|
||||
db = db.Where("status = ?", query.Status)
|
||||
}
|
||||
} else if userId, exists := c.Get("userID"); exists {
|
||||
// 登录用户:只看自己的,不限状态(后台管理需要看到草稿)
|
||||
db = db.Where("author_id = ?", userId)
|
||||
} else {
|
||||
// 未登录:只看已发布
|
||||
db = db.Where("status = ?", "published")
|
||||
} else if query.Status != "" {
|
||||
db = db.Where("status = ?", query.Status)
|
||||
}
|
||||
|
||||
if query.CategoryID > 0 {
|
||||
@@ -120,9 +165,13 @@ func GetPost(c *gin.Context) {
|
||||
query = query.Where("slug = ?", id)
|
||||
}
|
||||
|
||||
// 非管理员只能查看已发布文章
|
||||
// 非管理员只能查看已发布文章,但可以查看自己的草稿(编辑用)
|
||||
if !isAdmin(c) {
|
||||
query = query.Where("status = ?", "published")
|
||||
if userId, exists := c.Get("userID"); exists {
|
||||
query = query.Where("status = ? OR author_id = ?", "published", userId)
|
||||
} else {
|
||||
query = query.Where("status = ?", "published")
|
||||
}
|
||||
}
|
||||
|
||||
if err := query.First(&post).Error; err != nil {
|
||||
@@ -147,15 +196,16 @@ func CreatePost(c *gin.Context) {
|
||||
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,
|
||||
Title: req.Title,
|
||||
Slug: generateSlug(req.Title),
|
||||
Content: req.Content,
|
||||
SearchableText: string(utils.GenerateSearchableText(req.Content)),
|
||||
Summary: req.Summary,
|
||||
Cover: req.Cover,
|
||||
AuthorID: userID.(uint),
|
||||
CategoryID: req.CategoryID,
|
||||
Status: req.Status,
|
||||
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
|
||||
}
|
||||
|
||||
if req.Status == "published" {
|
||||
@@ -165,16 +215,7 @@ func CreatePost(c *gin.Context) {
|
||||
|
||||
// 处理标签
|
||||
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
|
||||
post.Tags = findOrCreateTags(req.Tags)
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&post).Error; err != nil {
|
||||
@@ -182,6 +223,15 @@ func CreatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理文件关联(包含封面)
|
||||
if len(req.FileIDs) > 0 {
|
||||
var files []models.File
|
||||
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
|
||||
if len(files) > 0 {
|
||||
database.DB.Model(&post).Association("Files").Replace(files)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": post})
|
||||
}
|
||||
|
||||
@@ -195,6 +245,15 @@ func UpdatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非管理员只能编辑自己的文章
|
||||
if !isAdmin(c) {
|
||||
userID, _ := c.Get("userID")
|
||||
if post.AuthorID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权编辑他人的文章"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req UpdatePostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -208,13 +267,13 @@ func UpdatePost(c *gin.Context) {
|
||||
}
|
||||
if req.Content != "" {
|
||||
updates["content"] = req.Content
|
||||
updates["searchable_text"] = string(utils.GenerateSearchableText(req.Content))
|
||||
}
|
||||
if req.Summary != "" {
|
||||
updates["summary"] = req.Summary
|
||||
}
|
||||
if req.Cover != "" {
|
||||
updates["cover"] = req.Cover
|
||||
}
|
||||
// 封面始终更新(空字符串表示移除封面)
|
||||
updates["cover"] = req.Cover
|
||||
if req.CategoryID > 0 {
|
||||
updates["category_id"] = req.CategoryID
|
||||
}
|
||||
@@ -230,20 +289,22 @@ func UpdatePost(c *gin.Context) {
|
||||
now := time.Now()
|
||||
updates["published_at"] = &now
|
||||
}
|
||||
updates["is_top"] = req.IsTop
|
||||
// 非管理员不允许置顶
|
||||
if isAdmin(c) {
|
||||
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)
|
||||
// 处理标签(nil 表示本次请求不涉及标签;空数组表示移除全部标签)
|
||||
if req.Tags != nil {
|
||||
if len(req.Tags) > 0 {
|
||||
tags := findOrCreateTags(req.Tags)
|
||||
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
database.DB.Model(&post).Association("Tags").Clear()
|
||||
}
|
||||
database.DB.Model(&post).Association("Tags").Replace(tags)
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&post).Updates(updates).Error; err != nil {
|
||||
@@ -251,6 +312,20 @@ func UpdatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理文件关联(包含封面)
|
||||
if req.FileIDs != nil {
|
||||
if len(req.FileIDs) > 0 {
|
||||
var files []models.File
|
||||
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
|
||||
if len(files) > 0 {
|
||||
database.DB.Model(&post).Association("Files").Replace(files)
|
||||
}
|
||||
} else {
|
||||
// 空数组,清除所有关联
|
||||
database.DB.Model(&post).Association("Files").Clear()
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": post})
|
||||
}
|
||||
|
||||
@@ -264,6 +339,35 @@ func DeletePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非管理员只能删除自己的文章
|
||||
if !isAdmin(c) {
|
||||
userID, _ := c.Get("userID")
|
||||
if post.AuthorID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除他人的文章"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取关联文件,删除物理文件和数据库记录
|
||||
var files []models.File
|
||||
database.DB.Model(&post).Association("Files").Find(&files)
|
||||
for _, f := range files {
|
||||
// 检查文件是否还被其他文章使用
|
||||
var count int64
|
||||
database.DB.Table("post_files").Where("file_id = ? AND post_id != ?", f.ID, post.ID).Count(&count)
|
||||
if count == 0 {
|
||||
// 没有其他文章使用,删除物理文件
|
||||
physPath := filepath.Join("./static/uploads", f.FilePath)
|
||||
if _, err := os.Stat(physPath); err == nil {
|
||||
os.Remove(physPath)
|
||||
}
|
||||
// 删除 files 表记录
|
||||
database.DB.Delete(&f)
|
||||
}
|
||||
}
|
||||
// 清除关联关系
|
||||
database.DB.Model(&post).Association("Files").Clear()
|
||||
|
||||
if err := database.DB.Delete(&post).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"})
|
||||
return
|
||||
@@ -271,9 +375,3 @@ func DeletePost(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// 判断是否为管理员
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, exists := c.Get("role")
|
||||
return exists && role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSettings 获取站点基础设置(管理员)
|
||||
func GetSettings(c *gin.Context) {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
opts, err := models.GetOptionsByUser(database.DB, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取设置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 默认值:站点信息取配置文件,评论默认显示、每页 10 条
|
||||
settings := map[string]string{
|
||||
models.OptionSiteName: cfg.App.Name,
|
||||
models.OptionSiteDesc: cfg.App.Description,
|
||||
models.OptionShowComments: "1",
|
||||
models.OptionCommentPageSize: "10",
|
||||
}
|
||||
for name := range settings {
|
||||
if v, ok := opts[name]; ok && v != "" {
|
||||
settings[name] = v
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": settings})
|
||||
}
|
||||
|
||||
// UpdateSettingsRequest 更新站点基础设置请求
|
||||
type UpdateSettingsRequest struct {
|
||||
SiteName string `json:"site_name" binding:"required"`
|
||||
SiteDesc string `json:"site_desc"`
|
||||
ShowComments bool `json:"show_comments"`
|
||||
CommentsPerPage int `json:"comments_per_page" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
// UpdateSettings 保存站点基础设置(管理员)
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
var req UpdateSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
showComments := "0"
|
||||
if req.ShowComments {
|
||||
showComments = "1"
|
||||
}
|
||||
options := map[string]string{
|
||||
models.OptionSiteName: req.SiteName,
|
||||
models.OptionSiteDesc: req.SiteDesc,
|
||||
models.OptionShowComments: showComments,
|
||||
models.OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
|
||||
}
|
||||
|
||||
if err := models.BatchSetOptions(database.DB, 0, options); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "设置已保存"})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 获取标签列表
|
||||
@@ -103,7 +104,13 @@ func DeleteTag(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&tag).Error; err != nil {
|
||||
// 使用事务:先清除 post_tags 中该标签的关联记录,再删除标签本身,保证数据一致性
|
||||
if err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DELETE FROM post_tags WHERE tag_id = ?", tag.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&tag).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"})
|
||||
return
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -109,9 +109,8 @@ func SwitchTheme(c *gin.Context) {
|
||||
// 创建新主题
|
||||
func CreateTheme(c *gin.Context) {
|
||||
type Request struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Template string `json:"template"`
|
||||
CreateCSS bool `json:"create_css"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
@@ -147,158 +146,46 @@ func CreateTheme(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 创建主题目录
|
||||
if err := os.MkdirAll(themePath, 0755); err != nil {
|
||||
if err := os.MkdirAll(themePath, 0700); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建主题目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果指定了模板,复制模板文件
|
||||
if req.Template != "" && req.Template != "blank" {
|
||||
templateSrc := filepath.Join("templates", req.Template)
|
||||
if _, err := os.Stat(templateSrc); err == nil {
|
||||
// 复制模板文件
|
||||
files := []string{"base.html", "index.html", "post.html", "page.html"}
|
||||
for _, file := range files {
|
||||
srcFile := filepath.Join(templateSrc, file)
|
||||
dstFile := filepath.Join(themePath, file)
|
||||
if data, err := os.ReadFile(srcFile); err == nil {
|
||||
os.WriteFile(dstFile, data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 创建空白模板
|
||||
baseHTML := `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{.SiteName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/{{.Name}}.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<h1 class="site-title">
|
||||
<a href="/">{{.SiteName}}</a>
|
||||
</h1>
|
||||
<p class="site-desc">{{.SiteDesc}}</p>
|
||||
<nav class="main-nav">
|
||||
<a href="/">首页</a>
|
||||
{{range .Pages}}
|
||||
<a href="/page/{{.Slug}}">{{.Title}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main container">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© {{.Year}} {{.SiteName}}. Powered by GoBlog.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
os.WriteFile(filepath.Join(themePath, "base.html"), []byte(baseHTML), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "index.html"), []byte(`{{define "content"}}
|
||||
<div class="post-list">
|
||||
<h2>欢迎来到我的博客</h2>
|
||||
<p>这是一个新主题</p>
|
||||
</div>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "post.html"), []byte(`{{define "content"}}
|
||||
<article class="post-detail">
|
||||
<h1>{{.Post.Title}}</h1>
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
os.WriteFile(filepath.Join(themePath, "page.html"), []byte(`{{define "content"}}
|
||||
<article class="page-detail">
|
||||
<h1>{{.Page.Title}}</h1>
|
||||
<div class="page-body">
|
||||
{{.Page.Content | html}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
`), 0644)
|
||||
// 创建新主题时必须选择已有主题作为模板
|
||||
if req.Template == "" || req.Template == "blank" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "必须选择一个已有主题作为模板",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建 CSS 文件
|
||||
if req.CreateCSS {
|
||||
cssPath := filepath.Join("static", "css", req.Name+".css")
|
||||
os.MkdirAll(filepath.Dir(cssPath), 0755)
|
||||
// 验证所选模板是否存在并复制模板文件
|
||||
templateSrc := filepath.Join("templates", req.Template)
|
||||
if _, err := os.Stat(templateSrc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "所选模板不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cssContent := `/* ` + req.Name + ` Theme */
|
||||
/* 在此添加你的样式 */
|
||||
// 复制模板文件
|
||||
files := []string{"base.html", "index.html", "post.html", "page.html"}
|
||||
for _, file := range files {
|
||||
srcFile := filepath.Join(templateSrc, file)
|
||||
dstFile := filepath.Join(themePath, file)
|
||||
if data, err := os.ReadFile(srcFile); err == nil {
|
||||
os.WriteFile(dstFile, data, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-title a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-desc {
|
||||
color: #666;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
margin-right: 15px;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 30px 0;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f5f5f5;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
`
|
||||
os.WriteFile(cssPath, []byte(cssContent), 0644)
|
||||
// 复制模板的 CSS 文件
|
||||
templateCSS := filepath.Join("static", "css", req.Template+".css")
|
||||
if data, err := os.ReadFile(templateCSS); err == nil {
|
||||
dstCSS := filepath.Join("static", "css", req.Name+".css")
|
||||
os.MkdirAll(filepath.Dir(dstCSS), 0755)
|
||||
os.WriteFile(dstCSS, data, 0644)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -350,19 +237,19 @@ func GetThemeFiles(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveThemeFilePath 根据主题名和文件名解析实际文件路径
|
||||
func resolveThemeFilePath(themeName, filename string) string {
|
||||
if filename == themeName+".css" {
|
||||
return filepath.Join("static", "css", filename)
|
||||
}
|
||||
return filepath.Join("templates", themeName, filename)
|
||||
}
|
||||
|
||||
// 获取主题文件内容
|
||||
func GetThemeFile(c *gin.Context) {
|
||||
themeName := c.Param("theme")
|
||||
filename := c.Param("file")
|
||||
|
||||
var filePath string
|
||||
|
||||
// 判断是模板文件还是 CSS 文件
|
||||
if filename == themeName+".css" {
|
||||
filePath = filepath.Join("static", "css", filename)
|
||||
} else {
|
||||
filePath = filepath.Join("templates", themeName, filename)
|
||||
}
|
||||
filePath := resolveThemeFilePath(themeName, filename)
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -401,17 +288,10 @@ func SaveThemeFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var filePath string
|
||||
|
||||
// 判断是模板文件还是 CSS 文件
|
||||
if filename == themeName+".css" {
|
||||
filePath = filepath.Join("static", "css", filename)
|
||||
} else {
|
||||
filePath = filepath.Join("templates", themeName, filename)
|
||||
}
|
||||
filePath := resolveThemeFilePath(themeName, filename)
|
||||
|
||||
// 保存文件
|
||||
if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, []byte(req.Content), 0600); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "保存文件失败",
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// uploadDir 上传文件保存根目录
|
||||
uploadDir = "./static/uploads"
|
||||
// uploadURLPrefix 上传文件对外访问的 URL 前缀
|
||||
uploadURLPrefix = "/static/uploads/"
|
||||
// maxUploadSize 单个文件最大大小(5MB)
|
||||
maxUploadSize = 5 << 20
|
||||
)
|
||||
|
||||
// allowedImageExts 允许上传的图片扩展名
|
||||
var allowedImageExts = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
}
|
||||
|
||||
// allowedImageMIMEs 允许上传的图片真实 MIME 类型
|
||||
var allowedImageMIMEs = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
// sanitizeBaseName 清理文件名(去除扩展名和不安全字符)
|
||||
func sanitizeBaseName(filename string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
|
||||
base = strings.ReplaceAll(base, " ", "-")
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range base {
|
||||
if r == '-' || r == '_' ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
res := b.String()
|
||||
if res == "" {
|
||||
res = "image"
|
||||
}
|
||||
if len(res) > 40 {
|
||||
res = res[:40]
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// UploadImage 上传图片(校验格式与大小,写入 files 表,按年月目录存储)
|
||||
func UploadImage(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验文件大小
|
||||
if file.Size > maxUploadSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验扩展名
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if !allowedImageExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"})
|
||||
return
|
||||
}
|
||||
|
||||
// 打开文件并检测真实 MIME 类型(防止伪造扩展名)
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, _ := src.Read(head)
|
||||
contentType := http.DetectContentType(head[:n])
|
||||
if !allowedImageMIMEs[contentType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重置读取位置,准备写入
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 按年月生成子目录:uploads/202608/
|
||||
now := time.Now()
|
||||
yearMonth := now.Format("200601")
|
||||
subDir := filepath.Join(uploadDir, yearMonth)
|
||||
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名:时间戳_原始名.扩展名
|
||||
filename := fmt.Sprintf("%d_%s%s", now.UnixNano(), sanitizeBaseName(file.Filename), ext)
|
||||
dstPath := filepath.Join(subDir, filename)
|
||||
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户 ID
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
// 相对路径(用于 URL 拼接和数据库存储)
|
||||
relPath := filepath.Join(yearMonth, filename)
|
||||
|
||||
// 写入 files 表
|
||||
fileRecord := models.File{
|
||||
UserID: userID.(uint),
|
||||
FileName: filename,
|
||||
OrigName: file.Filename,
|
||||
FilePath: relPath,
|
||||
FileSize: file.Size,
|
||||
MimeType: contentType,
|
||||
}
|
||||
if err := database.DB.Create(&fileRecord).Error; err != nil {
|
||||
// 文件已保存但数据库写入失败,记录日志但不阻断返回
|
||||
fmt.Printf("写入文件记录失败: %v\n", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": fileRecord.ID,
|
||||
"url": uploadURLPrefix + strings.ReplaceAll(relPath, "\\", "/"),
|
||||
"name": filename,
|
||||
"size": file.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// ListUploads 列出已上传的图片(从数据库读取,管理员看全部,普通用户看自己的)
|
||||
func ListUploads(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
var files []models.File
|
||||
query := database.DB.Order("created_at DESC")
|
||||
|
||||
if role != "admin" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
if err := query.Find(&files).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端需要的格式
|
||||
type FileItem struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OrigName string `json:"orig_name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
UserID uint `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PostTitle string `json:"post_title"` // 关联文章标题,为空表示未关联
|
||||
PostID uint `json:"post_id"` // 关联文章ID
|
||||
}
|
||||
|
||||
// 批量查询文件关联信息
|
||||
fileIDs := make([]uint, 0, len(files))
|
||||
for _, f := range files {
|
||||
fileIDs = append(fileIDs, f.ID)
|
||||
}
|
||||
|
||||
// 查询关联关系(带文章标题)
|
||||
type PostFileResult struct {
|
||||
FileID uint `json:"file_id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
}
|
||||
var postFileResults []PostFileResult
|
||||
database.DB.Table("post_files").
|
||||
Select("post_files.file_id, post_files.post_id, posts.title as post_title").
|
||||
Joins("LEFT JOIN posts ON posts.id = post_files.post_id").
|
||||
Where("post_files.file_id IN ?", fileIDs).
|
||||
Find(&postFileResults)
|
||||
|
||||
// 构建 fileID -> PostFileResult 映射
|
||||
postFileMap := make(map[uint]PostFileResult)
|
||||
for _, pf := range postFileResults {
|
||||
postFileMap[pf.FileID] = pf
|
||||
}
|
||||
|
||||
items := make([]FileItem, 0, len(files))
|
||||
for _, f := range files {
|
||||
item := FileItem{
|
||||
ID: f.ID,
|
||||
Name: f.FileName,
|
||||
OrigName: f.OrigName,
|
||||
URL: uploadURLPrefix + strings.ReplaceAll(f.FilePath, "\\", "/"),
|
||||
Size: f.FileSize,
|
||||
MimeType: f.MimeType,
|
||||
UserID: f.UserID,
|
||||
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
// 填充关联信息
|
||||
if pf, ok := postFileMap[f.ID]; ok {
|
||||
item.PostTitle = pf.PostTitle
|
||||
item.PostID = pf.PostID
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// DeleteUpload 删除已上传的图片(同时删除数据库记录和物理文件)
|
||||
func DeleteUpload(c *gin.Context) {
|
||||
fileID := c.Param("id")
|
||||
if fileID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var file models.File
|
||||
if err := database.DB.First(&file, fileID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 权限校验:管理员可删所有,普通用户只能删自己的
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" && file.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否被文章使用
|
||||
var count int64
|
||||
database.DB.Table("post_files").Where("file_id = ?", fileID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该文件已被文章使用,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
physPath := filepath.Join(uploadDir, file.FilePath)
|
||||
if _, err := os.Stat(physPath); err == nil {
|
||||
os.Remove(physPath)
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
if err := database.DB.Delete(&file).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 创建用户请求
|
||||
type CreateUserRequest 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"`
|
||||
Role string `json:"role"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// 更新用户请求
|
||||
type AdminUpdateUserRequest struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// 重置密码请求
|
||||
type ResetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// 获取用户列表(管理员)
|
||||
func GetUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := database.DB.Model(&models.User{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var users []models.User
|
||||
if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建用户(管理员)
|
||||
func CreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
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
|
||||
}
|
||||
|
||||
status := 1
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": user})
|
||||
}
|
||||
|
||||
// 更新用户信息(管理员)
|
||||
func AdminUpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
operatorID, _ := c.Get("userID")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminUpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isSelf := operatorID == user.ID
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"nickname": req.Nickname,
|
||||
}
|
||||
|
||||
if req.Email != "" && req.Email != user.Email {
|
||||
// 检查邮箱是否已被其他用户使用
|
||||
var count int64
|
||||
database.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被其他用户使用"})
|
||||
return
|
||||
}
|
||||
updates["email"] = req.Email
|
||||
}
|
||||
|
||||
if req.Role != "" && req.Role != user.Role {
|
||||
if isSelf {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能修改自己的角色"})
|
||||
return
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色"})
|
||||
return
|
||||
}
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
|
||||
if req.Status != nil && *req.Status != user.Status {
|
||||
if isSelf {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
|
||||
return
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
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 AdminResetPassword(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
|
||||
}
|
||||
|
||||
// 删除用户(管理员)
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
operatorID, _ := c.Get("userID")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if operatorID == user.ID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己的账号"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该用户是否有文章
|
||||
var count int64
|
||||
database.DB.Model(&models.Post{}).Where("author_id = ?", user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有文章,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该用户是否有页面
|
||||
database.DB.Model(&models.Page{}).Where("author_id = ?", user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有页面,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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": "删除成功"})
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package handlers
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -10,12 +8,42 @@ import (
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/handlers/backend"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// getConfig 获取全局配置(启动时已初始化,直接返回)
|
||||
func getConfig() *config.Config {
|
||||
if cfg := config.GetGlobalConfig(); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
return config.Load()
|
||||
}
|
||||
|
||||
// loadSiteInfo 加载站点名称和描述(优先数据库,回退配置文件)
|
||||
func loadSiteInfo(cfg *config.Config) (siteName, siteDesc string) {
|
||||
siteName = cfg.App.Name
|
||||
siteDesc = cfg.App.Description
|
||||
if v, err := models.GetOptionValue(database.DB, models.OptionSiteName, 0); err == nil && v != "" {
|
||||
siteName = v
|
||||
}
|
||||
if v, err := models.GetOptionValue(database.DB, models.OptionSiteDesc, 0); err == nil && v != "" {
|
||||
siteDesc = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// loadPublishedPages 加载所有已发布页面(按排序)
|
||||
func loadPublishedPages() []models.Page {
|
||||
var pages []models.Page
|
||||
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
|
||||
return pages
|
||||
}
|
||||
|
||||
// loadSidebarData 加载侧边栏数据
|
||||
func loadSidebarData() (categories []models.Category, tags []models.Tag, postCount, categoryCount, tagCount int64) {
|
||||
database.DB.Order("name ASC").Find(&categories)
|
||||
@@ -26,21 +54,25 @@ func loadSidebarData() (categories []models.Category, tags []models.Tag, postCou
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
// loginInfo 从上下文提取登录态信息(供侧边栏系统菜单区分已登录/未登录)
|
||||
func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) {
|
||||
tokenString, _ := c.Cookie("token")
|
||||
if tokenString == "" {
|
||||
return false, ""
|
||||
}
|
||||
claims := &backend.Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return backend.JWTSecret(), nil
|
||||
})
|
||||
|
||||
if err == nil && token.Valid {
|
||||
return true, claims.Username
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// 首页
|
||||
// 首页数据结构
|
||||
type IndexData struct {
|
||||
Title string
|
||||
SiteName string
|
||||
@@ -57,21 +89,23 @@ type IndexData struct {
|
||||
PostCount int64
|
||||
CategoryCount int64
|
||||
TagCount int64
|
||||
// 登录态(侧边栏系统菜单用)
|
||||
IsLoggedIn bool
|
||||
LoginName string
|
||||
}
|
||||
|
||||
// 首页(供模板使用)
|
||||
func IndexView(c *gin.Context) {
|
||||
cfg := config.GetGlobalConfig()
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
cfg := getConfig()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := 10
|
||||
pageSize := 5
|
||||
|
||||
categoryID, _ := strconv.Atoi(c.Query("category"))
|
||||
tagID, _ := strconv.Atoi(c.Query("tag"))
|
||||
authorID, _ := strconv.Atoi(c.Query("author"))
|
||||
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published")
|
||||
@@ -83,44 +117,31 @@ func IndexView(c *gin.Context) {
|
||||
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
|
||||
Where("post_tags.tag_id = ?", tagID)
|
||||
}
|
||||
if authorID > 0 {
|
||||
db = db.Where("author_id = ?", authorID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
// 调试日志
|
||||
slog.Info("首页查询", "total", total, "page", page, "categoryID", categoryID, "tagID", tagID)
|
||||
|
||||
var posts []models.Post
|
||||
offset := (page - 1) * pageSize
|
||||
db.Order("is_top DESC, published_at DESC, created_at DESC").
|
||||
Offset(offset).Limit(pageSize).Find(&posts)
|
||||
|
||||
// 调试日志
|
||||
slog.Info("查询结果", "postCount", len(posts))
|
||||
for i, p := range posts {
|
||||
slog.Info("文章", "index", i, "id", p.ID, "title", p.Title, "status", p.Status, "publishedAt", p.PublishedAt)
|
||||
// 如果摘要为空,则从内容中提取摘要
|
||||
for i := range posts {
|
||||
p := &posts[i]
|
||||
if p.Summary == "" && p.SearchableText != "" {
|
||||
runes := []rune(p.SearchableText)
|
||||
p.Summary = string(runes[:min(len(runes), 120)])
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页面
|
||||
var pages []models.Page
|
||||
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
|
||||
|
||||
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||
|
||||
// 加载侧边栏数据
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
|
||||
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
|
||||
siteName := cfg.App.Name
|
||||
siteDesc := cfg.App.Description
|
||||
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_name", 0).First(&option).Error; err == nil {
|
||||
siteName = option.Value
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
}
|
||||
siteName, siteDesc := loadSiteInfo(cfg)
|
||||
pages := loadPublishedPages()
|
||||
|
||||
data := IndexData{
|
||||
Title: "首页",
|
||||
@@ -139,6 +160,7 @@ func IndexView(c *gin.Context) {
|
||||
CategoryCount: categoryCount,
|
||||
TagCount: tagCount,
|
||||
}
|
||||
data.IsLoggedIn, data.LoginName = loginInfo(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index", data)
|
||||
}
|
||||
@@ -151,35 +173,39 @@ type PostDetailData struct {
|
||||
Description string
|
||||
Post models.Post
|
||||
Pages []models.Page
|
||||
Comments []models.Comment
|
||||
CommentCount int64
|
||||
Year int
|
||||
PrevPost *models.Post // 上一篇
|
||||
NextPost *models.Post // 下一篇
|
||||
ShowComments bool
|
||||
CommentCount int64
|
||||
// Sidebar 数据
|
||||
Categories []models.Category
|
||||
Tags []models.Tag
|
||||
PostCount int64
|
||||
CategoryCount int64
|
||||
TagCount int64
|
||||
// 登录态(侧边栏系统菜单用)
|
||||
IsLoggedIn bool
|
||||
LoginName string
|
||||
}
|
||||
|
||||
// 文章详情页(供模板使用)
|
||||
func PostView(c *gin.Context) {
|
||||
cfg := config.GetGlobalConfig()
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
cfg := getConfig()
|
||||
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
|
||||
query := database.DB.Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published").
|
||||
Session(&gorm.Session{}) // 创建可安全复用的查询会话,避免两次查询条件叠加
|
||||
|
||||
err := query.Where("slug = ?", slug).First(&post).Error
|
||||
// slug 未命中时,若参数为纯数字则回退按文章 ID 查询(兼容 /post/17 形式的链接)
|
||||
if err != nil {
|
||||
if id, convErr := strconv.Atoi(slug); convErr == nil {
|
||||
err = query.Where("id = ?", id).First(&post).Error
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "index", gin.H{
|
||||
@@ -193,11 +219,18 @@ func PostView(c *gin.Context) {
|
||||
// 增加浏览量
|
||||
database.DB.Model(&post).UpdateColumn("views", gorm.Expr("views + 1"))
|
||||
|
||||
// 获取页面
|
||||
var pages []models.Page
|
||||
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
|
||||
pages := loadPublishedPages()
|
||||
|
||||
showComments := true
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
|
||||
showComments = v != "0"
|
||||
}
|
||||
|
||||
// 统计评论数
|
||||
if showComments {
|
||||
var commentCount int64
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
|
||||
}
|
||||
var commentCount int64
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
|
||||
|
||||
@@ -211,19 +244,7 @@ func PostView(c *gin.Context) {
|
||||
nextErr := database.DB.Where("status = ? AND published_at > ?", "published", post.PublishedAt).
|
||||
Order("published_at ASC").First(&nextPost).Error
|
||||
|
||||
// 从数据库读取站点设置
|
||||
siteName := cfg.App.Name
|
||||
siteDesc := cfg.App.Description
|
||||
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_name", 0).First(&option).Error; err == nil {
|
||||
siteName = option.Value
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
}
|
||||
|
||||
// 加载侧边栏数据
|
||||
siteName, siteDesc := loadSiteInfo(cfg)
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
|
||||
data := PostDetailData{
|
||||
@@ -233,9 +254,9 @@ func PostView(c *gin.Context) {
|
||||
Description: post.Summary,
|
||||
Post: post,
|
||||
Pages: pages,
|
||||
Comments: post.Comments,
|
||||
CommentCount: commentCount,
|
||||
Year: time.Now().Year(),
|
||||
ShowComments: showComments,
|
||||
CommentCount: commentCount,
|
||||
// Sidebar 数据
|
||||
Categories: categories,
|
||||
Tags: tags,
|
||||
@@ -243,6 +264,7 @@ func PostView(c *gin.Context) {
|
||||
CategoryCount: categoryCount,
|
||||
TagCount: tagCount,
|
||||
}
|
||||
data.IsLoggedIn, data.LoginName = loginInfo(c)
|
||||
|
||||
// 只有当查询成功时才赋值
|
||||
if prevErr == nil {
|
||||
@@ -270,13 +292,13 @@ type PageDetailData struct {
|
||||
PostCount int64
|
||||
CategoryCount int64
|
||||
TagCount int64
|
||||
// 登录态(侧边栏系统菜单用)
|
||||
IsLoggedIn bool
|
||||
LoginName string
|
||||
}
|
||||
|
||||
func PageView(c *gin.Context) {
|
||||
cfg := config.GetGlobalConfig()
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
cfg := getConfig()
|
||||
slug := c.Param("slug")
|
||||
|
||||
var page models.Page
|
||||
@@ -290,23 +312,8 @@ func PageView(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有页面
|
||||
var pages []models.Page
|
||||
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
|
||||
|
||||
// 从数据库读取站点设置
|
||||
siteName := cfg.App.Name
|
||||
siteDesc := cfg.App.Description
|
||||
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_name", 0).First(&option).Error; err == nil {
|
||||
siteName = option.Value
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
}
|
||||
|
||||
// 加载侧边栏数据
|
||||
pages := loadPublishedPages()
|
||||
siteName, siteDesc := loadSiteInfo(cfg)
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
|
||||
data := PageDetailData{
|
||||
@@ -324,48 +331,115 @@ func PageView(c *gin.Context) {
|
||||
CategoryCount: categoryCount,
|
||||
TagCount: tagCount,
|
||||
}
|
||||
data.IsLoggedIn, data.LoginName = loginInfo(c)
|
||||
|
||||
c.HTML(http.StatusOK, "page", data)
|
||||
}
|
||||
|
||||
// 提交评论(表单提交)
|
||||
func ListComments(c *gin.Context) {
|
||||
// 统计评论数
|
||||
var commentCount int64
|
||||
postID := c.Param("postID")
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", postID, "approved").Count(&commentCount)
|
||||
|
||||
// 读取评论展示设置
|
||||
showComments := true
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
|
||||
showComments = v != "0"
|
||||
}
|
||||
commentPageSize := 10
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||
commentPageSize = n
|
||||
}
|
||||
}
|
||||
|
||||
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
|
||||
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
|
||||
if commentPage < 1 {
|
||||
commentPage = 1
|
||||
}
|
||||
var comments []models.Comment
|
||||
commentTotalPages := 0
|
||||
if showComments {
|
||||
var topCount int64
|
||||
database.DB.Model(&models.Comment{}).
|
||||
Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
|
||||
Count(&topCount)
|
||||
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
|
||||
if commentTotalPages > 0 && commentPage > commentTotalPages {
|
||||
commentPage = commentTotalPages
|
||||
}
|
||||
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
|
||||
Preload("Children", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("status = ?", "approved")
|
||||
}).
|
||||
Order("created_at ASC").
|
||||
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
|
||||
Find(&comments)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": comments,
|
||||
"page": commentPage,
|
||||
"total_pages": commentTotalPages,
|
||||
"total": commentCount,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 创建评论请求
|
||||
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 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 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, postID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "文章不存在")
|
||||
if err := database.DB.First(&post, req.PostID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := models.Comment{
|
||||
PostID: uint(postID),
|
||||
Author: author,
|
||||
Email: email,
|
||||
Website: website,
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
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.String(http.StatusInternalServerError, "提交失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/post/"+post.Slug)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "评论提交成功"})
|
||||
}
|
||||
|
||||
// 搜索结果页
|
||||
func SearchView(c *gin.Context) {
|
||||
cfg := config.GetGlobalConfig()
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
cfg := getConfig()
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
if keyword == "" {
|
||||
@@ -381,7 +455,7 @@ func SearchView(c *gin.Context) {
|
||||
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published").
|
||||
Where("title LIKE ? OR content LIKE ? OR summary LIKE ?",
|
||||
Where("title LIKE ? OR searchable_text LIKE ? OR summary LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
|
||||
var total int64
|
||||
@@ -392,26 +466,10 @@ func SearchView(c *gin.Context) {
|
||||
db.Order("published_at DESC").
|
||||
Offset(offset).Limit(pageSize).Find(&posts)
|
||||
|
||||
// 获取页面
|
||||
var pages []models.Page
|
||||
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
|
||||
|
||||
pages := loadPublishedPages()
|
||||
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||
|
||||
// 加载侧边栏数据
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
|
||||
// 从数据库读取主题设置
|
||||
siteName := cfg.App.Name
|
||||
siteDesc := cfg.App.Description
|
||||
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_name", 0).First(&option).Error; err == nil {
|
||||
siteName = option.Value
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
}
|
||||
siteName, siteDesc := loadSiteInfo(cfg)
|
||||
|
||||
data := IndexData{
|
||||
Title: "搜索: " + keyword,
|
||||
@@ -429,6 +487,7 @@ func SearchView(c *gin.Context) {
|
||||
CategoryCount: categoryCount,
|
||||
TagCount: tagCount,
|
||||
}
|
||||
data.IsLoggedIn, data.LoginName = loginInfo(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index", data)
|
||||
}
|
||||
@@ -1,33 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/routers"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建默认配置文件(如果不存在)
|
||||
if err := config.CreateDefaultConfig(); err != nil {
|
||||
slog.Error("创建默认配置文件失败", "error", err)
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
// 设置 Gin 模式
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 初始化日志系统
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
// 初始化日志系统(使用 Server.LogLevel)
|
||||
logger := newLogger(cfg.Server.LogLevel)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("启动博客系统",
|
||||
@@ -35,6 +37,8 @@ func main() {
|
||||
"theme", cfg.App.Theme,
|
||||
"db_driver", cfg.Database.Driver,
|
||||
"port", cfg.Server.Port,
|
||||
"mode", cfg.Server.Mode,
|
||||
"log_level", cfg.Server.LogLevel,
|
||||
)
|
||||
|
||||
// 初始化数据库
|
||||
@@ -42,37 +46,106 @@ func main() {
|
||||
slog.Error("数据库初始化失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// defer database.Close() // 如果提供
|
||||
|
||||
// 创建默认管理员
|
||||
if err := database.CreateDefaultAdmin(); err != nil {
|
||||
slog.Error("创建默认管理员失败", "error", err)
|
||||
// 从数据库加载主题(若不存在则使用配置值)
|
||||
theme, err := loadTheme(cfg.App.Theme)
|
||||
if err != nil {
|
||||
slog.Error("加载主题失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
|
||||
theme := cfg.App.Theme
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error; err == nil {
|
||||
theme = option.Value
|
||||
slog.Info("从数据库加载主题", "theme", theme)
|
||||
} else {
|
||||
slog.Info("使用配置文件中的主题", "theme", theme)
|
||||
// 创建路由引擎(使用自定义 slog 中间件)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(slogMiddleware(logger))
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// 配置模板渲染器(现在 LoadTemplates 返回 error)
|
||||
htmlRenderer, err := utils.LoadTemplates(theme)
|
||||
if err != nil {
|
||||
slog.Error("加载模板失败", "error", err, "theme", theme)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 创建路由引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 配置模板渲染器
|
||||
routers.SetupTemplates(r, theme)
|
||||
utils.SetGlobalRenderer(htmlRenderer)
|
||||
r.HTMLRender = htmlRenderer
|
||||
|
||||
// 配置所有路由
|
||||
routers.SetupRoutes(r, cfg)
|
||||
routers.SetupRoutes(r)
|
||||
|
||||
// 启动服务器
|
||||
port := routers.GetServerPort(cfg.Server.Port)
|
||||
slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode)
|
||||
if err := r.Run(port); err != nil {
|
||||
slog.Error("服务器启动失败", "error", err)
|
||||
os.Exit(1)
|
||||
// 启动服务器(优雅关闭)
|
||||
runServer(r, cfg.Server.Port)
|
||||
}
|
||||
|
||||
// newLogger 根据配置字符串创建 slog.Logger
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
lvl = slog.LevelInfo // 默认 info
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: lvl}
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
// loadTheme 从数据库读取系统主题,若不存在则使用 fallback
|
||||
func loadTheme(fallback string) (string, error) {
|
||||
var option models.Option
|
||||
err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error
|
||||
if err == nil {
|
||||
slog.Info("从数据库加载主题", "theme", option.Value)
|
||||
return option.Value, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
slog.Info("使用配置文件中的主题", "theme", fallback)
|
||||
return fallback, nil
|
||||
}
|
||||
return "", err // 真实错误必须返回
|
||||
}
|
||||
|
||||
// slogMiddleware 自定义请求日志中间件
|
||||
func slogMiddleware(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
logger.Info("HTTP请求",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"status", c.Writer.Status(),
|
||||
"duration", time.Since(start),
|
||||
"client_ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runServer 启动 HTTP 服务并支持优雅关闭
|
||||
func runServer(r *gin.Engine, port int) {
|
||||
addr := ":" + strconv.Itoa(port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("服务器启动成功", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("服务器启动失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
slog.Info("正在关闭服务器...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("服务器强制关闭", "error", err)
|
||||
} else {
|
||||
slog.Info("服务器已优雅关闭")
|
||||
}
|
||||
}
|
||||
|
||||
+21
-46
@@ -1,37 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"goblog/handlers/backend"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"goblog/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// tokenFromContext 统一从 Cookie 或 Authorization 头提取 JWT token
|
||||
// 优先读 Cookie(服务端渲染 + 同域 AJAX 自动携带),回退到 Authorization 头
|
||||
func tokenFromContext(c *gin.Context) string {
|
||||
if tokenString, err := c.Cookie("token"); err == nil && tokenString != "" {
|
||||
return tokenString
|
||||
}
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// JWT 认证中间件
|
||||
func JWTAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
tokenString := tokenFromContext(c)
|
||||
if tokenString == "" {
|
||||
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{}
|
||||
claims := &backend.Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return handlers.JWTSecret(), nil
|
||||
return backend.JWTSecret(), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
@@ -59,35 +66,3 @@ func AdminRequired() gin.HandlerFunc {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+58
-36
@@ -10,6 +10,14 @@ import (
|
||||
|
||||
const PasswordCryptLevel = 12
|
||||
|
||||
// 站点基础设置键名
|
||||
const (
|
||||
OptionSiteName = "site_name"
|
||||
OptionSiteDesc = "site_desc"
|
||||
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
|
||||
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
|
||||
)
|
||||
|
||||
// ==================== 用户模型 ====================
|
||||
|
||||
type User struct {
|
||||
@@ -24,7 +32,6 @@ type User struct {
|
||||
Avatar string `gorm:"size:255" json:"avatar"`
|
||||
Role string `gorm:"size:20;default:'user'" json:"role"` // admin, user
|
||||
Status int `gorm:"default:1" json:"status"` // 1:启用 0:禁用
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // 账号激活状态
|
||||
LastLoginAt *time.Time `json:"last_login_at"` // 最后登录时间
|
||||
LoginCount int `gorm:"default:0" json:"login_count"` // 登录次数统计
|
||||
}
|
||||
@@ -64,29 +71,31 @@ func (u *User) RecordLogin(db *gorm.DB) error {
|
||||
// ==================== 文章模型 ====================
|
||||
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Slug string `gorm:"uniqueIndex;size:200" json:"slug"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Summary string `gorm:"size:500" json:"summary"`
|
||||
Cover string `gorm:"size:255" json:"cover"`
|
||||
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
|
||||
Views int `gorm:"default:0" json:"views"`
|
||||
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Password string `gorm:"size:255" json:"password"` // 访问密码保护
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
|
||||
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
|
||||
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
|
||||
Comments []Comment `json:"comments,omitempty"`
|
||||
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"`
|
||||
SearchableText string `gorm:"size:255" json:"searchable_text"`
|
||||
Summary string `gorm:"size:500" json:"summary"`
|
||||
Cover string `gorm:"size:255" json:"cover"`
|
||||
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
|
||||
Files []File `gorm:"many2many:post_files;" json:"files,omitempty"` // 关联文件
|
||||
Views int `gorm:"default:0" json:"views"`
|
||||
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Password string `gorm:"size:255" json:"password"` // 访问密码保护
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
|
||||
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
|
||||
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
|
||||
Comments []Comment `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -201,12 +210,6 @@ func SetOptionValue(db *gorm.DB, name string, userID uint, value string) error {
|
||||
return db.Create(&option).Error
|
||||
}
|
||||
|
||||
// DeleteOption 删除配置项
|
||||
func DeleteOption(db *gorm.DB, name string, userID uint) error {
|
||||
result := db.Where("name = ? AND user_id = ?", name, userID).Delete(&Option{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetOptionsByUser 获取用户的所有配置
|
||||
func GetOptionsByUser(db *gorm.DB, userID uint) (map[string]string, error) {
|
||||
var options []Option
|
||||
@@ -226,12 +229,9 @@ func GetOptionsByUser(db *gorm.DB, userID uint) (map[string]string, error) {
|
||||
func BatchSetOptions(db *gorm.DB, userID uint, options map[string]string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for name, value := range options {
|
||||
opt := Option{
|
||||
Name: name,
|
||||
UserID: userID,
|
||||
Value: value,
|
||||
}
|
||||
if err := tx.Save(&opt).Error; err != nil {
|
||||
// 复合主键在 SQLite 旧表上可能缺失唯一约束,Save 会重复插入,
|
||||
// 改用查找-更新-不存在才插入的方式保证幂等
|
||||
if err := SetOptionValue(tx, name, userID, value); err != nil {
|
||||
slog.Error("保存配置失败", "name", name, "error", err)
|
||||
return err
|
||||
}
|
||||
@@ -263,3 +263,25 @@ type Page struct {
|
||||
func (Page) TableName() string {
|
||||
return "pages"
|
||||
}
|
||||
|
||||
// ==================== 文件模型 ====================
|
||||
|
||||
// File 上传文件记录
|
||||
type File 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:"-"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 上传者
|
||||
User User `gorm:"foreignKey:UserID" json:"user"` // 上传者信息
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"` // 存储文件名
|
||||
OrigName string `gorm:"size:255" json:"orig_name"` // 原始文件名
|
||||
FilePath string `gorm:"size:500;not null" json:"file_path"` // 相对存储路径(含年月目录)
|
||||
FileSize int64 `gorm:"default:0" json:"file_size"` // 文件大小(字节)
|
||||
MimeType string `gorm:"size:100" json:"mime_type"` // MIME 类型
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (File) TableName() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
+129
-99
@@ -1,113 +1,143 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"goblog/handlers"
|
||||
"goblog/handlers/backend"
|
||||
"goblog/handlers/frontend"
|
||||
"goblog/middleware"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
staticPath = "/static"
|
||||
adminPath = "/admin"
|
||||
apiPrefix = "/api"
|
||||
)
|
||||
|
||||
// SetupRoutes 配置所有路由
|
||||
func SetupRoutes(r *gin.Engine, cfg interface{}) {
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// 后台管理(放在前面避免冲突)
|
||||
r.Static("/admin/static", "./static/admin")
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.File("./static/admin/index.html")
|
||||
})
|
||||
|
||||
// 前端页面路由
|
||||
r.GET("/", handlers.IndexView)
|
||||
r.GET("/post/:slug", handlers.PostView)
|
||||
r.GET("/page/:slug", handlers.PageView)
|
||||
r.GET("/search", handlers.SearchView)
|
||||
r.POST("/comment", handlers.SubmitComment)
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 公开 API(支持可选认证,以便管理员查看所有文章)
|
||||
api.GET("/posts", middleware.OptionalAuth(), handlers.GetPosts)
|
||||
api.GET("/posts/:id", middleware.OptionalAuth(), handlers.GetPost)
|
||||
api.GET("/categories", handlers.GetCategories)
|
||||
api.GET("/categories/:id", handlers.GetCategory)
|
||||
api.GET("/tags", handlers.GetTags)
|
||||
api.GET("/tags/:id", handlers.GetTag)
|
||||
api.GET("/pages", handlers.GetPages)
|
||||
api.GET("/pages/:id", handlers.GetPage)
|
||||
api.GET("/comments", handlers.GetComments)
|
||||
|
||||
// 需要认证的 API
|
||||
api.POST("/comments", middleware.OptionalAuth(), handlers.CreateComment)
|
||||
|
||||
// 用户认证
|
||||
api.POST("/auth/login", handlers.Login)
|
||||
api.POST("/auth/register", handlers.Register)
|
||||
|
||||
// 需要登录的 API
|
||||
auth := api.Group("/")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
{
|
||||
auth.GET("/auth/me", handlers.GetCurrentUser)
|
||||
auth.PUT("/auth/me", handlers.UpdateUser)
|
||||
auth.PUT("/auth/password", handlers.ChangePassword)
|
||||
}
|
||||
|
||||
// 主题管理(可选认证)
|
||||
api.GET("/themes", handlers.GetThemes)
|
||||
api.GET("/theme", handlers.GetCurrentTheme)
|
||||
api.POST("/theme", middleware.JWTAuth(), handlers.SwitchTheme)
|
||||
api.POST("/themes/create", middleware.JWTAuth(), handlers.CreateTheme)
|
||||
api.GET("/themes/:theme/files", middleware.JWTAuth(), handlers.GetThemeFiles)
|
||||
api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.GetThemeFile)
|
||||
api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.SaveThemeFile)
|
||||
|
||||
// 管理员 API
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
|
||||
{
|
||||
// 文章管理
|
||||
admin.POST("/posts", handlers.CreatePost)
|
||||
admin.PUT("/posts/:id", handlers.UpdatePost)
|
||||
admin.DELETE("/posts/:id", handlers.DeletePost)
|
||||
|
||||
// 分类管理
|
||||
admin.POST("/categories", handlers.CreateCategory)
|
||||
admin.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
|
||||
// 标签管理
|
||||
admin.POST("/tags", handlers.CreateTag)
|
||||
admin.PUT("/tags/:id", handlers.UpdateTag)
|
||||
admin.DELETE("/tags/:id", handlers.DeleteTag)
|
||||
|
||||
// 页面管理
|
||||
admin.POST("/pages", handlers.CreatePage)
|
||||
admin.PUT("/pages/:id", handlers.UpdatePage)
|
||||
admin.DELETE("/pages/:id", handlers.DeletePage)
|
||||
|
||||
// 评论管理
|
||||
admin.GET("/admin/comments", handlers.GetComments)
|
||||
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
|
||||
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
|
||||
admin.DELETE("/comments/:id", handlers.DeleteComment)
|
||||
}
|
||||
}
|
||||
func SetupRoutes(r *gin.Engine) {
|
||||
// 静态资源路由
|
||||
r.Static(staticPath, "./static")
|
||||
// 前台页面
|
||||
setupFrontendRoutes(r)
|
||||
// 后台管理页面
|
||||
setupAdminPageRoutes(r)
|
||||
}
|
||||
|
||||
// SetupTemplates 配置模板渲染器
|
||||
func SetupTemplates(r *gin.Engine, theme string) {
|
||||
htmlRenderer := utils.LoadTemplates(theme)
|
||||
utils.SetGlobalRenderer(htmlRenderer)
|
||||
r.HTMLRender = htmlRenderer
|
||||
// setupFrontendRoutes 前台页面(服务端渲染)
|
||||
func setupFrontendRoutes(r *gin.Engine) {
|
||||
r.GET("/", frontend.IndexView)
|
||||
r.GET("/post/:slug", frontend.PostView)
|
||||
r.GET("/page/:slug", frontend.PageView)
|
||||
r.GET("/search", frontend.SearchView)
|
||||
// 评论管理
|
||||
r.GET("/posts/:postID/comments", frontend.ListComments)
|
||||
r.POST("/posts/:postID/comment", frontend.SubmitComment)
|
||||
r.GET("/logout", backend.WebLogout)
|
||||
}
|
||||
|
||||
// GetServerPort 获取服务器端口
|
||||
func GetServerPort(port int) string {
|
||||
return ":" + strconv.Itoa(port)
|
||||
// setupAdminPageRoutes 后台管理页面(服务端渲染)
|
||||
func setupAdminPageRoutes(r *gin.Engine) {
|
||||
r.GET(adminPath+"/login", backend.AdminLoginView)
|
||||
r.GET(adminPath, middleware.JWTAuth(), backend.AdminView)
|
||||
// API 路由
|
||||
setupAPIRoutes(r)
|
||||
}
|
||||
|
||||
// setupAPIRoutes API 路由分组
|
||||
func setupAPIRoutes(r *gin.Engine) {
|
||||
api := r.Group(apiPrefix)
|
||||
// 登录
|
||||
api.POST("/auth/login", backend.Login)
|
||||
setupAuthenticatedAPIRoutes(api) // 需要 JWT 的接口
|
||||
setupAdminAPIRoutes(api) // 管理员专属接口
|
||||
}
|
||||
|
||||
// setupAuthenticatedAPIRoutes 需要 JWT 的接口(所有登录用户)
|
||||
func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) {
|
||||
auth := api.Group("/")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
|
||||
// 仪表盘公开统计数据
|
||||
auth.GET("/dashboard/stats", backend.GetDashboardStats)
|
||||
// 当前用户信息
|
||||
auth.GET("/auth/me", backend.GetCurrentUser)
|
||||
auth.PUT("/auth/me", backend.UpdateUser)
|
||||
auth.PUT("/auth/password", backend.ChangePassword)
|
||||
auth.POST("/auth/logout", backend.Logout)
|
||||
|
||||
// 文章管理(读取 + 写入)
|
||||
auth.GET("/posts", backend.GetPosts)
|
||||
auth.GET("/posts/:id", backend.GetPost)
|
||||
auth.POST("/posts", backend.CreatePost)
|
||||
auth.PUT("/posts/:id", backend.UpdatePost)
|
||||
auth.DELETE("/posts/:id", backend.DeletePost)
|
||||
|
||||
// 分类读取
|
||||
auth.GET("/categories", backend.GetCategories)
|
||||
|
||||
// 标签读取(文章表单的标签候选,所有登录用户可用)
|
||||
auth.GET("/tags", backend.GetTags)
|
||||
|
||||
// 评论管理(查看所有评论,需登录)
|
||||
auth.GET("/comments", backend.GetComments)
|
||||
auth.GET("/comments/posts", backend.GetCommentPosts) // 有评论的文章列表(筛选下拉框)
|
||||
auth.PUT("/comments/:id/approve", backend.ApproveComment)
|
||||
auth.PUT("/comments/:id/spam", backend.MarkSpamComment)
|
||||
auth.DELETE("/comments/:id", backend.DeleteComment)
|
||||
|
||||
// Markdown 预览
|
||||
auth.POST("/markdown/preview", backend.PreviewMarkdown)
|
||||
|
||||
// 文件上传
|
||||
auth.POST("/upload", backend.UploadImage)
|
||||
auth.GET("/uploads", backend.ListUploads)
|
||||
auth.DELETE("/uploads/:id", backend.DeleteUpload)
|
||||
|
||||
}
|
||||
|
||||
// setupAdminAPIRoutes 管理员专属接口(JWT + AdminRequired)
|
||||
func setupAdminAPIRoutes(api *gin.RouterGroup) {
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
|
||||
|
||||
// 分类管理(写入)
|
||||
admin.POST("/categories", backend.CreateCategory)
|
||||
admin.GET("/categories/:id", backend.GetCategory)
|
||||
admin.PUT("/categories/:id", backend.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", backend.DeleteCategory)
|
||||
|
||||
// 标签管理(写入)
|
||||
admin.GET("/tags", backend.GetTags)
|
||||
admin.POST("/tags", backend.CreateTag)
|
||||
admin.GET("/tags/:id", backend.GetTag)
|
||||
admin.PUT("/tags/:id", backend.UpdateTag)
|
||||
admin.DELETE("/tags/:id", backend.DeleteTag)
|
||||
|
||||
// 页面管理(写入)
|
||||
admin.GET("/pages", backend.GetPages)
|
||||
admin.POST("/pages", backend.CreatePage)
|
||||
admin.GET("/pages/:id", backend.GetPage)
|
||||
admin.PUT("/pages/:id", backend.UpdatePage)
|
||||
admin.DELETE("/pages/:id", backend.DeletePage)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", backend.GetUsers)
|
||||
admin.POST("/users", backend.CreateUser)
|
||||
admin.PUT("/users/:id", backend.AdminUpdateUser)
|
||||
admin.PUT("/users/:id/password", backend.AdminResetPassword)
|
||||
admin.DELETE("/users/:id", backend.DeleteUser)
|
||||
|
||||
// 基础设置
|
||||
admin.GET("/settings", backend.GetSettings)
|
||||
admin.PUT("/settings", backend.UpdateSettings)
|
||||
|
||||
// 主题管理(登录用户可切换主题、创建主题、编辑文件)
|
||||
admin.GET("/themes", backend.GetThemes)
|
||||
admin.GET("/theme", backend.GetCurrentTheme)
|
||||
admin.POST("/theme", backend.SwitchTheme)
|
||||
admin.POST("/themes/create", backend.CreateTheme)
|
||||
admin.GET("/themes/:theme/files", backend.GetThemeFiles)
|
||||
admin.GET("/themes/:theme/files/:file", backend.GetThemeFile)
|
||||
admin.PUT("/themes/:theme/files/:file", backend.SaveThemeFile)
|
||||
}
|
||||
|
||||
@@ -1,988 +0,0 @@
|
||||
// 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;
|
||||
case 'themes':
|
||||
loadThemes();
|
||||
break;
|
||||
case 'theme-editor':
|
||||
// 主题编辑器由 editTheme() 函数加载
|
||||
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>
|
||||
${post.status !== 'published' ? `<button class="btn-primary" onclick="changePostStatus(${post.id}, 'published')">发布</button>` : `<button class="btn-secondary" onclick="changePostStatus(${post.id}, 'draft')">下架</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 changePostStatus(id, status) {
|
||||
const actionText = status === 'published' ? '发布' : '下架';
|
||||
if (!confirm(`确定要${actionText}这篇文章吗?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/posts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
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 = {
|
||||
slug: document.getElementById('modal-slug').value
|
||||
};
|
||||
|
||||
// 页面使用 title,分类和标签使用 name
|
||||
if (type === 'page') {
|
||||
data.title = document.getElementById('modal-name').value;
|
||||
data.content = document.getElementById('modal-content').value;
|
||||
data.order = parseInt(document.getElementById('modal-order').value) || 0;
|
||||
data.status = 'published';
|
||||
} else {
|
||||
data.name = document.getElementById('modal-name').value;
|
||||
}
|
||||
|
||||
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 editPage(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages/${id}`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
const page = data.data;
|
||||
|
||||
document.getElementById('modal-type').value = 'page';
|
||||
document.getElementById('modal-id').value = id;
|
||||
document.getElementById('modal-title').textContent = '编辑页面';
|
||||
document.getElementById('modal-name').value = page.title;
|
||||
document.getElementById('modal-slug').value = page.slug;
|
||||
|
||||
// 添加内容区域
|
||||
document.querySelector('.modal-extra').innerHTML = `
|
||||
<label>内容</label>
|
||||
<textarea id="modal-content" rows="5"></textarea>
|
||||
<label>排序</label>
|
||||
<input type="number" id="modal-order" value="0">
|
||||
`;
|
||||
document.getElementById('modal-content').value = page.content;
|
||||
document.getElementById('modal-order').value = page.order;
|
||||
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert('加载页面失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
|
||||
// 加载主题列表
|
||||
let currentTheme = null;
|
||||
async function loadThemes() {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentRes = await fetch(`${API_BASE}/theme`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const currentData = await currentRes.json();
|
||||
currentTheme = currentData.theme;
|
||||
|
||||
// 获取所有可用主题
|
||||
const themesRes = await fetch(`${API_BASE}/themes`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const themesData = await themesRes.json();
|
||||
|
||||
// 填充下拉列表
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
themeSelect.innerHTML = '';
|
||||
|
||||
themesData.themes.forEach(theme => {
|
||||
const option = document.createElement('option');
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
if (theme === currentTheme) {
|
||||
option.selected = true;
|
||||
}
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 显示主题列表
|
||||
displayThemeList(themesData.themes, currentTheme);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载主题失败:', error);
|
||||
showThemeMessage('加载主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题列表
|
||||
function displayThemeList(themes, currentTheme) {
|
||||
const themeList = document.getElementById('theme-list');
|
||||
themeList.innerHTML = '';
|
||||
|
||||
themes.forEach(theme => {
|
||||
const themeCard = document.createElement('div');
|
||||
themeCard.className = `theme-card ${theme === currentTheme ? 'active' : ''}`;
|
||||
|
||||
themeCard.innerHTML = `
|
||||
<div class="theme-card-header">
|
||||
<h3>${theme}</h3>
|
||||
${theme === currentTheme ? '<span class="theme-badge">当前使用</span>' : ''}
|
||||
</div>
|
||||
<div class="theme-card-body">
|
||||
<p>主题名称: ${theme}</p>
|
||||
<p>状态: ${theme === currentTheme ? '使用中' : '未使用'}</p>
|
||||
</div>
|
||||
<div class="theme-card-actions">
|
||||
<button class="btn-small btn-secondary" onclick="editTheme('${theme}')">编辑</button>
|
||||
${theme !== currentTheme ? `<button class="btn-small btn-primary" onclick="quickSwitch('${theme}')">切换</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
themeList.appendChild(themeCard);
|
||||
});
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
async function switchTheme() {
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
const selectedTheme = themeSelect.value;
|
||||
|
||||
if (!selectedTheme) {
|
||||
showThemeMessage('请选择一个主题', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTheme === currentTheme) {
|
||||
showThemeMessage('当前已经是该主题', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: selectedTheme })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题切换成功', 'success');
|
||||
currentTheme = selectedTheme;
|
||||
|
||||
// 刷新主题列表显示
|
||||
loadThemes();
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题切换失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换主题失败:', error);
|
||||
showThemeMessage('切换主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题消息
|
||||
function showThemeMessage(message, type = 'info') {
|
||||
const messageEl = document.getElementById('theme-message');
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `theme-message ${type}`;
|
||||
messageEl.style.display = 'block';
|
||||
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 显示创建主题弹窗
|
||||
function showCreateThemeModal() {
|
||||
document.getElementById('theme-modal').classList.remove('hidden');
|
||||
document.getElementById('new-theme-name').value = '';
|
||||
document.getElementById('theme-template').value = 'default';
|
||||
document.getElementById('create-css').checked = true;
|
||||
}
|
||||
|
||||
// 隐藏创建主题弹窗
|
||||
function hideThemeModal() {
|
||||
document.getElementById('theme-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 创建主题表单提交
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const createThemeForm = document.getElementById('create-theme-form');
|
||||
if (createThemeForm) {
|
||||
createThemeForm.addEventListener('submit', handleCreateTheme);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreateTheme(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const themeName = document.getElementById('new-theme-name').value.trim();
|
||||
const template = document.getElementById('theme-template').value;
|
||||
const createCSS = document.getElementById('create-css').checked;
|
||||
|
||||
if (!themeName) {
|
||||
showThemeMessage('请输入主题名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: themeName,
|
||||
template: template,
|
||||
create_css: createCSS
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题创建成功', 'success');
|
||||
hideThemeModal();
|
||||
|
||||
// 刷新主题列表
|
||||
setTimeout(() => {
|
||||
loadThemes();
|
||||
}, 500);
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建主题失败:', error);
|
||||
showThemeMessage('创建主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 快速切换主题
|
||||
async function quickSwitch(theme) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: theme })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '主题切换成功', 'success');
|
||||
currentTheme = theme;
|
||||
loadThemes();
|
||||
} else {
|
||||
showThemeMessage(data.error || '主题切换失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换主题失败:', error);
|
||||
showThemeMessage('切换主题失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主题编辑器 ====================
|
||||
|
||||
let currentEditingTheme = null;
|
||||
let currentEditingFile = null;
|
||||
let themeFiles = [];
|
||||
|
||||
// 编辑主题
|
||||
async function editTheme(themeName) {
|
||||
currentEditingTheme = themeName;
|
||||
currentEditingFile = null;
|
||||
|
||||
document.getElementById('editor-theme-name').textContent = themeName;
|
||||
document.getElementById('current-file-name').textContent = '选择文件进行编辑';
|
||||
document.getElementById('theme-file-editor').value = '';
|
||||
|
||||
showPage('theme-editor');
|
||||
|
||||
// 加载主题文件列表
|
||||
await loadThemeFiles(themeName);
|
||||
}
|
||||
|
||||
// 加载主题文件列表
|
||||
async function loadThemeFiles(themeName) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${themeName}/files`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
themeFiles = data.files || [];
|
||||
displayThemeFiles(themeFiles);
|
||||
} else {
|
||||
showThemeMessage(data.error || '加载文件列表失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error);
|
||||
showThemeMessage('加载文件列表失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示主题文件列表
|
||||
function displayThemeFiles(files) {
|
||||
const filesContainer = document.getElementById('theme-files');
|
||||
filesContainer.innerHTML = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
filesContainer.innerHTML = '<p class="no-files">暂无文件</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'theme-file-item';
|
||||
fileItem.innerHTML = `
|
||||
<span class="file-icon">${getFileIcon(file)}</span>
|
||||
<span class="file-name">${file}</span>
|
||||
`;
|
||||
fileItem.onclick = () => loadFileContent(file);
|
||||
filesContainer.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文件图标
|
||||
function getFileIcon(filename) {
|
||||
if (filename.endsWith('.html')) return '📄';
|
||||
if (filename.endsWith('.css')) return '🎨';
|
||||
if (filename.endsWith('.js')) return '📜';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
// 加载文件内容
|
||||
async function loadFileContent(filename) {
|
||||
try {
|
||||
currentEditingFile = filename;
|
||||
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${filename}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('current-file-name').textContent = filename;
|
||||
document.getElementById('theme-file-editor').value = data.content || '';
|
||||
|
||||
// 高亮当前文件
|
||||
document.querySelectorAll('.theme-file-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
event.currentTarget.classList.add('active');
|
||||
} else {
|
||||
showThemeMessage(data.error || '加载文件失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件失败:', error);
|
||||
showThemeMessage('加载文件失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前文件
|
||||
async function saveCurrentFile() {
|
||||
if (!currentEditingTheme || !currentEditingFile) {
|
||||
showThemeMessage('请先选择文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = document.getElementById('theme-file-editor').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${currentEditingFile}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showThemeMessage(data.message || '文件保存成功', 'success');
|
||||
|
||||
// 如果是模板文件,重新加载主题
|
||||
if (currentEditingFile.endsWith('.html')) {
|
||||
const renderer = await fetch(`${API_BASE}/theme`, { headers: getHeaders() });
|
||||
const rendererData = await renderer.json();
|
||||
if (rendererData.theme === currentEditingTheme) {
|
||||
// 重新加载当前主题以应用更改
|
||||
await fetch(`${API_BASE}/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: currentEditingTheme })
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showThemeMessage(data.error || '保存文件失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存文件失败:', error);
|
||||
showThemeMessage('保存文件失败', 'error');
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
<!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="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<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>
|
||||
<a href="#themes" class="menu-item" data-page="themes">主题管理</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>
|
||||
|
||||
<!-- 主题管理 -->
|
||||
<div id="themes-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>主题管理</h1>
|
||||
<button class="btn-primary" onclick="showCreateThemeModal()">新建主题</button>
|
||||
</div>
|
||||
<div class="theme-settings">
|
||||
<div class="form-group">
|
||||
<label>当前主题</label>
|
||||
<select id="theme-select" class="theme-selector">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="switchTheme()">切换主题</button>
|
||||
<div id="theme-message" class="theme-message"></div>
|
||||
</div>
|
||||
<div class="theme-list" id="theme-list">
|
||||
<!-- 主题列表将动态加载 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题编辑器 -->
|
||||
<div id="theme-editor-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>编辑主题: <span id="editor-theme-name"></span></h1>
|
||||
<div>
|
||||
<button class="btn-secondary" onclick="showPage('themes')">返回</button>
|
||||
<button class="btn-primary" onclick="saveCurrentFile()">保存文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-editor-container">
|
||||
<div class="theme-file-list">
|
||||
<h3>文件列表</h3>
|
||||
<div id="theme-files"></div>
|
||||
</div>
|
||||
<div class="theme-editor-content">
|
||||
<div class="editor-header">
|
||||
<h3 id="current-file-name">选择文件进行编辑</h3>
|
||||
</div>
|
||||
<textarea id="theme-file-editor" class="code-editor" placeholder="选择左侧文件进行编辑..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 分类/标签/页面 表单弹窗 -->
|
||||
<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>
|
||||
|
||||
<!-- 创建主题弹窗 -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div class="modal-content modal-large">
|
||||
<h3>新建主题</h3>
|
||||
<form id="create-theme-form">
|
||||
<div class="form-group">
|
||||
<label>主题名称 *</label>
|
||||
<input type="text" id="new-theme-name" required placeholder="例如: mytheme">
|
||||
<small>只能包含字母、数字和下划线</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>基于模板创建</label>
|
||||
<select id="theme-template">
|
||||
<option value="default">default(默认主题)</option>
|
||||
<option value="minimal">minimal(极简主题)</option>
|
||||
<option value="blank">blank(空白主题)</option>
|
||||
</select>
|
||||
<small>选择新主题的初始模板</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="create-css" checked> 创建样式文件
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideThemeModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">创建主题</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,664 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题管理 */
|
||||
.theme-settings {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.theme-settings .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.theme-settings label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-selector {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-selector:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.theme-settings .btn-primary {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.theme-message {
|
||||
margin-top: 15px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-message.success {
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.theme-message.error {
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.theme-message.warning {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.theme-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: #1890ff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.theme-card-header h3 {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-badge {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.theme-card-body p {
|
||||
margin: 8px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.theme-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-settings {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.theme-selector {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大尺寸模态框 */
|
||||
.modal-large {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-large .form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-large input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-large input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 主题卡片操作按钮 */
|
||||
.theme-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-small.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-small.btn-secondary:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.btn-small.btn-primary {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-small.btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
/* 主题编辑器 */
|
||||
.theme-editor-container {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.theme-file-list {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.theme-file-list h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-file-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-file-item:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.theme-file-item.active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.theme-file-item .file-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.theme-file-item .file-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.theme-editor-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: #f9f9f9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.code-editor:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.no-files {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.theme-editor-container {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.theme-file-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -648,3 +648,8 @@ a:hover {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: layui-icon;
|
||||
src: url(../font/iconfont.eot?v=240#iefix) format('embedded-opentype'), url(../font/iconfont.svg?v=240#iconfont) format('svg'), url(../font/iconfont.woff?v=240) format('woff'), url(../font/iconfont.ttf?v=240) format('truetype');
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 274 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
+2192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 前台评论 AJAX 功能
|
||||
*/
|
||||
|
||||
// 评论分页加载
|
||||
async function loadComments(postID, page = 1) {
|
||||
try {
|
||||
const url = `/posts/${postID}/comments?cpage=${page}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('加载评论失败');
|
||||
|
||||
const data = await res.json();
|
||||
renderComments(data.data || []);
|
||||
renderCommentPagination(data.page, data.total_pages, postID);
|
||||
} catch (err) {
|
||||
console.error('加载评论失败:', err);
|
||||
document.querySelector('.comments-list').innerHTML = '<p class="no-comments">评论加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染评论列表
|
||||
function renderComments(comments) {
|
||||
const listEl = document.querySelector('.comments-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!comments || comments.length === 0) {
|
||||
listEl.innerHTML = '<p class="no-comments">暂无评论,来发表第一条评论吧!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = comments.map(c => buildCommentHTML(c)).join('');
|
||||
}
|
||||
|
||||
// 构建单条评论 HTML
|
||||
function buildCommentHTML(comment) {
|
||||
let html = `
|
||||
<div class="comment-item" id="comment-${comment.id}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
${comment.website ? `<a href="${escapeHtml(comment.website)}" target="_blank">${escapeHtml(comment.author)}</a>` : escapeHtml(comment.author)}
|
||||
</span>
|
||||
<span class="comment-date">${formatDate(comment.created_at)}</span>
|
||||
</div>
|
||||
<div class="comment-content">${escapeHtml(comment.content)}</div>
|
||||
`;
|
||||
|
||||
// 子评论
|
||||
if (comment.children && comment.children.length > 0) {
|
||||
html += '<div class="comment-children">';
|
||||
comment.children.forEach(child => {
|
||||
html += `
|
||||
<div class="comment-item child" id="comment-${child.id}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">${escapeHtml(child.author)}</span>
|
||||
<span class="comment-date">${formatDate(child.created_at)}</span>
|
||||
</div>
|
||||
<div class="comment-content">${escapeHtml(child.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// 渲染评论分页
|
||||
function renderCommentPagination(currentPage, totalPages, postID) {
|
||||
const paginationEl = document.querySelector('.comment-pagination');
|
||||
if (!paginationEl) return;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
paginationEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
paginationEl.style.display = 'block';
|
||||
let html = '';
|
||||
|
||||
if (currentPage > 1) {
|
||||
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage - 1})" class="prev">← 上一页</a>`;
|
||||
}
|
||||
|
||||
html += `<span class="page-info">${currentPage} / ${totalPages}</span>`;
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage + 1})" class="next">下一页 →</a>`;
|
||||
}
|
||||
|
||||
paginationEl.innerHTML = html;
|
||||
}
|
||||
|
||||
// 提交评论(AJAX)
|
||||
async function submitComment(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.post_id = Number(data.post_id);
|
||||
// 简单验证
|
||||
if (!data.author || !data.email || !data.content) {
|
||||
alert('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
// 邮箱格式验证
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.email)) {
|
||||
alert('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '提交中...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/posts/${data.post_id}/comment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('评论提交成功,等待审核');
|
||||
form.reset();
|
||||
// 重新加载评论(回到第一页)
|
||||
const postID = form.querySelector('input[name="post_id"]').value;
|
||||
loadComments(postID, 1);
|
||||
} else {
|
||||
alert(result.error || '提交失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交评论失败:', err);
|
||||
alert('网络错误,请稍后重试');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '发表评论';
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数:转义 HTML
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 工具函数:格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 绑定评论表单提交事件
|
||||
const commentForm = document.querySelector('.comment-form');
|
||||
if (commentForm) {
|
||||
commentForm.addEventListener('submit', submitComment);
|
||||
}
|
||||
|
||||
// 如果当前是文章详情页,使用表单中的数字 postID 加载评论
|
||||
const postIDInput = commentForm && commentForm.querySelector('input[name="post_id"]');
|
||||
if (postIDInput && postIDInput.value) {
|
||||
// 从 URL 获取初始页码
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const cpage = parseInt(urlParams.get('cpage')) || 1;
|
||||
loadComments(postIDInput.value, cpage);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
{{define "admin_index"}}<!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="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/admin.css?v=5">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-header">
|
||||
<h2>GoBlog</h2>
|
||||
<div class="user-menu-wrapper">
|
||||
<span id="username" tabindex="0" aria-haspopup="true">{{.DisplayName}}</span>
|
||||
<div class="user-menu" role="menu">
|
||||
<button type="button" onclick="showProfileModal()">修改个人信息</button>
|
||||
<button type="button" onclick="showPasswordModal()">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="logout-btn">退出</span>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<aside class="sidebar">
|
||||
|
||||
<nav class="menu">
|
||||
{{range .Menus}}{{if eq .Group ""}}{{range $i, $item := .Items}}<a href="#{{$item.Key}}" class="menu-item{{if eq $item.Key "dashboard"}} active{{end}}" data-page="{{$item.Key}}">{{$item.Name}}</a>
|
||||
{{end}}{{else}}
|
||||
<div class="menu-group open">
|
||||
<button class="menu-group-header" type="button">{{.Group}}</button>
|
||||
<div class="menu-sub">
|
||||
{{range .Items}}<a href="#{{.Key}}" class="menu-item" data-page="{{.Key}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
{{if index .PageSet "dashboard"}}{{template "admin_page_dashboard" .}}{{end}}
|
||||
{{if index .PageSet "posts"}}{{template "admin_page_posts" .}}{{template "admin_page_post_form" .}}{{end}}
|
||||
{{if index .PageSet "categories"}}{{template "admin_page_categories" .}}{{end}}
|
||||
{{if index .PageSet "tags"}}{{template "admin_page_tags" .}}{{end}}
|
||||
{{if index .PageSet "pages"}}{{template "admin_page_pages" .}}{{end}}
|
||||
{{if index .PageSet "comments"}}{{template "admin_page_comments" .}}{{end}}
|
||||
{{if index .PageSet "users"}}{{template "admin_page_users" .}}{{end}}
|
||||
{{if index .PageSet "settings"}}{{template "admin_page_settings" .}}{{end}}
|
||||
{{if index .PageSet "files"}}{{template "admin_page_files" .}}{{end}}
|
||||
{{if index .PageSet "themes"}}{{template "admin_page_themes" .}}{{template "admin_page_theme_editor" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{if or (index .PageSet "categories") (index .PageSet "tags") (index .PageSet "pages")}}{{template "admin_modal_common" .}}{{end}}
|
||||
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
|
||||
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
|
||||
{{template "admin_modal_profile" .}}
|
||||
{{template "admin_modal_password" .}}
|
||||
|
||||
<script>window.__currentUser = {{.CurrentUser }}</script>
|
||||
<script src="/static/js/admin.js?v=23"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_profile"}}
|
||||
<div id="profile-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>修改个人信息</h3>
|
||||
<form id="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="profile-nickname">昵称</label>
|
||||
<input type="text" id="profile-nickname" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-email">邮箱</label>
|
||||
<input type="email" id="profile-email" maxlength="100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-avatar">头像地址</label>
|
||||
<input type="url" id="profile-avatar" maxlength="255" placeholder="https://">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideProfileModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_password"}}
|
||||
<div id="password-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>修改密码</h3>
|
||||
<form id="password-form">
|
||||
<div class="form-group">
|
||||
<label for="old-password">旧密码</label>
|
||||
<input type="password" id="old-password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">新密码</label>
|
||||
<input type="password" id="new-password" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">确认新密码</label>
|
||||
<input type="password" id="confirm-password" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hidePasswordModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_dashboard"}}
|
||||
<!-- 仪表盘 -->
|
||||
<div id="dashboard-page" class="page">
|
||||
<h1>仪表盘</h1>
|
||||
<div class="stats-grid">
|
||||
<a href="#posts" class="stat-card" data-page="posts" onclick="showPage('posts')">
|
||||
<h3>文章总数</h3>
|
||||
<p class="stat-number" id="stat-posts">0</p>
|
||||
</a>
|
||||
<a href="#categories" class="stat-card" data-page="categories" onclick="showPage('categories')">
|
||||
<h3>分类总数</h3>
|
||||
<p class="stat-number" id="stat-categories">0</p>
|
||||
</a>
|
||||
<a href="#tags" class="stat-card" data-page="tags" onclick="showPage('tags')">
|
||||
<h3>标签总数</h3>
|
||||
<p class="stat-number" id="stat-tags">0</p>
|
||||
</a>
|
||||
<a href="#comments" class="stat-card" data-page="comments" onclick="showPage('comments')">
|
||||
<h3>待审核评论</h3>
|
||||
<p class="stat-number" id="stat-comments">0</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_posts"}}
|
||||
<!-- 文章管理 -->
|
||||
<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>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="posts-list"></tbody>
|
||||
</table>
|
||||
<div id="posts-pagination" class="pagination"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_post_form"}}
|
||||
<!-- 文章表单 -->
|
||||
<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>
|
||||
<div class="tag-select" id="post-tag-select">
|
||||
<div class="tag-select-box" id="post-tag-box">
|
||||
<span class="tag-chips" id="post-tags-selected"></span>
|
||||
<input type="text" id="post-tags-input" class="tag-input" placeholder="输入标签名,回车添加" autocomplete="off">
|
||||
</div>
|
||||
<div class="tag-suggest hidden" id="post-tags-suggest"></div>
|
||||
</div>
|
||||
<small class="tag-hint">点击标签右上角 × 可移除;输入的内容若不存在,将作为新标签在保存时创建</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>封面图</label>
|
||||
<input type="hidden" id="post-cover">
|
||||
<input type="file" id="post-cover-file" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none">
|
||||
<div class="cover-uploader">
|
||||
<img id="post-cover-preview" class="cover-preview hidden" src="" alt="封面预览">
|
||||
<div class="cover-uploader-actions">
|
||||
<button type="button" class="btn-secondary" onclick="document.getElementById('post-cover-file').click()">选择图片</button>
|
||||
<button type="button" class="btn-danger hidden" id="post-cover-remove" onclick="removeCover()">移除</button>
|
||||
<span class="upload-hint">支持 jpg/png/gif/webp,最大 5MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>摘要</label>
|
||||
<textarea id="post-summary" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容 (支持 Markdown 语法)</label>
|
||||
<div class="md-editor">
|
||||
<div class="md-toolbar">
|
||||
<div class="md-tools" id="md-tools">
|
||||
<button type="button" title="加粗" onclick="mdWrap('**','**','粗体文字')"><b>B</b></button>
|
||||
<button type="button" title="斜体" onclick="mdWrap('*','*','斜体文字')"><i>I</i></button>
|
||||
<button type="button" title="删除线" onclick="mdWrap('~~','~~','删除线文字')"><s>S</s></button>
|
||||
<button type="button" title="标题" onclick="mdInsertLine('## ','标题')">H</button>
|
||||
<button type="button" title="链接" onclick="mdWrap('[','](https://)','链接文字')">🔗</button>
|
||||
<button type="button" title="图片" onclick="mdInsertImage()">🖼</button>
|
||||
<button type="button" title="行内代码" onclick="mdWrap('`','`','代码')"></></button>
|
||||
<button type="button" title="代码块" onclick="mdWrap('\n```\n','\n```\n','代码块')">{ }</button>
|
||||
<button type="button" title="引用" onclick="mdInsertLine('> ','引用内容')">”</button>
|
||||
<button type="button" title="无序列表" onclick="mdInsertLine('- ','列表项')">•</button>
|
||||
<button type="button" title="有序列表" onclick="mdInsertLine('1. ','列表项')">1.</button>
|
||||
<button type="button" title="表格" onclick="mdInsertTable()">⊞</button>
|
||||
</div>
|
||||
<div class="md-tabs">
|
||||
<button type="button" id="md-tab-edit" class="active" onclick="mdShowEdit()">编辑</button>
|
||||
<button type="button" id="md-tab-preview" onclick="mdShowPreview()">预览</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md-body">
|
||||
<textarea id="post-content" required placeholder="支持 Markdown 语法,也兼容 HTML 内容"></textarea>
|
||||
<div id="md-preview" class="md-preview hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="post-istop"> 置顶文章
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 图片插入对话框 -->
|
||||
<div id="image-modal" class="modal hidden">
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<h3>插入图片</h3>
|
||||
<div class="form-group">
|
||||
<label>图片描述</label>
|
||||
<input type="text" id="img-desc" placeholder="输入图片描述(alt 文本)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>选择图片</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-secondary" onclick="document.getElementById('img-file-input').click()">选择文件</button>
|
||||
<input type="file" id="img-file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" onchange="onImageFileSelected(this)">
|
||||
<span id="img-file-name" style="color:#888;font-size:13px">未选择</span>
|
||||
</div>
|
||||
<div id="img-preview-box" style="margin-top:8px;display:none">
|
||||
<img id="img-preview-thumb" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #ddd">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideImageModal()">取消</button>
|
||||
<button type="button" class="btn-primary" id="img-upload-btn" onclick="confirmInsertImage()" disabled>上传并插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_categories"}}
|
||||
<!-- 分类管理 -->
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_tags"}}
|
||||
<!-- 标签管理 -->
|
||||
<div id="tags-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tags-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_pages"}}
|
||||
<!-- 页面管理 -->
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_comments"}}
|
||||
<!-- 评论管理 -->
|
||||
<div id="comments-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>评论管理</h1>
|
||||
<div class="comment-filters">
|
||||
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
|
||||
<option value="">全部文章</option>
|
||||
</select>
|
||||
<select id="comment-status-filter" class="comment-status-filter" onchange="filterCommentsByStatus()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="spam">垃圾</option>
|
||||
</select>
|
||||
</div>
|
||||
</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 id="comments-pagination" class="pagination"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_users"}}
|
||||
<!-- 用户管理 -->
|
||||
<div id="users-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<button class="btn-primary" onclick="showUserForm()">新建用户</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>最后登录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_settings"}}
|
||||
<!-- 基础设置 -->
|
||||
<div id="settings-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>基础设置</h1>
|
||||
</div>
|
||||
<form id="settings-form" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label>站点名称</label>
|
||||
<input type="text" id="setting-site-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>站点描述</label>
|
||||
<textarea id="setting-site-desc" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="setting-show-comments">
|
||||
前台显示评论
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>评论每页显示条数</label>
|
||||
<input type="number" id="setting-comments-per-page" min="1" max="100" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">保存设置</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_files"}}
|
||||
<!-- 文件管理 -->
|
||||
<div id="files-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>文件管理</h1>
|
||||
<div>
|
||||
<input type="file" id="file-upload-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" multiple>
|
||||
<button class="btn-primary" onclick="document.getElementById('file-upload-input').click()">上传图片</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="upload-hint">支持 jpg/png/gif/webp 格式,单个文件最大 5MB</p>
|
||||
<div class="file-grid" id="file-grid"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_themes"}}
|
||||
<!-- 主题管理 -->
|
||||
<div id="themes-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>主题管理</h1>
|
||||
<button class="btn-primary" onclick="showCreateThemeModal()">新建主题</button>
|
||||
</div>
|
||||
<div class="theme-settings">
|
||||
<div class="form-group">
|
||||
<label>当前主题</label>
|
||||
<select id="theme-select" class="theme-selector">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
<button class="btn-primary" onclick="switchTheme()">切换主题</button>
|
||||
</div>
|
||||
<div id="theme-message" class="theme-message"></div>
|
||||
</div>
|
||||
<div class="theme-list" id="theme-list">
|
||||
<!-- 主题列表将动态加载 -->
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_theme_editor"}}
|
||||
<!-- 主题编辑器 -->
|
||||
<div id="theme-editor-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>编辑主题: <span id="editor-theme-name"></span></h1>
|
||||
<div>
|
||||
<button class="btn-secondary" onclick="showPage('themes')">返回</button>
|
||||
<button class="btn-primary" onclick="saveCurrentFile()">保存文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-editor-container">
|
||||
<div class="theme-file-list">
|
||||
<h3>文件列表</h3>
|
||||
<div id="theme-files"></div>
|
||||
</div>
|
||||
<div class="theme-editor-content">
|
||||
<div class="editor-header">
|
||||
<h3 id="current-file-name">选择文件进行编辑</h3>
|
||||
</div>
|
||||
<textarea id="theme-file-editor" class="code-editor" placeholder="选择左侧文件进行编辑..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_common"}}
|
||||
<!-- 分类/标签/页面 表单弹窗 -->
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_user"}}
|
||||
<!-- 用户表单弹窗 -->
|
||||
<div id="user-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3 id="user-modal-title">新建用户</h3>
|
||||
<form id="user-form">
|
||||
<input type="hidden" id="user-id">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="user-username" required minlength="3" maxlength="50">
|
||||
</div>
|
||||
<div class="form-group" id="user-password-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="user-password" minlength="6" placeholder="至少 6 位">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input type="text" id="user-nickname">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" id="user-email" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select id="user-role">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select id="user-status">
|
||||
<option value="1">启用</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideUserModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_theme"}}
|
||||
<!-- 创建主题弹窗 -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div class="modal-content modal-large">
|
||||
<h3>新建主题</h3>
|
||||
<form id="create-theme-form">
|
||||
<div class="form-group">
|
||||
<label>主题名称 *</label>
|
||||
<input type="text" id="new-theme-name" required placeholder="例如: mytheme">
|
||||
<small>只能包含字母、数字和下划线</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>基于模板创建</label>
|
||||
<select id="theme-template">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideThemeModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">创建主题</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!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="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/admin.css?v=4">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-page">
|
||||
<div class="login-box">
|
||||
<h2>管理员登录</h2>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="login-username" required autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="login-password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div id="login-error" class="login-error hidden"></div>
|
||||
<button type="submit" id="login-submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 已登录则直接进入后台(通过 Cookie 自动认证,调用接口验证有效性)
|
||||
(async function () {
|
||||
try {
|
||||
var res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
location.replace('/admin');
|
||||
}
|
||||
} catch (e) { /* 网络异常继续显示登录页 */ }
|
||||
})();
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('login-error');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
|
||||
const username = document.getElementById('login-username').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
// 服务端已通过 Set-Cookie 写入 JWT,直接跳转即可
|
||||
location.href = '/admin';
|
||||
} else {
|
||||
showError(data.error || '登录失败');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,9 @@
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/layui.css">
|
||||
<script src="/static/js/frontend.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
{{if .Posts}}
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
<div class="post-cover">
|
||||
<a href="/post/{{.Slug}}">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}">
|
||||
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
{{if .IsTop}}<span class="top-badge">置顶</span>{{end}}
|
||||
</h2>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
|
||||
<span><i class="layui-icon"></i><a href="/?author={{.AuthorID}}">{{.Author.Username}}</a></span>
|
||||
<span class="post-date">{{if .PublishedAt}}<i class="layui-icon"></i>{{.PublishedAt.Format "2006-01-02"}}{{else}}未发布{{end}}</span>
|
||||
<span class="post-category">
|
||||
{{if .Category}}<a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
|
||||
{{if .Category}}<i class="layui-icon"></i><a href="/?category={{.CategoryID}}">{{.Category.Name}}</a>{{else}}未分类{{end}}
|
||||
</span>
|
||||
{{if .Tags}}
|
||||
<span class="post-tags">
|
||||
@@ -27,9 +26,12 @@
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Views}} 阅读</span>
|
||||
|
||||
<span class="post-views"><i class="layui-icon"></i>{{.Views}} 阅读</span>
|
||||
<span class="post-comments"><i class="layui-icon"></i>{{len .Comments}} 评论</span>
|
||||
</div>
|
||||
<p class="post-summary">{{.Summary}}</p>
|
||||
<a href="/post/{{.Slug}}">
|
||||
<p class="post-summary">{{.Summary}}</p></a>
|
||||
<a href="/post/{{.Slug}}" class="read-more">阅读全文 →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
+27
-52
@@ -2,31 +2,30 @@
|
||||
<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>
|
||||
<div class="post-meta">
|
||||
<span><i class="layui-icon"></i><a href="/?author={{.Post.Author.ID}}">{{.Post.Author.Username}}</a></span>
|
||||
<span class="post-date">{{if .Post.PublishedAt}}<i class="layui-icon"></i>{{.Post.PublishedAt.Format "2006-01-02 15:04"}}{{else}}未发布{{end}}</span>
|
||||
<span class="post-category">
|
||||
{{if .Post.Category}}<i class="layui-icon"></i><a href="/?category={{.Post.CategoryID}}">{{.Post.Category.Name}}</a>{{else}}未分类{{end}}
|
||||
</span>
|
||||
{{if .Post.Tags}}
|
||||
<span class="post-tags">
|
||||
{{range .Post.Tags}}
|
||||
<a href="/?tag={{.ID}}" class="tag">{{.Name}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="post-views">{{.Post.Views}} 阅读</span>
|
||||
</div>
|
||||
<span class="post-views"><i class="layui-icon"></i>{{.Post.Views}} 阅读</span>
|
||||
<span class="post-comments"><i class="layui-icon"></i>{{len .Post.Comments}} 评论</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Post.Cover}}
|
||||
<div class="post-cover">
|
||||
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
|
||||
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
{{.Post.Content | markdown}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -59,10 +58,11 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
{{if .ShowComments}}
|
||||
<section class="comments-section" id="comments">
|
||||
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<form class="comment-form" action="/api/comments" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
@@ -80,37 +80,12 @@
|
||||
</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}}
|
||||
<!-- 评论由 AJAX 动态加载 -->
|
||||
</div>
|
||||
|
||||
<div class="pagination comment-pagination" style="display:none">
|
||||
<!-- 分页由 AJAX 动态渲染 -->
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<aside class="sidebar">
|
||||
<!-- 博客信息 -->
|
||||
<div class="sidebar-widget blog-info">
|
||||
<img src="/static/images/avatar.png" alt="头像" class="blog-avatar" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%2280%22><rect fill=%22%231890ff%22 width=%2280%22 height=%2280%22/><text x=%2250%%22 y=%2250%%22 fill=%22white%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2232%22>B</text></svg>'">
|
||||
<img src="/static/images/avatar.jpg" alt="头像" class="blog-avatar" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%2280%22><rect fill=%22%231890ff%22 width=%2280%22 height=%2280%22/><text x=%2250%%22 y=%2250%%22 fill=%22white%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2232%22>B</text></svg>'">
|
||||
<div class="blog-name">{{.SiteName}}</div>
|
||||
<div class="blog-description">{{.SiteDesc}}</div>
|
||||
<div class="blog-stats">
|
||||
@@ -61,16 +61,22 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<!-- 系统菜单(根据登录态区分:已登录显示进入后台/退出登录,未登录显示用户登录) -->
|
||||
<div class="sidebar-widget">
|
||||
<h3 class="widget-title">⚙️ 系统</h3>
|
||||
<ul class="system-menu">
|
||||
{{if .IsLoggedIn}}
|
||||
<li class="system-menu-item">
|
||||
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
|
||||
<a href="/admin" class="system-menu-link">🔐 进入后台^_^</a>
|
||||
</li>
|
||||
<li class="system-menu-item">
|
||||
<a href="/logout" class="system-menu-link">🚪 退出登录{{if .LoginName}}({{.LoginName}}){{end}}</a>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="system-menu-item">
|
||||
<a href="/admin/login" class="system-menu-link">👤 用户登录</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</header>
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
{{.Post.Content | markdown}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="comments-section">
|
||||
{{if .ShowComments}}
|
||||
<section class="comments-section" id="comments">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
@@ -106,5 +107,18 @@
|
||||
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .CommentTotalPages 1}}
|
||||
<div class="pagination comment-pagination">
|
||||
{{if gt .CommentPage 1}}
|
||||
<a href="/post/{{.Post.Slug}}?cpage={{sub .CommentPage 1}}#comments" class="prev">← 上一页</a>
|
||||
{{end}}
|
||||
<span class="page-info">{{.CommentPage}} / {{.CommentTotalPages}}</span>
|
||||
{{if lt .CommentPage .CommentTotalPages}}
|
||||
<a href="/post/{{.Post.Slug}}?cpage={{add .CommentPage 1}}#comments" class="next">下一页 →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/frontend.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
<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}}">
|
||||
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
|
||||
+12
-38
@@ -19,14 +19,12 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Post.Cover}}
|
||||
<div class="post-cover">
|
||||
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
|
||||
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
{{.Post.Content | markdown}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -59,10 +57,11 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
{{if .ShowComments}}
|
||||
<section class="comments-section" id="comments">
|
||||
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<form class="comment-form" action="/api/comments" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
@@ -80,37 +79,12 @@
|
||||
</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}}
|
||||
<!-- 评论由 AJAX 动态加载 -->
|
||||
</div>
|
||||
|
||||
<div class="pagination comment-pagination" style="display:none">
|
||||
<!-- 分页由 AJAX 动态渲染 -->
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
// markdownEngine 全局 Markdown 渲染引擎
|
||||
// 启用 GFM 扩展(表格、删除线、任务列表、自动链接等)
|
||||
// 启用 WithUnsafe 允许原始 HTML 透传,兼容旧文章中的 HTML 内容
|
||||
var markdownEngine = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithRendererOptions(
|
||||
goldmarkhtml.WithUnsafe(),
|
||||
goldmarkhtml.WithHardWraps(),
|
||||
),
|
||||
)
|
||||
|
||||
// searchableTextParser 复用同一套扩展配置,但只做解析,不渲染。
|
||||
// 与 markdownEngine 共享 goldmark 实例的 Parser,避免重复构造解析器开销。
|
||||
var searchableTextParser = markdownEngine.Parser()
|
||||
|
||||
// RenderMarkdown 将 Markdown 文本渲染为 HTML
|
||||
func RenderMarkdown(source string) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(source), &buf); err != nil {
|
||||
// 渲染失败时原样返回,避免页面空白
|
||||
return template.HTML(source)
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// GenerateSearchableText 提取 Markdown 中的可见纯文本(基于 goldmark AST)
|
||||
func GenerateSearchableText(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 全角空格转半角
|
||||
raw = strings.ReplaceAll(raw, "\u3000", " ")
|
||||
|
||||
source := []byte(raw)
|
||||
doc := searchableTextParser.Parse(text.NewReader(source))
|
||||
|
||||
var sb strings.Builder
|
||||
extractSearchableText(doc, source, &sb)
|
||||
|
||||
// 压缩空白(等价于原 reMultiSpace + TrimSpace)
|
||||
return strings.Join(strings.Fields(sb.String()), " ")
|
||||
}
|
||||
|
||||
// extractSearchableText 深度优先遍历 AST,把可见文本写入 sb
|
||||
func extractSearchableText(node ast.Node, source []byte, sb *strings.Builder) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Image:
|
||||
// 图片:整棵子树跳过(含 alt 文本),对应原 reImage / reRefImage
|
||||
return
|
||||
|
||||
case *ast.HTMLBlock, *ast.RawHTML:
|
||||
// 原始 HTML:跳过,对应原 reHTMLTag
|
||||
return
|
||||
|
||||
case *ast.AutoLink:
|
||||
// 自动链接 <url> / <email>:保留 URL / 邮箱
|
||||
// n.URL(source) 返回链接目标;邮箱自动链接会带 mailto: 前缀,这里去掉
|
||||
url := string(n.URL(source))
|
||||
url = strings.TrimPrefix(url, "mailto:")
|
||||
sb.WriteString(url)
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
|
||||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||||
lines := n.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
seg := lines.At(i)
|
||||
sb.Write(seg.Value(source))
|
||||
}
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
|
||||
case *ast.Text:
|
||||
sb.Write(n.Segment.Value(source))
|
||||
if n.SoftLineBreak() || n.HardLineBreak() {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
return
|
||||
|
||||
case *ast.String:
|
||||
// 代码块 / 部分扩展产生的字符串节点
|
||||
sb.Write(n.Value)
|
||||
return
|
||||
}
|
||||
|
||||
// 递归子节点
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
extractSearchableText(child, source, sb)
|
||||
}
|
||||
|
||||
// 块级元素结束后补一个空格,避免相邻段落/列表项文本粘连
|
||||
switch node.Kind() {
|
||||
case ast.KindParagraph, ast.KindHeading, ast.KindListItem,
|
||||
ast.KindBlockquote, ast.KindList:
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdown(t *testing.T) {
|
||||
// Markdown 语法渲染
|
||||
out := string(RenderMarkdown("# 标题\n\n**加粗** 和 `代码`"))
|
||||
if !strings.Contains(out, "<h1>标题</h1>") || !strings.Contains(out, "<strong>加粗</strong>") || !strings.Contains(out, "<code>代码</code>") {
|
||||
t.Errorf("Markdown 渲染结果不符合预期: %s", out)
|
||||
}
|
||||
|
||||
// GFM 表格
|
||||
out = string(RenderMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |"))
|
||||
if !strings.Contains(out, "<table>") {
|
||||
t.Errorf("GFM 表格未渲染: %s", out)
|
||||
}
|
||||
|
||||
// 兼容旧文章的 HTML 内容
|
||||
out = string(RenderMarkdown("<div class=\"old\"><p>旧的HTML内容</p></div>"))
|
||||
if !strings.Contains(out, "<div class=\"old\">") {
|
||||
t.Errorf("HTML 内容未透传: %s", out)
|
||||
}
|
||||
}
|
||||
+47
-16
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin/render"
|
||||
@@ -21,6 +22,7 @@ func templateFuncs() template.FuncMap {
|
||||
"html": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"markdown": RenderMarkdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +67,14 @@ func (r *htmlRender) WriteContentType(w http.ResponseWriter) {
|
||||
}
|
||||
|
||||
// LoadTemplates 加载指定主题的模板
|
||||
func LoadTemplates(theme string) *TemplateRenderer {
|
||||
func LoadTemplates(theme string) (*TemplateRenderer, error) {
|
||||
r := &TemplateRenderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
r.reload(theme)
|
||||
return r
|
||||
if err := r.reload(theme); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Reload 重新加载主题模板(线程安全)
|
||||
@@ -81,33 +85,60 @@ func (r *TemplateRenderer) Reload(theme string) {
|
||||
}
|
||||
|
||||
// reload 内部重载方法(需要持有锁)
|
||||
func (r *TemplateRenderer) reload(theme string) {
|
||||
func (r *TemplateRenderer) reload(theme string) error {
|
||||
templateDir := "templates/" + theme
|
||||
|
||||
// 需要独立渲染的页面模板
|
||||
pages := []string{"index", "post", "page"}
|
||||
|
||||
// 收集主题目录下的公共局部模板
|
||||
partials := []string{}
|
||||
matches, err := filepath.Glob(templateDir + "/*.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描主题目录失败: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range matches {
|
||||
base := filepath.Base(f)
|
||||
if base == "base.html" {
|
||||
continue
|
||||
}
|
||||
name := base[:len(base)-len(".html")]
|
||||
isPage := false
|
||||
for _, p := range pages {
|
||||
if p == name {
|
||||
isPage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isPage {
|
||||
partials = append(partials, f)
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个页面创建独立的模板组合
|
||||
pages := []string{"index", "post", "page", "sidebar"}
|
||||
newTemplates := make(map[string]*template.Template)
|
||||
|
||||
for _, pageName := range pages {
|
||||
// 只加载 base.html 和对应的页面模板
|
||||
allFiles := []string{
|
||||
templateDir + "/base.html",
|
||||
templateDir + "/" + pageName + ".html",
|
||||
}
|
||||
// 加载 base.html、对应页面模板以及所有公共局部模板
|
||||
allFiles := make([]string, 0, 2+len(partials))
|
||||
allFiles = append(allFiles,
|
||||
templateDir+"/base.html",
|
||||
templateDir+"/"+pageName+".html",
|
||||
)
|
||||
allFiles = append(allFiles, partials...)
|
||||
|
||||
// 解析模板文件
|
||||
// 解析模板文件,遇到错误立即返回
|
||||
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
|
||||
if err != nil {
|
||||
// 如果解析失败,记录错误但不中断
|
||||
fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err)
|
||||
continue
|
||||
return fmt.Errorf("解析页面模板 %s 失败: %w", pageName, err)
|
||||
}
|
||||
newTemplates[pageName] = tmpl
|
||||
}
|
||||
|
||||
// 更新模板映射
|
||||
// 所有页面模板解析成功后才更新映射
|
||||
r.templates = newTemplates
|
||||
fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 全局模板渲染器实例
|
||||
|
||||
Reference in New Issue
Block a user