增加用户管理

This commit is contained in:
2026-07-28 18:02:05 +08:00
parent 21be62fa7c
commit 0b22d4f126
10 changed files with 822 additions and 8 deletions
+262
View File
@@ -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
}
+2
View File
@@ -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
+4
View File
@@ -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=
BIN
View File
Binary file not shown.
Binary file not shown.
+255
View File
@@ -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": "删除成功"})
}
+7
View File
@@ -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)
+203
View File
@@ -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
View File
@@ -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
View File
@@ -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;