Compare commits
18
Commits
0f0850357d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337cb82756 | ||
|
|
4b0d545f47 | ||
|
|
f376d94a49 | ||
|
|
caa9436e6a | ||
|
|
7f5bb974fa | ||
|
|
533f31e868 | ||
|
|
2bcfa1da93 | ||
|
|
f216ef4d1b | ||
|
|
cfe5cf53aa | ||
|
|
20395a79a2 | ||
|
|
16ba817ecf | ||
|
|
052803679a | ||
|
|
6e88700f96 | ||
|
|
d4d009f8b2 | ||
|
|
abee7e86aa | ||
|
|
4a9ac54963 | ||
|
|
f1a6c5abaa | ||
|
|
f1780de632 |
@@ -44,3 +44,9 @@ go.work.sum
|
||||
|
||||
# vendor 目录(如使用 go mod 则无需提交)
|
||||
vendor/
|
||||
|
||||
# 命令行工具
|
||||
cmd/
|
||||
.opencode/.npmrc
|
||||
.opencode/opencode.jsonc
|
||||
.opencode/openwork.json
|
||||
|
||||
+4
-2
@@ -19,8 +19,9 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
Mode string
|
||||
Port int
|
||||
Mode string
|
||||
LogLevel string // 日志级别:debug/info/warn/error,默认 info
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -70,6 +71,7 @@ func Load() *Config {
|
||||
func setDefaults() {
|
||||
viper.SetDefault("server.port", 8080)
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.log_level", "info")
|
||||
viper.SetDefault("database.driver", "sqlite")
|
||||
viper.SetDefault("database.dsn", "goblog.db")
|
||||
viper.SetDefault("app.name", "GoBlog")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug 或 release
|
||||
log_level: info # info, warn, error
|
||||
|
||||
database:
|
||||
driver: sqlite # sqlite 或 mysql
|
||||
|
||||
@@ -33,6 +33,7 @@ func Init(cfg *config.DatabaseConfig) error {
|
||||
&models.Comment{},
|
||||
&models.Page{},
|
||||
&models.Option{},
|
||||
&models.File{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
golang.org/x/crypto v0.53.0
|
||||
@@ -30,6 +31,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
|
||||
@@ -50,6 +50,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
|
||||
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
|
||||
const adminTemplatePath = "./static/admin/index.html"
|
||||
const adminTemplatePath = "./templates/admin/index.html"
|
||||
|
||||
// AdminPageData 后台页面模板数据
|
||||
type AdminPageData struct {
|
||||
@@ -54,11 +54,11 @@ func menuPageSet(menus []AdminMenuGroup) map[string]bool {
|
||||
|
||||
// AdminLoginView 独立登录页
|
||||
func AdminLoginView(c *gin.Context) {
|
||||
c.File("./static/admin/login.html")
|
||||
c.File("./templates/admin/login.html")
|
||||
}
|
||||
|
||||
// AdminView 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
|
||||
// 依赖 OptionalAuth 中间件注入 userID/role 等上下文
|
||||
// 依赖 JWTAuth 中间件注入 userID/role 等上下文
|
||||
func AdminView(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
@@ -68,7 +68,7 @@ func AdminView(c *gin.Context) {
|
||||
|
||||
// 校验用户当前状态(禁用账号不允许进入后台)
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil || user.Status == 0 || !user.IsActive {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil || user.Status == 0 {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
|
||||
+48
-61
@@ -30,35 +30,40 @@ type AdminMenuGroup struct {
|
||||
Items []AdminMenuItem `json:"items"`
|
||||
}
|
||||
|
||||
var adminMenus = []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
}},
|
||||
{Group: "内容管理", Items: []AdminMenuItem{
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "categories", Name: "分类管理"},
|
||||
{Key: "tags", Name: "标签管理"},
|
||||
{Key: "files", Name: "文件管理"},
|
||||
{Key: "pages", Name: "页面管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
{Group: "系统管理", Items: []AdminMenuItem{
|
||||
{Key: "users", Name: "用户管理"},
|
||||
{Key: "themes", Name: "主题管理"},
|
||||
{Key: "settings", Name: "基础设置"},
|
||||
}},
|
||||
}
|
||||
var userMenus = []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "files", Name: "文件管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
}
|
||||
|
||||
// GetMenusByRole 根据角色返回后台菜单
|
||||
func GetMenusByRole(role string) []AdminMenuGroup {
|
||||
if role == "admin" {
|
||||
return []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
}},
|
||||
{Group: "内容管理", Items: []AdminMenuItem{
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "categories", Name: "分类管理"},
|
||||
{Key: "tags", Name: "标签管理"},
|
||||
{Key: "pages", Name: "页面管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
{Group: "系统管理", Items: []AdminMenuItem{
|
||||
{Key: "users", Name: "用户管理"},
|
||||
{Key: "files", Name: "文件管理"},
|
||||
{Key: "themes", Name: "主题管理"},
|
||||
{Key: "settings", Name: "基础设置"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
// 普通用户:仪表盘 + 文章管理 + 评论管理
|
||||
return []AdminMenuGroup{
|
||||
{Group: "", Items: []AdminMenuItem{
|
||||
{Key: "dashboard", Name: "仪表盘"},
|
||||
{Key: "posts", Name: "文章管理"},
|
||||
{Key: "comments", Name: "评论管理"},
|
||||
}},
|
||||
switch role {
|
||||
case "admin":
|
||||
return adminMenus
|
||||
default:
|
||||
return userMenus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +73,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// JWT Claims
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
@@ -98,7 +95,7 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status == 0 || !user.IsActive {
|
||||
if user.Status == 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
|
||||
return
|
||||
}
|
||||
@@ -132,24 +129,10 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时写入 Cookie,供服务端渲染后台页面时识别登录态
|
||||
// 写入 Cookie,供服务端渲染后台页面时识别登录态
|
||||
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": tokenString,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"avatar": user.Avatar,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"login_count": user.LoginCount,
|
||||
},
|
||||
// 菜单由登录接口根据角色返回
|
||||
"menus": GetMenusByRole(user.Role),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "登录成功"})
|
||||
}
|
||||
|
||||
// 退出登录(清除服务端 Cookie)
|
||||
@@ -197,21 +180,19 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.Nickname != "" {
|
||||
updates["nickname"] = req.Nickname
|
||||
}
|
||||
if req.Email != "" {
|
||||
updates["email"] = req.Email
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
updates["avatar"] = req.Avatar
|
||||
updates := map[string]interface{}{
|
||||
"nickname": req.Nickname,
|
||||
"email": req.Email,
|
||||
"avatar": req.Avatar,
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||
return
|
||||
}
|
||||
user.Nickname = req.Nickname
|
||||
user.Email = req.Email
|
||||
user.Avatar = req.Avatar
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": user})
|
||||
}
|
||||
@@ -254,3 +235,9 @@ func ChangePassword(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
||||
}
|
||||
|
||||
// 判断是否为管理员
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, exists := c.Get("role")
|
||||
return exists && role == "admin"
|
||||
}
|
||||
|
||||
+90
-58
@@ -2,6 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
@@ -10,43 +11,72 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 创建评论请求
|
||||
type CreateCommentRequest struct {
|
||||
PostID uint `json:"post_id" binding:"required"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Author string `json:"author" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Website string `json:"website"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// 获取评论列表
|
||||
func GetComments(c *gin.Context) {
|
||||
postID := c.Query("post_id")
|
||||
postSlug := c.Query("post_slug") // 前台使用 slug
|
||||
status := c.Query("status")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "10")
|
||||
|
||||
// 解析分页参数
|
||||
page := 1
|
||||
pageSize := 10
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 10 {
|
||||
pageSize = ps
|
||||
}
|
||||
|
||||
db := database.DB.Model(&models.Comment{}).Preload("User").
|
||||
Preload("Post", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "title", "slug")
|
||||
})
|
||||
|
||||
// 非管理员只能查看自己文章上的评论
|
||||
if !isAdmin(c) {
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
db = db.Where("post_id IN (?)",
|
||||
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
|
||||
}
|
||||
}
|
||||
|
||||
if postID != "" {
|
||||
db = db.Where("post_id = ?", postID)
|
||||
}
|
||||
// 状态过滤(管理员和普通用户均适用)
|
||||
if status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 非管理员只能查看自己文章上的评论
|
||||
if !isAdmin(c) {
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
// 未指定具体文章时,限制为自己名下的文章
|
||||
if postID == "" && postSlug == "" {
|
||||
db = db.Where("post_id IN (?)",
|
||||
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按文章 ID 或 slug 过滤
|
||||
if postID != "" {
|
||||
db = db.Where("post_id = ?", postID)
|
||||
} else if postSlug != "" {
|
||||
var post models.Post
|
||||
if err := database.DB.Where("slug = ?", postSlug).First(&post).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
|
||||
return
|
||||
}
|
||||
db = db.Where("post_id = ?", post.ID)
|
||||
}
|
||||
|
||||
// 只统计并查询顶级评论(parent_id IS NULL),子评论在 Preload 中加载
|
||||
// Count 必须与列表使用相同过滤条件,否则分页总数会包含子评论而不准确
|
||||
db = db.Where("parent_id IS NULL")
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var comments []models.Comment
|
||||
db.Order("created_at DESC").Find(&comments)
|
||||
offset := (page - 1) * pageSize
|
||||
db.Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&comments)
|
||||
|
||||
// 预加载子评论
|
||||
for i := range comments {
|
||||
database.DB.Model(&comments[i]).Association("Children").Find(&comments[i].Children)
|
||||
}
|
||||
|
||||
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
|
||||
type commentWithPost struct {
|
||||
@@ -59,48 +89,50 @@ func GetComments(c *gin.Context) {
|
||||
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": list,
|
||||
"page": page,
|
||||
"total_pages": totalPages,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
func CreateComment(c *gin.Context) {
|
||||
var req CreateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
// GetCommentPosts 获取所有有评论的文章列表(用于评论管理页的文章筛选下拉框)
|
||||
// 与评论列表分页无关,始终返回权限范围内的全部文章
|
||||
func GetCommentPosts(c *gin.Context) {
|
||||
// 有评论的文章 ID 子查询
|
||||
postIDsWithComments := database.DB.Model(&models.Comment{}).Select("DISTINCT post_id")
|
||||
|
||||
query := database.DB.Model(&models.Post{}).
|
||||
Select("id", "title", "slug").
|
||||
Where("id IN (?)", postIDsWithComments)
|
||||
|
||||
// 非管理员只能看到自己名下的文章
|
||||
if !isAdmin(c) {
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
query = query.Where("author_id = ?", userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文章是否存在
|
||||
var post models.Post
|
||||
if err := database.DB.First(&post, req.PostID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
|
||||
return
|
||||
var posts []models.Post
|
||||
query.Order("id DESC").Find(&posts)
|
||||
|
||||
type postOption struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
list := make([]postOption, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
list = append(list, postOption{ID: p.ID, Title: p.Title, Slug: p.Slug})
|
||||
}
|
||||
|
||||
comment := models.Comment{
|
||||
PostID: req.PostID,
|
||||
ParentID: req.ParentID,
|
||||
Author: req.Author,
|
||||
Email: req.Email,
|
||||
Website: req.Website,
|
||||
Content: req.Content,
|
||||
IP: c.ClientIP(),
|
||||
Status: "pending", // 默认待审核
|
||||
}
|
||||
|
||||
// 如果用户已登录
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
uid := userID.(uint)
|
||||
comment.UserID = &uid
|
||||
comment.Status = "approved" // 登录用户评论自动通过
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发表评论失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": comment})
|
||||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||
}
|
||||
|
||||
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
|
||||
|
||||
+72
-29
@@ -2,8 +2,9 @@ package backend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/database"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gosimple/slug"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -34,6 +36,7 @@ type CreatePostRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
IsTop bool `json:"is_top"`
|
||||
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
|
||||
}
|
||||
|
||||
// 更新文章请求
|
||||
@@ -46,6 +49,7 @@ type UpdatePostRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
IsTop bool `json:"is_top"`
|
||||
FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序)
|
||||
}
|
||||
|
||||
// Markdown 预览请求
|
||||
@@ -65,10 +69,7 @@ func PreviewMarkdown(c *gin.Context) {
|
||||
|
||||
// 生成 slug
|
||||
func generateSlug(title string) string {
|
||||
slug := strings.ToLower(title)
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
// 简化处理,实际项目中可能需要更完善的 slug 生成
|
||||
slug := slug.Make(title)
|
||||
return slug
|
||||
}
|
||||
|
||||
@@ -195,15 +196,16 @@ func CreatePost(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
post := models.Post{
|
||||
Title: req.Title,
|
||||
Slug: generateSlug(req.Title),
|
||||
Content: req.Content,
|
||||
Summary: req.Summary,
|
||||
Cover: req.Cover,
|
||||
AuthorID: userID.(uint),
|
||||
CategoryID: req.CategoryID,
|
||||
Status: req.Status,
|
||||
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
|
||||
Title: req.Title,
|
||||
Slug: generateSlug(req.Title),
|
||||
Content: req.Content,
|
||||
SearchableText: string(utils.GenerateSearchableText(req.Content)),
|
||||
Summary: req.Summary,
|
||||
Cover: req.Cover,
|
||||
AuthorID: userID.(uint),
|
||||
CategoryID: req.CategoryID,
|
||||
Status: req.Status,
|
||||
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
|
||||
}
|
||||
|
||||
if req.Status == "published" {
|
||||
@@ -221,6 +223,15 @@ func CreatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理文件关联(包含封面)
|
||||
if len(req.FileIDs) > 0 {
|
||||
var files []models.File
|
||||
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
|
||||
if len(files) > 0 {
|
||||
database.DB.Model(&post).Association("Files").Replace(files)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": post})
|
||||
}
|
||||
|
||||
@@ -256,13 +267,13 @@ func UpdatePost(c *gin.Context) {
|
||||
}
|
||||
if req.Content != "" {
|
||||
updates["content"] = req.Content
|
||||
updates["searchable_text"] = string(utils.GenerateSearchableText(req.Content))
|
||||
}
|
||||
if req.Summary != "" {
|
||||
updates["summary"] = req.Summary
|
||||
}
|
||||
if req.Cover != "" {
|
||||
updates["cover"] = req.Cover
|
||||
}
|
||||
// 封面始终更新(空字符串表示移除封面)
|
||||
updates["cover"] = req.Cover
|
||||
if req.CategoryID > 0 {
|
||||
updates["category_id"] = req.CategoryID
|
||||
}
|
||||
@@ -283,12 +294,16 @@ func UpdatePost(c *gin.Context) {
|
||||
updates["is_top"] = req.IsTop
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
if len(req.Tags) > 0 {
|
||||
tags := findOrCreateTags(req.Tags)
|
||||
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
|
||||
return
|
||||
// 处理标签(nil 表示本次请求不涉及标签;空数组表示移除全部标签)
|
||||
if req.Tags != nil {
|
||||
if len(req.Tags) > 0 {
|
||||
tags := findOrCreateTags(req.Tags)
|
||||
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
database.DB.Model(&post).Association("Tags").Clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +312,20 @@ func UpdatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理文件关联(包含封面)
|
||||
if req.FileIDs != nil {
|
||||
if len(req.FileIDs) > 0 {
|
||||
var files []models.File
|
||||
database.DB.Where("id IN ?", req.FileIDs).Find(&files)
|
||||
if len(files) > 0 {
|
||||
database.DB.Model(&post).Association("Files").Replace(files)
|
||||
}
|
||||
} else {
|
||||
// 空数组,清除所有关联
|
||||
database.DB.Model(&post).Association("Files").Clear()
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": post})
|
||||
}
|
||||
|
||||
@@ -319,6 +348,26 @@ func DeletePost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取关联文件,删除物理文件和数据库记录
|
||||
var files []models.File
|
||||
database.DB.Model(&post).Association("Files").Find(&files)
|
||||
for _, f := range files {
|
||||
// 检查文件是否还被其他文章使用
|
||||
var count int64
|
||||
database.DB.Table("post_files").Where("file_id = ? AND post_id != ?", f.ID, post.ID).Count(&count)
|
||||
if count == 0 {
|
||||
// 没有其他文章使用,删除物理文件
|
||||
physPath := filepath.Join("./static/uploads", f.FilePath)
|
||||
if _, err := os.Stat(physPath); err == nil {
|
||||
os.Remove(physPath)
|
||||
}
|
||||
// 删除 files 表记录
|
||||
database.DB.Delete(&f)
|
||||
}
|
||||
}
|
||||
// 清除关联关系
|
||||
database.DB.Model(&post).Association("Files").Clear()
|
||||
|
||||
if err := database.DB.Delete(&post).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"})
|
||||
return
|
||||
@@ -326,9 +375,3 @@ func DeletePost(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// 判断是否为管理员
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, exists := c.Get("role")
|
||||
return exists && role == "admin"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 获取标签列表
|
||||
@@ -103,7 +104,13 @@ func DeleteTag(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&tag).Error; err != nil {
|
||||
// 使用事务:先清除 post_tags 中该标签的关联记录,再删除标签本身,保证数据一致性
|
||||
if err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DELETE FROM post_tags WHERE tag_id = ?", tag.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&tag).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"})
|
||||
return
|
||||
}
|
||||
|
||||
+132
-61
@@ -6,15 +6,17 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// uploadDir 上传文件保存目录
|
||||
// uploadDir 上传文件保存根目录
|
||||
uploadDir = "./static/uploads"
|
||||
// uploadURLPrefix 上传文件对外访问的 URL 前缀
|
||||
uploadURLPrefix = "/static/uploads/"
|
||||
@@ -39,14 +41,6 @@ var allowedImageMIMEs = map[string]bool{
|
||||
"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))
|
||||
@@ -70,7 +64,7 @@ func sanitizeBaseName(filename string) string {
|
||||
return res
|
||||
}
|
||||
|
||||
// UploadImage 上传图片(校验格式与大小)
|
||||
// UploadImage 上传图片(校验格式与大小,写入 files 表,按年月目录存储)
|
||||
func UploadImage(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -115,15 +109,19 @@ func UploadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确保上传目录存在
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
// 按年月生成子目录:uploads/202608/
|
||||
now := time.Now()
|
||||
yearMonth := now.Format("200601")
|
||||
subDir := filepath.Join(uploadDir, yearMonth)
|
||||
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名:时间戳_原始名.扩展名
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext)
|
||||
dstPath := filepath.Join(uploadDir, filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", now.UnixNano(), sanitizeBaseName(file.Filename), ext)
|
||||
dstPath := filepath.Join(subDir, filename)
|
||||
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
@@ -137,79 +135,152 @@ func UploadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户 ID
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
// 相对路径(用于 URL 拼接和数据库存储)
|
||||
relPath := filepath.Join(yearMonth, filename)
|
||||
|
||||
// 写入 files 表
|
||||
fileRecord := models.File{
|
||||
UserID: userID.(uint),
|
||||
FileName: filename,
|
||||
OrigName: file.Filename,
|
||||
FilePath: relPath,
|
||||
FileSize: file.Size,
|
||||
MimeType: contentType,
|
||||
}
|
||||
if err := database.DB.Create(&fileRecord).Error; err != nil {
|
||||
// 文件已保存但数据库写入失败,记录日志但不阻断返回
|
||||
fmt.Printf("写入文件记录失败: %v\n", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": uploadURLPrefix + filename,
|
||||
"id": fileRecord.ID,
|
||||
"url": uploadURLPrefix + strings.ReplaceAll(relPath, "\\", "/"),
|
||||
"name": filename,
|
||||
"size": file.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// ListUploads 列出已上传的图片
|
||||
// ListUploads 列出已上传的图片(从数据库读取,管理员看全部,普通用户看自己的)
|
||||
func ListUploads(c *gin.Context) {
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
var files []models.File
|
||||
query := database.DB.Order("created_at DESC")
|
||||
|
||||
if role != "admin" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
if err := query.Find(&files).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(uploadDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
|
||||
return
|
||||
// 转换为前端需要的格式
|
||||
type FileItem struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OrigName string `json:"orig_name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
UserID uint `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PostTitle string `json:"post_title"` // 关联文章标题,为空表示未关联
|
||||
PostID uint `json:"post_id"` // 关联文章ID
|
||||
}
|
||||
|
||||
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"),
|
||||
})
|
||||
// 批量查询文件关联信息
|
||||
fileIDs := make([]uint, 0, len(files))
|
||||
for _, f := range files {
|
||||
fileIDs = append(fileIDs, f.ID)
|
||||
}
|
||||
|
||||
// 按修改时间倒序(最新的在前)
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime > files[j].ModTime
|
||||
})
|
||||
// 查询关联关系(带文章标题)
|
||||
type PostFileResult struct {
|
||||
FileID uint `json:"file_id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
}
|
||||
var postFileResults []PostFileResult
|
||||
database.DB.Table("post_files").
|
||||
Select("post_files.file_id, post_files.post_id, posts.title as post_title").
|
||||
Joins("LEFT JOIN posts ON posts.id = post_files.post_id").
|
||||
Where("post_files.file_id IN ?", fileIDs).
|
||||
Find(&postFileResults)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": files})
|
||||
// 构建 fileID -> PostFileResult 映射
|
||||
postFileMap := make(map[uint]PostFileResult)
|
||||
for _, pf := range postFileResults {
|
||||
postFileMap[pf.FileID] = pf
|
||||
}
|
||||
|
||||
items := make([]FileItem, 0, len(files))
|
||||
for _, f := range files {
|
||||
item := FileItem{
|
||||
ID: f.ID,
|
||||
Name: f.FileName,
|
||||
OrigName: f.OrigName,
|
||||
URL: uploadURLPrefix + strings.ReplaceAll(f.FilePath, "\\", "/"),
|
||||
Size: f.FileSize,
|
||||
MimeType: f.MimeType,
|
||||
UserID: f.UserID,
|
||||
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
// 填充关联信息
|
||||
if pf, ok := postFileMap[f.ID]; ok {
|
||||
item.PostTitle = pf.PostTitle
|
||||
item.PostID = pf.PostID
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// DeleteUpload 删除已上传的图片
|
||||
// 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": "非法的文件名"})
|
||||
fileID := c.Param("id")
|
||||
if fileID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"})
|
||||
var file models.File
|
||||
if err := database.DB.First(&file, fileID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(uploadDir, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
// 权限校验:管理员可删所有,普通用户只能删自己的
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" && file.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"})
|
||||
// 检查文件是否被文章使用
|
||||
var count int64
|
||||
database.DB.Table("post_files").Where("file_id = ?", fileID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该文件已被文章使用,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
physPath := filepath.Join(uploadDir, file.FilePath)
|
||||
if _, err := os.Stat(physPath); err == nil {
|
||||
os.Remove(physPath)
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
if err := database.DB.Delete(&file).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -92,11 +92,6 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
role := req.Role
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
status := 1
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
@@ -106,9 +101,8 @@ func CreateUser(c *gin.Context) {
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
Role: role,
|
||||
Role: req.Role,
|
||||
Status: status,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
|
||||
+126
-68
@@ -8,9 +8,11 @@ import (
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/handlers/backend"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -54,15 +56,23 @@ func loadSidebarData() (categories []models.Category, tags []models.Tag, postCou
|
||||
|
||||
// loginInfo 从上下文提取登录态信息(供侧边栏系统菜单区分已登录/未登录)
|
||||
func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) {
|
||||
if _, ok := c.Get("userID"); !ok {
|
||||
tokenString, _ := c.Cookie("token")
|
||||
if tokenString == "" {
|
||||
return false, ""
|
||||
}
|
||||
name, _ := c.Get("username")
|
||||
loginName, _ = name.(string)
|
||||
return true, loginName
|
||||
claims := &backend.Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return backend.JWTSecret(), nil
|
||||
})
|
||||
|
||||
if err == nil && token.Valid {
|
||||
return true, claims.Username
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// 首页
|
||||
// 首页数据结构
|
||||
type IndexData struct {
|
||||
Title string
|
||||
SiteName string
|
||||
@@ -84,16 +94,18 @@ type IndexData struct {
|
||||
LoginName string
|
||||
}
|
||||
|
||||
// 首页(供模板使用)
|
||||
func IndexView(c *gin.Context) {
|
||||
cfg := getConfig()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := 10
|
||||
pageSize := 5
|
||||
|
||||
categoryID, _ := strconv.Atoi(c.Query("category"))
|
||||
tagID, _ := strconv.Atoi(c.Query("tag"))
|
||||
authorID, _ := strconv.Atoi(c.Query("author"))
|
||||
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published")
|
||||
@@ -105,6 +117,9 @@ func IndexView(c *gin.Context) {
|
||||
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
|
||||
Where("post_tags.tag_id = ?", tagID)
|
||||
}
|
||||
if authorID > 0 {
|
||||
db = db.Where("author_id = ?", authorID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
@@ -114,6 +129,15 @@ func IndexView(c *gin.Context) {
|
||||
db.Order("is_top DESC, published_at DESC, created_at DESC").
|
||||
Offset(offset).Limit(pageSize).Find(&posts)
|
||||
|
||||
// 如果摘要为空,则从内容中提取摘要
|
||||
for i := range posts {
|
||||
p := &posts[i]
|
||||
if p.Summary == "" && p.SearchableText != "" {
|
||||
runes := []rune(p.SearchableText)
|
||||
p.Summary = string(runes[:min(len(runes), 120)])
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||
categories, tags, postCount, categoryCount, tagCount := loadSidebarData()
|
||||
siteName, siteDesc := loadSiteInfo(cfg)
|
||||
@@ -149,15 +173,11 @@ type PostDetailData struct {
|
||||
Description string
|
||||
Post models.Post
|
||||
Pages []models.Page
|
||||
Comments []models.Comment
|
||||
CommentCount int64
|
||||
Year int
|
||||
PrevPost *models.Post // 上一篇
|
||||
NextPost *models.Post // 下一篇
|
||||
// 评论展示设置与分页
|
||||
ShowComments bool
|
||||
CommentPage int
|
||||
CommentTotalPages int
|
||||
ShowComments bool
|
||||
CommentCount int64
|
||||
// Sidebar 数据
|
||||
Categories []models.Category
|
||||
Tags []models.Tag
|
||||
@@ -169,6 +189,7 @@ type PostDetailData struct {
|
||||
LoginName string
|
||||
}
|
||||
|
||||
// 文章详情页(供模板使用)
|
||||
func PostView(c *gin.Context) {
|
||||
cfg := getConfig()
|
||||
slug := c.Param("slug")
|
||||
@@ -200,46 +221,18 @@ func PostView(c *gin.Context) {
|
||||
|
||||
pages := loadPublishedPages()
|
||||
|
||||
// 统计评论数
|
||||
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, models.OptionShowComments, 0); optErr == nil {
|
||||
showComments = v != "0"
|
||||
}
|
||||
commentPageSize := 10
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||
commentPageSize = n
|
||||
}
|
||||
}
|
||||
|
||||
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
|
||||
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
|
||||
if commentPage < 1 {
|
||||
commentPage = 1
|
||||
}
|
||||
var comments []models.Comment
|
||||
commentTotalPages := 0
|
||||
// 统计评论数
|
||||
if showComments {
|
||||
var topCount int64
|
||||
database.DB.Model(&models.Comment{}).
|
||||
Where("post_id = ? AND status = ? AND parent_id IS NULL", 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 commentCount int64
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
|
||||
}
|
||||
var commentCount int64
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", post.ID, "approved").Count(&commentCount)
|
||||
|
||||
// 获取上一篇(发布时间更早的文章)
|
||||
var prevPost models.Post
|
||||
@@ -261,13 +254,9 @@ func PostView(c *gin.Context) {
|
||||
Description: post.Summary,
|
||||
Post: post,
|
||||
Pages: pages,
|
||||
Comments: comments,
|
||||
CommentCount: commentCount,
|
||||
Year: time.Now().Year(),
|
||||
// 评论展示设置与分页
|
||||
ShowComments: showComments,
|
||||
CommentPage: commentPage,
|
||||
CommentTotalPages: commentTotalPages,
|
||||
ShowComments: showComments,
|
||||
CommentCount: commentCount,
|
||||
// Sidebar 数据
|
||||
Categories: categories,
|
||||
Tags: tags,
|
||||
@@ -347,36 +336,105 @@ func PageView(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "page", data)
|
||||
}
|
||||
|
||||
// 提交评论(表单提交)
|
||||
func ListComments(c *gin.Context) {
|
||||
// 统计评论数
|
||||
var commentCount int64
|
||||
postID := c.Param("postID")
|
||||
database.DB.Model(&models.Comment{}).Where("post_id = ? AND status = ?", postID, "approved").Count(&commentCount)
|
||||
|
||||
// 读取评论展示设置
|
||||
showComments := true
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
|
||||
showComments = v != "0"
|
||||
}
|
||||
commentPageSize := 10
|
||||
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||
commentPageSize = n
|
||||
}
|
||||
}
|
||||
|
||||
// 顶级评论分页查询(回复随父评论一起展示,不单独分页)
|
||||
commentPage, _ := strconv.Atoi(c.DefaultQuery("cpage", "1"))
|
||||
if commentPage < 1 {
|
||||
commentPage = 1
|
||||
}
|
||||
var comments []models.Comment
|
||||
commentTotalPages := 0
|
||||
if showComments {
|
||||
var topCount int64
|
||||
database.DB.Model(&models.Comment{}).
|
||||
Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
|
||||
Count(&topCount)
|
||||
commentTotalPages = int(math.Ceil(float64(topCount) / float64(commentPageSize)))
|
||||
if commentTotalPages > 0 && commentPage > commentTotalPages {
|
||||
commentPage = commentTotalPages
|
||||
}
|
||||
database.DB.Where("post_id = ? AND status = ? AND parent_id IS NULL", postID, "approved").
|
||||
Preload("Children", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("status = ?", "approved")
|
||||
}).
|
||||
Order("created_at ASC").
|
||||
Offset((commentPage - 1) * commentPageSize).Limit(commentPageSize).
|
||||
Find(&comments)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": comments,
|
||||
"page": commentPage,
|
||||
"total_pages": commentTotalPages,
|
||||
"total": commentCount,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 创建评论请求
|
||||
type CreateCommentRequest struct {
|
||||
PostID uint `json:"post_id" binding:"required"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Author string `json:"author" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Website string `json:"website"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// 提交评论
|
||||
func SubmitComment(c *gin.Context) {
|
||||
postID, _ := strconv.Atoi(c.PostForm("post_id"))
|
||||
author := c.PostForm("author")
|
||||
email := c.PostForm("email")
|
||||
website := c.PostForm("website")
|
||||
content := c.PostForm("content")
|
||||
var req CreateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文章是否存在
|
||||
var post models.Post
|
||||
if err := database.DB.First(&post, postID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "文章不存在")
|
||||
if err := database.DB.First(&post, req.PostID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := models.Comment{
|
||||
PostID: uint(postID),
|
||||
Author: author,
|
||||
Email: email,
|
||||
Website: website,
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
PostID: req.PostID,
|
||||
ParentID: req.ParentID,
|
||||
Author: req.Author,
|
||||
Email: req.Email,
|
||||
Website: req.Website,
|
||||
Content: req.Content,
|
||||
IP: c.ClientIP(),
|
||||
Status: "pending", // 默认待审核
|
||||
}
|
||||
// 如果用户已登录
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
uid := userID.(uint)
|
||||
comment.UserID = &uid
|
||||
comment.Status = "approved" // 登录用户评论自动通过
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&comment).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "提交失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/post/"+post.Slug)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "评论提交成功"})
|
||||
}
|
||||
|
||||
// 搜索结果页
|
||||
@@ -397,7 +455,7 @@ func SearchView(c *gin.Context) {
|
||||
|
||||
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
|
||||
Where("status = ?", "published").
|
||||
Where("title LIKE ? OR content LIKE ? OR summary LIKE ?",
|
||||
Where("title LIKE ? OR searchable_text LIKE ? OR summary LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
|
||||
var total int64
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"goblog/config"
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
"goblog/routers"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,10 +28,8 @@ func main() {
|
||||
// 设置 Gin 模式
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 初始化日志系统
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
// 初始化日志系统(使用 Server.LogLevel)
|
||||
logger := newLogger(cfg.Server.LogLevel)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("启动博客系统",
|
||||
@@ -30,6 +37,8 @@ func main() {
|
||||
"theme", cfg.App.Theme,
|
||||
"db_driver", cfg.Database.Driver,
|
||||
"port", cfg.Server.Port,
|
||||
"mode", cfg.Server.Mode,
|
||||
"log_level", cfg.Server.LogLevel,
|
||||
)
|
||||
|
||||
// 初始化数据库
|
||||
@@ -37,31 +46,106 @@ func main() {
|
||||
slog.Error("数据库初始化失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// defer database.Close() // 如果提供
|
||||
|
||||
// 从数据库读取主题设置,如果不存在则使用配置文件中的值
|
||||
theme := cfg.App.Theme
|
||||
var option models.Option
|
||||
if err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error; err == nil {
|
||||
theme = option.Value
|
||||
slog.Info("从数据库加载主题", "theme", theme)
|
||||
} else {
|
||||
slog.Info("使用配置文件中的主题", "theme", theme)
|
||||
// 从数据库加载主题(若不存在则使用配置值)
|
||||
theme, err := loadTheme(cfg.App.Theme)
|
||||
if err != nil {
|
||||
slog.Error("加载主题失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 创建路由引擎
|
||||
r := gin.Default()
|
||||
// 创建路由引擎(使用自定义 slog 中间件)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(slogMiddleware(logger))
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// 配置模板渲染器
|
||||
routers.SetupTemplates(r, theme)
|
||||
// 配置模板渲染器(现在 LoadTemplates 返回 error)
|
||||
htmlRenderer, err := utils.LoadTemplates(theme)
|
||||
if err != nil {
|
||||
slog.Error("加载模板失败", "error", err, "theme", theme)
|
||||
os.Exit(1)
|
||||
}
|
||||
utils.SetGlobalRenderer(htmlRenderer)
|
||||
r.HTMLRender = htmlRenderer
|
||||
|
||||
// 配置所有路由
|
||||
routers.SetupRoutes(r)
|
||||
|
||||
// 启动服务器
|
||||
port := routers.GetServerPort(cfg.Server.Port)
|
||||
slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode)
|
||||
if err := r.Run(port); err != nil {
|
||||
slog.Error("服务器启动失败", "error", err)
|
||||
os.Exit(1)
|
||||
// 启动服务器(优雅关闭)
|
||||
runServer(r, cfg.Server.Port)
|
||||
}
|
||||
|
||||
// newLogger 根据配置字符串创建 slog.Logger
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
lvl = slog.LevelInfo // 默认 info
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: lvl}
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
// loadTheme 从数据库读取系统主题,若不存在则使用 fallback
|
||||
func loadTheme(fallback string) (string, error) {
|
||||
var option models.Option
|
||||
err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error
|
||||
if err == nil {
|
||||
slog.Info("从数据库加载主题", "theme", option.Value)
|
||||
return option.Value, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
slog.Info("使用配置文件中的主题", "theme", fallback)
|
||||
return fallback, nil
|
||||
}
|
||||
return "", err // 真实错误必须返回
|
||||
}
|
||||
|
||||
// slogMiddleware 自定义请求日志中间件
|
||||
func slogMiddleware(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
logger.Info("HTTP请求",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"status", c.Writer.Status(),
|
||||
"duration", time.Since(start),
|
||||
"client_ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runServer 启动 HTTP 服务并支持优雅关闭
|
||||
func runServer(r *gin.Engine, port int) {
|
||||
addr := ":" + strconv.Itoa(port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("服务器启动成功", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("服务器启动失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
slog.Info("正在关闭服务器...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("服务器强制关闭", "error", err)
|
||||
} else {
|
||||
slog.Info("服务器已优雅关闭")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,28 +66,3 @@ func AdminRequired() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选认证中间件(用于某些既支持游客又支持登录用户的接口/页面)
|
||||
func OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString := tokenFromContext(c)
|
||||
if tokenString == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
claims := &backend.Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return backend.JWTSecret(), nil
|
||||
})
|
||||
|
||||
if err == nil && token.Valid {
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
+47
-24
@@ -32,7 +32,6 @@ type User struct {
|
||||
Avatar string `gorm:"size:255" json:"avatar"`
|
||||
Role string `gorm:"size:20;default:'user'" json:"role"` // admin, user
|
||||
Status int `gorm:"default:1" json:"status"` // 1:启用 0:禁用
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // 账号激活状态
|
||||
LastLoginAt *time.Time `json:"last_login_at"` // 最后登录时间
|
||||
LoginCount int `gorm:"default:0" json:"login_count"` // 登录次数统计
|
||||
}
|
||||
@@ -72,29 +71,31 @@ func (u *User) RecordLogin(db *gorm.DB) error {
|
||||
// ==================== 文章模型 ====================
|
||||
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Slug string `gorm:"uniqueIndex;size:200" json:"slug"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Summary string `gorm:"size:500" json:"summary"`
|
||||
Cover string `gorm:"size:255" json:"cover"`
|
||||
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
|
||||
Views int `gorm:"default:0" json:"views"`
|
||||
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Password string `gorm:"size:255" json:"password"` // 访问密码保护
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
|
||||
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
|
||||
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
|
||||
Comments []Comment `json:"comments,omitempty"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Slug string `gorm:"uniqueIndex;size:200" json:"slug"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
SearchableText string `gorm:"size:255" json:"searchable_text"`
|
||||
Summary string `gorm:"size:500" json:"summary"`
|
||||
Cover string `gorm:"size:255" json:"cover"`
|
||||
AuthorID uint `gorm:"index" json:"author_id"` // 增加索引
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 增加索引
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags"`
|
||||
Files []File `gorm:"many2many:post_files;" json:"files,omitempty"` // 关联文件
|
||||
Views int `gorm:"default:0" json:"views"`
|
||||
Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Password string `gorm:"size:255" json:"password"` // 访问密码保护
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"` // 是否允许评论
|
||||
AllowPing bool `gorm:"default:true" json:"allow_ping"` // 是否允许Pingback
|
||||
Template string `gorm:"size:100" json:"template"` // 自定义模板文件
|
||||
Comments []Comment `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -262,3 +263,25 @@ type Page struct {
|
||||
func (Page) TableName() string {
|
||||
return "pages"
|
||||
}
|
||||
|
||||
// ==================== 文件模型 ====================
|
||||
|
||||
// File 上传文件记录
|
||||
type File struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 上传者
|
||||
User User `gorm:"foreignKey:UserID" json:"user"` // 上传者信息
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"` // 存储文件名
|
||||
OrigName string `gorm:"size:255" json:"orig_name"` // 原始文件名
|
||||
FilePath string `gorm:"size:500;not null" json:"file_path"` // 相对存储路径(含年月目录)
|
||||
FileSize int64 `gorm:"default:0" json:"file_size"` // 文件大小(字节)
|
||||
MimeType string `gorm:"size:100" json:"mime_type"` // MIME 类型
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (File) TableName() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
+124
-132
@@ -1,151 +1,143 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"goblog/handlers/backend"
|
||||
"goblog/handlers/frontend"
|
||||
"goblog/middleware"
|
||||
"goblog/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
staticPath = "/static"
|
||||
adminPath = "/admin"
|
||||
apiPrefix = "/api"
|
||||
)
|
||||
|
||||
// SetupRoutes 配置所有路由
|
||||
func SetupRoutes(r *gin.Engine) {
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
// 静态资源路由
|
||||
r.Static(staticPath, "./static")
|
||||
// 前台页面
|
||||
setupFrontendRoutes(r)
|
||||
// 后台管理页面
|
||||
setupAdminPageRoutes(r)
|
||||
}
|
||||
|
||||
// 后台管理(放在前面避免冲突)
|
||||
r.Static("/admin/static", "./static/admin")
|
||||
// 独立登录页
|
||||
r.GET("/admin/login", backend.AdminLoginView)
|
||||
// 后台首页:可选认证中间件注入用户信息,服务端按角色渲染菜单与页面框架
|
||||
r.GET("/admin", middleware.OptionalAuth(), backend.AdminView)
|
||||
|
||||
// 前端页面路由(可选认证:侧边栏根据登录态显示系统菜单)
|
||||
r.GET("/", middleware.OptionalAuth(), frontend.IndexView)
|
||||
r.GET("/post/:slug", middleware.OptionalAuth(), frontend.PostView)
|
||||
r.GET("/page/:slug", middleware.OptionalAuth(), frontend.PageView)
|
||||
r.GET("/search", middleware.OptionalAuth(), frontend.SearchView)
|
||||
r.POST("/comment", frontend.SubmitComment)
|
||||
// 前台退出登录:清除 Cookie 后回首页
|
||||
// setupFrontendRoutes 前台页面(服务端渲染)
|
||||
func setupFrontendRoutes(r *gin.Engine) {
|
||||
r.GET("/", frontend.IndexView)
|
||||
r.GET("/post/:slug", frontend.PostView)
|
||||
r.GET("/page/:slug", frontend.PageView)
|
||||
r.GET("/search", frontend.SearchView)
|
||||
// 评论管理
|
||||
r.GET("/posts/:postID/comments", frontend.ListComments)
|
||||
r.POST("/posts/:postID/comment", frontend.SubmitComment)
|
||||
r.GET("/logout", backend.WebLogout)
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 公开 API(支持可选认证,以便管理员查看所有文章)
|
||||
api.GET("/posts", middleware.OptionalAuth(), backend.GetPosts)
|
||||
api.GET("/dashboard/stats", middleware.OptionalAuth(), backend.GetDashboardStats)
|
||||
api.GET("/posts/:id", middleware.OptionalAuth(), backend.GetPost)
|
||||
api.GET("/categories", backend.GetCategories)
|
||||
api.GET("/categories/:id", backend.GetCategory)
|
||||
api.GET("/tags", backend.GetTags)
|
||||
api.GET("/tags/:id", backend.GetTag)
|
||||
api.GET("/pages", backend.GetPages)
|
||||
api.GET("/pages/:id", backend.GetPage)
|
||||
api.GET("/comments", backend.GetComments)
|
||||
|
||||
// 需要认证的 API
|
||||
api.POST("/comments", middleware.OptionalAuth(), backend.CreateComment)
|
||||
|
||||
// 用户认证
|
||||
api.POST("/auth/login", backend.Login)
|
||||
api.POST("/auth/logout", backend.Logout)
|
||||
|
||||
// 需要登录的 API
|
||||
auth := api.Group("/")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
{
|
||||
auth.GET("/auth/me", backend.GetCurrentUser)
|
||||
|
||||
// 仪表盘统计(所有登录用户可访问,handler 内部按角色过滤数据)
|
||||
auth.GET("/dashboard", middleware.OptionalAuth(), backend.GetDashboardStats)
|
||||
auth.PUT("/auth/me", backend.UpdateUser)
|
||||
auth.PUT("/auth/password", backend.ChangePassword)
|
||||
|
||||
// 文章管理(所有登录用户可新建文章,修改/删除自己的文章)
|
||||
auth.POST("/posts", backend.CreatePost)
|
||||
auth.PUT("/posts/:id", backend.UpdatePost)
|
||||
auth.DELETE("/posts/:id", backend.DeletePost)
|
||||
|
||||
// Markdown 预览(文章编辑器用)
|
||||
auth.POST("/markdown/preview", backend.PreviewMarkdown)
|
||||
|
||||
// 文件上传(文章编辑器封面图等)
|
||||
auth.POST("/upload", backend.UploadImage)
|
||||
|
||||
// 评论管理(登录用户管理自己文章的评论)
|
||||
auth.GET("/my/comments", backend.GetComments)
|
||||
auth.PUT("/my/comments/:id/approve", backend.ApproveComment)
|
||||
auth.PUT("/my/comments/:id/spam", backend.MarkSpamComment)
|
||||
auth.DELETE("/my/comments/:id", backend.DeleteComment)
|
||||
}
|
||||
|
||||
// 主题管理(可选认证)
|
||||
api.GET("/themes", backend.GetThemes)
|
||||
api.GET("/theme", backend.GetCurrentTheme)
|
||||
api.POST("/theme", middleware.JWTAuth(), backend.SwitchTheme)
|
||||
api.POST("/themes/create", middleware.JWTAuth(), backend.CreateTheme)
|
||||
api.GET("/themes/:theme/files", middleware.JWTAuth(), backend.GetThemeFiles)
|
||||
api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), backend.GetThemeFile)
|
||||
api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), backend.SaveThemeFile)
|
||||
|
||||
// 管理员 API
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
|
||||
{
|
||||
// Markdown 预览(文章编辑器用)已移至登录组
|
||||
|
||||
// 分类管理
|
||||
admin.POST("/categories", backend.CreateCategory)
|
||||
admin.PUT("/categories/:id", backend.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", backend.DeleteCategory)
|
||||
|
||||
// 标签管理
|
||||
admin.POST("/tags", backend.CreateTag)
|
||||
admin.PUT("/tags/:id", backend.UpdateTag)
|
||||
admin.DELETE("/tags/:id", backend.DeleteTag)
|
||||
|
||||
// 页面管理
|
||||
admin.POST("/pages", backend.CreatePage)
|
||||
admin.PUT("/pages/:id", backend.UpdatePage)
|
||||
admin.DELETE("/pages/:id", backend.DeletePage)
|
||||
|
||||
// 评论管理
|
||||
admin.GET("/comments", backend.GetComments)
|
||||
admin.PUT("/comments/:id/approve", backend.ApproveComment)
|
||||
admin.PUT("/comments/:id/spam", backend.MarkSpamComment)
|
||||
admin.DELETE("/comments/:id", backend.DeleteComment)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", backend.GetUsers)
|
||||
admin.POST("/users", backend.CreateUser)
|
||||
admin.PUT("/users/:id", backend.AdminUpdateUser)
|
||||
admin.PUT("/users/:id/password", backend.AdminResetPassword)
|
||||
admin.DELETE("/users/:id", backend.DeleteUser)
|
||||
|
||||
// 基础设置
|
||||
admin.GET("/settings", backend.GetSettings)
|
||||
admin.PUT("/settings", backend.UpdateSettings)
|
||||
|
||||
// 文件管理(图片上传)
|
||||
admin.POST("/upload", backend.UploadImage)
|
||||
admin.GET("/uploads", backend.ListUploads)
|
||||
admin.DELETE("/uploads/:name", backend.DeleteUpload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTemplates 配置模板渲染器
|
||||
func SetupTemplates(r *gin.Engine, theme string) {
|
||||
htmlRenderer := utils.LoadTemplates(theme)
|
||||
utils.SetGlobalRenderer(htmlRenderer)
|
||||
r.HTMLRender = htmlRenderer
|
||||
// setupAdminPageRoutes 后台管理页面(服务端渲染)
|
||||
func setupAdminPageRoutes(r *gin.Engine) {
|
||||
r.GET(adminPath+"/login", backend.AdminLoginView)
|
||||
r.GET(adminPath, middleware.JWTAuth(), backend.AdminView)
|
||||
// API 路由
|
||||
setupAPIRoutes(r)
|
||||
}
|
||||
|
||||
// GetServerPort 获取服务器端口
|
||||
func GetServerPort(port int) string {
|
||||
return ":" + strconv.Itoa(port)
|
||||
// setupAPIRoutes API 路由分组
|
||||
func setupAPIRoutes(r *gin.Engine) {
|
||||
api := r.Group(apiPrefix)
|
||||
// 登录
|
||||
api.POST("/auth/login", backend.Login)
|
||||
setupAuthenticatedAPIRoutes(api) // 需要 JWT 的接口
|
||||
setupAdminAPIRoutes(api) // 管理员专属接口
|
||||
}
|
||||
|
||||
// setupAuthenticatedAPIRoutes 需要 JWT 的接口(所有登录用户)
|
||||
func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) {
|
||||
auth := api.Group("/")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
|
||||
// 仪表盘公开统计数据
|
||||
auth.GET("/dashboard/stats", backend.GetDashboardStats)
|
||||
// 当前用户信息
|
||||
auth.GET("/auth/me", backend.GetCurrentUser)
|
||||
auth.PUT("/auth/me", backend.UpdateUser)
|
||||
auth.PUT("/auth/password", backend.ChangePassword)
|
||||
auth.POST("/auth/logout", backend.Logout)
|
||||
|
||||
// 文章管理(读取 + 写入)
|
||||
auth.GET("/posts", backend.GetPosts)
|
||||
auth.GET("/posts/:id", backend.GetPost)
|
||||
auth.POST("/posts", backend.CreatePost)
|
||||
auth.PUT("/posts/:id", backend.UpdatePost)
|
||||
auth.DELETE("/posts/:id", backend.DeletePost)
|
||||
|
||||
// 分类读取
|
||||
auth.GET("/categories", backend.GetCategories)
|
||||
|
||||
// 标签读取(文章表单的标签候选,所有登录用户可用)
|
||||
auth.GET("/tags", backend.GetTags)
|
||||
|
||||
// 评论管理(查看所有评论,需登录)
|
||||
auth.GET("/comments", backend.GetComments)
|
||||
auth.GET("/comments/posts", backend.GetCommentPosts) // 有评论的文章列表(筛选下拉框)
|
||||
auth.PUT("/comments/:id/approve", backend.ApproveComment)
|
||||
auth.PUT("/comments/:id/spam", backend.MarkSpamComment)
|
||||
auth.DELETE("/comments/:id", backend.DeleteComment)
|
||||
|
||||
// Markdown 预览
|
||||
auth.POST("/markdown/preview", backend.PreviewMarkdown)
|
||||
|
||||
// 文件上传
|
||||
auth.POST("/upload", backend.UploadImage)
|
||||
auth.GET("/uploads", backend.ListUploads)
|
||||
auth.DELETE("/uploads/:id", backend.DeleteUpload)
|
||||
|
||||
}
|
||||
|
||||
// setupAdminAPIRoutes 管理员专属接口(JWT + AdminRequired)
|
||||
func setupAdminAPIRoutes(api *gin.RouterGroup) {
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
|
||||
|
||||
// 分类管理(写入)
|
||||
admin.POST("/categories", backend.CreateCategory)
|
||||
admin.GET("/categories/:id", backend.GetCategory)
|
||||
admin.PUT("/categories/:id", backend.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", backend.DeleteCategory)
|
||||
|
||||
// 标签管理(写入)
|
||||
admin.GET("/tags", backend.GetTags)
|
||||
admin.POST("/tags", backend.CreateTag)
|
||||
admin.GET("/tags/:id", backend.GetTag)
|
||||
admin.PUT("/tags/:id", backend.UpdateTag)
|
||||
admin.DELETE("/tags/:id", backend.DeleteTag)
|
||||
|
||||
// 页面管理(写入)
|
||||
admin.GET("/pages", backend.GetPages)
|
||||
admin.POST("/pages", backend.CreatePage)
|
||||
admin.GET("/pages/:id", backend.GetPage)
|
||||
admin.PUT("/pages/:id", backend.UpdatePage)
|
||||
admin.DELETE("/pages/:id", backend.DeletePage)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", backend.GetUsers)
|
||||
admin.POST("/users", backend.CreateUser)
|
||||
admin.PUT("/users/:id", backend.AdminUpdateUser)
|
||||
admin.PUT("/users/:id/password", backend.AdminResetPassword)
|
||||
admin.DELETE("/users/:id", backend.DeleteUser)
|
||||
|
||||
// 基础设置
|
||||
admin.GET("/settings", backend.GetSettings)
|
||||
admin.PUT("/settings", backend.UpdateSettings)
|
||||
|
||||
// 主题管理(登录用户可切换主题、创建主题、编辑文件)
|
||||
admin.GET("/themes", backend.GetThemes)
|
||||
admin.GET("/theme", backend.GetCurrentTheme)
|
||||
admin.POST("/theme", backend.SwitchTheme)
|
||||
admin.POST("/themes/create", backend.CreateTheme)
|
||||
admin.GET("/themes/:theme/files", backend.GetThemeFiles)
|
||||
admin.GET("/themes/:theme/files/:file", backend.GetThemeFile)
|
||||
admin.PUT("/themes/:theme/files/:file", backend.SaveThemeFile)
|
||||
}
|
||||
|
||||
@@ -22,20 +22,66 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
.admin-header {
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #001529;
|
||||
color: #fff;
|
||||
align-items: center;
|
||||
}
|
||||
.admin-header #username {
|
||||
margin-left: auto;
|
||||
}
|
||||
.user-menu-wrapper {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.user-menu-wrapper #username {
|
||||
margin-left: 0;
|
||||
}
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 1100;
|
||||
display: none;
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 12px rgba(0,0,0,0.16);
|
||||
}
|
||||
.user-menu-wrapper:hover .user-menu,
|
||||
.user-menu-wrapper:focus-within .user-menu {
|
||||
display: block;
|
||||
}
|
||||
.user-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-menu button:hover {
|
||||
background: #f5f5f5;
|
||||
color: #1890ff;
|
||||
}
|
||||
.admin-header span {
|
||||
background: transparent;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.logo p {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-top: 5px;
|
||||
.admin-header span:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.menu {
|
||||
@@ -97,27 +143,6 @@ body {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: #fff;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info button:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
@@ -1067,6 +1092,22 @@ a.stat-card:hover {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 评论管理页顶部筛选区(文章 + 状态) */
|
||||
.comment-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.comment-status-filter {
|
||||
width: 140px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 基础设置表单 */
|
||||
.settings-form {
|
||||
max-width: 560px;
|
||||
@@ -1104,3 +1145,144 @@ a.stat-card:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 文章标签选择器 ==================== */
|
||||
.tag-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 外观与普通输入框一致,内部容纳标签胶囊与输入框 */
|
||||
.tag-select-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tag-select-box:focus-within {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.tag-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 200px;
|
||||
padding: 2px 4px 2px 10px;
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 12px;
|
||||
background: #e6f4ff;
|
||||
color: #0958d9;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tag-chip-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 标签右上角删除按钮 */
|
||||
.tag-chip-remove {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #0958d9;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-chip-remove:hover {
|
||||
background: #0958d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 输入框:覆盖 .form-group input 的默认样式,融入胶囊容器 */
|
||||
.tag-select-box .tag-input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 140px;
|
||||
width: auto;
|
||||
padding: 4px 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tag-select-box .tag-input:focus {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 候选下拉列表 */
|
||||
.tag-suggest {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.tag-suggest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-suggest-item.active {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.tag-suggest-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-suggest-count {
|
||||
flex-shrink: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 输入内容不存在时,作为新建标签的候选 */
|
||||
.tag-suggest-new .tag-suggest-name {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.tag-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -108,6 +108,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
bindForm('modal-form', handleModalSubmit);
|
||||
bindForm('user-form', handleUserSubmit);
|
||||
bindForm('settings-form', handleSettingsSubmit);
|
||||
bindForm('profile-form', handleProfileSubmit);
|
||||
bindForm('password-form', handlePasswordSubmit);
|
||||
|
||||
// 封面图上传
|
||||
const coverFileInput = document.getElementById('post-cover-file');
|
||||
@@ -115,6 +117,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
coverFileInput.addEventListener('change', handleCoverFileChange);
|
||||
}
|
||||
|
||||
// 文章标签选择器
|
||||
bindTagSelector();
|
||||
|
||||
// 文件管理上传
|
||||
const fileUploadInput = document.getElementById('file-upload-input');
|
||||
if (fileUploadInput) {
|
||||
@@ -122,6 +127,86 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 显示个人信息弹窗
|
||||
function showProfileModal() {
|
||||
document.getElementById('profile-nickname').value = currentUser?.nickname || '';
|
||||
document.getElementById('profile-email').value = currentUser?.email || '';
|
||||
document.getElementById('profile-avatar').value = currentUser?.avatar || '';
|
||||
document.getElementById('profile-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideProfileModal() {
|
||||
document.getElementById('profile-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 保存个人信息
|
||||
async function handleProfileSubmit(e) {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
nickname: document.getElementById('profile-nickname').value.trim(),
|
||||
email: document.getElementById('profile-email').value.trim(),
|
||||
avatar: document.getElementById('profile-avatar').value.trim()
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || '保存失败');
|
||||
return;
|
||||
}
|
||||
currentUser = Object.assign({}, currentUser, body, data.data || {});
|
||||
document.getElementById('username').textContent = currentUser.nickname || currentUser.username;
|
||||
hideProfileModal();
|
||||
alert('个人信息已保存');
|
||||
} catch (err) {
|
||||
alert('保存失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示修改密码弹窗
|
||||
function showPasswordModal() {
|
||||
document.getElementById('password-form').reset();
|
||||
document.getElementById('password-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hidePasswordModal() {
|
||||
document.getElementById('password-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
async function handlePasswordSubmit(e) {
|
||||
e.preventDefault();
|
||||
const oldPassword = document.getElementById('old-password').value;
|
||||
const newPassword = document.getElementById('new-password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/password`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || '修改密码失败');
|
||||
return;
|
||||
}
|
||||
hidePasswordModal();
|
||||
alert(data.message || '密码修改成功');
|
||||
} catch (err) {
|
||||
alert('修改密码失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 安全绑定表单提交(元素不存在时忽略)
|
||||
function bindForm(formId, handler) {
|
||||
const form = document.getElementById(formId);
|
||||
@@ -187,6 +272,7 @@ function showPage(pageName) {
|
||||
loadPages();
|
||||
break;
|
||||
case 'comments':
|
||||
loadCommentPostFilter();
|
||||
loadComments();
|
||||
break;
|
||||
case 'users':
|
||||
@@ -298,7 +384,11 @@ async function showPostForm(isEdit = false) {
|
||||
document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章';
|
||||
document.getElementById('post-form').reset();
|
||||
document.getElementById('post-id').value = '';
|
||||
_coverFileID = null;
|
||||
_contentFileIDs.clear();
|
||||
setCover('');
|
||||
resetTagSelector([]);
|
||||
loadAllTags();
|
||||
mdShowEdit();
|
||||
|
||||
// 非管理员隐藏置顶选项
|
||||
@@ -327,10 +417,19 @@ 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 || '';
|
||||
// 重置文件追踪
|
||||
_coverFileID = null;
|
||||
_contentFileIDs.clear();
|
||||
// 从关联文件中恢复追踪
|
||||
if (post.files && Array.isArray(post.files)) {
|
||||
post.files.forEach(f => _contentFileIDs.add(f.id));
|
||||
}
|
||||
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(', ') || '';
|
||||
// 回填已选标签,并加载全部标签供输入时筛选
|
||||
resetTagSelector(post.tags?.map(t => t.name));
|
||||
loadAllTags();
|
||||
|
||||
// 加载分类并设置选中
|
||||
const catRes = await fetch(`${API_BASE}/categories`);
|
||||
@@ -353,8 +452,9 @@ async function handlePostSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('post-id').value;
|
||||
const tagsStr = document.getElementById('post-tags').value;
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
// 输入框中尚未确认的内容也一并作为标签提交,标签增删由后端统一处理
|
||||
commitTagInput();
|
||||
const tags = getSelectedTags();
|
||||
|
||||
const data = {
|
||||
title: document.getElementById('post-title').value,
|
||||
@@ -364,7 +464,8 @@ async function handlePostSubmit(e) {
|
||||
category_id: parseInt(document.getElementById('post-category').value),
|
||||
status: document.getElementById('post-status').value,
|
||||
is_top: document.getElementById('post-istop').checked,
|
||||
tags: tags
|
||||
tags: tags,
|
||||
file_ids: buildFileIDs()
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -390,6 +491,302 @@ async function handlePostSubmit(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 标签选择器 ====================
|
||||
// 已选标签与候选筛选均在前端完成,保存时把完整标签名列表交给后端处理增删
|
||||
|
||||
// 全部标签(用于输入时筛选候选)
|
||||
let _allTags = [];
|
||||
// 当前文章已选标签名(保持添加顺序)
|
||||
let _selectedTags = [];
|
||||
// 下拉当前展示的候选项
|
||||
let _tagOptions = [];
|
||||
// 键盘高亮的候选索引
|
||||
let _tagActiveIndex = -1;
|
||||
|
||||
// 下拉最多展示条数
|
||||
const TAG_SUGGEST_LIMIT = 20;
|
||||
|
||||
// 拉取全部标签(每次打开文章表单时刷新,保证新建的标签也能被搜到)
|
||||
async function loadAllTags() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tags`, { headers: getHeaders() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
_allTags = (data.data || [])
|
||||
.filter(t => t && t.name)
|
||||
.map(t => ({ name: String(t.name), count: t.post_count || 0 }));
|
||||
} catch (err) {
|
||||
console.error('加载标签失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 规整标签名列表:去空白、忽略大小写去重
|
||||
function normalizeTagNames(names) {
|
||||
const result = [];
|
||||
names.forEach(name => {
|
||||
const value = String(name === null || name === undefined ? '' : name).trim();
|
||||
if (value && !result.some(t => t.toLowerCase() === value.toLowerCase())) {
|
||||
result.push(value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 重置选择器(names 为编辑文章时回填的已有标签)
|
||||
function resetTagSelector(names) {
|
||||
_selectedTags = Array.isArray(names) ? normalizeTagNames(names) : [];
|
||||
const input = document.getElementById('post-tags-input');
|
||||
if (input) input.value = '';
|
||||
renderSelectedTags();
|
||||
hideTagSuggest();
|
||||
}
|
||||
|
||||
// 获取已选标签(提交时使用)
|
||||
function getSelectedTags() {
|
||||
return _selectedTags.slice();
|
||||
}
|
||||
|
||||
// 渲染已选标签胶囊(右上角 × 可移除)
|
||||
function renderSelectedTags() {
|
||||
const box = document.getElementById('post-tags-selected');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
|
||||
_selectedTags.forEach(name => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip';
|
||||
chip.title = name;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tag-chip-name';
|
||||
label.textContent = name;
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'tag-chip-remove';
|
||||
remove.textContent = '×';
|
||||
remove.setAttribute('aria-label', `移除标签 ${name}`);
|
||||
remove.addEventListener('click', () => removeTag(name));
|
||||
|
||||
chip.appendChild(label);
|
||||
chip.appendChild(remove);
|
||||
box.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标签,空值或重复时忽略
|
||||
function addTag(name) {
|
||||
const value = String(name === null || name === undefined ? '' : name).trim();
|
||||
if (!value) return false;
|
||||
if (_selectedTags.some(t => t.toLowerCase() === value.toLowerCase())) return false;
|
||||
_selectedTags.push(value);
|
||||
renderSelectedTags();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
function removeTag(name) {
|
||||
const index = _selectedTags.indexOf(name);
|
||||
if (index === -1) return;
|
||||
_selectedTags.splice(index, 1);
|
||||
renderSelectedTags();
|
||||
// 下拉处于打开状态时重新筛选,被移除的标签可以立即再次选择
|
||||
const suggest = document.getElementById('post-tags-suggest');
|
||||
if (suggest && !suggest.classList.contains('hidden')) {
|
||||
updateTagSuggest();
|
||||
}
|
||||
}
|
||||
|
||||
// 确认输入框内容(支持中英文逗号一次输入多个标签)
|
||||
function commitTagInput() {
|
||||
const input = document.getElementById('post-tags-input');
|
||||
if (!input) return;
|
||||
input.value.split(/[,,]/).forEach(part => addTag(part));
|
||||
input.value = '';
|
||||
hideTagSuggest();
|
||||
}
|
||||
|
||||
// 计算候选项:排除已选标签,按“包含”匹配输入内容;无匹配时把输入内容作为一条新建候选
|
||||
function buildTagOptions(keyword) {
|
||||
const raw = String(keyword || '').trim();
|
||||
const kw = raw.toLowerCase();
|
||||
const selected = new Set(_selectedTags.map(t => t.toLowerCase()));
|
||||
|
||||
const matched = _allTags.filter(t =>
|
||||
!selected.has(t.name.toLowerCase()) && (!kw || t.name.toLowerCase().includes(kw))
|
||||
);
|
||||
const options = matched.slice(0, TAG_SUGGEST_LIMIT)
|
||||
.map(t => ({ name: t.name, count: t.count, isNew: false }));
|
||||
|
||||
// 输入的内容不是已有标签时,作为下拉列表的第一条(保存后由后端创建)
|
||||
if (raw && !selected.has(kw) && !matched.some(t => t.name.toLowerCase() === kw)) {
|
||||
options.unshift({ name: raw, count: 0, isNew: true });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// 按输入框内容刷新下拉候选
|
||||
function updateTagSuggest() {
|
||||
const input = document.getElementById('post-tags-input');
|
||||
if (!input) return;
|
||||
_tagOptions = buildTagOptions(input.value);
|
||||
_tagActiveIndex = _tagOptions.length > 0 ? 0 : -1;
|
||||
renderTagSuggest();
|
||||
}
|
||||
|
||||
// 渲染下拉候选
|
||||
function renderTagSuggest() {
|
||||
const box = document.getElementById('post-tags-suggest');
|
||||
if (!box) return;
|
||||
|
||||
box.innerHTML = '';
|
||||
if (_tagOptions.length === 0) {
|
||||
box.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
_tagOptions.forEach((option, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tag-suggest-item'
|
||||
+ (option.isNew ? ' tag-suggest-new' : '')
|
||||
+ (index === _tagActiveIndex ? ' active' : '');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tag-suggest-name';
|
||||
name.textContent = option.isNew ? `新建标签「${option.name}」` : option.name;
|
||||
item.appendChild(name);
|
||||
|
||||
if (!option.isNew && option.count > 0) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tag-suggest-count';
|
||||
count.textContent = `${option.count} 篇文章`;
|
||||
item.appendChild(count);
|
||||
}
|
||||
|
||||
// mousedown 早于输入框 blur,避免点击时候选已被隐藏
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
chooseTagOption(index);
|
||||
});
|
||||
item.addEventListener('mouseenter', () => {
|
||||
_tagActiveIndex = index;
|
||||
highlightTagOption();
|
||||
});
|
||||
|
||||
box.appendChild(item);
|
||||
});
|
||||
|
||||
box.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 只切换高亮项,避免整个下拉重绘
|
||||
function highlightTagOption() {
|
||||
const box = document.getElementById('post-tags-suggest');
|
||||
if (!box) return;
|
||||
box.querySelectorAll('.tag-suggest-item').forEach((item, index) => {
|
||||
item.classList.toggle('active', index === _tagActiveIndex);
|
||||
});
|
||||
// 键盘移动时保证高亮项可见
|
||||
box.querySelector('.tag-suggest-item.active')?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// 选中一条候选
|
||||
function chooseTagOption(index) {
|
||||
const option = _tagOptions[index];
|
||||
if (!option) return;
|
||||
addTag(option.name);
|
||||
|
||||
const input = document.getElementById('post-tags-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
// 保持下拉打开,便于连续选择多个标签
|
||||
updateTagSuggest();
|
||||
}
|
||||
|
||||
// 收起下拉候选
|
||||
function hideTagSuggest() {
|
||||
const box = document.getElementById('post-tags-suggest');
|
||||
if (box) box.classList.add('hidden');
|
||||
_tagOptions = [];
|
||||
_tagActiveIndex = -1;
|
||||
}
|
||||
|
||||
// 绑定标签选择器交互(文章表单未渲染时跳过)
|
||||
function bindTagSelector() {
|
||||
const input = document.getElementById('post-tags-input');
|
||||
const select = document.getElementById('post-tag-select');
|
||||
if (!input || !select) return;
|
||||
|
||||
// 点击容器空白处也聚焦输入框
|
||||
document.getElementById('post-tag-box')?.addEventListener('mousedown', (e) => {
|
||||
if (e.target === input) return;
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
// 输入逗号即把前面的内容作为标签添加,保留最后一段继续输入
|
||||
if (/[,,]/.test(input.value)) {
|
||||
const parts = input.value.split(/[,,]/);
|
||||
input.value = parts.pop() || '';
|
||||
parts.forEach(part => addTag(part));
|
||||
}
|
||||
updateTagSuggest();
|
||||
});
|
||||
|
||||
// 聚焦时列出已有标签,可直接选择
|
||||
input.addEventListener('focus', () => updateTagSuggest());
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (_tagOptions.length === 0) {
|
||||
updateTagSuggest();
|
||||
return;
|
||||
}
|
||||
_tagActiveIndex = (_tagActiveIndex + 1) % _tagOptions.length;
|
||||
highlightTagOption();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (_tagOptions.length === 0) return;
|
||||
_tagActiveIndex = (_tagActiveIndex - 1 + _tagOptions.length) % _tagOptions.length;
|
||||
highlightTagOption();
|
||||
break;
|
||||
case 'Enter':
|
||||
// 阻止回车提交表单:有高亮项则选中,否则直接采用输入内容
|
||||
e.preventDefault();
|
||||
if (_tagActiveIndex >= 0 && _tagOptions[_tagActiveIndex]) {
|
||||
chooseTagOption(_tagActiveIndex);
|
||||
} else {
|
||||
commitTagInput();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
hideTagSuggest();
|
||||
break;
|
||||
case 'Backspace':
|
||||
// 输入框为空时删除最后一个已选标签
|
||||
if (input.value === '' && _selectedTags.length > 0) {
|
||||
removeTag(_selectedTags[_selectedTags.length - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => hideTagSuggest());
|
||||
|
||||
// 点击选择器外部时收起下拉
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (!select.contains(e.target)) {
|
||||
hideTagSuggest();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Markdown 编辑器 ============
|
||||
|
||||
// 在选区两侧插入包裹符号(加粗、斜体、链接等)
|
||||
@@ -430,6 +827,87 @@ function mdInsertTable() {
|
||||
ta.selectionStart = ta.selectionEnd = start + table.length;
|
||||
}
|
||||
|
||||
// ==================== 图片插入对话框 ====================
|
||||
|
||||
let _imgFile = null; // 当前选择的图片文件
|
||||
|
||||
// 打开图片插入对话框
|
||||
function mdInsertImage() {
|
||||
_imgFile = null;
|
||||
document.getElementById('img-desc').value = '';
|
||||
document.getElementById('img-file-name').textContent = '未选择';
|
||||
document.getElementById('img-preview-box').style.display = 'none';
|
||||
document.getElementById('img-file-input').value = '';
|
||||
document.getElementById('img-upload-btn').disabled = true;
|
||||
document.getElementById('image-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 关闭图片插入对话框
|
||||
function hideImageModal() {
|
||||
document.getElementById('image-modal').classList.add('hidden');
|
||||
_imgFile = null;
|
||||
}
|
||||
|
||||
// 选择图片文件后:校验、预览、启用按钮
|
||||
function onImageFileSelected(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const err = validateImageFile(file);
|
||||
if (err) {
|
||||
alert(err);
|
||||
input.value = '';
|
||||
_imgFile = null;
|
||||
document.getElementById('img-upload-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_imgFile = file;
|
||||
document.getElementById('img-file-name').textContent = file.name;
|
||||
|
||||
// 预览
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('img-preview-thumb').src = e.target.result;
|
||||
document.getElementById('img-preview-box').style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
document.getElementById('img-upload-btn').disabled = false;
|
||||
}
|
||||
|
||||
// 确认上传并插入编辑器
|
||||
async function confirmInsertImage() {
|
||||
if (!_imgFile) return;
|
||||
|
||||
const desc = document.getElementById('img-desc').value.trim() || _imgFile.name.replace(/\.[^.]+$/, '');
|
||||
const btn = document.getElementById('img-upload-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '上传中...';
|
||||
|
||||
try {
|
||||
const data = await uploadImageFile(_imgFile);
|
||||
// 记录文件 ID
|
||||
if (data.id) {
|
||||
_contentFileIDs.add(data.id);
|
||||
}
|
||||
// 插入 Markdown 图片语法到编辑器
|
||||
mdShowEdit();
|
||||
const ta = document.getElementById('post-content');
|
||||
const imgMd = ``;
|
||||
const start = ta.selectionStart;
|
||||
ta.value = ta.value.substring(0, start) + imgMd + ta.value.substring(ta.selectionEnd);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + imgMd.length;
|
||||
|
||||
hideImageModal();
|
||||
} catch (err) {
|
||||
alert('上传失败:' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '上传并插入';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到编辑模式
|
||||
function mdShowEdit() {
|
||||
document.getElementById('post-content').classList.remove('hidden');
|
||||
@@ -552,7 +1030,7 @@ async function loadTags() {
|
||||
// 加载页面
|
||||
async function loadPages() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages`);
|
||||
const res = await fetch(`${API_BASE}/admin/pages`);
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('pages-list');
|
||||
@@ -573,30 +1051,36 @@ async function loadPages() {
|
||||
}
|
||||
}
|
||||
|
||||
// 评论管理 API 基础路径(管理员走 admin 组,普通用户走 my 组)
|
||||
function commentsAPIBase() {
|
||||
return (currentUser && currentUser.role === 'admin') ? `${API_BASE}/admin` : `${API_BASE}/my`;
|
||||
}
|
||||
// 评论列表分页与筛选状态(每页 10 条)
|
||||
const COMMENTS_PAGE_SIZE = 10;
|
||||
let commentsCurrentPage = 1;
|
||||
let commentsCurrentPostId = ''; // 当前按文章筛选的 post_id
|
||||
let commentsCurrentStatus = ''; // 当前按状态筛选的 status
|
||||
|
||||
// 加载评论(postId 为空时加载全部)
|
||||
async function loadComments(postId = '') {
|
||||
// 加载评论列表(分页 + 文章/状态筛选)
|
||||
async function loadComments(page = commentsCurrentPage) {
|
||||
try {
|
||||
const url = postId ? `${commentsAPIBase()}/comments?post_id=${postId}` : `${commentsAPIBase()}/comments`;
|
||||
const res = await fetch(url, { headers: getHeaders() });
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page);
|
||||
params.set('page_size', COMMENTS_PAGE_SIZE);
|
||||
if (commentsCurrentPostId) params.set('post_id', commentsCurrentPostId);
|
||||
if (commentsCurrentStatus) params.set('status', commentsCurrentStatus);
|
||||
|
||||
const res = await fetch(`${API_BASE}/comments?${params.toString()}`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
// 加载全部评论时刷新文章筛选下拉框
|
||||
if (!postId) {
|
||||
populateCommentPostFilter(data.data || []);
|
||||
}
|
||||
|
||||
const list = data.data || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.max(1, data.total_pages || Math.ceil(total / COMMENTS_PAGE_SIZE));
|
||||
commentsCurrentPage = Math.min(Math.max(1, data.page || page), totalPages);
|
||||
|
||||
const tbody = document.getElementById('comments-list');
|
||||
tbody.innerHTML = data.data?.map(c => `
|
||||
tbody.innerHTML = list.map(c => `
|
||||
<tr>
|
||||
<td>${c.author}</td>
|
||||
<td>${c.content.substring(0, 50)}${c.content.length > 50 ? '...' : ''}</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><span class="status-badge status-${c.status}">${getStatusText(c.status)}</span></td>
|
||||
<td>${new Date(c.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
${c.status === 'pending' ? `<button class="btn-success" onclick="approveComment(${c.id})">通过</button>` : ''}
|
||||
@@ -604,11 +1088,28 @@ async function loadComments(postId = '') {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无评论</td></tr>';
|
||||
|
||||
renderCommentsPagination(total, totalPages);
|
||||
} catch (err) {
|
||||
console.error('加载评论失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染评论列表分页条
|
||||
function renderCommentsPagination(total, totalPages) {
|
||||
const box = document.getElementById('comments-pagination');
|
||||
if (!box) return;
|
||||
|
||||
const buttons = [];
|
||||
buttons.push(`<button ${commentsCurrentPage <= 1 ? 'disabled' : ''} onclick="loadComments(${commentsCurrentPage - 1})">上一页</button>`);
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
buttons.push(`<button class="${i === commentsCurrentPage ? 'active' : ''}" onclick="loadComments(${i})">${i}</button>`);
|
||||
}
|
||||
buttons.push(`<button ${commentsCurrentPage >= totalPages ? 'disabled' : ''} onclick="loadComments(${commentsCurrentPage + 1})">下一页</button>`);
|
||||
|
||||
box.innerHTML = buttons.join('') + `<span class="pagination-info">共 ${total} 条,第 ${commentsCurrentPage}/${totalPages} 页</span>`;
|
||||
}
|
||||
|
||||
// 显示分类表单
|
||||
function showCategoryForm() {
|
||||
showModal('category', '新建分类');
|
||||
@@ -727,7 +1228,7 @@ function editTag(id, name, slug) {
|
||||
// 编辑页面
|
||||
async function editPage(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages/${id}`, { headers: getHeaders() });
|
||||
const res = await fetch(`${API_BASE}/admin/pages/${id}`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
const page = data.data;
|
||||
|
||||
@@ -785,7 +1286,7 @@ async function deletePage(id) {
|
||||
|
||||
// 通过评论
|
||||
async function approveComment(id) {
|
||||
await fetch(`${commentsAPIBase()}/comments/${id}/approve`, {
|
||||
await fetch(`${API_BASE}/comments/${id}/approve`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -795,7 +1296,7 @@ async function approveComment(id) {
|
||||
// 删除评论
|
||||
async function deleteComment(id) {
|
||||
if (!confirm('确定要删除这条评论吗?')) return;
|
||||
await fetch(`${commentsAPIBase()}/comments/${id}`, {
|
||||
await fetch(`${API_BASE}/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -808,7 +1309,9 @@ function getStatusText(status) {
|
||||
'published': '已发布',
|
||||
'draft': '草稿',
|
||||
'private': '私密',
|
||||
'pending': '待审核'
|
||||
'pending': '待审核',
|
||||
'approved': '已通过',
|
||||
'spam': '垃圾'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
@@ -850,33 +1353,43 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// 填充评论筛选下拉框(所有有评论的文章)
|
||||
function populateCommentPostFilter(comments) {
|
||||
// 加载评论筛选下拉框(所有有评论的文章,来自后端接口而非当前页)
|
||||
async function loadCommentPostFilter() {
|
||||
const select = document.getElementById('comment-post-filter');
|
||||
if (!select) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/comments/posts`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
// 按文章去重
|
||||
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>';
|
||||
(data.data || []).forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.title;
|
||||
opt.title = p.title; // 悬停显示完整标题
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// 恢复当前选中的文章;若已不存在则重置为全部
|
||||
select.value = commentsCurrentPostId;
|
||||
if (select.value !== commentsCurrentPostId) {
|
||||
commentsCurrentPostId = '';
|
||||
select.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
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 = '';
|
||||
} catch (err) {
|
||||
console.error('加载文章筛选失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 按选中文章过滤评论列表
|
||||
function filterCommentsByPost() {
|
||||
loadComments(document.getElementById('comment-post-filter').value);
|
||||
commentsCurrentPostId = document.getElementById('comment-post-filter').value;
|
||||
loadComments(1);
|
||||
}
|
||||
|
||||
// 按选中状态过滤评论列表
|
||||
function filterCommentsByStatus() {
|
||||
commentsCurrentStatus = document.getElementById('comment-status-filter').value;
|
||||
loadComments(1);
|
||||
}
|
||||
|
||||
// 加载基础设置
|
||||
@@ -1093,14 +1606,14 @@ let currentTheme = null;
|
||||
async function loadThemes() {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentRes = await fetch(`${API_BASE}/theme`, {
|
||||
const currentRes = await fetch(`${API_BASE}/admin/theme`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const currentData = await currentRes.json();
|
||||
currentTheme = currentData.theme;
|
||||
|
||||
// 获取所有可用主题
|
||||
const themesRes = await fetch(`${API_BASE}/themes`, {
|
||||
const themesRes = await fetch(`${API_BASE}/admin/themes`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const themesData = await themesRes.json();
|
||||
@@ -1172,7 +1685,7 @@ async function switchTheme() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
const response = await fetch(`${API_BASE}/admin/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
@@ -1219,7 +1732,7 @@ async function showCreateThemeModal() {
|
||||
const themeSelect = document.getElementById('theme-template');
|
||||
try {
|
||||
// 获取所有可用主题
|
||||
const res = await fetch(`${API_BASE}/themes`, {
|
||||
const res = await fetch(`${API_BASE}/admin/themes`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const themesData = await res.json();
|
||||
@@ -1270,7 +1783,7 @@ async function handleCreateTheme(e) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/create`, {
|
||||
const response = await fetch(`${API_BASE}/admin/themes/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
@@ -1304,7 +1817,7 @@ async function handleCreateTheme(e) {
|
||||
// 快速切换主题
|
||||
async function quickSwitch(theme) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/theme`, {
|
||||
const response = await fetch(`${API_BASE}/admin/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
@@ -1352,7 +1865,7 @@ async function editTheme(themeName) {
|
||||
// 加载主题文件列表
|
||||
async function loadThemeFiles(themeName) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${themeName}/files`, {
|
||||
const response = await fetch(`${API_BASE}/admin/themes/${themeName}/files`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -1404,7 +1917,7 @@ async function loadFileContent(filename) {
|
||||
try {
|
||||
currentEditingFile = filename;
|
||||
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${filename}`, {
|
||||
const response = await fetch(`${API_BASE}/admin/themes/${currentEditingTheme}/files/${filename}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -1437,7 +1950,7 @@ async function saveCurrentFile() {
|
||||
const content = document.getElementById('theme-file-editor').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/themes/${currentEditingTheme}/files/${currentEditingFile}`, {
|
||||
const response = await fetch(`${API_BASE}/admin/themes/${currentEditingTheme}/files/${currentEditingFile}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
@@ -1453,11 +1966,11 @@ async function saveCurrentFile() {
|
||||
|
||||
// 如果是模板文件,重新加载主题
|
||||
if (currentEditingFile.endsWith('.html')) {
|
||||
const renderer = await fetch(`${API_BASE}/theme`, { headers: getHeaders() });
|
||||
const renderer = await fetch(`${API_BASE}/admin/theme`, { headers: getHeaders() });
|
||||
const rendererData = await renderer.json();
|
||||
if (rendererData.theme === currentEditingTheme) {
|
||||
// 重新加载当前主题以应用更改
|
||||
await fetch(`${API_BASE}/theme`, {
|
||||
await fetch(`${API_BASE}/admin/theme`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
@@ -1478,6 +1991,11 @@ async function saveCurrentFile() {
|
||||
|
||||
// ==================== 文件上传与管理 ====================
|
||||
|
||||
// 当前封面文件 ID(上传封面时记录)
|
||||
let _coverFileID = null;
|
||||
// 正文中插入的图片文件 ID 集合
|
||||
let _contentFileIDs = new Set();
|
||||
|
||||
// 允许的图片类型与大小限制(与后端保持一致)
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
@@ -1526,6 +2044,7 @@ async function handleCoverFileChange(e) {
|
||||
|
||||
try {
|
||||
const data = await uploadImageFile(file);
|
||||
_coverFileID = data.id || null;
|
||||
setCover(data.url);
|
||||
} catch (uErr) {
|
||||
alert(uErr.message);
|
||||
@@ -1553,13 +2072,23 @@ function setCover(url) {
|
||||
|
||||
// 移除封面图
|
||||
function removeCover() {
|
||||
_coverFileID = null;
|
||||
setCover('');
|
||||
}
|
||||
|
||||
// 构建提交时的 file_ids(封面 + 正文图片,去重)
|
||||
function buildFileIDs() {
|
||||
const ids = new Set(_contentFileIDs);
|
||||
if (_coverFileID) {
|
||||
ids.add(_coverFileID);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
// 加载已上传文件列表
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/uploads`, { headers: getHeaders() });
|
||||
const res = await fetch(`${API_BASE}/uploads`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('file-grid');
|
||||
|
||||
@@ -1568,19 +2097,33 @@ async function loadFiles() {
|
||||
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">
|
||||
grid.innerHTML = data.data.map(f => {
|
||||
// 根据是否关联文章显示不同操作
|
||||
let actionsHTML;
|
||||
if (f.post_title) {
|
||||
// 已关联文章,显示文章标题
|
||||
actionsHTML = `<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>
|
||||
<span class="file-linked">已关联: <a href="#/post/edit/${f.post_id}" title="查看文章">${f.post_title}</a></span>
|
||||
</div>`;
|
||||
} else {
|
||||
// 未关联,显示删除按钮
|
||||
actionsHTML = `<div class="file-actions">
|
||||
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteFile(${f.id})">删除</button>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="file-card">
|
||||
<div class="file-thumb"><img src="${f.url}" alt="${f.orig_name || f.name}" loading="lazy"></div>
|
||||
<div class="file-info">
|
||||
<div class="file-name" title="${f.orig_name || f.name}">${f.orig_name || f.name}</div>
|
||||
<div class="file-meta">${formatFileSize(f.size)} · ${f.created_at}</div>
|
||||
</div>
|
||||
${actionsHTML}
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('加载文件失败:', err);
|
||||
}
|
||||
@@ -1609,11 +2152,11 @@ async function handleFileUpload(e) {
|
||||
}
|
||||
|
||||
// 删除已上传文件
|
||||
async function deleteFile(name) {
|
||||
async function deleteFile(id) {
|
||||
if (!confirm('确定要删除这个文件吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/uploads/${encodeURIComponent(name)}`, {
|
||||
const res = await fetch(`${API_BASE}/uploads/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 前台评论 AJAX 功能
|
||||
*/
|
||||
|
||||
// 评论分页加载
|
||||
async function loadComments(postID, page = 1) {
|
||||
try {
|
||||
const url = `/posts/${postID}/comments?cpage=${page}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('加载评论失败');
|
||||
|
||||
const data = await res.json();
|
||||
renderComments(data.data || []);
|
||||
renderCommentPagination(data.page, data.total_pages, postID);
|
||||
} catch (err) {
|
||||
console.error('加载评论失败:', err);
|
||||
document.querySelector('.comments-list').innerHTML = '<p class="no-comments">评论加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染评论列表
|
||||
function renderComments(comments) {
|
||||
const listEl = document.querySelector('.comments-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!comments || comments.length === 0) {
|
||||
listEl.innerHTML = '<p class="no-comments">暂无评论,来发表第一条评论吧!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = comments.map(c => buildCommentHTML(c)).join('');
|
||||
}
|
||||
|
||||
// 构建单条评论 HTML
|
||||
function buildCommentHTML(comment) {
|
||||
let html = `
|
||||
<div class="comment-item" id="comment-${comment.id}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
${comment.website ? `<a href="${escapeHtml(comment.website)}" target="_blank">${escapeHtml(comment.author)}</a>` : escapeHtml(comment.author)}
|
||||
</span>
|
||||
<span class="comment-date">${formatDate(comment.created_at)}</span>
|
||||
</div>
|
||||
<div class="comment-content">${escapeHtml(comment.content)}</div>
|
||||
`;
|
||||
|
||||
// 子评论
|
||||
if (comment.children && comment.children.length > 0) {
|
||||
html += '<div class="comment-children">';
|
||||
comment.children.forEach(child => {
|
||||
html += `
|
||||
<div class="comment-item child" id="comment-${child.id}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">${escapeHtml(child.author)}</span>
|
||||
<span class="comment-date">${formatDate(child.created_at)}</span>
|
||||
</div>
|
||||
<div class="comment-content">${escapeHtml(child.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// 渲染评论分页
|
||||
function renderCommentPagination(currentPage, totalPages, postID) {
|
||||
const paginationEl = document.querySelector('.comment-pagination');
|
||||
if (!paginationEl) return;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
paginationEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
paginationEl.style.display = 'block';
|
||||
let html = '';
|
||||
|
||||
if (currentPage > 1) {
|
||||
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage - 1})" class="prev">← 上一页</a>`;
|
||||
}
|
||||
|
||||
html += `<span class="page-info">${currentPage} / ${totalPages}</span>`;
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
html += `<a href="javascript:void(0)" onclick="loadComments('${postID}', ${currentPage + 1})" class="next">下一页 →</a>`;
|
||||
}
|
||||
|
||||
paginationEl.innerHTML = html;
|
||||
}
|
||||
|
||||
// 提交评论(AJAX)
|
||||
async function submitComment(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.post_id = Number(data.post_id);
|
||||
// 简单验证
|
||||
if (!data.author || !data.email || !data.content) {
|
||||
alert('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
// 邮箱格式验证
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.email)) {
|
||||
alert('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '提交中...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/posts/${data.post_id}/comment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('评论提交成功,等待审核');
|
||||
form.reset();
|
||||
// 重新加载评论(回到第一页)
|
||||
const postID = form.querySelector('input[name="post_id"]').value;
|
||||
loadComments(postID, 1);
|
||||
} else {
|
||||
alert(result.error || '提交失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交评论失败:', err);
|
||||
alert('网络错误,请稍后重试');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '发表评论';
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数:转义 HTML
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 工具函数:格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 绑定评论表单提交事件
|
||||
const commentForm = document.querySelector('.comment-form');
|
||||
if (commentForm) {
|
||||
commentForm.addEventListener('submit', submitComment);
|
||||
}
|
||||
|
||||
// 如果当前是文章详情页,使用表单中的数字 postID 加载评论
|
||||
const postIDInput = commentForm && commentForm.querySelector('input[name="post_id"]');
|
||||
if (postIDInput && postIDInput.value) {
|
||||
// 从 URL 获取初始页码
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const cpage = parseInt(urlParams.get('cpage')) || 1;
|
||||
loadComments(postIDInput.value, cpage);
|
||||
}
|
||||
});
|
||||
@@ -5,15 +5,24 @@
|
||||
<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?v=3">
|
||||
<link rel="stylesheet" href="/static/css/admin.css?v=5">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-header">
|
||||
<h2>GoBlog</h2>
|
||||
<div class="user-menu-wrapper">
|
||||
<span id="username" tabindex="0" aria-haspopup="true">{{.DisplayName}}</span>
|
||||
<div class="user-menu" role="menu">
|
||||
<button type="button" onclick="showProfileModal()">修改个人信息</button>
|
||||
<button type="button" onclick="showPasswordModal()">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="logout-btn">退出</span>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>GoBlog</h2>
|
||||
<p>后台管理</p>
|
||||
</div>
|
||||
|
||||
<nav class="menu">
|
||||
{{range .Menus}}{{if eq .Group ""}}{{range $i, $item := .Items}}<a href="#{{$item.Key}}" class="menu-item{{if eq $item.Key "dashboard"}} active{{end}}" data-page="{{$item.Key}}">{{$item.Name}}</a>
|
||||
{{end}}{{else}}
|
||||
@@ -26,10 +35,6 @@
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
</nav>
|
||||
<div class="user-info">
|
||||
<span id="username">{{.DisplayName}}</span>
|
||||
<button id="logout-btn">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
@@ -49,13 +54,67 @@
|
||||
{{if or (index .PageSet "categories") (index .PageSet "tags") (index .PageSet "pages")}}{{template "admin_modal_common" .}}{{end}}
|
||||
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
|
||||
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
|
||||
{{template "admin_modal_profile" .}}
|
||||
{{template "admin_modal_password" .}}
|
||||
|
||||
<script>window.__currentUser = {{.CurrentUser}};</script>
|
||||
<script src="/static/admin/app.js?v=21"></script>
|
||||
<script>window.__currentUser = {{.CurrentUser }}</script>
|
||||
<script src="/static/js/admin.js?v=23"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_profile"}}
|
||||
<div id="profile-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>修改个人信息</h3>
|
||||
<form id="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="profile-nickname">昵称</label>
|
||||
<input type="text" id="profile-nickname" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-email">邮箱</label>
|
||||
<input type="email" id="profile-email" maxlength="100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-avatar">头像地址</label>
|
||||
<input type="url" id="profile-avatar" maxlength="255" placeholder="https://">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideProfileModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_modal_password"}}
|
||||
<div id="password-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>修改密码</h3>
|
||||
<form id="password-form">
|
||||
<div class="form-group">
|
||||
<label for="old-password">旧密码</label>
|
||||
<input type="password" id="old-password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">新密码</label>
|
||||
<input type="password" id="new-password" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">确认新密码</label>
|
||||
<input type="password" id="confirm-password" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hidePasswordModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_dashboard"}}
|
||||
<!-- 仪表盘 -->
|
||||
<div id="dashboard-page" class="page">
|
||||
@@ -134,8 +193,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>标签(用逗号分隔)</label>
|
||||
<input type="text" id="post-tags" placeholder="标签1, 标签2, 标签3">
|
||||
<label>标签</label>
|
||||
<div class="tag-select" id="post-tag-select">
|
||||
<div class="tag-select-box" id="post-tag-box">
|
||||
<span class="tag-chips" id="post-tags-selected"></span>
|
||||
<input type="text" id="post-tags-input" class="tag-input" placeholder="输入标签名,回车添加" autocomplete="off">
|
||||
</div>
|
||||
<div class="tag-suggest hidden" id="post-tags-suggest"></div>
|
||||
</div>
|
||||
<small class="tag-hint">点击标签右上角 × 可移除;输入的内容若不存在,将作为新标签在保存时创建</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>封面图</label>
|
||||
@@ -164,7 +230,7 @@
|
||||
<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="mdInsertImage()">🖼</button>
|
||||
<button type="button" title="行内代码" onclick="mdWrap('`','`','代码')"></></button>
|
||||
<button type="button" title="代码块" onclick="mdWrap('\n```\n','\n```\n','代码块')">{ }</button>
|
||||
<button type="button" title="引用" onclick="mdInsertLine('> ','引用内容')">”</button>
|
||||
@@ -191,6 +257,32 @@
|
||||
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 图片插入对话框 -->
|
||||
<div id="image-modal" class="modal hidden">
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<h3>插入图片</h3>
|
||||
<div class="form-group">
|
||||
<label>图片描述</label>
|
||||
<input type="text" id="img-desc" placeholder="输入图片描述(alt 文本)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>选择图片</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-secondary" onclick="document.getElementById('img-file-input').click()">选择文件</button>
|
||||
<input type="file" id="img-file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" onchange="onImageFileSelected(this)">
|
||||
<span id="img-file-name" style="color:#888;font-size:13px">未选择</span>
|
||||
</div>
|
||||
<div id="img-preview-box" style="margin-top:8px;display:none">
|
||||
<img id="img-preview-thumb" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #ddd">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideImageModal()">取消</button>
|
||||
<button type="button" class="btn-primary" id="img-upload-btn" onclick="confirmInsertImage()" disabled>上传并插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_page_categories"}}
|
||||
@@ -219,7 +311,6 @@
|
||||
<div id="tags-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>标签管理</h1>
|
||||
<button class="btn-primary" onclick="showTagForm()">新建标签</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -262,9 +353,17 @@
|
||||
<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 class="comment-filters">
|
||||
<select id="comment-post-filter" class="comment-post-filter" onchange="filterCommentsByPost()">
|
||||
<option value="">全部文章</option>
|
||||
</select>
|
||||
<select id="comment-status-filter" class="comment-status-filter" onchange="filterCommentsByStatus()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="spam">垃圾</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -279,6 +378,7 @@
|
||||
</thead>
|
||||
<tbody id="comments-list"></tbody>
|
||||
</table>
|
||||
<div id="comments-pagination" class="pagination"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -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?v=3">
|
||||
<link rel="stylesheet" href="/static/css/admin.css?v=4">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-page">
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/layui.css">
|
||||
<script src="/static/js/frontend.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
{{if .Posts}}
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
<div class="post-cover">
|
||||
<a href="/post/{{.Slug}}">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}">
|
||||
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Post.Cover}}
|
||||
<div class="post-cover">
|
||||
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
|
||||
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | markdown}}
|
||||
@@ -62,9 +60,9 @@
|
||||
|
||||
{{if .ShowComments}}
|
||||
<section class="comments-section" id="comments">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<form class="comment-form" action="/api/comments" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
@@ -82,50 +80,12 @@
|
||||
</form>
|
||||
|
||||
<div class="comments-list">
|
||||
{{range .Comments}}
|
||||
<div class="comment-item" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
{{if .Website}}
|
||||
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
|
||||
{{else}}
|
||||
{{.Author}}
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
|
||||
{{if .Children}}
|
||||
<div class="comment-children">
|
||||
{{range .Children}}
|
||||
<div class="comment-item child" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{.Author}}</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
|
||||
{{end}}
|
||||
<!-- 评论由 AJAX 动态加载 -->
|
||||
</div>
|
||||
|
||||
{{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 class="pagination comment-pagination" style="display:none">
|
||||
<!-- 分页由 AJAX 动态渲染 -->
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<ul class="system-menu">
|
||||
{{if .IsLoggedIn}}
|
||||
<li class="system-menu-item">
|
||||
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
|
||||
<a href="/admin" class="system-menu-link">🔐 进入后台^_^</a>
|
||||
</li>
|
||||
<li class="system-menu-item">
|
||||
<a href="/logout" class="system-menu-link">🚪 退出登录{{if .LoginName}}({{.LoginName}}){{end}}</a>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/frontend.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
<div class="post-list">
|
||||
{{range .Posts}}
|
||||
<article class="post-item">
|
||||
{{if .Cover}}
|
||||
<div class="post-cover">
|
||||
<a href="/post/{{.Slug}}">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}">
|
||||
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="post-content">
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Post.Cover}}
|
||||
<div class="post-cover">
|
||||
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
|
||||
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="post-body">
|
||||
{{.Post.Content | markdown}}
|
||||
@@ -61,9 +59,9 @@
|
||||
|
||||
{{if .ShowComments}}
|
||||
<section class="comments-section" id="comments">
|
||||
<h3>评论 ({{.CommentCount}})</h3>
|
||||
<h3>评论 (<span id="comment-count">{{.CommentCount}}</span>)</h3>
|
||||
|
||||
<form class="comment-form" action="/comment" method="POST">
|
||||
<form class="comment-form" action="/api/comments" method="POST">
|
||||
<input type="hidden" name="post_id" value="{{.Post.ID}}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="author" placeholder="昵称" required>
|
||||
@@ -81,50 +79,12 @@
|
||||
</form>
|
||||
|
||||
<div class="comments-list">
|
||||
{{range .Comments}}
|
||||
<div class="comment-item" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
{{if .Website}}
|
||||
<a href="{{.Website}}" target="_blank">{{.Author}}</a>
|
||||
{{else}}
|
||||
{{.Author}}
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
|
||||
{{if .Children}}
|
||||
<div class="comment-children">
|
||||
{{range .Children}}
|
||||
<div class="comment-item child" id="comment-{{.ID}}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{.Author}}</span>
|
||||
<span class="comment-date">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{.Content}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="no-comments">暂无评论,来发表第一条评论吧!</p>
|
||||
{{end}}
|
||||
<!-- 评论由 AJAX 动态加载 -->
|
||||
</div>
|
||||
|
||||
{{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 class="pagination comment-pagination" style="display:none">
|
||||
<!-- 分页由 AJAX 动态渲染 -->
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -3,10 +3,13 @@ package utils
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
// markdownEngine 全局 Markdown 渲染引擎
|
||||
@@ -20,6 +23,10 @@ var markdownEngine = goldmark.New(
|
||||
),
|
||||
)
|
||||
|
||||
// searchableTextParser 复用同一套扩展配置,但只做解析,不渲染。
|
||||
// 与 markdownEngine 共享 goldmark 实例的 Parser,避免重复构造解析器开销。
|
||||
var searchableTextParser = markdownEngine.Parser()
|
||||
|
||||
// RenderMarkdown 将 Markdown 文本渲染为 HTML
|
||||
func RenderMarkdown(source string) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
@@ -29,3 +36,77 @@ func RenderMarkdown(source string) template.HTML {
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// GenerateSearchableText 提取 Markdown 中的可见纯文本(基于 goldmark AST)
|
||||
func GenerateSearchableText(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 全角空格转半角
|
||||
raw = strings.ReplaceAll(raw, "\u3000", " ")
|
||||
|
||||
source := []byte(raw)
|
||||
doc := searchableTextParser.Parse(text.NewReader(source))
|
||||
|
||||
var sb strings.Builder
|
||||
extractSearchableText(doc, source, &sb)
|
||||
|
||||
// 压缩空白(等价于原 reMultiSpace + TrimSpace)
|
||||
return strings.Join(strings.Fields(sb.String()), " ")
|
||||
}
|
||||
|
||||
// extractSearchableText 深度优先遍历 AST,把可见文本写入 sb
|
||||
func extractSearchableText(node ast.Node, source []byte, sb *strings.Builder) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Image:
|
||||
// 图片:整棵子树跳过(含 alt 文本),对应原 reImage / reRefImage
|
||||
return
|
||||
|
||||
case *ast.HTMLBlock, *ast.RawHTML:
|
||||
// 原始 HTML:跳过,对应原 reHTMLTag
|
||||
return
|
||||
|
||||
case *ast.AutoLink:
|
||||
// 自动链接 <url> / <email>:保留 URL / 邮箱
|
||||
// n.URL(source) 返回链接目标;邮箱自动链接会带 mailto: 前缀,这里去掉
|
||||
url := string(n.URL(source))
|
||||
url = strings.TrimPrefix(url, "mailto:")
|
||||
sb.WriteString(url)
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
|
||||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||||
lines := n.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
seg := lines.At(i)
|
||||
sb.Write(seg.Value(source))
|
||||
}
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
|
||||
case *ast.Text:
|
||||
sb.Write(n.Segment.Value(source))
|
||||
if n.SoftLineBreak() || n.HardLineBreak() {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
return
|
||||
|
||||
case *ast.String:
|
||||
// 代码块 / 部分扩展产生的字符串节点
|
||||
sb.Write(n.Value)
|
||||
return
|
||||
}
|
||||
|
||||
// 递归子节点
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
extractSearchableText(child, source, sb)
|
||||
}
|
||||
|
||||
// 块级元素结束后补一个空格,避免相邻段落/列表项文本粘连
|
||||
switch node.Kind() {
|
||||
case ast.KindParagraph, ast.KindHeading, ast.KindListItem,
|
||||
ast.KindBlockquote, ast.KindList:
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
+30
-29
@@ -67,12 +67,14 @@ func (r *htmlRender) WriteContentType(w http.ResponseWriter) {
|
||||
}
|
||||
|
||||
// LoadTemplates 加载指定主题的模板
|
||||
func LoadTemplates(theme string) *TemplateRenderer {
|
||||
func LoadTemplates(theme string) (*TemplateRenderer, error) {
|
||||
r := &TemplateRenderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
r.reload(theme)
|
||||
return r
|
||||
if err := r.reload(theme); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Reload 重新加载主题模板(线程安全)
|
||||
@@ -83,34 +85,35 @@ func (r *TemplateRenderer) Reload(theme string) {
|
||||
}
|
||||
|
||||
// reload 内部重载方法(需要持有锁)
|
||||
func (r *TemplateRenderer) reload(theme string) {
|
||||
func (r *TemplateRenderer) reload(theme string) error {
|
||||
templateDir := "templates/" + theme
|
||||
|
||||
// 需要独立渲染的页面模板
|
||||
pages := []string{"index", "post", "page"}
|
||||
|
||||
// 收集主题目录下的公共局部模板(例如 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)
|
||||
matches, err := filepath.Glob(templateDir + "/*.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描主题目录失败: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range matches {
|
||||
base := filepath.Base(f)
|
||||
if base == "base.html" {
|
||||
continue
|
||||
}
|
||||
name := base[:len(base)-len(".html")]
|
||||
isPage := false
|
||||
for _, p := range pages {
|
||||
if p == name {
|
||||
isPage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isPage {
|
||||
partials = append(partials, f)
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个页面创建独立的模板组合
|
||||
@@ -125,19 +128,17 @@ func (r *TemplateRenderer) reload(theme string) {
|
||||
)
|
||||
allFiles = append(allFiles, partials...)
|
||||
|
||||
// 解析模板文件
|
||||
// 解析模板文件,遇到错误立即返回
|
||||
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...)
|
||||
if err != nil {
|
||||
// 如果解析失败,记录错误但不中断
|
||||
fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err)
|
||||
continue
|
||||
return fmt.Errorf("解析页面模板 %s 失败: %w", pageName, err)
|
||||
}
|
||||
newTemplates[pageName] = tmpl
|
||||
}
|
||||
|
||||
// 更新模板映射
|
||||
// 所有页面模板解析成功后才更新映射
|
||||
r.templates = newTemplates
|
||||
fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 全局模板渲染器实例
|
||||
|
||||
Reference in New Issue
Block a user