From 6da430945351cb9382d29bf46a693ea7dc76aa8f Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 15 Sep 2026 02:52:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=E8=B4=A6=E5=8F=B7=E5=B0=81=E7=A6=81=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交完成了全站管理员后台核心功能开发: 1. 新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封 2. 重构认证链路,统一账号封禁错误码与处理逻辑 3. 新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态 4. 重构管理员后台路由与布局,统一权限校验逻辑 5. 优化通知轮询逻辑,页面切回前台时立即刷新未读计数 6. 合并原公告、外观管理页到统一后台面板 --- backend/handler/admin_user.go | 111 +++ backend/handler/auth.go | 20 +- backend/handler/handlers.go | 1 + backend/middleware/auth.go | 30 +- backend/router/router.go | 9 +- backend/service/admin_user.go | 293 ++++++++ backend/service/auth.go | 29 +- frontend/app/admin/AdminNav.tsx | 169 +++++ frontend/app/admin/announcements/page.tsx | 34 +- frontend/app/admin/appearance/page.tsx | 35 +- frontend/app/admin/layout.tsx | 53 ++ frontend/app/admin/page.tsx | 323 ++++++++ frontend/app/admin/users/UsersAdmin.tsx | 872 ++++++++++++++++++++++ frontend/app/admin/users/page.tsx | 49 ++ frontend/app/layout.tsx | 2 + frontend/components/AccountGuard.tsx | 74 ++ frontend/components/Header.tsx | 38 +- frontend/components/NotificationBell.tsx | 18 +- frontend/lib/api.ts | 165 +++- frontend/lib/userEvents.ts | 28 + 20 files changed, 2248 insertions(+), 105 deletions(-) create mode 100644 backend/handler/admin_user.go create mode 100644 backend/service/admin_user.go create mode 100644 frontend/app/admin/AdminNav.tsx create mode 100644 frontend/app/admin/layout.tsx create mode 100644 frontend/app/admin/page.tsx create mode 100644 frontend/app/admin/users/UsersAdmin.tsx create mode 100644 frontend/app/admin/users/page.tsx create mode 100644 frontend/components/AccountGuard.tsx 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 ( +
+ +
+ +
{children}
+
+
+ ); +} 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} +

+ +
+

+ {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 ( + +
+ +
+

+ {title} +

+

{desc}

+
+ +
+ + ); +} + +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} + + + + + + )) + )} +
+ + {/* 板块分布 */} +
+ + {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 ( +
+ +
+

+ {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)" + /> +
+ + {/* 搜索 + 筛选 */} +
+
+
+ + setKeyword(e.target.value)} + placeholder="搜索用户名、昵称或邮箱…" + aria-label="搜索用户" + className="field pl-10 pr-9 py-2.5" + /> + {keyword && ( + + )} + +
+ {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.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({