增加用户管理
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// 一次性迁移工具:将远程 Typecho 数据库 typecho_contents 表中的文章(post)和页面(page)
|
||||
// 迁移到本地 goblog.db 的 posts / pages 表。slug 相同的记录执行覆盖更新。
|
||||
// 用法: go run ./cmd/migrate_typecho -host 47.93.160.42 -port 3630 -user blog -pass 'xxx'
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goblog/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// typechoContent 对应 typecho_contents 表的一行
|
||||
type typechoContent struct {
|
||||
CID int64
|
||||
Title sql.NullString
|
||||
Slug sql.NullString
|
||||
Created int64
|
||||
Modified int64
|
||||
Text sql.NullString
|
||||
Order int64
|
||||
Template sql.NullString
|
||||
Type string
|
||||
Status string
|
||||
Password sql.NullString
|
||||
AllowComment string
|
||||
AllowPing string
|
||||
}
|
||||
|
||||
func main() {
|
||||
host := flag.String("host", "47.93.160.42", "MySQL主机")
|
||||
port := flag.Int("port", 3630, "MySQL端口")
|
||||
user := flag.String("user", "blog", "MySQL用户名")
|
||||
pass := flag.String("pass", "qI7=bL4@iJ", "MySQL密码")
|
||||
dbName := flag.String("db", "typecho", "typecho数据库名(留空自动探测)")
|
||||
sqlitePath := flag.String("sqlite", "goblog.db", "本地SQLite文件路径")
|
||||
dryRun := flag.Bool("dry-run", false, "只对比统计,不写入")
|
||||
flag.Parse()
|
||||
|
||||
// ---------- 连接远程 MySQL ----------
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.User = *user
|
||||
cfg.Passwd = *pass
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = fmt.Sprintf("%s:%d", *host, *port)
|
||||
cfg.DBName = *dbName
|
||||
cfg.Timeout = 10 * time.Second
|
||||
cfg.ReadTimeout = 30 * time.Second
|
||||
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||
|
||||
mysqlDB, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("MySQL DSN错误: %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
if err = mysqlDB.Ping(); err != nil {
|
||||
log.Fatalf("MySQL连接失败: %v", err)
|
||||
}
|
||||
log.Printf("MySQL连接成功: %s", cfg.Addr)
|
||||
|
||||
// 自动探测包含 typecho_contents 的数据库
|
||||
schema := *dbName
|
||||
if schema == "" {
|
||||
err = mysqlDB.QueryRow(
|
||||
"SELECT table_schema FROM information_schema.tables WHERE table_name = 'typecho_contents' LIMIT 1",
|
||||
).Scan(&schema)
|
||||
if err != nil {
|
||||
log.Fatalf("未找到 typecho_contents 表: %v", err)
|
||||
}
|
||||
log.Printf("探测到typecho数据库: %s", schema)
|
||||
}
|
||||
|
||||
// ---------- 连接本地 SQLite ----------
|
||||
localDB, err := gorm.Open(sqlite.Open(*sqlitePath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("SQLite连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 迁移文章归属到本地第一个管理员账号
|
||||
var author models.User
|
||||
if err = localDB.Where("role = ?", "admin").First(&author).Error; err != nil {
|
||||
if err = localDB.First(&author).Error; err != nil {
|
||||
log.Fatalf("本地无任何用户,无法确定文章作者: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("迁移内容将归属本地用户: %s (ID=%d)", author.Username, author.ID)
|
||||
|
||||
// ---------- 读取远程内容 ----------
|
||||
rows, err := mysqlDB.Query(fmt.Sprintf(
|
||||
"SELECT cid, title, slug, created, modified, text, `order`, template, type, status, password, allowComment, allowPing "+
|
||||
"FROM `%s`.typecho_contents WHERE type IN ('post', 'page') ORDER BY cid", schema))
|
||||
if err != nil {
|
||||
log.Fatalf("查询 typecho_contents 失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contents []typechoContent
|
||||
for rows.Next() {
|
||||
var c typechoContent
|
||||
if err = rows.Scan(&c.CID, &c.Title, &c.Slug, &c.Created, &c.Modified, &c.Text,
|
||||
&c.Order, &c.Template, &c.Type, &c.Status, &c.Password, &c.AllowComment, &c.AllowPing); err != nil {
|
||||
log.Fatalf("读取行失败: %v", err)
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Fatalf("遍历结果失败: %v", err)
|
||||
}
|
||||
log.Printf("远程共读取 %d 条内容(post+page)", len(contents))
|
||||
|
||||
// ---------- 写入本地 ----------
|
||||
var postNew, postUpd, pageNew, pageUpd, skipped int
|
||||
for _, c := range contents {
|
||||
title := strings.TrimSpace(c.Title.String)
|
||||
if title == "" {
|
||||
log.Printf("跳过 cid=%d: 标题为空", c.CID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSpace(c.Slug.String)
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("typecho-%d", c.CID)
|
||||
}
|
||||
content := cleanContent(c.Text.String)
|
||||
createdAt := time.Unix(c.Created, 0)
|
||||
updatedAt := time.Unix(c.Modified, 0)
|
||||
if c.Modified == 0 {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
status := "draft"
|
||||
if c.Status == "publish" {
|
||||
status = "published"
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
log.Printf("[dry-run] %s cid=%d slug=%s title=%s status=%s", c.Type, c.CID, slug, title, status)
|
||||
continue
|
||||
}
|
||||
|
||||
switch c.Type {
|
||||
case "post":
|
||||
post := models.Post{
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Content: content,
|
||||
Summary: extractSummary(content),
|
||||
AuthorID: author.ID,
|
||||
Status: status,
|
||||
Password: c.Password.String,
|
||||
AllowComment: c.AllowComment == "1",
|
||||
AllowPing: c.AllowPing == "1",
|
||||
Template: c.Template.String,
|
||||
}
|
||||
if status == "published" {
|
||||
post.PublishedAt = &createdAt
|
||||
}
|
||||
|
||||
var existing models.Post
|
||||
err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error
|
||||
if err == nil {
|
||||
// 覆盖更新已存在的文章
|
||||
post.ID = existing.ID
|
||||
post.CreatedAt = createdAt
|
||||
post.UpdatedAt = updatedAt
|
||||
post.Views = existing.Views
|
||||
post.CategoryID = existing.CategoryID
|
||||
post.IsTop = existing.IsTop
|
||||
if err = localDB.Unscoped().Model(&models.Post{}).Where("id = ?", existing.ID).
|
||||
Select("Title", "Content", "Summary", "AuthorID", "Status", "Password",
|
||||
"AllowComment", "AllowPing", "Template", "PublishedAt", "CreatedAt", "UpdatedAt", "DeletedAt").
|
||||
Updates(map[string]interface{}{
|
||||
"title": post.Title, "content": post.Content, "summary": post.Summary,
|
||||
"author_id": post.AuthorID, "status": post.Status, "password": post.Password,
|
||||
"allow_comment": post.AllowComment, "allow_ping": post.AllowPing,
|
||||
"template": post.Template, "published_at": post.PublishedAt,
|
||||
"created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
log.Fatalf("更新文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
postUpd++
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
post.CreatedAt = createdAt
|
||||
post.UpdatedAt = updatedAt
|
||||
if err = localDB.Create(&post).Error; err != nil {
|
||||
log.Fatalf("插入文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
postNew++
|
||||
} else {
|
||||
log.Fatalf("查询本地文章失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
|
||||
case "page":
|
||||
var existing models.Page
|
||||
err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error
|
||||
if err == nil {
|
||||
if err = localDB.Unscoped().Model(&models.Page{}).Where("id = ?", existing.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "author_id": author.ID,
|
||||
"status": status, "order": c.Order, "password": c.Password.String,
|
||||
"allow_comment": c.AllowComment == "1",
|
||||
"created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
log.Fatalf("更新页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
pageUpd++
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
page := models.Page{
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Content: content,
|
||||
AuthorID: author.ID,
|
||||
Status: status,
|
||||
Order: int(c.Order),
|
||||
Password: c.Password.String,
|
||||
AllowComment: c.AllowComment == "1",
|
||||
}
|
||||
page.CreatedAt = createdAt
|
||||
page.UpdatedAt = updatedAt
|
||||
if err = localDB.Create(&page).Error; err != nil {
|
||||
log.Fatalf("插入页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
pageNew++
|
||||
} else {
|
||||
log.Fatalf("查询本地页面失败 slug=%s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("迁移完成: 文章新增 %d 篇 / 覆盖 %d 篇, 页面新增 %d 个 / 覆盖 %d 个, 跳过 %d 条",
|
||||
postNew, postUpd, pageNew, pageUpd, skipped)
|
||||
}
|
||||
|
||||
// cleanContent 清理typecho内容: 去掉 <!--markdown--> 标记前缀
|
||||
func cleanContent(text string) string {
|
||||
return strings.TrimPrefix(text, "<!--markdown-->")
|
||||
}
|
||||
|
||||
// extractSummary 提取摘要: 优先取 <!--more--> 之前的部分, 否则取前200个字符
|
||||
func extractSummary(content string) string {
|
||||
if idx := strings.Index(content, "<!--more-->"); idx >= 0 {
|
||||
content = content[:idx]
|
||||
}
|
||||
// 去掉常见markdown标记后截断
|
||||
plain := strings.NewReplacer("#", "", "*", "", "`", "", ">", "", "\r", "").Replace(content)
|
||||
plain = strings.TrimSpace(plain)
|
||||
runes := []rune(plain)
|
||||
if len(runes) > 200 {
|
||||
return string(runes[:200])
|
||||
}
|
||||
return plain
|
||||
}
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
@@ -14,6 +15,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
@@ -31,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"goblog/database"
|
||||
"goblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 创建用户请求
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// 更新用户请求
|
||||
type AdminUpdateUserRequest struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// 重置密码请求
|
||||
type ResetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// 获取用户列表(管理员)
|
||||
func GetUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := database.DB.Model(&models.User{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var users []models.User
|
||||
if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建用户(管理员)
|
||||
func CreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
database.DB.Model(&models.User{}).Where("username = ?", req.Username).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
database.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
role := req.Role
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
status := 1
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
Role: role,
|
||||
Status: status,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": user})
|
||||
}
|
||||
|
||||
// 更新用户信息(管理员)
|
||||
func AdminUpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
operatorID, _ := c.Get("userID")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminUpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isSelf := operatorID == user.ID
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"nickname": req.Nickname,
|
||||
}
|
||||
|
||||
if req.Email != "" && req.Email != user.Email {
|
||||
// 检查邮箱是否已被其他用户使用
|
||||
var count int64
|
||||
database.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被其他用户使用"})
|
||||
return
|
||||
}
|
||||
updates["email"] = req.Email
|
||||
}
|
||||
|
||||
if req.Role != "" && req.Role != user.Role {
|
||||
if isSelf {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能修改自己的角色"})
|
||||
return
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色"})
|
||||
return
|
||||
}
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
|
||||
if req.Status != nil && *req.Status != user.Status {
|
||||
if isSelf {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
|
||||
return
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": user})
|
||||
}
|
||||
|
||||
// 重置用户密码(管理员)
|
||||
func AdminResetPassword(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
|
||||
}
|
||||
|
||||
// 删除用户(管理员)
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
operatorID, _ := c.Get("userID")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if operatorID == user.ID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己的账号"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该用户是否有文章
|
||||
var count int64
|
||||
database.DB.Model(&models.Post{}).Where("author_id = ?", user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有文章,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该用户是否有页面
|
||||
database.DB.Model(&models.Page{}).Where("author_id = ?", user.ID).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有页面,无法删除"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -100,6 +100,13 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
|
||||
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
|
||||
admin.DELETE("/comments/:id", handlers.DeleteComment)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", handlers.GetUsers)
|
||||
admin.POST("/users", handlers.CreateUser)
|
||||
admin.PUT("/users/:id", handlers.AdminUpdateUser)
|
||||
admin.PUT("/users/:id/password", handlers.AdminResetPassword)
|
||||
admin.DELETE("/users/:id", handlers.DeleteUser)
|
||||
|
||||
// 文件管理(图片上传)
|
||||
admin.POST("/upload", handlers.UploadImage)
|
||||
admin.GET("/uploads", handlers.ListUploads)
|
||||
|
||||
@@ -43,6 +43,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 模态框表单
|
||||
document.getElementById('modal-form').addEventListener('submit', handleModalSubmit);
|
||||
|
||||
// 用户表单
|
||||
document.getElementById('user-form').addEventListener('submit', handleUserSubmit);
|
||||
|
||||
// 封面图上传
|
||||
const coverFileInput = document.getElementById('post-cover-file');
|
||||
if (coverFileInput) {
|
||||
@@ -104,6 +107,9 @@ function showPage(pageName) {
|
||||
case 'comments':
|
||||
loadComments();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'files':
|
||||
loadFiles();
|
||||
break;
|
||||
@@ -709,6 +715,203 @@ function getStatusText(status) {
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users?page_size=100`, { headers: getHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('users-list');
|
||||
tbody.innerHTML = data.data?.map(u => {
|
||||
const isSelf = currentUser && u.id === currentUser.id;
|
||||
return `
|
||||
<tr>
|
||||
<td>${u.username}${isSelf ? ' <span class="status-badge status-published">当前</span>' : ''}</td>
|
||||
<td>${u.nickname || '-'}</td>
|
||||
<td>${u.email || '-'}</td>
|
||||
<td>${u.role === 'admin' ? '管理员' : '普通用户'}</td>
|
||||
<td><span class="status-badge ${u.status === 1 ? 'status-published' : 'status-draft'}">${u.status === 1 ? '启用' : '禁用'}</span></td>
|
||||
<td>${u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '-'}</td>
|
||||
<td>
|
||||
<button class="btn-success" onclick="editUser(${u.id})">编辑</button>
|
||||
<button class="btn-secondary" onclick="resetUserPassword(${u.id}, '${u.username}')">重置密码</button>
|
||||
${isSelf ? '' : `
|
||||
${u.status === 1 ? `<button class="btn-secondary" onclick="toggleUserStatus(${u.id}, 0)">禁用</button>` : `<button class="btn-primary" onclick="toggleUserStatus(${u.id}, 1)">启用</button>`}
|
||||
<button class="btn-danger" onclick="deleteUser(${u.id}, '${u.username}')">删除</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('') || '<tr><td colspan="7" style="text-align:center">暂无用户</td></tr>';
|
||||
|
||||
// 缓存用户数据供编辑使用
|
||||
usersCache = data.data || [];
|
||||
} catch (err) {
|
||||
console.error('加载用户失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let usersCache = [];
|
||||
|
||||
// 显示新建用户表单
|
||||
function showUserForm() {
|
||||
document.getElementById('user-modal-title').textContent = '新建用户';
|
||||
document.getElementById('user-form').reset();
|
||||
document.getElementById('user-id').value = '';
|
||||
document.getElementById('user-username').disabled = false;
|
||||
document.getElementById('user-password-group').classList.remove('hidden');
|
||||
document.getElementById('user-password').required = true;
|
||||
document.getElementById('user-role').disabled = false;
|
||||
document.getElementById('user-status').disabled = false;
|
||||
document.getElementById('user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
function editUser(id) {
|
||||
const user = usersCache.find(u => u.id === id);
|
||||
if (!user) return;
|
||||
|
||||
const isSelf = currentUser && user.id === currentUser.id;
|
||||
|
||||
document.getElementById('user-modal-title').textContent = '编辑用户';
|
||||
document.getElementById('user-id').value = user.id;
|
||||
document.getElementById('user-username').value = user.username;
|
||||
document.getElementById('user-username').disabled = true;
|
||||
// 编辑时不修改密码,隐藏密码输入
|
||||
document.getElementById('user-password-group').classList.add('hidden');
|
||||
document.getElementById('user-password').required = false;
|
||||
document.getElementById('user-password').value = '';
|
||||
document.getElementById('user-nickname').value = user.nickname || '';
|
||||
document.getElementById('user-email').value = user.email || '';
|
||||
document.getElementById('user-role').value = user.role;
|
||||
document.getElementById('user-status').value = String(user.status);
|
||||
// 不能修改自己的角色和状态
|
||||
document.getElementById('user-role').disabled = isSelf;
|
||||
document.getElementById('user-status').disabled = isSelf;
|
||||
document.getElementById('user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 隐藏用户弹窗
|
||||
function hideUserModal() {
|
||||
document.getElementById('user-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 提交用户表单
|
||||
async function handleUserSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('user-id').value;
|
||||
const data = {
|
||||
nickname: document.getElementById('user-nickname').value,
|
||||
email: document.getElementById('user-email').value,
|
||||
role: document.getElementById('user-role').value,
|
||||
status: parseInt(document.getElementById('user-status').value)
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
data.username = document.getElementById('user-username').value;
|
||||
data.password = document.getElementById('user-password').value;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id ? `${API_BASE}/admin/users/${id}` : `${API_BASE}/admin/users`;
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
hideUserModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 启用/禁用用户
|
||||
async function toggleUserStatus(id, status) {
|
||||
const actionText = status === 1 ? '启用' : '禁用';
|
||||
if (!confirm(`确定要${actionText}这个用户吗?`)) return;
|
||||
|
||||
try {
|
||||
const user = usersCache.find(u => u.id === id);
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
nickname: user?.nickname || '',
|
||||
email: user?.email || '',
|
||||
status: status
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 重置用户密码
|
||||
async function resetUserPassword(id, username) {
|
||||
const newPassword = prompt(`请输入用户「${username}」的新密码(至少 6 位):`);
|
||||
if (newPassword === null) return;
|
||||
if (newPassword.length < 6) {
|
||||
alert('密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('密码重置成功');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '重置失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`确定要删除用户「${username}」吗?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || '删除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
|
||||
// 加载主题列表
|
||||
|
||||
+76
-5
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>后台管理 - GoBlog</title>
|
||||
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/admin/style.css">
|
||||
<link rel="stylesheet" href="/static/admin/style.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
|
||||
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
|
||||
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
|
||||
<a href="#users" class="menu-item" data-page="users">用户管理</a>
|
||||
<a href="#files" class="menu-item" data-page="files">文件管理</a>
|
||||
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
|
||||
</nav>
|
||||
@@ -163,8 +164,10 @@
|
||||
<button type="button" id="md-tab-preview" onclick="mdShowPreview()">预览</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="post-content" rows="15" required placeholder="支持 Markdown 语法,也兼容 HTML 内容"></textarea>
|
||||
<div id="md-preview" class="md-preview hidden"></div>
|
||||
<div class="md-body">
|
||||
<textarea id="post-content" required placeholder="支持 Markdown 语法,也兼容 HTML 内容"></textarea>
|
||||
<div id="md-preview" class="md-preview hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -172,7 +175,7 @@
|
||||
<input type="checkbox" id="post-istop"> 置顶文章
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -254,6 +257,28 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div id="users-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<button class="btn-primary" onclick="showUserForm()">新建用户</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>最后登录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 文件管理 -->
|
||||
<div id="files-page" class="page hidden">
|
||||
<div class="page-header">
|
||||
@@ -339,6 +364,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户表单弹窗 -->
|
||||
<div id="user-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3 id="user-modal-title">新建用户</h3>
|
||||
<form id="user-form">
|
||||
<input type="hidden" id="user-id">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="user-username" required minlength="3" maxlength="50">
|
||||
</div>
|
||||
<div class="form-group" id="user-password-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="user-password" minlength="6" placeholder="至少 6 位">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input type="text" id="user-nickname">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" id="user-email" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select id="user-role">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select id="user-status">
|
||||
<option value="1">启用</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideUserModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建主题弹窗 -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div class="modal-content modal-large">
|
||||
@@ -371,6 +442,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin/app.js?v=11"></script>
|
||||
<script src="/static/admin/app.js?v=12"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-3
@@ -841,11 +841,22 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 编辑/预览共享同一容器,切换时区域尺寸不变 */
|
||||
.md-body {
|
||||
position: relative;
|
||||
height: 460px;
|
||||
min-height: 240px;
|
||||
resize: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-editor textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -855,8 +866,7 @@ body {
|
||||
}
|
||||
|
||||
.md-preview {
|
||||
min-height: 320px;
|
||||
max-height: 600px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
Reference in New Issue
Block a user