diff --git a/cmd/migrate_typecho/main.go b/cmd/migrate_typecho/main.go new file mode 100644 index 0000000..988c667 --- /dev/null +++ b/cmd/migrate_typecho/main.go @@ -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内容: 去掉 标记前缀 +func cleanContent(text string) string { + return strings.TrimPrefix(text, "") +} + +// extractSummary 提取摘要: 优先取 之前的部分, 否则取前200个字符 +func extractSummary(content string) string { + if idx := strings.Index(content, ""); 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 +} diff --git a/go.mod b/go.mod index 2b57522..431a8fb 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3034aa4..8993b60 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/goblog.db b/goblog.db index e68b331..d6277ef 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/goblog.db.bak-migrate b/goblog.db.bak-migrate new file mode 100644 index 0000000..c9bb116 Binary files /dev/null and b/goblog.db.bak-migrate differ diff --git a/handlers/user.go b/handlers/user.go new file mode 100644 index 0000000..2ed6b56 --- /dev/null +++ b/handlers/user.go @@ -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": "删除成功"}) +} diff --git a/routers/router.go b/routers/router.go index cc2fdb4..361a409 100644 --- a/routers/router.go +++ b/routers/router.go @@ -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) diff --git a/static/admin/app.js b/static/admin/app.js index 335c82f..42fd03a 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -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 ` + + ${u.username}${isSelf ? ' 当前' : ''} + ${u.nickname || '-'} + ${u.email || '-'} + ${u.role === 'admin' ? '管理员' : '普通用户'} + ${u.status === 1 ? '启用' : '禁用'} + ${u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '-'} + + + + ${isSelf ? '' : ` + ${u.status === 1 ? `` : ``} + `} + + + `; + }).join('') || '暂无用户'; + + // 缓存用户数据供编辑使用 + 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('网络错误'); + } +} + // ==================== 主题管理 ==================== // 加载主题列表 diff --git a/static/admin/index.html b/static/admin/index.html index 563b0ff..5dd54ae 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -5,7 +5,7 @@ 后台管理 - GoBlog - +
@@ -21,6 +21,7 @@ 标签管理 页面管理 评论管理 + 用户管理 文件管理 主题管理 @@ -163,8 +164,10 @@
- - +
+ + +
@@ -172,7 +175,7 @@ 置顶文章
- + @@ -254,6 +257,28 @@ + + + + + + - + diff --git a/static/admin/style.css b/static/admin/style.css index dbd55e7..c18c9d2 100644 --- a/static/admin/style.css +++ b/static/admin/style.css @@ -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;