feat: 新增管理员用户管理功能与账号封禁体系

此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
This commit is contained in:
2026-09-15 02:52:26 +08:00
parent 24c6dea38c
commit 6da4309453
20 changed files with 2248 additions and 105 deletions

View File

@@ -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": "操作失败"})
}
}

View File

@@ -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)

View File

@@ -19,4 +19,5 @@ type Handlers struct {
Announcement *service.AnnouncementService
Upload *service.UploadService
Setting *service.SettingService
AdminUser *service.AdminUserService
}

View File

@@ -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,11 +47,11 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
if c.GetBool(AccountBannedKey) {
bannedJSON(c)
return
}
if user.Banned {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "账号已被封禁"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
c.Set("user", user)
@@ -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 限频一次),不阻塞请求

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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 (
<div className="panel p-3">
{/* 品牌头 */}
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
管理面板
</span>
<span className="block text-[10.5px] font-semibold mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
J13 ADMIN
</span>
</span>
</div>
{/* 导航 */}
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
站点管理
</p>
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
</Link>
);
})}
</nav>
{/* 返回前台 */}
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
<ArrowLeft size={16} className="shrink-0" style={{ color: "var(--ink-3)" }} />
返回站点
</Link>
</div>
{/* 管理员身份卡 */}
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
<p className="text-[13px] font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="text-[11px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
@{user.username}
</p>
</div>
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10.5px] font-bold leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<ShieldCheck size={11} />
管理员
</span>
</div>
</div>
);
}
/**
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
*/
export function AdminNavMobile() {
const pathname = usePathname();
return (
<div
className="lg:hidden sticky z-30 -mx-4 sm:-mx-6 px-4 sm:px-6 py-2.5 mb-4"
style={{
top: 68,
background: "color-mix(in srgb, var(--bg) 88%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderBottom: "1px solid var(--line)",
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
</Link>
);
})}
<Link
href="/"
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={{ color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}

View File

@@ -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 (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以发布和管理站点公告。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担;本页只负责内容直出
export default function AdminAnnouncementsPage() {
return <AnnouncementAdmin />;
}

View File

@@ -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 兜底。
export default async function AdminAppearancePage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以配置站点外观。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担。
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
export default async function AdminAppearancePage() {
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
}

View File

@@ -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 (
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
return (
<div>
<AdminNavMobile />
<div className="flex items-start gap-6 xl:gap-7">
<aside className="hidden lg:block w-60 xl:w-64 shrink-0">
<div className="sticky" style={{ top: 84 }}>
<AdminSidebar user={me.user} />
</div>
</aside>
<section className="flex-1 min-w-0">{children}</section>
</div>
</div>
);
}

323
frontend/app/admin/page.tsx Normal file
View File

@@ -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 (
<div className="panel p-4 sm:p-5">
<div className="flex items-start justify-between gap-2">
<p className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
</div>
<p
className="mt-3 text-[26px] sm:text-[30px] font-extrabold tabular-nums leading-none tracking-tight"
style={{ color: "var(--ink)" }}
>
{value}
</p>
<p className="mt-2 text-[11.5px] sm:text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>
{foot}
</p>
</div>
);
}
// 快捷管理入口:整卡可点(stretch-link),图标 + 标题 + 描述 + 箭头
function QuickLink({
href,
icon,
title,
desc,
soft,
color,
}: {
href: string;
icon: React.ReactNode;
title: string;
desc: string;
soft: string;
color: string;
}) {
return (
<Link href={href} className="panel hover-lift relative block p-5 group">
<div className="flex items-start gap-3.5">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta mt-1 text-[12.5px] leading-relaxed">{desc}</p>
</div>
<ChevronRight
size={17}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
</Link>
);
}
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
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 (
<div>
{/* 页头 */}
<div className="mb-5">
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{greeting(now.getHours())},{me.user?.nickname}
</h1>
<p className="meta mt-1.5 text-[12.5px]">{dateLabel} · 这里是站点运营全貌</p>
</div>
{/* 统计 Bento */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<StatCard
label="注册用户"
value={stats ? fmt(stats.users) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_users)}` : "统计暂不可用"}
icon={<Users size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<StatCard
label="话题总数"
value={stats ? fmt(stats.posts) : "—"}
foot={stats ? `今日新发 +${fmt(stats.today_posts)}` : "统计暂不可用"}
icon={<PenLine size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<StatCard
label="回复总数"
value={stats ? fmt(stats.comments) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_comments)}` : "统计暂不可用"}
icon={<MessageCircle size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<StatCard
label="在线用户"
value={stats ? fmt(stats.online) : "—"}
foot={stats ? `近 5 分钟活跃 · 峰值 ${fmt(stats.peak_online)}` : "统计暂不可用"}
icon={<Activity size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</div>
{/* 快捷入口 */}
<h2 className="sr-only">快捷管理</h2>
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4 mt-4">
<QuickLink
href="/admin/users"
icon={<Users size={18} />}
title="用户管理"
desc="查看全站成员,授予管理员权限或封禁违规账号"
soft="var(--accent-soft)"
color="var(--accent)"
/>
<QuickLink
href="/admin/announcements"
icon={<Megaphone size={18} />}
title="公告管理"
desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态"
soft="var(--clay-soft)"
color="var(--clay)"
/>
<QuickLink
href="/admin/appearance"
icon={<Palette size={18} />}
title="外观设置"
desc="配置全站主题强调色,实时预览后保存并对所有用户生效"
soft="var(--gold-soft)"
color="var(--gold)"
/>
</div>
{/* 成员与板块 */}
<div className="grid lg:grid-cols-2 gap-4 mt-4 items-start">
{/* 最新注册成员 */}
<div className="panel overflow-hidden">
<PanelHead title="最新注册成员" extra={stats ? `共 ${fmt(stats.users)} 人` : undefined} />
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 6).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px] font-semibold"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden="true" />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
</div>
{/* 板块分布 */}
<div className="panel overflow-hidden">
<PanelHead title="板块话题分布" extra={`${boards.length} 个板块`} />
{topBoards.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无板块数据</p>
) : (
topBoards.map((b) => (
<Link
key={b.id}
href={`/?board=${b.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--panel-2)", color: "var(--accent)" }}
aria-hidden="true"
>
<LayoutGrid size={14} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span className="text-[12px] font-bold tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
{fmt(b.post_count)}
</span>
</span>
{/* 相对占比迷你条(纯装饰) */}
<span
className="block mt-1.5 h-1 rounded-full overflow-hidden"
style={{ background: "var(--tint)" }}
aria-hidden="true"
>
<span
className="block h-full rounded-full"
style={{
width: `${Math.max(4, Math.round((b.post_count / maxBoardCount) * 100))}%`,
background: "var(--accent)",
}}
/>
</span>
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -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: <ShieldCheck size={23} />,
},
demote: {
title: "取消管理员",
desc: (u) =>
`将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
confirmText: "确认降级",
tone: "danger",
icon: <ShieldOff size={23} />,
},
ban: {
title: "封禁账号",
desc: (u) =>
`封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
confirmText: "确认封禁",
tone: "danger",
icon: <Ban size={23} />,
},
unban: {
title: "解除封禁",
desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
confirmText: "解除封禁",
tone: "ok",
icon: <RotateCcw size={21} />,
},
};
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{value}
{sub && (
<span className="ml-1.5 text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
)}
</p>
</div>
</div>
);
}
// 角色/状态徽章
function Badge({
color,
soft,
icon,
children,
}: {
color: string;
soft: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold leading-[1.6] whitespace-nowrap"
style={{ background: soft, color }}
>
{icon}
{children}
</span>
);
}
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<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
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<UserFilter>(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<number | null>(null);
// 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
const [pending, setPending] = useState<PendingAction | null>(null);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
);
}
const busy = actingId === u.id;
return (
<div className="flex items-center gap-1 justify-end">
<Link
href={`/u/${u.id}`}
title="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<ExternalLink size={15} />
</Link>
<button
type="button"
title={u.role === "admin" ? "取消管理员" : "设为管理员"}
aria-label={u.role === "admin" ? `取消 ${u.username} 的管理员` : `将 ${u.username} 设为管理员`}
disabled={busy}
onClick={() => requestRoleChange(u)}
className={ghostBtn}
style={u.role === "admin" ? { color: "var(--accent)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === "admin" ? "var(--accent)" : "var(--ink-3)";
}}
>
{busy ? <Loader2 size={15} className="animate-spin" /> : u.role === "admin" ? <ShieldOff size={15} /> : <ShieldCheck size={15} />}
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => requestBanChange(u)}
className={ghostBtn}
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--danger-soft)";
e.currentTarget.style.color = "var(--danger)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
}}
>
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
</button>
</div>
);
};
const renderBadges = (u: AdminUser) => (
<div className="flex items-center gap-1.5 flex-wrap">
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<Badge color="var(--ink-3)" soft="var(--panel-2)" icon={<UsersIcon size={11} />}>
普通用户
</Badge>
)}
{u.banned && (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
)}
</div>
);
// 桌面端列模板(与表头一致)
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 (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UsersIcon size={18} />
</span>
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">检索全部成员,管理管理员角色与封禁状态;操作即时生效</p>
</div>
{/* 汇总 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
label="注册用户"
value={String(summary.total)}
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
icon={<UsersIcon size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
label="管理员"
value={String(summary.admins)}
icon={<ShieldCheck size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
label="已封禁"
value={String(summary.banned)}
icon={<Ban size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
<MiniStat
label="当前筛选"
value={String(total)}
sub="人"
icon={<UserPlus size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
</div>
{/* 搜索 + 筛选 */}
<div className="panel p-3 sm:p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<form onSubmit={submitSearch} className="relative flex-1" role="search">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索用户名、昵称或邮箱…"
aria-label="搜索用户"
className="field pl-10 pr-9 py-2.5"
/>
{keyword && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
)}
</form>
<div className="flex items-center gap-1.5 flex-wrap" role="group" aria-label="用户筛选">
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => changeFilter(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: active ? "var(--panel)" : "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{tabCount(t.key)}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 用户列表 */}
<div className="panel overflow-hidden">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "admin" ? (
"管理员"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
{/* 桌面表头 */}
{users.length > 0 && (
<div
className={`${rowGrid} text-[11.5px] font-semibold uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span>成员</span>
<span>角色</span>
<span>状态</span>
<span>内容</span>
<span>注册</span>
<span>最近活跃</span>
<span className="text-right">操作</span>
</div>
)}
<div aria-busy={loading} style={{ opacity: loading ? 0.55 : 1, transition: "opacity .15s" }}>
{error ? (
<p className="alert-error m-4">{error}</p>
) : loading && users.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : users.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<UsersIcon size={22} />
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{hasFilter ? "没有匹配的成员" : "暂无成员"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
</p>
</div>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<div className="flex items-center gap-3 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<div>
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<span className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
普通用户
</span>
)}
</div>
<div>
{u.banned ? (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
) : (
<span className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold" style={{ color: "var(--ok)" }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--ok)" }} aria-hidden="true" />
正常
</span>
)}
</div>
<p className="text-[12.5px] font-semibold tabular-nums" style={{ color: "var(--ink-2)" }}>
帖 {u.post_count}
<span className="mx-1" style={{ color: "var(--line-2)" }}>·</span>
评 {u.comment_count}
</p>
<p className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
</p>
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
</div>
{/* 移动卡片 */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
{renderBadges(u)}
{isSelf && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
)}
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<p className="mt-2.5 text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
</p>
{!isSelf && (
<div className="flex items-center gap-2 mt-3">
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestRoleChange(u)}
className="btn btn-line btn-sm"
style={u.role === "admin" ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
{actingId === u.id ? (
<Loader2 size={13} className="animate-spin" />
) : u.role === "admin" ? (
<ShieldOff size={13} />
) : (
<ShieldCheck size={13} />
)}
{u.role === "admin" ? "取消管理员" : "设为管理员"}
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestBanChange(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</div>
)}
</div>
</div>
);
})
)}
</div>
{/* 分页 */}
{total > 0 && (
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between gap-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页 · 共 {total} 人
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goPage(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => goPage(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
<ActionConfirmDialog
action={pending}
busy={actingId === pending?.user.id}
onCancel={() => setPending(null)}
onConfirm={confirmPending}
/>
</div>
);
}
// 危险操作确认弹窗:遮罩 + 居中卡片;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 (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onCancel()}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="user-action-title"
aria-describedby="user-action-desc"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{ background: iconSoft, color: iconColor }}
aria-hidden="true"
>
{cfg.icon}
</span>
<div className="min-w-0 flex-1">
<h3
id="user-action-title"
className="text-[16px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
{cfg.title}
</h3>
<p id="user-action-desc" className="meta mt-1.5 text-[13px] leading-relaxed">
{cfg.desc(action.user)}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
type="button"
className="btn btn-line"
onClick={onCancel}
disabled={busy}
autoFocus
>
取消
</button>
{cfg.tone === "danger" ? (
<button
type="button"
className="btn btn-danger"
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
) : (
<button
type="button"
className="btn"
style={
cfg.tone === "ok"
? { background: "var(--ok-soft)", color: "var(--ok)" }
: undefined
}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<UsersAdmin
initial={initial}
initError={initError}
initialQuery={{ q, filter, page }}
currentUserId={me.user?.id ?? 0}
/>
);
}

View File

@@ -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({
</main>
<Footer />
<Toaster />
<AccountGuard />
</body>
</html>
);

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Ban } from "lucide-react";
import { onForceLogout } from "@/lib/userEvents";
/**
* 账号安全守卫:监听全局强制下线事件,被封禁时弹出不可误关的告知模态。
*
* 登录态清理(cookie/Header 游客化/router.refresh)由 Header 复用统一
* 登出链路完成;本组件只负责"告知"这一职责,故不处理任何凭据逻辑。
* 挂载于 root layout,覆盖任意页面(含后台)。
*/
export default function AccountGuard() {
const router = useRouter();
const [banned, setBanned] = useState(false);
useEffect(
() =>
onForceLogout((reason) => {
if (reason === "banned") setBanned(true);
}),
[]
);
if (!banned) return null;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 60%, transparent)" }}
role="alertdialog"
aria-modal="true"
aria-labelledby="banned-dialog-title"
aria-describedby="banned-dialog-desc"
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6 text-center"
style={{ boxShadow: "var(--shadow-lg)" }}
>
<span
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
<Ban size={26} />
</span>
<h2
id="banned-dialog-title"
className="mt-4 text-[18px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
账号已被封禁
</h2>
<p id="banned-dialog-desc" className="meta mt-2 text-[13px] leading-relaxed">
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
如对处理结果有异议,请联系管理员核实。
</p>
<button
type="button"
onClick={() => {
setBanned(false);
// 回到首页展示游客视图(若当前停留在受保护页面)
router.push("/");
}}
className="btn btn-primary w-full mt-5"
autoFocus
>
我知道了
</button>
</div>
</div>
);
}

