Compare commits
10
Commits
aab3aeb5b2
...
ed5a6c043e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed5a6c043e | ||
|
|
156b78d45e | ||
|
|
9854b4f195 | ||
|
|
503510de2c | ||
|
|
a9a890a849 | ||
|
|
0b22d4f126 | ||
|
|
21be62fa7c | ||
|
|
98161a2fe8 | ||
|
|
786f11a2ca | ||
|
|
8d0b75e484 |
+46
@@ -0,0 +1,46 @@
|
||||
# 编译产物
|
||||
*.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/
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -133,41 +132,3 @@ func getEnv(key, defaultValue string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+4
-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
|
||||
}
|
||||
@@ -36,28 +38,6 @@ func Init(cfg *config.DatabaseConfig) error {
|
||||
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,16 @@ 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/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 +29,7 @@ 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/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 +38,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 +54,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,12 @@ 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/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 +68,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 +84,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 +115,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 +146,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.
+17
-2
@@ -7,6 +7,7 @@ import (
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 创建评论请求
|
||||
@@ -24,7 +25,10 @@ func GetComments(c *gin.Context) {
|
||||
postID := c.Query("post_id")
|
||||
status := c.Query("status")
|
||||
|
||||
db := database.DB.Model(&models.Comment{}).Preload("User")
|
||||
db := database.DB.Model(&models.Comment{}).Preload("User").
|
||||
Preload("Post", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "title", "slug")
|
||||
})
|
||||
|
||||
if postID != "" {
|
||||
db = db.Where("post_id = ?", postID)
|
||||
@@ -36,7 +40,18 @@ func GetComments(c *gin.Context) {
|
||||
var comments []models.Comment
|
||||
db.Order("created_at DESC").Find(&comments)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": comments})
|
||||
// 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})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -47,6 +48,21 @@ type UpdatePostRequest struct {
|
||||
IsTop bool `json:"is_top"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -77,6 +93,11 @@ func GetPosts(c *gin.Context) {
|
||||
db = db.Where("category_id = ?", query.CategoryID)
|
||||
}
|
||||
|
||||
userId, exists := c.Get("userID")
|
||||
if exists {
|
||||
db = db.Where("author_id = ?", userId)
|
||||
}
|
||||
|
||||
if query.TagID > 0 {
|
||||
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
|
||||
Where("post_tags.tag_id = ?", query.TagID)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 站点基础设置键名
|
||||
const (
|
||||
OptionSiteName = "site_name"
|
||||
OptionSiteDesc = "site_desc"
|
||||
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
|
||||
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
|
||||
)
|
||||
|
||||
// GetSettings 获取站点基础设置(管理员)
|
||||
func GetSettings(c *gin.Context) {
|
||||
cfg := config.GetGlobalConfig()
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
|
||||
opts, err := models.GetOptionsByUser(database.DB, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取设置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 默认值:站点信息取配置文件,评论默认显示、每页 10 条
|
||||
settings := map[string]string{
|
||||
OptionSiteName: cfg.App.Name,
|
||||
OptionSiteDesc: cfg.App.Description,
|
||||
OptionShowComments: "1",
|
||||
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{
|
||||
OptionSiteName: req.SiteName,
|
||||
OptionSiteDesc: req.SiteDesc,
|
||||
OptionShowComments: showComments,
|
||||
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": "设置已保存"})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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,
|
||||
}
|
||||
|
||||
// UploadedFile 上传文件信息
|
||||
type UploadedFile struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
}
|
||||
|
||||
// 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 上传图片(校验格式与大小)
|
||||
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
|
||||
}
|
||||
|
||||
// 确保上传目录存在
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名:时间戳_原始名.扩展名
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext)
|
||||
dstPath := filepath.Join(uploadDir, 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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": uploadURLPrefix + filename,
|
||||
"name": filename,
|
||||
"size": file.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// ListUploads 列出已上传的图片
|
||||
func ListUploads(c *gin.Context) {
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(uploadDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]UploadedFile, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if !allowedImageExts[ext] {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, UploadedFile{
|
||||
Name: e.Name(),
|
||||
URL: uploadURLPrefix + e.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// 按修改时间倒序(最新的在前)
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime > files[j].ModTime
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": files})
|
||||
}
|
||||
|
||||
// DeleteUpload 删除已上传的图片
|
||||
func DeleteUpload(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
// 防止路径穿越
|
||||
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(uploadDir, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
role := req.Role
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
status := 1
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
Role: role,
|
||||
Status: status,
|
||||
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{"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": "删除成功"})
|
||||
}
|
||||
+78
-26
@@ -11,6 +11,7 @@ import (
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -37,6 +38,7 @@ func templateFuncs() template.FuncMap {
|
||||
"html": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"markdown": utils.RenderMarkdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,12 @@ func IndexView(c *gin.Context) {
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published")
|
||||
|
||||
userId, exists := c.Get("userID")
|
||||
userId, exists = 1, true
|
||||
if exists {
|
||||
db = db.Or("author_id = ?", userId)
|
||||
}
|
||||
|
||||
if categoryID > 0 {
|
||||
db = db.Where("category_id = ?", categoryID)
|
||||
}
|
||||
@@ -98,7 +106,7 @@ func IndexView(c *gin.Context) {
|
||||
// 调试日志
|
||||
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)
|
||||
slog.Info("文章", "index", i, "id", p.ID, "title", p.Title, "status", p.Status, "publishedAt", p.PublishedAt, "views", p.Views, "commentsCount", len(p.Comments))
|
||||
}
|
||||
|
||||
// 获取页面
|
||||
@@ -114,12 +122,11 @@ func IndexView(c *gin.Context) {
|
||||
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 v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
|
||||
siteName = v
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
|
||||
siteDesc = v
|
||||
}
|
||||
|
||||
data := IndexData{
|
||||
@@ -156,6 +163,10 @@ type PostDetailData struct {
|
||||
Year int
|
||||
PrevPost *models.Post // 上一篇
|
||||
NextPost *models.Post // 下一篇
|
||||
// 评论展示设置与分页
|
||||
ShowComments bool
|
||||
CommentPage int
|
||||
CommentTotalPages int
|
||||
// Sidebar 数据
|
||||
Categories []models.Category
|
||||
Tags []models.Tag
|
||||
@@ -172,14 +183,17 @@ func PostView(c *gin.Context) {
|
||||
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{
|
||||
@@ -201,6 +215,43 @@ func PostView(c *gin.Context) {
|
||||
var commentCount int64
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
|
||||
|
||||
// 读取评论展示设置
|
||||
showComments := true
|
||||
if v, optErr := models.GetOptionValue(database.DB, OptionShowComments, 0); optErr == nil {
|
||||
showComments = v != "0"
|
||||
}
|
||||
commentPageSize := 10
|
||||
if v, optErr := models.GetOptionValue(database.DB, 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", post.ID, "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", post.ID, "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)
|
||||
}
|
||||
|
||||
// 获取上一篇(发布时间更早的文章)
|
||||
var prevPost models.Post
|
||||
prevErr := database.DB.Where("status = ? AND published_at < ?", "published", post.PublishedAt).
|
||||
@@ -215,12 +266,11 @@ func PostView(c *gin.Context) {
|
||||
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 v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
|
||||
siteName = v
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
|
||||
siteDesc = v
|
||||
}
|
||||
|
||||
// 加载侧边栏数据
|
||||
@@ -233,9 +283,13 @@ func PostView(c *gin.Context) {
|
||||
Description: post.Summary,
|
||||
Post: post,
|
||||
Pages: pages,
|
||||
Comments: post.Comments,
|
||||
Comments: comments,
|
||||
CommentCount: commentCount,
|
||||
Year: time.Now().Year(),
|
||||
// 评论展示设置与分页
|
||||
ShowComments: showComments,
|
||||
CommentPage: commentPage,
|
||||
CommentTotalPages: commentTotalPages,
|
||||
// Sidebar 数据
|
||||
Categories: categories,
|
||||
Tags: tags,
|
||||
@@ -298,14 +352,12 @@ func PageView(c *gin.Context) {
|
||||
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 v, err := models.GetOptionValue(database.DB, "site_name", 0); err == nil {
|
||||
siteName = v
|
||||
}
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "site_desc", 0).First(&option).Error; err == nil {
|
||||
siteDesc = option.Value
|
||||
if v, err := models.GetOptionValue(database.DB, "site_desc", 0); err == nil {
|
||||
siteDesc = v
|
||||
}
|
||||
|
||||
// 加载侧边栏数据
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建默认配置文件(如果不存在)
|
||||
if err := config.CreateDefaultConfig(); err != nil {
|
||||
slog.Error("创建默认配置文件失败", "error", err)
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
@@ -43,12 +38,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 创建默认管理员
|
||||
if err := database.CreateDefaultAdmin(); err != nil {
|
||||
slog.Error("创建默认管理员失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
|
||||
theme := cfg.App.Theme
|
||||
var option models.Option
|
||||
|
||||
+3
-6
@@ -226,12 +226,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
|
||||
}
|
||||
|
||||
+20
-1
@@ -76,6 +76,9 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
|
||||
admin.PUT("/posts/:id", handlers.UpdatePost)
|
||||
admin.DELETE("/posts/:id", handlers.DeletePost)
|
||||
|
||||
// Markdown 预览(文章编辑器用)
|
||||
admin.POST("/markdown/preview", handlers.PreviewMarkdown)
|
||||
|
||||
// 分类管理
|
||||
admin.POST("/categories", handlers.CreateCategory)
|
||||
admin.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
@@ -92,10 +95,26 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
|
||||
admin.DELETE("/pages/:id", handlers.DeletePage)
|
||||
|
||||
// 评论管理
|
||||
admin.GET("/admin/comments", handlers.GetComments)
|
||||
admin.GET("/comments", handlers.GetComments)
|
||||
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
|
||||
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
|
||||
admin.DELETE("/comments/:id", handlers.DeleteComment)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", handlers.GetUsers)
|
||||
admin.POST("/users", handlers.CreateUser)
|
||||
admin.PUT("/users/:id", handlers.AdminUpdateUser)
|
||||
admin.PUT("/users/:id/password", handlers.AdminResetPassword)
|
||||
admin.DELETE("/users/:id", handlers.DeleteUser)
|
||||
|
||||
// 基础设置
|
||||
admin.GET("/settings", handlers.GetSettings)
|
||||
admin.PUT("/settings", handlers.UpdateSettings)
|
||||
|
||||
// 文件管理(图片上传)
|
||||
admin.POST("/upload", handlers.UploadImage)
|
||||
admin.GET("/uploads", handlers.ListUploads)
|
||||
admin.DELETE("/uploads/:name", handlers.DeleteUpload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+556
-5
@@ -42,6 +42,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// 模态框表单
|
||||
document.getElementById('modal-form').addEventListener('submit', handleModalSubmit);
|
||||
|
||||
// 用户表单
|
||||
document.getElementById('user-form').addEventListener('submit', handleUserSubmit);
|
||||
|
||||
// 基础设置表单
|
||||
document.getElementById('settings-form').addEventListener('submit', handleSettingsSubmit);
|
||||
|
||||
// 封面图上传
|
||||
const coverFileInput = document.getElementById('post-cover-file');
|
||||
if (coverFileInput) {
|
||||
coverFileInput.addEventListener('change', handleCoverFileChange);
|
||||
}
|
||||
|
||||
// 文件管理上传
|
||||
const fileUploadInput = document.getElementById('file-upload-input');
|
||||
if (fileUploadInput) {
|
||||
fileUploadInput.addEventListener('change', handleFileUpload);
|
||||
}
|
||||
});
|
||||
|
||||
// 显示登录页
|
||||
@@ -92,9 +110,18 @@ function showPage(pageName) {
|
||||
case 'comments':
|
||||
loadComments();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'files':
|
||||
loadFiles();
|
||||
break;
|
||||
case 'themes':
|
||||
loadThemes();
|
||||
break;
|
||||
case 'settings':
|
||||
loadSettings();
|
||||
break;
|
||||
case 'theme-editor':
|
||||
// 主题编辑器由 editTheme() 函数加载
|
||||
break;
|
||||
@@ -196,6 +223,8 @@ async function showPostForm(isEdit = false) {
|
||||
document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章';
|
||||
document.getElementById('post-form').reset();
|
||||
document.getElementById('post-id').value = '';
|
||||
setCover('');
|
||||
mdShowEdit();
|
||||
|
||||
// 加载分类选项
|
||||
const res = await fetch(`${API_BASE}/categories`);
|
||||
@@ -217,7 +246,7 @@ async function editPost(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 || '';
|
||||
setCover(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(', ') || '';
|
||||
@@ -231,6 +260,7 @@ async function editPost(id) {
|
||||
).join('') || '';
|
||||
|
||||
document.getElementById('post-form-title').textContent = '编辑文章';
|
||||
mdShowEdit();
|
||||
showPage('post-form');
|
||||
} catch (err) {
|
||||
alert('加载文章失败');
|
||||
@@ -279,6 +309,76 @@ async function handlePostSubmit(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Markdown 编辑器 ============
|
||||
|
||||
// 在选区两侧插入包裹符号(加粗、斜体、链接等)
|
||||
function mdWrap(prefix, suffix, placeholder) {
|
||||
mdShowEdit();
|
||||
const ta = document.getElementById('post-content');
|
||||
const start = ta.selectionStart, end = ta.selectionEnd;
|
||||
const selected = ta.value.substring(start, end) || placeholder;
|
||||
ta.value = ta.value.substring(0, start) + prefix + selected + suffix + ta.value.substring(end);
|
||||
ta.focus();
|
||||
ta.selectionStart = start + prefix.length;
|
||||
ta.selectionEnd = start + prefix.length + selected.length;
|
||||
}
|
||||
|
||||
// 在行首插入前缀(标题、引用、列表等)
|
||||
function mdInsertLine(prefix, placeholder) {
|
||||
mdShowEdit();
|
||||
const ta = document.getElementById('post-content');
|
||||
const start = ta.selectionStart, end = ta.selectionEnd;
|
||||
const selected = ta.value.substring(start, end) || placeholder;
|
||||
// 不在行首时先换行,保证前缀生效
|
||||
const needNewline = start > 0 && ta.value[start - 1] !== '\n';
|
||||
const insert = (needNewline ? '\n' : '') + prefix + selected;
|
||||
ta.value = ta.value.substring(0, start) + insert + ta.value.substring(end);
|
||||
ta.focus();
|
||||
ta.selectionStart = start + insert.length - selected.length;
|
||||
ta.selectionEnd = start + insert.length;
|
||||
}
|
||||
|
||||
// 插入表格模板
|
||||
function mdInsertTable() {
|
||||
mdShowEdit();
|
||||
const ta = document.getElementById('post-content');
|
||||
const table = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 内容 | 内容 | 内容 |\n';
|
||||
const start = ta.selectionStart;
|
||||
ta.value = ta.value.substring(0, start) + table + ta.value.substring(ta.selectionEnd);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + table.length;
|
||||
}
|
||||
|
||||
// 切换到编辑模式
|
||||
function mdShowEdit() {
|
||||
document.getElementById('post-content').classList.remove('hidden');
|
||||
document.getElementById('md-preview').classList.add('hidden');
|
||||
document.getElementById('md-tab-edit').classList.add('active');
|
||||
document.getElementById('md-tab-preview').classList.remove('active');
|
||||
}
|
||||
|
||||
// 切换到预览模式(调用后端渲染,与前台显示效果一致)
|
||||
async function mdShowPreview() {
|
||||
const ta = document.getElementById('post-content');
|
||||
const preview = document.getElementById('md-preview');
|
||||
document.getElementById('md-tab-edit').classList.remove('active');
|
||||
document.getElementById('md-tab-preview').classList.add('active');
|
||||
ta.classList.add('hidden');
|
||||
preview.classList.remove('hidden');
|
||||
preview.innerHTML = '<p class="md-preview-tip">渲染中...</p>';
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/markdown/preview`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ content: ta.value })
|
||||
});
|
||||
const data = await res.json();
|
||||
preview.innerHTML = data.html || '<p class="md-preview-tip">暂无内容</p>';
|
||||
} catch (err) {
|
||||
preview.innerHTML = '<p class="md-preview-tip">预览失败,请检查网络</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 更改文章状态
|
||||
async function changePostStatus(id, status) {
|
||||
const actionText = status === 'published' ? '发布' : '下架';
|
||||
@@ -392,18 +492,24 @@ async function loadPages() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载评论
|
||||
async function loadComments() {
|
||||
// 加载评论(postId 为空时加载全部)
|
||||
async function loadComments(postId = '') {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/comments`, { headers: getHeaders() });
|
||||
const url = postId ? `${API_BASE}/admin/comments?post_id=${postId}` : `${API_BASE}/admin/comments`;
|
||||
const res = await fetch(url, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
// 加载全部评论时刷新文章筛选下拉框
|
||||
if (!postId) {
|
||||
populateCommentPostFilter(data.data || []);
|
||||
}
|
||||
|
||||
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><a href="/post/${encodeURIComponent(c.post_slug || c.post_id)}" target="_blank">${c.post_title || '文章 #' + c.post_id}</a></td>
|
||||
<td><span class="status-badge status-${c.status}">${c.status}</span></td>
|
||||
<td>${new Date(c.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
@@ -621,6 +727,279 @@ function getStatusText(status) {
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users?page_size=100`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('users-list');
|
||||
tbody.innerHTML = data.data?.map(u => {
|
||||
const isSelf = currentUser && u.id === currentUser.id;
|
||||
return `
|
||||
<tr>
|
||||
<td>${u.username}${isSelf ? ' <span class="status-badge status-published">当前</span>' : ''}</td>
|
||||
<td>${u.nickname || '-'}</td>
|
||||
<td>${u.email || '-'}</td>
|
||||
<td>${u.role === 'admin' ? '管理员' : '普通用户'}</td>
|
||||
<td><span class="status-badge ${u.status === 1 ? 'status-published' : 'status-draft'}">${u.status === 1 ? '启用' : '禁用'}</span></td>
|
||||
<td>${u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '-'}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editUser(${u.id})">编辑</button>
|
||||
<button class="btn-secondary" onclick="resetUserPassword(${u.id}, '${u.username}')">重置密码</button>
|
||||
${isSelf ? '' : `
|
||||
${u.status === 1 ? `<button class="btn-secondary" onclick="toggleUserStatus(${u.id}, 0)">禁用</button>` : `<button class="btn-primary" onclick="toggleUserStatus(${u.id}, 1)">启用</button>`}
|
||||
<button class="btn-danger" onclick="deleteUser(${u.id}, '${u.username}')">删除</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('') || '<tr><td colspan="7" style="text-align:center">暂无用户</td></tr>';
|
||||
|
||||
// 缓存用户数据供编辑使用
|
||||
usersCache = data.data || [];
|
||||
} catch (err) {
|
||||
console.error('加载用户失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 填充评论筛选下拉框(所有有评论的文章)
|
||||
function populateCommentPostFilter(comments) {
|
||||
const select = document.getElementById('comment-post-filter');
|
||||
if (!select) return;
|
||||
|
||||
// 按文章去重
|
||||
const posts = new Map();
|
||||
comments.forEach(c => {
|
||||
if (!posts.has(c.post_id)) {
|
||||
posts.set(c.post_id, c.post_title || ('文章 #' + c.post_id));
|
||||
}
|
||||
});
|
||||
|
||||
select.innerHTML = '<option value="">全部文章</option>';
|
||||
posts.forEach((title, id) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = title;
|
||||
opt.title = title; // 悬停显示完整标题
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.value = '';
|
||||
}
|
||||
|
||||
// 按选中文章过滤评论列表
|
||||
function filterCommentsByPost() {
|
||||
loadComments(document.getElementById('comment-post-filter').value);
|
||||
}
|
||||
|
||||
// 加载基础设置
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/settings`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert(data.error || '加载设置失败');
|
||||
return;
|
||||
}
|
||||
const s = data.data || {};
|
||||
document.getElementById('setting-site-name').value = s.site_name || '';
|
||||
document.getElementById('setting-site-desc').value = s.site_desc || '';
|
||||
document.getElementById('setting-show-comments').checked = s.show_comments !== '0';
|
||||
document.getElementById('setting-comments-per-page').value = s.comments_per_page || '10';
|
||||
} catch (err) {
|
||||
console.error('加载设置失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存基础设置
|
||||
async function handleSettingsSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const body = {
|
||||
site_name: document.getElementById('setting-site-name').value.trim(),
|
||||
site_desc: document.getElementById('setting-site-desc').value.trim(),
|
||||
show_comments: document.getElementById('setting-show-comments').checked,
|
||||
comments_per_page: parseInt(document.getElementById('setting-comments-per-page').value, 10)
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('设置已保存');
|
||||
} else {
|
||||
alert(data.error || '保存失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('保存失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
let usersCache = [];
|
||||
|
||||
// 显示新建用户表单
|
||||
function showUserForm() {
|
||||
document.getElementById('user-modal-title').textContent = '新建用户';
|
||||
document.getElementById('user-form').reset();
|
||||
document.getElementById('user-id').value = '';
|
||||
document.getElementById('user-username').disabled = false;
|
||||
document.getElementById('user-password-group').classList.remove('hidden');
|
||||
document.getElementById('user-password').required = true;
|
||||
document.getElementById('user-role').disabled = false;
|
||||
document.getElementById('user-status').disabled = false;
|
||||
document.getElementById('user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
function editUser(id) {
|
||||
const user = usersCache.find(u => u.id === id);
|
||||
if (!user) return;
|
||||
|
||||
const isSelf = currentUser && user.id === currentUser.id;
|
||||
|
||||
document.getElementById('user-modal-title').textContent = '编辑用户';
|
||||
document.getElementById('user-id').value = user.id;
|
||||
document.getElementById('user-username').value = user.username;
|
||||
document.getElementById('user-username').disabled = true;
|
||||
// 编辑时不修改密码,隐藏密码输入
|
||||
document.getElementById('user-password-group').classList.add('hidden');
|
||||
document.getElementById('user-password').required = false;
|
||||
document.getElementById('user-password').value = '';
|
||||
document.getElementById('user-nickname').value = user.nickname || '';
|
||||
document.getElementById('user-email').value = user.email || '';
|
||||
document.getElementById('user-role').value = user.role;
|
||||
document.getElementById('user-status').value = String(user.status);
|
||||
// 不能修改自己的角色和状态
|
||||
document.getElementById('user-role').disabled = isSelf;
|
||||
document.getElementById('user-status').disabled = isSelf;
|
||||
document.getElementById('user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 隐藏用户弹窗
|
||||
function hideUserModal() {
|
||||
document.getElementById('user-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 提交用户表单
|
||||
async function handleUserSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('user-id').value;
|
||||
const data = {
|
||||
nickname: document.getElementById('user-nickname').value,
|
||||
email: document.getElementById('user-email').value,
|
||||
role: document.getElementById('user-role').value,
|
||||
status: parseInt(document.getElementById('user-status').value)
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
data.username = document.getElementById('user-username').value;
|
||||
data.password = document.getElementById('user-password').value;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id ? `${API_BASE}/admin/users/${id}` : `${API_BASE}/admin/users`;
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
hideUserModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 启用/禁用用户
|
||||
async function toggleUserStatus(id, status) {
|
||||
const actionText = status === 1 ? '启用' : '禁用';
|
||||
if (!confirm(`确定要${actionText}这个用户吗?`)) return;
|
||||
|
||||
try {
|
||||
const user = usersCache.find(u => u.id === id);
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
nickname: user?.nickname || '',
|
||||
email: user?.email || '',
|
||||
status: status
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 重置用户密码
|
||||
async function resetUserPassword(id, username) {
|
||||
const newPassword = prompt(`请输入用户「${username}」的新密码(至少 6 位):`);
|
||||
if (newPassword === null) return;
|
||||
if (newPassword.length < 6) {
|
||||
alert('密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('密码重置成功');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '重置失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`确定要删除用户「${username}」吗?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '删除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
|
||||
// 加载主题列表
|
||||
@@ -986,3 +1365,175 @@ async function saveCurrentFile() {
|
||||
showThemeMessage('保存文件失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 文件上传与管理 ====================
|
||||
|
||||
// 允许的图片类型与大小限制(与后端保持一致)
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// 校验图片文件,通过返回 null,否则返回错误信息
|
||||
function validateImageFile(file) {
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return '不支持的图片格式,仅支持 jpg/png/gif/webp';
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return '图片大小超过限制(最大 5MB)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 上传图片到服务器,返回 { url, name, size }
|
||||
async function uploadImageFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch(`${API_BASE}/admin/upload`, {
|
||||
method: 'POST',
|
||||
// 注意:上传 FormData 时不能手动设置 Content-Type,浏览器会自动处理 boundary
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '上传失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 封面图:选择文件后上传
|
||||
async function handleCoverFileChange(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const err = validateImageFile(file);
|
||||
if (err) {
|
||||
alert(err);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await uploadImageFile(file);
|
||||
setCover(data.url);
|
||||
} catch (uErr) {
|
||||
alert(uErr.message);
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
// 设置封面图 URL 并更新预览
|
||||
function setCover(url) {
|
||||
document.getElementById('post-cover').value = url || '';
|
||||
const preview = document.getElementById('post-cover-preview');
|
||||
const removeBtn = document.getElementById('post-cover-remove');
|
||||
if (!preview || !removeBtn) return;
|
||||
|
||||
if (url) {
|
||||
preview.src = url;
|
||||
preview.classList.remove('hidden');
|
||||
removeBtn.classList.remove('hidden');
|
||||
} else {
|
||||
preview.src = '';
|
||||
preview.classList.add('hidden');
|
||||
removeBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除封面图
|
||||
function removeCover() {
|
||||
setCover('');
|
||||
}
|
||||
|
||||
// 加载已上传文件列表
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/uploads`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('file-grid');
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
grid.innerHTML = '<p class="no-files">暂无上传文件</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = data.data.map(f => `
|
||||
<div class="file-card">
|
||||
<div class="file-thumb"><img src="${f.url}" alt="${f.name}" loading="lazy"></div>
|
||||
<div class="file-info">
|
||||
<div class="file-name" title="${f.name}">${f.name}</div>
|
||||
<div class="file-meta">${formatFileSize(f.size)} · ${f.mod_time}</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteFile('${f.name}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error('加载文件失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件管理:批量上传
|
||||
async function handleFileUpload(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (const file of files) {
|
||||
const err = validateImageFile(file);
|
||||
if (err) {
|
||||
alert(`${file.name}:${err}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uploadImageFile(file);
|
||||
} catch (uErr) {
|
||||
alert(`${file.name}:${uErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// 删除已上传文件
|
||||
async function deleteFile(name) {
|
||||
if (!confirm('确定要删除这个文件吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/uploads/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadFiles();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || '删除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 复制文件链接
|
||||
function copyFileURL(url) {
|
||||
const full = window.location.origin + url;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(full)
|
||||
.then(() => alert('链接已复制:' + full))
|
||||
.catch(() => prompt('复制链接:', full));
|
||||
} else {
|
||||
prompt('复制链接:', full);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
+156
-7
@@ -5,7 +5,7 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/admin/style.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -21,7 +21,10 @@
|
||||
<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="#users" class="menu-item" data-page="users">用户管理</a>
|
||||
<a href="#files" class="menu-item" data-page="files">文件管理</a>
|
||||
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
|
||||
<a href="#settings" class="menu-item" data-page="settings">基础设置</a>
|
||||
</nav>
|
||||
<div class="user-info">
|
||||
<span id="username">Admin</span>
|
||||
@@ -123,23 +126,57 @@
|
||||
<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">
|
||||
<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>内容 (支持 HTML)</label>
|
||||
<textarea id="post-content" rows="15" required></textarea>
|
||||
<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="mdWrap('','图片描述')">🖼</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">保存</button>
|
||||
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -205,6 +242,9 @@
|
||||
<div id="comments-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>评论管理</h1>
|
||||
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
|
||||
<option value="">全部文章</option>
|
||||
</select>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -221,6 +261,69 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<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>
|
||||
|
||||
<!-- 基础设置 -->
|
||||
<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>
|
||||
|
||||
<!-- 文件管理 -->
|
||||
<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>
|
||||
|
||||
<!-- 主题管理 -->
|
||||
<div id="themes-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
@@ -293,6 +396,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户表单弹窗 -->
|
||||
<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>
|
||||
|
||||
<!-- 创建主题弹窗 -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div class="modal-content modal-large">
|
||||
@@ -325,6 +474,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=9"></script>
|
||||
<script src="/static/admin/app.js?v=16"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -662,3 +662,341 @@ body {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用隐藏 */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 封面图上传 */
|
||||
.cover-uploader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.cover-uploader-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 文件管理网格 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.file-thumb {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: 10px 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.file-actions .btn-small {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown 编辑器 */
|
||||
.md-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-editor:focus-within {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.md-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.md-tools button {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.md-tools button:hover {
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.md-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.md-tabs button {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.md-tabs button.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 编辑/预览共享同一容器,切换时区域尺寸不变 */
|
||||
.md-body {
|
||||
position: relative;
|
||||
height: 460px;
|
||||
min-height: 240px;
|
||||
resize: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-editor textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
resize: none;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.md-editor textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.md-preview {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.md-preview-tip {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 预览区内容排版 */
|
||||
.md-preview h1,
|
||||
.md-preview h2,
|
||||
.md-preview h3,
|
||||
.md-preview h4 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.md-preview p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.md-preview blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 8px 16px;
|
||||
border-left: 4px solid #1890ff;
|
||||
background: #f5f9ff;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.md-preview pre {
|
||||
margin: 12px 0;
|
||||
padding: 12px 16px;
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.md-preview code {
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.md-preview pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md-preview table {
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.md-preview th,
|
||||
.md-preview td {
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-preview th {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.md-preview img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.md-preview ul,
|
||||
.md-preview ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* 评论管理:文章筛选下拉框(固定宽度,超长标题省略显示) */
|
||||
.comment-post-filter {
|
||||
width: 260px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 基础设置表单 */
|
||||
.settings-form {
|
||||
max-width: 560px;
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-form input[type="text"],
|
||||
.settings-form input[type="number"],
|
||||
.settings-form textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-form .checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-form .checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
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 |
@@ -7,6 +7,8 @@
|
||||
<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">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
{{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,7 +28,9 @@
|
||||
{{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}}" class="read-more">阅读全文 →</a>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
{{.Post.Content | markdown}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -59,7 +59,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">
|
||||
@@ -112,5 +113,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}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | html}}
|
||||
{{.Post.Content | markdown}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -59,7 +59,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">
|
||||
@@ -112,5 +113,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}}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
// markdownEngine 全局 Markdown 渲染引擎
|
||||
// 启用 GFM 扩展(表格、删除线、任务列表、自动链接等)
|
||||
// 启用 WithUnsafe 允许原始 HTML 透传,兼容旧文章中的 HTML 内容
|
||||
var markdownEngine = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithRendererOptions(
|
||||
goldmarkhtml.WithUnsafe(),
|
||||
goldmarkhtml.WithHardWraps(),
|
||||
),
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +86,43 @@ func (r *TemplateRenderer) Reload(theme string) {
|
||||
func (r *TemplateRenderer) reload(theme string) {
|
||||
templateDir := "templates/" + theme
|
||||
|
||||
// 需要独立渲染的页面模板
|
||||
pages := []string{"index", "post", "page"}
|
||||
|
||||
// 收集主题目录下的公共局部模板(例如 sidebar/header/footer),
|
||||
// 这些模板通过 {{template "xxx" .}} 被 base.html 引用,
|
||||
// 必须与页面模板一起解析,否则渲染时会因模板未定义而失败(白屏)。
|
||||
partials := []string{}
|
||||
if matches, err := filepath.Glob(templateDir + "/*.html"); err == nil {
|
||||
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 和对应的页面模板
|
||||
// 加载 base.html、对应页面模板以及所有公共局部模板
|
||||
allFiles := []string{
|
||||
templateDir + "/base.html",
|
||||
templateDir + "/" + pageName + ".html",
|
||||
}
|
||||
allFiles = append(allFiles, partials...)
|
||||
|
||||
// 解析模板文件
|
||||
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
|
||||
|
||||
Reference in New Issue
Block a user