diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go
new file mode 100644
index 0000000..4adf890
--- /dev/null
+++ b/backend/handler/admin_user.go
@@ -0,0 +1,111 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/freefire/jiang13-bbs/middleware"
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// ===== 用户管理(RequireAdmin 兜底,前端不做权限判定) =====
+
+// AdminListUsers 用户分页列表(搜索/角色/状态筛选 + 全站汇总)
+func (h *Handlers) AdminListUsers(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+
+ res, err := h.AdminUser.List(service.AdminUserListQuery{
+ Page: page,
+ Size: size,
+ Keyword: strings.TrimSpace(c.Query("q")),
+ Role: c.Query("role"),
+ Status: c.Query("status"),
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "users": res.Users,
+ "total": res.Total,
+ "page": res.Page,
+ "size": res.Size,
+ "summary": res.Summary,
+ })
+}
+
+// adminUserActionBody 角色/封禁变更的通用请求体
+type adminUserRoleBody struct {
+ Role string `json:"role"`
+}
+
+type adminUserBanBody struct {
+ Banned bool `json:"banned"`
+}
+
+// AdminUpdateUserRole 设置用户角色(user / admin)
+func (h *Handlers) AdminUpdateUserRole(c *gin.Context) {
+ id, ok := parseAdminUserID(c)
+ if !ok {
+ return
+ }
+ var body adminUserRoleBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ user, err := h.AdminUser.SetRole(middleware.CurrentUser(c).ID, id, body.Role)
+ if err != nil {
+ respondAdminUserError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"user": user})
+}
+
+// AdminSetUserBan 封禁 / 解封用户
+func (h *Handlers) AdminSetUserBan(c *gin.Context) {
+ id, ok := parseAdminUserID(c)
+ if !ok {
+ return
+ }
+ var body adminUserBanBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ user, err := h.AdminUser.SetBanned(middleware.CurrentUser(c).ID, id, body.Banned)
+ if err != nil {
+ respondAdminUserError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"user": user})
+}
+
+// parseAdminUserID 解析路径中的用户 ID,失败时直接写出 400
+func parseAdminUserID(c *gin.Context) (uint, bool) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
+ return 0, false
+ }
+ return uint(id), true
+}
+
+// respondAdminUserError 统一映射用户管理业务错误到 HTTP 状态码
+func respondAdminUserError(c *gin.Context, err error) {
+ switch {
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
+ case errors.Is(err, service.ErrAdminSelfAction),
+ errors.Is(err, service.ErrLastAdmin),
+ errors.Is(err, service.ErrInvalidUserRole):
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
+ }
+}
diff --git a/backend/handler/auth.go b/backend/handler/auth.go
index d813e09..81e49e4 100644
--- a/backend/handler/auth.go
+++ b/backend/handler/auth.go
@@ -1,6 +1,7 @@
package handler
import (
+ "errors"
"net/http"
"time"
@@ -146,6 +147,11 @@ func (h *Handlers) Login(c *gin.Context) {
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password)
if err != nil {
+ // 封禁与凭据错误区分:前端可据此展示针对性提示
+ if errors.Is(err, service.ErrAccountBanned) {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "account_banned"})
+ return
+ }
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
@@ -172,6 +178,11 @@ func (h *Handlers) Refresh(c *gin.Context) {
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken)
if err != nil {
clearAuthCookies(c, !h.Cfg.DevMode)
+ if errors.Is(err, service.ErrAccountBanned) {
+ // 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
+ c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
+ return
+ }
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
@@ -238,7 +249,14 @@ func meUserBody(user *model.User) gin.H {
func (h *Handlers) Me(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
- c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
+ resp := gin.H{"user": nil, "unread_count": 0}
+ // OptionalAuth 识别到封禁:HTTP 仍为 200(/me 不阻断页面),
+ // 但带 banned/code,客户端静默校正时据此强制下线并弹告知
+ if c.GetBool(middleware.AccountBannedKey) {
+ resp["banned"] = true
+ resp["code"] = "account_banned"
+ }
+ c.JSON(http.StatusOK, resp)
return
}
user, err := h.Auth.GetUserByID(claims.ID)
diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go
index 2b5f5de..fd1259e 100644
--- a/backend/handler/handlers.go
+++ b/backend/handler/handlers.go
@@ -19,4 +19,5 @@ type Handlers struct {
Announcement *service.AnnouncementService
Upload *service.UploadService
Setting *service.SettingService
+ AdminUser *service.AdminUserService
}
diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go
index e202890..d9c72f0 100644
--- a/backend/middleware/auth.go
+++ b/backend/middleware/auth.go
@@ -1,6 +1,7 @@
package middleware
import (
+ "errors"
"net/http"
"strings"
@@ -8,6 +9,19 @@ import (
"github.com/gin-gonic/gin"
)
+// AccountBannedKey 写入 gin.Context 的标记:当前凭据所属账号已被封禁。
+// parseToken 失败原因对 handler 不可见,通过 context 显式传递,
+// 以便 /me 等 OptionalAuth 接口也能告知前端"被封禁"而非"未登录"。
+const AccountBannedKey = "account_banned"
+
+// 封禁响应体:code 供前端机器识别,error 供直接展示
+func bannedJSON(c *gin.Context) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "error": "账号已被封禁",
+ "code": "account_banned",
+ })
+}
+
// AuthMiddleware 认证中间件
type AuthMiddleware struct {
auth *service.AuthService
@@ -33,13 +47,13 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if !ok {
+ if c.GetBool(AccountBannedKey) {
+ bannedJSON(c)
+ return
+ }
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
- if user.Banned {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "账号已被封禁"})
- return
- }
c.Set("user", user)
c.Next()
}
@@ -50,6 +64,10 @@ func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if !ok {
+ if c.GetBool(AccountBannedKey) {
+ bannedJSON(c)
+ return
+ }
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
@@ -84,6 +102,10 @@ func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool)
}
// 实时校验:token_version 匹配 + 未封禁(防止旧 JWT 在封禁/改密码后仍有效)
if _, err := m.auth.ValidateClaims(claims); err != nil {
+ // 封禁原因写入 context:401 与 403 的区分由上层中间件/handler 完成
+ if errors.Is(err, service.ErrAccountBanned) {
+ c.Set(AccountBannedKey, true)
+ }
return nil, false
}
// 异步刷新在线心跳(SQL 每 60s 限频一次),不阻塞请求
diff --git a/backend/router/router.go b/backend/router/router.go
index b7d69d1..694af18 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -49,6 +49,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
announcementSvc := service.NewAnnouncementService(model.DB)
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
settingSvc := service.NewSettingService(model.DB)
+ adminUserSvc := service.NewAdminUserService(model.DB)
if err := uploadSvc.EnsureDir(); err != nil {
return nil, err
}
@@ -67,6 +68,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Announcement: announcementSvc,
Upload: uploadSvc,
Setting: settingSvc,
+ AdminUser: adminUserSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
@@ -97,12 +99,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
+ // 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验)
+ pubAPI.POST("/logout", middleware.CSRFMiddleware(), h.Logout)
}
// 需登录 API(先鉴权,再 CSRF 防护)
api := r.Group("/api", authMW.RequireAuth(), middleware.CSRFMiddleware())
{
- api.POST("/logout", h.Logout)
api.POST("/change-password", h.ChangePassword)
api.PUT("/profile", h.UpdateProfile)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
@@ -141,6 +144,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
// 站点外观设置(主题色)
adminAPI.PUT("/settings", h.UpdateSettings)
+ // 用户管理:列表(搜索/筛选/汇总)、角色变更、封禁解封
+ adminAPI.GET("/users", h.AdminListUsers)
+ adminAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
+ adminAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
}
r.NoRoute(func(c *gin.Context) {
diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go
new file mode 100644
index 0000000..42b883c
--- /dev/null
+++ b/backend/service/admin_user.go
@@ -0,0 +1,293 @@
+package service
+
+import (
+ "errors"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// 管理员用户操作的业务护栏(handler 层映射为 400,前端只展示消息)
+var (
+ // ErrAdminSelfAction 不能对自己的账号执行管理操作(自锁/误操作保护)
+ ErrAdminSelfAction = errors.New("不能对自己的账号执行该操作")
+ // ErrLastAdmin 至少保留一名未封禁的管理员,防止站点失去管理入口
+ ErrLastAdmin = errors.New("至少保留一名未封禁的管理员")
+ // ErrInvalidUserRole 角色入参非法
+ ErrInvalidUserRole = errors.New("角色参数无效")
+)
+
+// AdminUserService 后台用户管理
+type AdminUserService struct {
+ db *gorm.DB
+}
+
+func NewAdminUserService(db *gorm.DB) *AdminUserService {
+ return &AdminUserService{db: db}
+}
+
+// AdminUserItem 后台用户列表项:email / last_seen 在 User 模型上 json:"-",
+// 仅管理员接口通过此 DTO 显式带出
+type AdminUserItem struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ Email string `json:"email"`
+ Avatar string `json:"avatar"`
+ Signature string `json:"signature"`
+ Role string `json:"role"`
+ Banned bool `json:"banned"`
+ PostCount int64 `json:"post_count"`
+ CommentCount int64 `json:"comment_count"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt *time.Time `json:"last_seen_at"`
+}
+
+// AdminUserSummary 列表顶部汇总(总数/管理员/封禁/今日新增)
+type AdminUserSummary struct {
+ Total int64 `json:"total"`
+ Admins int64 `json:"admins"`
+ Banned int64 `json:"banned"`
+ TodayNew int64 `json:"today_new"`
+}
+
+// AdminUserListQuery 用户列表查询
+type AdminUserListQuery struct {
+ Page int
+ Size int
+ Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
+ Role string // "" 全部 | "admin" 仅管理员
+ Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
+}
+
+// AdminUserListResult 分页结果 + 汇总
+type AdminUserListResult struct {
+ Users []AdminUserItem `json:"users"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"size"`
+ Summary AdminUserSummary `json:"summary"`
+}
+
+// List 分页查询用户并批量填充发帖/回复计数,避免 N+1
+func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, error) {
+ if q.Page < 1 {
+ q.Page = 1
+ }
+ if q.Size < 1 || q.Size > 50 {
+ q.Size = 20
+ }
+
+ query := s.db.Model(&model.User{})
+ if kw := q.Keyword; kw != "" {
+ like := "%" + kw + "%"
+ query = query.Where("username ILIKE ? OR nickname ILIKE ? OR email ILIKE ?", like, like, like)
+ }
+ if q.Role == string(model.RoleAdmin) {
+ query = query.Where("role = ?", model.RoleAdmin)
+ }
+ if q.Status == "banned" {
+ query = query.Where("banned = ?", true)
+ } else if q.Status == "normal" {
+ query = query.Where("banned = ?", false)
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ var users []model.User
+ if err := query.Order("id DESC").
+ Offset((q.Page - 1) * q.Size).Limit(q.Size).
+ Find(&users).Error; err != nil {
+ return nil, err
+ }
+
+ items := s.toItems(users)
+
+ // 汇总数据(不受筛选条件影响,始终反映全站)
+ now := time.Now()
+ dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ summary := AdminUserSummary{}
+ s.db.Model(&model.User{}).Count(&summary.Total)
+ s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&summary.Admins)
+ s.db.Model(&model.User{}).Where("banned = ?", true).Count(&summary.Banned)
+ s.db.Model(&model.User{}).Where("created_at >= ?", dayStart).Count(&summary.TodayNew)
+
+ return &AdminUserListResult{
+ Users: items,
+ Total: total,
+ Page: q.Page,
+ Size: q.Size,
+ Summary: summary,
+ }, nil
+}
+
+// toItems 批量把 User 模型转为 DTO,并用两条 GROUP BY 填充计数
+func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
+ items := make([]AdminUserItem, 0, len(users))
+ ids := make([]uint, 0, len(users))
+ for _, u := range users {
+ ids = append(ids, u.ID)
+ items = append(items, AdminUserItem{
+ ID: u.ID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Email: u.Email,
+ Avatar: u.Avatar,
+ Signature: u.Signature,
+ Role: string(u.Role),
+ Banned: u.Banned,
+ CreatedAt: u.CreatedAt,
+ LastSeenAt: u.LastSeenAt,
+ })
+ }
+ if len(ids) == 0 {
+ return items
+ }
+
+ type countRow struct {
+ UserID uint
+ Cnt int64
+ }
+ postCounts := map[uint]int64{}
+ commentCounts := map[uint]int64{}
+
+ var postRows []countRow
+ s.db.Model(&model.Post{}).
+ Select("user_id, COUNT(*) AS cnt").
+ Where("user_id IN ?", ids).
+ Group("user_id").Scan(&postRows)
+ for _, r := range postRows {
+ postCounts[r.UserID] = r.Cnt
+ }
+
+ var commentRows []countRow
+ s.db.Model(&model.Comment{}).
+ Select("user_id, COUNT(*) AS cnt").
+ Where("user_id IN ? AND status = ?", ids, model.ContentStatusPublished).
+ Group("user_id").Scan(&commentRows)
+ for _, r := range commentRows {
+ commentCounts[r.UserID] = r.Cnt
+ }
+
+ for i := range items {
+ items[i].PostCount = postCounts[items[i].ID]
+ items[i].CommentCount = commentCounts[items[i].ID]
+ }
+ return items
+}
+
+// getItem 读取单个用户 DTO(供变更后回传最新状态)
+func (s *AdminUserService) getItem(tx *gorm.DB, id uint) (*AdminUserItem, error) {
+ var u model.User
+ if err := tx.First(&u, id).Error; err != nil {
+ return nil, err
+ }
+ items := s.toItems([]model.User{u})
+ return &items[0], nil
+}
+
+// SetRole 修改用户角色。管理员降级时递增 token_version 并撤销 refresh token,
+// 使其旧 JWT(claims 中仍带 admin)立即失效,需重新登录获得新角色身份
+func (s *AdminUserService) SetRole(operatorID, targetID uint, role string) (*AdminUserItem, error) {
+ if role != string(model.RoleAdmin) && role != string(model.RoleUser) {
+ return nil, ErrInvalidUserRole
+ }
+ if operatorID == targetID {
+ return nil, ErrAdminSelfAction
+ }
+
+ var item *AdminUserItem
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var u model.User
+ if err := tx.First(&u, targetID).Error; err != nil {
+ return err
+ }
+ if string(u.Role) == role {
+ return nil
+ }
+ // 降级管理员:确保还存在另一名未封禁管理员
+ if u.Role == model.RoleAdmin {
+ var otherAdmins int64
+ if err := tx.Model(&model.User{}).
+ Where("role = ? AND banned = ? AND id <> ?", model.RoleAdmin, false, targetID).
+ Count(&otherAdmins).Error; err != nil {
+ return err
+ }
+ if otherAdmins == 0 {
+ return ErrLastAdmin
+ }
+ }
+ if err := tx.Model(&u).Update("role", model.Role(role)).Error; err != nil {
+ return err
+ }
+ // 管理员被降级:强制下线,避免 15 分钟 JWT 窗口内仍保有管理权限
+ if role == string(model.RoleUser) {
+ if err := invalidateUserSessions(tx, targetID); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ item, err = s.getItem(s.db, targetID)
+ return item, err
+}
+
+// SetBanned 封禁/解封用户。封禁时同事务使该用户全部登录态立即失效
+func (s *AdminUserService) SetBanned(operatorID, targetID uint, banned bool) (*AdminUserItem, error) {
+ if operatorID == targetID {
+ return nil, ErrAdminSelfAction
+ }
+
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var u model.User
+ if err := tx.First(&u, targetID).Error; err != nil {
+ return err
+ }
+ if u.Banned == banned {
+ return nil
+ }
+ // 封禁管理员:确保还存在另一名未封禁管理员,避免站点失去管理入口
+ if banned && u.Role == model.RoleAdmin {
+ var otherAdmins int64
+ if err := tx.Model(&model.User{}).
+ Where("role = ? AND banned = ? AND id <> ?", model.RoleAdmin, false, targetID).
+ Count(&otherAdmins).Error; err != nil {
+ return err
+ }
+ if otherAdmins == 0 {
+ return ErrLastAdmin
+ }
+ }
+ if err := tx.Model(&u).Update("banned", banned).Error; err != nil {
+ return err
+ }
+ if banned {
+ if err := invalidateUserSessions(tx, targetID); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return s.getItem(s.db, targetID)
+}
+
+// invalidateUserSessions 在事务内递增 token_version 并撤销全部 refresh token,
+// 与 AuthService 的强制下线逻辑等价(封禁/管理员降级时调用)
+func invalidateUserSessions(tx *gorm.DB, userID uint) error {
+ if err := tx.Model(&model.User{}).Where("id = ?", userID).
+ UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false).
+ Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
+}
diff --git a/backend/service/auth.go b/backend/service/auth.go
index 524e0a8..286914b 100644
--- a/backend/service/auth.go
+++ b/backend/service/auth.go
@@ -126,7 +126,7 @@ func (s *AuthService) Login(username, password string) (string, string, *model.U
}
// 封禁判定放在密码比较之后,同样避免时序差异
if user.Banned {
- return "", "", nil, errors.New("账号已被封禁")
+ return "", "", nil, ErrAccountBanned
}
accessToken, err := s.generateToken(&user)
@@ -184,13 +184,14 @@ func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
if err := s.db.First(&user, claims.ID).Error; err != nil {
return nil, errors.New("用户不存在")
}
+ // 封禁优先于版本判定:封禁必然伴随 token_version 递增,
+ // 但前端需要明确知道"被封禁"而非笼统的登录失效
+ if user.Banned {
+ return nil, ErrAccountBanned
+ }
// token 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
if user.TokenVersion != claims.TokenVersion {
- return nil, errors.New("token 已失效")
- }
- // 实时校验封禁状态(不依赖 JWT 中的缓存值)
- if user.Banned {
- return nil, errors.New("账号已被封禁")
+ return nil, ErrTokenRevoked
}
return &user, nil
}
@@ -218,6 +219,14 @@ func generateRandomToken() string {
return base64.URLEncoding.EncodeToString(b)
}
+// 账号级登录态错误:前端据此区分"被封禁强制下线"与普通登录过期
+var (
+ // ErrAccountBanned 账号已被封禁(登录/刷新/鉴权全链路统一返回,便于前端识别并告知用户)
+ ErrAccountBanned = errors.New("账号已被封禁")
+ // ErrTokenRevoked 凭据版本失配:改密/管理员强制下线等导致旧 JWT 立即作废
+ ErrTokenRevoked = errors.New("登录态已失效")
+)
+
// refresh token 相关错误
var (
ErrRefreshInvalid = errors.New("refresh token 无效")
@@ -299,7 +308,7 @@ func (s *AuthService) loadActiveUser(tx *gorm.DB, userID uint) (*model.User, err
return nil, errors.New("用户不存在")
}
if user.Banned {
- return nil, errors.New("账号已被封禁")
+ return nil, ErrAccountBanned
}
return &user, nil
}
@@ -311,6 +320,12 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
return nil, ErrRefreshInvalid
}
if rt.Revoked {
+ // 撤销常源于封禁/改密:补查封禁状态,让被封禁用户的刷新请求
+ // 得到可识别的原因(前端据此弹封禁提示,而非笼统的登录过期)
+ var owner model.User
+ if err := s.db.Select("banned").First(&owner, rt.UserID).Error; err == nil && owner.Banned {
+ return nil, ErrAccountBanned
+ }
return nil, errors.New("refresh token 已撤销")
}
if time.Now().After(rt.ExpiresAt) {
diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx
new file mode 100644
index 0000000..97cf408
--- /dev/null
+++ b/frontend/app/admin/AdminNav.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { LucideIcon } from "lucide-react";
+import {
+ LayoutDashboard,
+ Users,
+ Megaphone,
+ Palette,
+ ShieldCheck,
+ ArrowLeft,
+} from "lucide-react";
+import type { User } from "@/lib/api";
+import Avatar from "@/components/Avatar";
+
+// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
+const NAV_ITEMS: { href: string; label: string; icon: LucideIcon; exact?: boolean }[] = [
+ { href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true },
+ { href: "/admin/users", label: "用户管理", icon: Users },
+ { href: "/admin/announcements", label: "公告管理", icon: Megaphone },
+ { href: "/admin/appearance", label: "外观设置", icon: Palette },
+];
+
+function isActive(pathname: string, href: string, exact?: boolean) {
+ return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
+}
+
+/**
+ * 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 管理员身份卡。
+ * 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
+ * 与 j13-bcard / 顶部 pill 保持一致。
+ */
+export function AdminSidebar({ user }: { user: User }) {
+ const pathname = usePathname();
+
+ return (
+
+ {/* 品牌头 */}
+
+
+
+
+
+
+ 管理面板
+
+
+ J13 ADMIN
+
+
+
+
+ {/* 导航 */}
+
+
+ {/* 返回前台 */}
+
+
+ {/* 管理员身份卡 */}
+
+
+
+
+ {user.nickname}
+
+
+ @{user.username}
+
+
+
+
+ 管理员
+
+
+
+ );
+}
+
+/**
+ * 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
+ * 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
+ */
+export function AdminNavMobile() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/admin/announcements/page.tsx b/frontend/app/admin/announcements/page.tsx
index bf119af..f1d562c 100644
--- a/frontend/app/admin/announcements/page.tsx
+++ b/frontend/app/admin/announcements/page.tsx
@@ -1,41 +1,11 @@
import type { Metadata } from "next";
-import Link from "next/link";
-import { cookies } from "next/headers";
-import { ShieldAlert } from "lucide-react";
-import { authCookieHeader } from "@/lib/cookies";
-import { getMeCached } from "@/lib/serverData";
import AnnouncementAdmin from "./AnnouncementAdmin";
export const metadata: Metadata = {
title: "站点公告管理",
- robots: { index: false, follow: false },
};
-// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
-// 非管理员访问时 SSR 直接给出无权限提示(所有写接口仍由后端 403 兜底)。
-export default async function AdminAnnouncementsPage() {
- const cookie = authCookieHeader(await cookies());
- const me = await getMeCached(cookie || undefined);
-
- if (me.user?.role !== "admin") {
- return (
-
-
-
-
-
- 需要管理员权限
-
-
仅站点管理员可以发布和管理站点公告。
-
- 返回首页
-
-
- );
- }
-
+// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担;本页只负责内容直出
+export default function AdminAnnouncementsPage() {
return ;
}
diff --git a/frontend/app/admin/appearance/page.tsx b/frontend/app/admin/appearance/page.tsx
index 0455f74..74b2283 100644
--- a/frontend/app/admin/appearance/page.tsx
+++ b/frontend/app/admin/appearance/page.tsx
@@ -1,43 +1,14 @@
import type { Metadata } from "next";
-import Link from "next/link";
-import { cookies } from "next/headers";
-import { ShieldAlert } from "lucide-react";
-import { authCookieHeader } from "@/lib/cookies";
-import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import { getPublicSettingsCached } from "@/lib/serverData";
import AppearanceAdmin from "./AppearanceAdmin";
export const metadata: Metadata = {
title: "外观设置",
- robots: { index: false, follow: false },
};
-// 站点外观(主题色)管理:权限以 Go 端 RequireAdmin 为唯一判定,
-// 非管理员 SSR 直出无权限提示,写接口仍由后端 403 兜底。
+// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担。
+// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
export default async function AdminAppearancePage() {
- const cookie = authCookieHeader(await cookies());
- const me = await getMeCached(cookie || undefined);
-
- if (me.user?.role !== "admin") {
- return (
-
-
-
-
-
- 需要管理员权限
-
-
仅站点管理员可以配置站点外观。
-
- 返回首页
-
-
- );
- }
-
- // 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
const { accent } = await getPublicSettingsCached();
return ;
}
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
new file mode 100644
index 0000000..0440d11
--- /dev/null
+++ b/frontend/app/admin/layout.tsx
@@ -0,0 +1,53 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { cookies } from "next/headers";
+import { ShieldAlert } from "lucide-react";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached } from "@/lib/serverData";
+import { AdminSidebar, AdminNavMobile } from "./AdminNav";
+
+// 整个 /admin 树不进入索引(子页面无需重复声明)
+export const metadata: Metadata = {
+ robots: { index: false, follow: false },
+};
+
+// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
+// 非管理员访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
+export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+
+ if (me.user?.role !== "admin") {
+ return (
+
+
+
+
+
+ 需要管理员权限
+
+
此区域仅供站点管理员访问,如有疑问请联系站长。
+
+ 返回首页
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..a75480c
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,323 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { cookies } from "next/headers";
+import {
+ Users,
+ PenLine,
+ MessageCircle,
+ Activity,
+ Megaphone,
+ Palette,
+ ChevronRight,
+ UserPlus,
+ LayoutGrid,
+} from "lucide-react";
+import { fetchOverview, type OverviewResponse } from "@/lib/api";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached } from "@/lib/serverData";
+import Avatar from "@/components/Avatar";
+import { formatRelative } from "@/lib/format";
+
+export const metadata: Metadata = {
+ title: "管理仪表盘",
+};
+
+// 精确千分位(管理员视角需要真实数值,不用首页的紧凑 K/M)
+const fmt = (n: number) => n.toLocaleString("zh-CN");
+
+function greeting(hour: number) {
+ if (hour < 6) return "凌晨好";
+ if (hour < 12) return "上午好";
+ if (hour < 14) return "中午好";
+ if (hour < 18) return "下午好";
+ return "晚上好";
+}
+
+// 统计 Bento 卡:总量 + 今日增量,语义色与全站令牌一致
+function StatCard({
+ label,
+ value,
+ foot,
+ icon,
+ color,
+ soft,
+}: {
+ label: string;
+ value: string;
+ foot: string;
+ icon: React.ReactNode;
+ color: string;
+ soft: string;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {icon}
+
+
+
+ {value}
+
+
+ {foot}
+
+
+ );
+}
+
+// 快捷管理入口:整卡可点(stretch-link),图标 + 标题 + 描述 + 箭头
+function QuickLink({
+ href,
+ icon,
+ title,
+ desc,
+ soft,
+ color,
+}: {
+ href: string;
+ icon: React.ReactNode;
+ title: string;
+ desc: string;
+ soft: string;
+ color: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function PanelHead({ title, extra }: { title: string; extra?: string }) {
+ return (
+
+
+ {title}
+
+ {extra && (
+
+ {extra}
+
+ )}
+
+ );
+}
+
+export default async function AdminDashboardPage() {
+ const cookie = authCookieHeader(await cookies());
+ // 与 layout 的 /me 请求共享 React cache;概览失败降级,不阻塞仪表盘骨架
+ const [me, overview] = await Promise.all([
+ getMeCached(cookie || undefined),
+ fetchOverview(cookie).catch(() => null),
+ ]);
+
+ const now = new Date();
+ const dateLabel = new Intl.DateTimeFormat("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ weekday: "long",
+ }).format(now);
+
+ const stats = overview?.stats;
+ const boards = overview?.boards ?? [];
+ const newUsers = overview?.new_users ?? [];
+ const topBoards = [...boards].sort((a, b) => b.post_count - a.post_count).slice(0, 6);
+ const maxBoardCount = Math.max(1, ...topBoards.map((b) => b.post_count));
+
+ return (
+
+ {/* 页头 */}
+
+
+ {greeting(now.getHours())},{me.user?.nickname}
+
+
{dateLabel} · 这里是站点运营全貌
+
+
+ {/* 统计 Bento */}
+
+ }
+ color="var(--accent)"
+ soft="var(--accent-soft)"
+ />
+ }
+ color="var(--clay)"
+ soft="var(--clay-soft)"
+ />
+ }
+ color="var(--ok)"
+ soft="var(--ok-soft)"
+ />
+ }
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+
+
+ {/* 快捷入口 */}
+
快捷管理
+
+ }
+ title="用户管理"
+ desc="查看全站成员,授予管理员权限或封禁违规账号"
+ soft="var(--accent-soft)"
+ color="var(--accent)"
+ />
+ }
+ title="公告管理"
+ desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态"
+ soft="var(--clay-soft)"
+ color="var(--clay)"
+ />
+ }
+ title="外观设置"
+ desc="配置全站主题强调色,实时预览后保存并对所有用户生效"
+ soft="var(--gold-soft)"
+ color="var(--gold)"
+ />
+
+
+ {/* 成员与板块 */}
+
+ {/* 最新注册成员 */}
+
+
+ {newUsers.length === 0 ? (
+
暂无成员数据
+ ) : (
+ newUsers.slice(0, 6).map((u) => (
+
+
+
+
+ {u.nickname}
+
+
+ @{u.username}
+
+
+
+
+ {formatRelative(u.created_at)}加入
+
+
+ ))
+ )}
+
+
+ {/* 板块分布 */}
+
+
+ {topBoards.length === 0 ? (
+
暂无板块数据
+ ) : (
+ topBoards.map((b) => (
+
+
+
+
+
+
+
+ {b.name}
+
+
+ {fmt(b.post_count)}
+
+
+ {/* 相对占比迷你条(纯装饰) */}
+
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx
new file mode 100644
index 0000000..8ef7e5d
--- /dev/null
+++ b/frontend/app/admin/users/UsersAdmin.tsx
@@ -0,0 +1,872 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import {
+ Ban,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ RotateCcw,
+ Search,
+ ShieldCheck,
+ ShieldOff,
+ Users as UsersIcon,
+ UserPlus,
+ X,
+} from "lucide-react";
+import {
+ apiAdminListUsers,
+ apiAdminSetUserBan,
+ apiAdminSetUserRole,
+ type AdminUser,
+ type AdminUserSummary,
+ type AdminUsersResponse,
+} from "@/lib/api";
+import { formatDate, formatRelative } from "@/lib/format";
+import { toast } from "@/lib/toast";
+import Avatar from "@/components/Avatar";
+
+export type UserFilter = "all" | "admin" | "banned";
+
+const PAGE_SIZE = 20;
+
+const FILTER_TABS: { key: UserFilter; label: string }[] = [
+ { key: "all", label: "全部" },
+ { key: "admin", label: "管理员" },
+ { key: "banned", label: "已封禁" },
+];
+
+function filterToParams(filter: UserFilter): { role?: string; status?: string } {
+ if (filter === "admin") return { role: "admin" };
+ if (filter === "banned") return { status: "banned" };
+ return {};
+}
+
+// 待二次确认的危险操作种类
+export type PendingKind = "promote" | "demote" | "ban" | "unban";
+
+export interface PendingAction {
+ kind: PendingKind;
+ user: AdminUser;
+}
+
+// 确认弹窗的文案与视觉配置(说明需拼入用户名,故 desc 为函数)
+const CONFIRM_CONFIG: Record<
+ PendingKind,
+ {
+ title: string;
+ desc: (u: AdminUser) => string;
+ confirmText: string;
+ tone: "accent" | "danger" | "ok";
+ icon: React.ReactNode;
+ }
+> = {
+ promote: {
+ title: "设为管理员",
+ desc: (u) =>
+ `@${u.username} 将获得管理面板访问权限,可管理公告、外观设置与用户账号。`,
+ confirmText: "设为管理员",
+ tone: "accent",
+ icon: ,
+ },
+ demote: {
+ title: "取消管理员",
+ desc: (u) =>
+ `将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
+ confirmText: "确认降级",
+ tone: "danger",
+ icon: ,
+ },
+ ban: {
+ title: "封禁账号",
+ desc: (u) =>
+ `封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
+ confirmText: "确认封禁",
+ tone: "danger",
+ icon: ,
+ },
+ unban: {
+ title: "解除封禁",
+ desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
+ confirmText: "解除封禁",
+ tone: "ok",
+ icon: ,
+ },
+};
+
+// 汇总小卡
+function MiniStat({
+ label,
+ value,
+ sub,
+ icon,
+ color,
+ soft,
+}: {
+ label: string;
+ value: string;
+ sub?: string;
+ icon: React.ReactNode;
+ color: string;
+ soft: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+ {sub && (
+
+ {sub}
+
+ )}
+
+
+
+ );
+}
+
+// 角色/状态徽章
+function Badge({
+ color,
+ soft,
+ icon,
+ children,
+}: {
+ color: string;
+ soft: string;
+ icon: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+export default function UsersAdmin({
+ initial,
+ initError,
+ initialQuery,
+ currentUserId,
+}: {
+ initial: AdminUsersResponse | null;
+ initError: string;
+ initialQuery: { q: string; filter: UserFilter; page: number };
+ currentUserId: number;
+}) {
+ const [users, setUsers] = useState(initial?.users ?? []);
+ const [summary, setSummary] = useState(
+ initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
+ );
+ const [total, setTotal] = useState(initial?.total ?? 0);
+ const [page, setPage] = useState(initialQuery.page);
+ const [filter, setFilter] = useState(initialQuery.filter);
+ const [keyword, setKeyword] = useState(initialQuery.q);
+ const [appliedQ, setAppliedQ] = useState(initialQuery.q);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(initError);
+ const [actingId, setActingId] = useState(null);
+ // 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
+ const [pending, setPending] = useState(null);
+
+ // 防止快速翻页/输入时旧响应覆盖新结果
+ const seqRef = useRef(0);
+ const debounceRef = useRef | null>(null);
+ const urlFirstRun = useRef(true);
+ const router = useRouter();
+
+ const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
+ const seq = ++seqRef.current;
+ setLoading(true);
+ try {
+ const res = await apiAdminListUsers({
+ page: opts.page,
+ size: PAGE_SIZE,
+ q: opts.q || undefined,
+ ...filterToParams(opts.filter),
+ });
+ if (seq !== seqRef.current) return;
+ setUsers(res.users);
+ setSummary(res.summary);
+ setTotal(res.total);
+ setError("");
+ } catch (e) {
+ if (seq !== seqRef.current) return;
+ setError(e instanceof Error ? e.message : "获取用户列表失败");
+ } finally {
+ if (seq === seqRef.current) setLoading(false);
+ }
+ }, []);
+
+ // 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ const q = keyword.trim();
+ if (q !== appliedQ) {
+ setAppliedQ(q);
+ setPage(1);
+ load({ page: 1, filter, q });
+ }
+ }, 400);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [keyword]);
+
+ // 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
+ useEffect(() => {
+ if (urlFirstRun.current) {
+ urlFirstRun.current = false;
+ return;
+ }
+ const p = new URLSearchParams();
+ if (appliedQ) p.set("q", appliedQ);
+ if (filter !== "all") p.set("filter", filter);
+ if (page > 1) p.set("page", String(page));
+ const qs = p.toString();
+ router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
+ }, [appliedQ, filter, page, router]);
+
+ const submitSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ const q = keyword.trim();
+ if (q === appliedQ) return;
+ setAppliedQ(q);
+ setPage(1);
+ load({ page: 1, filter, q });
+ };
+
+ // 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
+ const clearSearch = () => {
+ setKeyword("");
+ if (appliedQ !== "" || page !== 1) {
+ setAppliedQ("");
+ setPage(1);
+ load({ page: 1, filter, q: "" });
+ }
+ };
+
+ const changeFilter = (f: UserFilter) => {
+ if (f === filter) return;
+ setFilter(f);
+ setPage(1);
+ load({ page: 1, filter: f, q: appliedQ });
+ };
+
+ const goPage = (p: number) => {
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const next = Math.min(Math.max(1, p), totalPages);
+ if (next === page) return;
+ setPage(next);
+ load({ page: next, filter, q: appliedQ });
+ };
+
+ // 危险操作不再使用浏览器原生 confirm,统一走站点风格的二次确认弹窗
+ const requestRoleChange = (u: AdminUser) => {
+ setPending({ kind: u.role === "admin" ? "demote" : "promote", user: u });
+ };
+
+ const requestBanChange = (u: AdminUser) => {
+ setPending({ kind: u.banned ? "unban" : "ban", user: u });
+ };
+
+ // 确认弹窗中点击"确认"后执行;成功关闭弹窗并重拉列表,
+ // 保证筛选结果集与汇总计数一致;失败保留弹窗,错误走 toast
+ const confirmPending = async () => {
+ const action = pending;
+ if (!action) return;
+ const { kind, user: u } = action;
+ setActingId(u.id);
+ try {
+ if (kind === "promote" || kind === "demote") {
+ await apiAdminSetUserRole(u.id, kind === "promote" ? "admin" : "user");
+ toast(kind === "promote" ? "已设为管理员" : "已取消管理员", "ok");
+ } else {
+ await apiAdminSetUserBan(u.id, kind === "ban");
+ toast(kind === "ban" ? "已封禁" : "已解封", "ok");
+ }
+ setPending(null);
+ await load({ page, filter, q: appliedQ });
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "操作失败");
+ } finally {
+ setActingId(null);
+ }
+ };
+
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const tabCount = (f: UserFilter) =>
+ f === "all" ? summary.total : f === "admin" ? summary.admins : summary.banned;
+ // 有关键词或非默认筛选时,空态文案与"无成员"区分
+ const hasFilter = appliedQ !== "" || filter !== "all";
+
+ // 行内操作按钮(圆形幽灵按钮,32px)
+ const ghostBtn =
+ "w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
+
+ const renderActions = (u: AdminUser, isSelf: boolean) => {
+ if (isSelf) {
+ return (
+
+ 当前账号
+
+ );
+ }
+ const busy = actingId === u.id;
+ return (
+
+
+
+
+
+
+
+ );
+ };
+
+ const renderBadges = (u: AdminUser) => (
+
+ {u.role === "admin" ? (
+ }>
+ 管理员
+
+ ) : (
+ }>
+ 普通用户
+
+ )}
+ {u.banned && (
+ }>
+ 已封禁
+
+ )}
+
+ );
+
+ // 桌面端列模板(与表头一致)
+ const rowGrid =
+ "hidden md:grid md:grid-cols-[minmax(0,1.7fr)_88px_92px_104px_100px_108px_150px] gap-3 items-center px-4 sm:px-5 py-3.5";
+
+ return (
+
+ {/* 页头 */}
+
+
+
+
+
+ 用户管理
+
+
检索全部成员,管理管理员角色与封禁状态;操作即时生效
+
+
+ {/* 汇总 */}
+
+ 0 ? `今日 +${summary.today_new}` : undefined}
+ icon={}
+ color="var(--accent)"
+ soft="var(--accent-soft)"
+ />
+ }
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+ }
+ color="var(--danger)"
+ soft="var(--danger-soft)"
+ />
+ }
+ color="var(--ok)"
+ soft="var(--ok-soft)"
+ />
+
+
+ {/* 搜索 + 筛选 */}
+
+
+
+
+ {FILTER_TABS.map((t) => {
+ const active = filter === t.key;
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* 用户列表 */}
+
+
+
+ {appliedQ ? (
+ <>
+ 搜索“{appliedQ}”
+ >
+ ) : filter === "admin" ? (
+ "管理员"
+ ) : filter === "banned" ? (
+ "已封禁成员"
+ ) : (
+ "全部成员"
+ )}
+
+
+ 共 {total} 人
+
+
+
+ {/* 桌面表头 */}
+ {users.length > 0 && (
+
+ 成员
+ 角色
+ 状态
+ 内容
+ 注册
+ 最近活跃
+ 操作
+
+ )}
+
+
+ {error ? (
+
{error}
+ ) : loading && users.length === 0 ? (
+
+ 加载中…
+
+ ) : users.length === 0 ? (
+
+
+
+
+
+ {hasFilter ? "没有匹配的成员" : "暂无成员"}
+
+
+ {hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
+
+
+ ) : (
+ users.map((u) => {
+ const isSelf = u.id === currentUserId;
+ return (
+
+ {/* 桌面行 */}
+
+
+
+
+
+ {u.nickname}
+
+
+ @{u.username}
+ {u.email ? ` · ${u.email}` : ""}
+
+
+
+
+ {u.role === "admin" ? (
+ }>
+ 管理员
+
+ ) : (
+
+ 普通用户
+
+ )}
+
+
+ {u.banned ? (
+ }>
+ 已封禁
+
+ ) : (
+
+
+ 正常
+
+ )}
+
+
+ 帖 {u.post_count}
+ ·
+ 评 {u.comment_count}
+
+
+ {formatDate(u.created_at)}
+
+
+ {u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
+
+
{renderActions(u, isSelf)}
+
+
+ {/* 移动卡片 */}
+
+
+
+
+
+
+ {u.nickname}
+
+ {renderBadges(u)}
+ {isSelf && (
+
+ 当前账号
+
+ )}
+
+
+ @{u.username}
+ {u.email ? ` · ${u.email}` : ""}
+
+
+
+
+ 帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
+ {u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
+
+ {!isSelf && (
+
+
+ 主页
+
+
+
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+ {/* 分页 */}
+ {total > 0 && (
+
+
+ 第 {page} / {totalPages} 页 · 共 {total} 人
+
+
+
+
+
+
+ )}
+
+
+ {/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
+
setPending(null)}
+ onConfirm={confirmPending}
+ />
+
+ );
+}
+
+// 危险操作确认弹窗:遮罩 + 居中卡片;ESC/点遮罩等同取消,执行中锁定所有关闭途径
+function ActionConfirmDialog({
+ action,
+ busy,
+ onCancel,
+ onConfirm,
+}: {
+ action: PendingAction | null;
+ busy: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}) {
+ useEffect(() => {
+ if (!action) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !busy) onCancel();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [action, busy, onCancel]);
+
+ if (!action) return null;
+ const cfg = CONFIRM_CONFIG[action.kind];
+ const iconColor =
+ cfg.tone === "danger" ? "var(--danger)" : cfg.tone === "ok" ? "var(--ok)" : "var(--accent)";
+ const iconSoft =
+ cfg.tone === "danger"
+ ? "var(--danger-soft)"
+ : cfg.tone === "ok"
+ ? "var(--ok-soft)"
+ : "var(--accent-soft)";
+
+ return (
+ !busy && onCancel()}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="user-action-title"
+ aria-describedby="user-action-desc"
+ >
+
+
+ {cfg.icon}
+
+
+
+ {cfg.title}
+
+
+ {cfg.desc(action.user)}
+
+
+
+
+
+ {cfg.tone === "danger" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx
new file mode 100644
index 0000000..41a764e
--- /dev/null
+++ b/frontend/app/admin/users/page.tsx
@@ -0,0 +1,49 @@
+import type { Metadata } from "next";
+import { cookies } from "next/headers";
+import { authCookieHeader } from "@/lib/cookies";
+import { fetchAdminUsers, type AdminUsersResponse } from "@/lib/api";
+import { getMeCached } from "@/lib/serverData";
+import UsersAdmin, { type UserFilter } from "./UsersAdmin";
+
+export const metadata: Metadata = {
+ title: "用户管理",
+};
+
+interface UsersPageProps {
+ searchParams: Promise<{ q?: string; filter?: string; page?: string }>;
+}
+
+// 筛选项映射为后端查询参数:管理员走 role,封禁走 status,其余不过滤
+function filterToQuery(filter: UserFilter): { role?: string; status?: string } {
+ if (filter === "admin") return { role: "admin" };
+ if (filter === "banned") return { status: "banned" };
+ return {};
+}
+
+// 管理员鉴权由 app/admin/layout.tsx 统一承担;列表首屏 SSR 直出,F5 无骨架闪动
+export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
+ const sp = await searchParams;
+ const q = sp.q?.trim() || "";
+ const filter: UserFilter = sp.filter === "admin" || sp.filter === "banned" ? sp.filter : "all";
+ const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
+
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+
+ let initial: AdminUsersResponse | null = null;
+ let initError = "";
+ try {
+ initial = await fetchAdminUsers({ page, q, ...filterToQuery(filter) }, cookie || undefined);
+ } catch (e) {
+ initError = e instanceof Error ? e.message : "获取用户列表失败";
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 96a1aa9..490f4a1 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -5,6 +5,7 @@ import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
+import AccountGuard from "@/components/AccountGuard";
import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
@@ -102,6 +103,7 @@ export default async function RootLayout({
+