View File

@@ -18,12 +18,11 @@ import {
Home,
User as UserIcon,
ChevronDown,
Megaphone,
Palette,
ShieldCheck,
} from "lucide-react";
import { apiLogout, apiMe, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { onUserUpdate } from "@/lib/userEvents";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
@@ -60,6 +59,21 @@ export default function Header({
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
}), []);
// 强制下线(当前唯一来源:账号被管理员封禁)。
// 统一复用登出清理:尽力调用 /api/logout 清除 HttpOnly cookie(该端点不要求
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
useEffect(() => onForceLogout(() => {
setMenuOpen(false);
apiLogout()
.catch(() => {})
.finally(() => {
setUser(null);
setUnread(0);
router.refresh();
});
}), [router]);
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求
@@ -281,14 +295,9 @@ export default function Header({
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user?.role === "admin" && (
<>
<MobileLink href="/admin/announcements">
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
<MobileLink href="/admin/appearance">
<span className="inline-flex items-center gap-2"><Palette size={15} /> 外观设置</span>
</MobileLink>
</>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
@@ -449,11 +458,8 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
</div>
{user.role === "admin" && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理
</Link>
<Link href="/admin/appearance" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Palette size={15} style={{ color: "var(--accent)" }} /> 外观设置
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
</Link>
</div>
)}

View File

@@ -21,16 +21,28 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 轮询未读数(30 秒)
// 轮询未读数(30 秒);页面从后台切回时立即探测一次,
// 使账号被封禁等服务端状态变化在用户切回标签页的瞬间即可被发现
useEffect(() => {
let cancelled = false;
const fetchCount = () => {
apiUnreadCount()
.then((res) => setUnread(res.count || 0))
.then((res) => {
if (!cancelled) setUnread(res.count || 0);
})
.catch(() => {});
};
fetchCount();
const timer = setInterval(fetchCount, 30000);
return () => clearInterval(timer);
const onVisible = () => {
if (document.visibilityState === "visible") fetchCount();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
cancelled = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
// 点击外部关闭下拉

View File

@@ -1,4 +1,5 @@
import { CSRF_COOKIE } from "./cookies";
import { emitForceLogout } from "./userEvents";
// API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
@@ -35,6 +36,9 @@ export interface User {
export interface MeResponse {
user: User | null;
unread_count: number;
// 后端 OptionalAuth 识别到凭据所属账号已封禁时携带(HTTP 仍 200,不阻断页面)
banned?: boolean;
code?: string;
}
export interface Board {
@@ -279,25 +283,51 @@ function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<boolean> | null = null;
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
let bannedNotified = false;
// 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方
function detectBannedBody(res: Response): void {
if (res.status !== 403 || bannedNotified) return;
res
.clone()
.json()
.then((data: { code?: string }) => {
if (data?.code === "account_banned" && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
}
})
.catch(() => {});
}
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<boolean> {
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
return res.ok;
if (res.ok) return { ok: true, banned: false };
if (res.status === 403) {
const data = await res.json().catch(() => ({} as { code?: string }));
if (data.code === "account_banned") return { ok: false, banned: true };
}
return { ok: false, banned: false };
} catch {
return false;
return { ok: false, banned: false };
}
}
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求;
// 任意环节识别到账号封禁,派发全局强制下线事件(只派一次)
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
let res = await fetch(url, { ...init, credentials: "include" });
detectBannedBody(res);
if (res.status === 401) {
// 串行化 refresh:多个并发 401 只触发一次 refresh
@@ -306,12 +336,16 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
const result = await refreshPromise;
if (result.ok) {
// refresh 成功,重试原请求(CSRF cookie 可能已更新)
const newHeaders = clientHeaders();
const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders };
res = await fetch(url, mergedInit);
detectBannedBody(res);
} else if (result.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
}
}
return res;
@@ -430,7 +464,13 @@ export async function apiMe(): Promise<MeResponse> {
credentials: "include",
cache: "no-store",
});
return res.json();
const data: MeResponse = await res.json();
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
if (data.banned === true && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
}
return data;
}
export async function apiLogin(username: string, password: string) {
@@ -440,7 +480,10 @@ export async function apiLogin(username: string, password: string) {
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, password }),
});
return res.json();
const data = await res.json();
// 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示)
if (res.ok && data?.user) bannedNotified = false;
return data;
}
export async function apiRegister(username: string, email: string, password: string) {
@@ -739,6 +782,110 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
}
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃仅管理员接口返回
export interface AdminUser {
id: number;
username: string;
nickname: string;
email: string;
avatar: string;
signature: string;
role: "user" | "admin" | string;
banned: boolean;
post_count: number;
comment_count: number;
created_at: string;
last_seen_at: string | null;
}
export interface AdminUserSummary {
total: number;
admins: number;
banned: number;
today_new: number;
}
export interface AdminUsersResponse {
users: AdminUser[];
total: number;
page: number;
size: number;
summary: AdminUserSummary;
}
export interface AdminUsersQuery {
page?: number;
size?: number;
q?: string;
role?: string;
status?: string;
}
function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
const params = new URLSearchParams();
if (query.page && query.page > 1) params.set("page", String(query.page));
if (query.size) params.set("size", String(query.size));
if (query.q) params.set("q", query.q);
if (query.role) params.set("role", query.role);
if (query.status) params.set("status", query.status);
return params;
}
// SSR 后台用户列表(转发管理员 cookie 直连后端);权限不足时抛错由页面层处理
export async function fetchAdminUsers(
query: AdminUsersQuery,
cookieHeader?: string
): Promise<AdminUsersResponse> {
const res = await fetch(
`${API_BASE}/api/admin/users?${adminUsersParams(query)}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) throw new Error("获取用户列表失败");
return res.json();
}
// 客户端后台用户列表(走 rewrite,携带 cookie + CSRF)
export async function apiAdminListUsers(query: AdminUsersQuery = {}): Promise<AdminUsersResponse> {
const res = await fetchWithRefresh(`/api/admin/users?${adminUsersParams(query)}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取用户列表失败");
return data as AdminUsersResponse;
}
// 变更用户角色:role 仅接受 "user" | "admin"
export async function apiAdminSetUserRole(
id: number,
role: "user" | "admin"
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/role`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ role }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "角色更新失败");
return data as { user: AdminUser };
}
// 封禁 / 解封用户
export async function apiAdminSetUserBan(
id: number,
banned: boolean
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/ban`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ banned }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "操作失败");
return data as { user: AdminUser };
}
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise<PublicSettings> {

View File

@@ -27,3 +27,31 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
window.addEventListener(USER_UPDATED_EVENT, listener);
return () => window.removeEventListener(USER_UPDATED_EVENT, listener);
}
/**
* 强制下线事件。
*
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发,
* Header 负责复用登出清理链路(清 cookie、游客化、router.refresh),
* AccountGuard 负责弹出封禁告知——状态清理与告知 UI 解耦,
* 避免各请求调用方各自处理导致残留(参考强制下线单一入口原则)。
*/
export type ForceLogoutReason = "banned";
export const FORCE_LOGOUT_EVENT = "j13:force-logout";
export function emitForceLogout(reason: ForceLogoutReason = "banned"): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(FORCE_LOGOUT_EVENT, { detail: { reason } }));
}
export function onForceLogout(handler: (reason: ForceLogoutReason) => void): () => void {
if (typeof window === "undefined") return () => {};
const listener = (e: Event) => {
const detail = (e as CustomEvent<{ reason: ForceLogoutReason }>).detail;
handler(detail?.reason ?? "banned");
};
window.addEventListener(FORCE_LOGOUT_EVENT, listener);
return () => window.removeEventListener(FORCE_LOGOUT_EVENT, listener);
}