完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:30:08 +08:00
parent 1f2e4b2a33
commit c0462a3500
37 changed files with 1340 additions and 472 deletions

View File

@@ -150,14 +150,16 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致
## 四级 RBAC + 内容审核(2026-09)
- 角色:`owner`(全站唯一,迁移把 id=1 testuser 升站长,唯一硬保护:不可改角色/封禁) > `super_admin`(全站后台,仅 owner 可授予) > `admin`(公告+全站帖/评论审核+置顶加精) > `board_admin`(仅 user_boards 授权板块帖/评论审核) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids);RequirePerm(PermAnnouncements/PermUsers/PermSettings) 挂在 staffAPI 子组;板块隔离在 service 层按 Actor.BoardIDs
- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台+群聊全站监管,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids+can_manage_messages);RequirePerm(announcements/users/settings/messages);板块隔离按 BoardIDs;板管仪表盘 analytics 按授权板过滤
- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);站长可在群内任命 `ChatRoleAdmin`;站长/超管 `CanOverseeChat` 含私聊;禁言 API 已接
- 彻底删除帖/评:仅 owner;其余管理员仅软删+恢复
- 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible)
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403
- 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列
- 用户安全设置「最近登录设备」:GET /api/me/login-devices(RequireAuth,仅本人仍有效的 refresh 会话,按归一化 IP+UA 指纹去重);DELETE /api/me/login-devices/:id 剔除其它设备(吊销该指纹下除当前会话外的 refresh,JWT `fid` 使 access 立即失效,需重新输入密码)。展示设备/浏览器与原始 IP(不使用 ip2region);本机标 current=当前在线,其余有效会话标在线。SSR 转发 refresh cookie + 浏览器 UA/IP,直出 `/u/:id?tab=security`
- 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400
- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(待审角标 WS `moderation:changed` + 回前台 HTTP 校准,无定时轮询);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...]
- 前端:lib/roles.ts(canModerateAuthor/canPurgeContent/canAccessAdminMessages 等);RoleBadge;Forbidden.tsx;用户管理 /admin/users
- 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组
---

View File

@@ -73,6 +73,27 @@ func (h *Handlers) AdminUpdateUserRole(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": user})
}
// AdminSetUserMessages 站长授予/撤销站点消息管理
func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
id, ok := parseAdminUserID(c)
if !ok {
return
}
var body struct {
CanManageMessages bool `json:"can_manage_messages"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
user, err := h.AdminUser.SetCanManageMessages(middleware.CurrentActor(c), id, body.CanManageMessages)
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)

View File

@@ -309,6 +309,7 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H {
"email": user.Email,
"role": user.Role,
"board_ids": boardIDs,
"can_manage_messages": user.CanManageMessages,
}
}
@@ -346,8 +347,15 @@ func (h *Handlers) Me(c *gin.Context) {
if user.Role == model.RoleBoardAdmin {
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
}
body := meUserBody(user, boardIDs)
// 群管理员数量:非站长/超管用于后台消息入口判定
if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin {
if n, err := h.Chat.CountAdminRooms(user.ID); err == nil {
body["chat_admin_room_count"] = n
}
}
c.JSON(http.StatusOK, gin.H{
"user": meUserBody(user, boardIDs),
"user": body,
"unread_count": unread,
"chat_unread_count": chatUnread,
})

View File

@@ -21,9 +21,12 @@ func chatErrToStatus(err error) int {
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve),
errors.Is(err, service.ErrChatHallPinFixed):
errors.Is(err, service.ErrChatHallPinFixed), errors.Is(err, service.ErrChatSiteOwnerOnly),
errors.Is(err, service.ErrChatMuteDenied), errors.Is(err, service.ErrChatCannotMuteOwner),
errors.Is(err, service.ErrChatAdminAccess):
return http.StatusForbidden
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone):
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone),
errors.Is(err, service.ErrChatInvalidRole):
return http.StatusBadRequest
default:
return http.StatusBadRequest
@@ -307,7 +310,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
if err := h.Chat.Kick(claims.ID, roomID, uint(targetID)); err != nil {
if err := h.Chat.Kick(claims.ID, roomID, uint(targetID), h.chatOversee(claims.ID)); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
@@ -319,6 +322,63 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SetChatMemberMute 禁言/解禁成员
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
var body struct {
Muted bool `json:"muted"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
}
// SetChatMemberRole 站长任命/撤销群管理员
func (h *Handlers) SetChatMemberRole(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
actor := h.loadActor(claims.ID)
role := model.RoleUser
if actor != nil {
role = actor.Role
}
if err := h.Chat.SetMemberRole(role, roomID, uint(targetID), body.Role); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "role": body.Role})
}
// ListChatMessages 历史消息
func (h *Handlers) ListChatMessages(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -474,11 +534,21 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
func (h *Handlers) AdminChatMessages(c *gin.Context) {
claims := middleware.CurrentUser(c)
actor := h.loadActor(claims.ID)
if actor == nil || !h.Chat.CanAccessAdminMessages(actor) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
q := c.Query("q")
roomType := c.Query("room_type")
msgs, total, err := h.Chat.AdminListMessages(page, q, roomType)
msgs, total, err := h.Chat.AdminListMessages(actor, page, q, roomType)
if err != nil {
if errors.Is(err, service.ErrChatAdminAccess) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if err.Error() == "房间类型无效" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -486,12 +556,24 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"})
return
}
c.JSON(http.StatusOK, gin.H{"messages": msgs, "total": total, "page": page})
c.JSON(http.StatusOK, gin.H{
"messages": msgs,
"total": total,
"page": page,
"can_oversee": service.CanOverseeChat(actor.Role),
"can_recall": true,
"site_messages": actor.HasSiteMessagePerm(),
})
}
// AdminRecallChatMessage 管理端撤回群聊消息(staff;监管者可撤任意)
// AdminRecallChatMessage 管理端撤回群聊消息
func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
actor := h.loadActor(claims.ID)
if actor == nil || !h.Chat.CanAccessAdminMessages(actor) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"})
return
}
roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64)
if err != nil || roomID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"})
@@ -502,8 +584,7 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
return
}
oversee := h.chatOversee(claims.ID)
// 非监管 staff:仅允许撤回自己发送的(与前台一致)
oversee := service.CanOverseeChat(actor.Role)
msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})

View File

@@ -176,7 +176,8 @@ func (h *Handlers) DeleteComment(c *gin.Context) {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
case errors.Is(err, service.ErrCommentForbidden),
errors.Is(err, service.ErrAuthorProtected):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentDeleteMeta),
errors.Is(err, service.ErrCommentInvalidType):
@@ -206,7 +207,8 @@ func (h *Handlers) RestoreComment(c *gin.Context) {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
case errors.Is(err, service.ErrCommentForbidden),
errors.Is(err, service.ErrAuthorProtected):
c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"})
case errors.Is(err, service.ErrCommentNotDeleted):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -218,7 +220,7 @@ func (h *Handlers) RestoreComment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "已恢复"})
}
// PurgeComment 硬删评论及子树(仅版主;不占位)
// PurgeComment 硬删评论及子树(仅站长;不占位)
func (h *Handlers) PurgeComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
@@ -230,7 +232,8 @@ func (h *Handlers) PurgeComment(c *gin.Context) {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
case errors.Is(err, service.ErrCommentForbidden),
errors.Is(err, service.ErrPurgeOwnerOnly):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})

View File

@@ -247,7 +247,9 @@ func (h *Handlers) writeModerationError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, service.ErrContentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
case errors.Is(err, service.ErrModerationForbidden):
case errors.Is(err, service.ErrModerationForbidden),
errors.Is(err, service.ErrAuthorProtected),
errors.Is(err, service.ErrPurgeOwnerOnly):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrNotPending),
errors.Is(err, service.ErrContentNotDeleted):

View File

@@ -536,7 +536,8 @@ func respondPostModError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrPostNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden):
case errors.Is(err, service.ErrPostForbidden),
errors.Is(err, service.ErrAuthorProtected):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryPrizesLocked):

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -47,7 +48,13 @@ func (h *Handlers) TelemetryPageView(c *gin.Context) {
// GET /api/admin/analytics/overview?range=7d|30d
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
rangeQ := c.DefaultQuery("range", "7d")
data, err := h.Analytics.Overview(rangeQ)
actor := middleware.CurrentActor(c)
scoped := actor != nil && actor.Role == model.RoleBoardAdmin
var boardIDs []uint
if scoped {
boardIDs = actor.BoardIDs
}
data, err := h.Analytics.Overview(rangeQ, boardIDs, scoped)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
return

View File

@@ -121,6 +121,7 @@ type User struct {
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
@@ -483,9 +484,19 @@ type LoginLog struct {
// 群聊成员角色
const (
ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言
ChatRoleAdmin = "admin" // 群管理员(仅站长可任命):本群撤回他人消息/踢人/禁言
ChatRoleMember = "member" // 普通成员
)
// ValidChatMemberRole 群内角色白名单(任命接口仅允许 admin/member)
func ValidChatMemberRole(r string) bool {
switch r {
case ChatRoleOwner, ChatRoleAdmin, ChatRoleMember:
return true
}
return false
}
// ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅
type ChatRoom struct {
ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -208,6 +208,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
chatAPI.GET("/rooms/:id/members", h.ListChatMembers)
chatAPI.POST("/rooms/:id/members", h.InviteChatMembers)
chatAPI.DELETE("/rooms/:id/members/:uid", h.KickChatMember)
chatAPI.PUT("/rooms/:id/members/:uid/mute", h.SetChatMemberMute)
chatAPI.PUT("/rooms/:id/members/:uid/role", h.SetChatMemberRole)
chatAPI.GET("/rooms/:id/messages", h.ListChatMessages)
chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage)
chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage)
@@ -244,12 +246,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 积分与类型帖概览(管理员及以上)
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
// 站点分析概览(任意 staff)
// 站点分析概览(任意 staff;板管按授权板过滤)
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
// 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则)
staffAPI.GET("/chat/messages", h.AdminChatMessages)
staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage)
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
// (见下方 msgAPI)
// 站点公告文章管理(管理员及以上)
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
@@ -275,6 +276,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
usersAPI.GET("/users", h.AdminListUsers)
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
// 站长内容档案(handler 内再校验 owner)
@@ -284,6 +286,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
}
// 消息管理后台:登录即可(handler 内校验站点消息权或群管理员)
msgAPI := r.Group("/api/admin", authMW.RequireAuth(), middleware.CSRFMiddleware())
{
msgAPI.GET("/chat/messages", h.AdminChatMessages)
msgAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage)
}
r.NoRoute(func(c *gin.Context) {
c.JSON(404, gin.H{"error": "not found"})
})

View File

@@ -13,6 +13,7 @@ const (
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
)
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
@@ -22,6 +23,7 @@ type Actor struct {
Username string
Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
}
// IsStaff 是否管理团队成员
@@ -41,10 +43,28 @@ func (a *Actor) HasPerm(p string) bool {
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
case PermModeration:
return a.IsStaff()
case PermMessages:
return a.HasSiteMessagePerm()
}
return false
}
// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages
func (a *Actor) HasSiteMessagePerm() bool {
if a == nil {
return false
}
if a.Role == model.RoleOwner || a.Role == model.RoleSuperAdmin {
return true
}
return a.CanManageMessages
}
// CanPurgeContent 彻底删除帖/评:仅站长
func (a *Actor) CanPurgeContent() bool {
return a != nil && a.Role == model.RoleOwner
}
// CanModerateBoard 是否可管理指定板块的内容:
// 管理员及以上不限板块;板块管理员仅限被授权板块
func (a *Actor) CanModerateBoard(boardID uint) bool {
@@ -65,6 +85,17 @@ func (a *Actor) CanModerateBoard(boardID uint) bool {
return false
}
// CanModerateAuthor 板管不可软删/恢复管理员及以上作者的内容;其他角色不受此限
func (a *Actor) CanModerateAuthor(authorRole model.Role) bool {
if a == nil {
return false
}
if a.Role == model.RoleBoardAdmin {
return model.RoleLevel(authorRole) < model.RoleLevel(model.RoleAdmin)
}
return true
}
// CanAssignRole 操作者能否把他人设置为目标角色(站长不可被授予,仅站长可授予超管)
func (a *Actor) CanAssignRole(target model.Role) bool {
if a == nil {
@@ -82,10 +113,13 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
// LoadActor 读取用户实时角色与板块授权
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
var u model.User
if err := s.db.Select("id", "username", "role").First(&u, id).Error; err != nil {
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
return nil, err
}
actor := &Actor{ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{}}
actor := &Actor{
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
CanManageMessages: u.CanManageMessages,
}
if u.Role == model.RoleBoardAdmin {
var ids []uint
if err := s.db.Model(&model.UserBoard{}).
@@ -99,6 +133,15 @@ func (s *AuthService) LoadActor(id uint) (*Actor, error) {
return actor, nil
}
// GetUserRole 读取用户角色(内容作者保护用);用户不存在返回 user
func (s *AuthService) GetUserRole(id uint) (model.Role, error) {
var u model.User
if err := s.db.Select("role").First(&u, id).Error; err != nil {
return model.RoleUser, err
}
return u.Role, nil
}
// GetUserBoardIDs 读取用户被授权的板块 ID(供 /me 等场景)
func (s *AuthService) GetUserBoardIDs(id uint) ([]uint, error) {
var ids []uint

View File

@@ -15,6 +15,25 @@ var (
ErrContentNotFound = errors.New("内容不存在")
)
func (s *ModerationService) authorRole(userID uint) (model.Role, error) {
var u model.User
if err := s.db.Select("role").First(&u, userID).Error; err != nil {
return model.RoleUser, err
}
return u.Role, nil
}
func (s *ModerationService) assertCanModerateAuthor(actor *Actor, authorID uint) error {
role, err := s.authorRole(authorID)
if err != nil {
return err
}
if !actor.CanModerateAuthor(role) {
return ErrAuthorProtected
}
return nil
}
const adminContentPageSize = 15
// AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除
@@ -303,12 +322,15 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C
return &cm, post.BoardID, nil
}
// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者
// SoftDeletePost 软删帖子(未删除时);须选类型,「其他」才须填理由,并通知作者
func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) {
post, err := s.loadPostUnscoped(actor, id)
if err != nil {
return 0, err
}
if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil {
return 0, err
}
if post.DeletedAt.Valid {
return 0, errors.New("帖子已在回收站")
}
@@ -317,12 +339,16 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de
if !model.ValidPostDeleteType(deleteType) {
return 0, errors.New("无效的删除类型")
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return 0, errors.New("请填写删除理由")
}
if len([]rune(deleteReason)) > 200 {
return 0, errors.New("删除理由不能超过 200 字")
}
} else {
deleteReason = ""
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
@@ -343,14 +369,20 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error {
if err != nil {
return err
}
if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil {
return err
}
if !post.DeletedAt.Valid {
return errors.New("帖子未被删除")
}
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
}
// PurgePost 彻底删除已软删帖子及其评论
// PurgePost 彻底删除已软删帖子及其评论(仅站长)
func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
if !actor.CanPurgeContent() {
return ErrPurgeOwnerOnly
}
post, err := s.loadPostUnscoped(actor, id)
if err != nil {
return err
@@ -372,6 +404,9 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType,
if err != nil {
return err
}
if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil {
return err
}
if cm.DeletedAt.Valid {
return errors.New("评论已在回收站")
}
@@ -414,6 +449,9 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
if err != nil {
return err
}
if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil {
return err
}
if !cm.DeletedAt.Valid {
return errors.New("评论未被删除")
}
@@ -433,8 +471,11 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
return nil
}
// PurgeComment 彻底删除已软删评论
// PurgeComment 彻底删除已软删评论(仅站长)
func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
if !actor.CanPurgeContent() {
return ErrPurgeOwnerOnly
}
cm, _, err := s.loadCommentUnscoped(actor, id)
if err != nil {
return err

View File

@@ -53,6 +53,7 @@ type AdminUserItem struct {
Role string `json:"role"`
BoardIDs []uint `json:"board_ids"`
Banned bool `json:"banned"`
CanManageMessages bool `json:"can_manage_messages"`
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
Points int `json:"points"`
@@ -231,6 +232,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
Role: string(u.Role),
BoardIDs: []uint{},
Banned: u.Banned,
CanManageMessages: u.CanManageMessages,
Points: u.Points,
CreatedAt: u.CreatedAt,
LastSeenAt: u.LastSeenAt,
@@ -407,6 +409,38 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
return nil, ErrOwnerOnly
}
if operator.ID == 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.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
if u.Role == model.RoleSuperAdmin {
enabled = false
}
if u.CanManageMessages == enabled {
return nil
}
// GORM bool 零值省略:强制 Select 写入
return tx.Model(&u).Select("can_manage_messages").Update("can_manage_messages", enabled).Error
})
if err != nil {
return nil, err
}
return s.getItem(s.db, targetID)
}
// SetBanned 封禁/解封用户。站长账号不可封禁;封禁时同事务使登录态立即失效
func (s *AdminUserService) SetBanned(operator *Actor, targetID uint, banned bool) (*AdminUserItem, error) {
if operator == nil {

View File

@@ -81,8 +81,9 @@ func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount {
return out
}
// Overview 返回近 N 日访问与内容趋势
func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
// Overview 返回近 N 日访问与内容趋势。
// scoped=true 时按 boardIDs 过滤内容类指标,并隐藏全站 PV/UV/注册。
func (s *AnalyticsService) Overview(rangeQ string, boardIDs []uint, scoped bool) (*AnalyticsOverview, error) {
days := parseRangeDays(rangeQ)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
@@ -97,6 +98,17 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
TopViewed: make([]TopViewPost, 0),
}
if scoped && len(boardIDs) == 0 {
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)})
}
out.PostsSeries = fillDaySeries(start, end, nil)
out.CommentsSeries = fillDaySeries(start, end, nil)
out.RegistersSeries = fillDaySeries(start, end, nil)
return out, nil
}
if !scoped {
var stats []model.SiteDailyStats
if err := s.db.Where("date >= ? AND date <= ?", start, end).
Order("date ASC").Find(&stats).Error; err != nil {
@@ -115,6 +127,11 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
out.TodayUV = st.UV
}
}
} else {
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)})
}
}
type dayRow struct {
Day string
@@ -132,28 +149,43 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
return m, nil
}
boardFilter := ""
var postArgs []interface{}
postArgs = append(postArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
if scoped {
boardFilter = " AND board_id IN ?"
postArgs = append(postArgs, boardIDs)
}
postMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM posts
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+boardFilter+`
GROUP BY day ORDER BY day`,
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
postArgs...)
if err != nil {
return nil, err
}
out.PostsSeries = fillDaySeries(start, end, postMap)
var commentArgs []interface{}
commentArgs = append(commentArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
commentBoard := ""
if scoped {
commentBoard = " AND post_id IN (SELECT id FROM posts WHERE board_id IN ?)"
commentArgs = append(commentArgs, boardIDs)
}
commentMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM comments
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+commentBoard+`
GROUP BY day ORDER BY day`,
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
commentArgs...)
if err != nil {
return nil, err
}
out.CommentsSeries = fillDaySeries(start, end, commentMap)
if !scoped {
regMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM users
@@ -164,6 +196,9 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
return nil, err
}
out.RegistersSeries = fillDaySeries(start, end, regMap)
} else {
out.RegistersSeries = fillDaySeries(start, end, nil)
}
type boardRow struct {
ID uint
@@ -171,13 +206,18 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
PostCount int64
}
var boards []boardRow
if err := s.db.Raw(`
boardSQL := `
SELECT b.id, b.name, count(p.id) AS post_count
FROM boards b
LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ?
WHERE b.deleted_at IS NULL
GROUP BY b.id, b.name
ORDER BY post_count DESC`, model.ContentStatusPublished).Scan(&boards).Error; err != nil {
WHERE b.deleted_at IS NULL`
boardArgs := []interface{}{model.ContentStatusPublished}
if scoped {
boardSQL += ` AND b.id IN ?`
boardArgs = append(boardArgs, boardIDs)
}
boardSQL += ` GROUP BY b.id, b.name ORDER BY post_count DESC`
if err := s.db.Raw(boardSQL, boardArgs...).Scan(&boards).Error; err != nil {
return nil, err
}
for _, b := range boards {
@@ -192,11 +232,14 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
PostType string
Cnt int64
}
var types []typeRow
if err := s.db.Model(&model.Post{}).
typeQ := s.db.Model(&model.Post{}).
Select("post_type, count(*) as cnt").
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
Group("post_type").Scan(&types).Error; err != nil {
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished)
if scoped {
typeQ = typeQ.Where("board_id IN ?", boardIDs)
}
var types []typeRow
if err := typeQ.Group("post_type").Scan(&types).Error; err != nil {
return nil, err
}
for _, t := range types {
@@ -208,10 +251,13 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
})
}
topQ := s.db.Select("id, title, view_count, board_id").
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished)
if scoped {
topQ = topQ.Where("board_id IN ?", boardIDs)
}
var tops []model.Post
if err := s.db.Select("id, title, view_count, board_id").
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
if err := topQ.Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
return nil, err
}
for _, p := range tops {

View File

@@ -35,9 +35,14 @@ var (
ErrChatDMDissolve = errors.New("私聊会话不可解散")
ErrChatUserGone = errors.New("用户不存在或已封禁")
ErrChatHallPinFixed = errors.New("大厅已置顶且不可取消")
ErrChatSiteOwnerOnly = errors.New("仅站长可任命群管理员")
ErrChatInvalidRole = errors.New("无效的群成员角色")
ErrChatMuteDenied = errors.New("无权禁言该成员")
ErrChatCannotMuteOwner = errors.New("不能禁言群主或站长")
ErrChatAdminAccess = errors.New("无权管理消息")
)
// CanOverseeChat 站长/超管可旁路查看全部群并撤回任意消息
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
func CanOverseeChat(role model.Role) bool {
return role == model.RoleOwner || role == model.RoleSuperAdmin
}
@@ -530,7 +535,7 @@ func (s *ChatService) Join(userID, roomID uint) (member *model.ChatRoomMember, j
return &m, true, nil
}
// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID
// InviteMembers 群主/群管(或监管)拉人入群;返回实际新加入的用户 ID
func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) {
room, err := s.getRoom(roomID)
if err != nil {
@@ -544,7 +549,7 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove
if err != nil {
return nil, err
}
if op.Role != model.ChatRoleOwner {
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
return nil, ErrChatOwnerOnly
}
}
@@ -702,7 +707,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
}
var list []model.ChatRoomMember
err := s.db.Where("room_id = ?", roomID).
Order("CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC").
Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC").
Preload("User").
Find(&list).Error
if err != nil {
@@ -711,18 +716,11 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
return list, nil
}
// Kick 群主踢人(不能踢群主)
func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
// Kick 群主/群管/全站监管踢人(不能踢群主;群管不能踢其他群管)
func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) error {
if _, err := s.getRoom(roomID); err != nil {
return err
}
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return err
}
if op.Role != model.ChatRoleOwner {
return ErrChatOwnerOnly
}
target, err := s.membership(s.db, roomID, targetID)
if err != nil {
return err
@@ -730,9 +728,158 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
if target.Role == model.ChatRoleOwner {
return ErrChatOwnerOnly
}
if !oversee {
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return err
}
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
return ErrChatOwnerOnly
}
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
return ErrChatOwnerOnly
}
}
return s.removeMember(roomID, targetID)
}
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
room, err := s.getRoom(roomID)
if err != nil {
return err
}
if room.RoomType == model.ChatRoomTypeDirect {
return ErrChatOwnerOnly
}
target, err := s.membership(s.db, roomID, targetID)
if err != nil {
return err
}
if target.Role == model.ChatRoleOwner {
return ErrChatCannotMuteOwner
}
var targetUser model.User
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
return ErrChatUserGone
}
if targetUser.Role == model.RoleOwner {
return ErrChatCannotMuteOwner
}
if !oversee {
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return err
}
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
return ErrChatMuteDenied
}
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
return ErrChatMuteDenied
}
}
return s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, targetID).
Select("muted").Update("muted", muted).Error
}
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
func (s *ChatService) SetMemberRole(operatorSiteRole model.Role, roomID, targetID uint, role string) error {
if operatorSiteRole != model.RoleOwner {
return ErrChatSiteOwnerOnly
}
if role != model.ChatRoleAdmin && role != model.ChatRoleMember {
return ErrChatInvalidRole
}
room, err := s.getRoom(roomID)
if err != nil {
return err
}
if room.RoomType != model.ChatRoomTypeGroup {
return ErrChatOwnerOnly
}
target, err := s.membership(s.db, roomID, targetID)
if err != nil {
return err
}
if target.Role == model.ChatRoleOwner || room.OwnerID == targetID {
return ErrChatOwnerOnly
}
return s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, targetID).
Update("role", role).Error
}
// CountAdminRooms 用户作为群管理员的群数量
func (s *ChatService) CountAdminRooms(userID uint) (int64, error) {
var n int64
err := s.db.Model(&model.ChatRoomMember{}).
Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id").
Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?",
userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup).
Count(&n).Error
return n, err
}
// ListAdminRoomIDs 用户作为群管理员的群 ID 列表
func (s *ChatService) ListAdminRoomIDs(userID uint) ([]uint, error) {
var ids []uint
err := s.db.Model(&model.ChatRoomMember{}).
Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id").
Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?",
userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup).
Pluck("chat_room_members.room_id", &ids).Error
return ids, err
}
// CanAccessAdminMessages 可否进入后台消息管理
func (s *ChatService) CanAccessAdminMessages(actor *Actor) bool {
if actor == nil {
return false
}
if actor.HasSiteMessagePerm() {
return true
}
n, err := s.CountAdminRooms(actor.ID)
return err == nil && n > 0
}
// isChatRoomModerator 群主或群管理员
func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
m, err := s.membership(s.db, roomID, userID)
if err != nil {
return false
}
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
}
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee)
func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool {
var u model.User
if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil {
return false
}
return u.CanManageMessages
}
// canModerateRoomMessages 可撤回他人消息 / 看已撤回正文:全站监管、站点消息管(仅群)、群主/群管
func (s *ChatService) canModerateRoomMessages(userID, roomID uint, oversee bool) bool {
if oversee {
return true
}
if s.isChatRoomModerator(userID, roomID) {
return true
}
if !s.hasSiteGroupMessagePerm(userID) {
return false
}
room, err := s.getRoom(roomID)
if err != nil {
return false
}
return room.RoomType == model.ChatRoomTypeGroup
}
// SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。
func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) {
content = strings.TrimSpace(content)
@@ -907,8 +1054,9 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
// 已撤回正文:仅监管者(站长/超管)保留,供聊天界面审计展示;普通成员清空
if !oversee {
// 已撤回正文:监管者 / 站点消息管(群)/ 群主群管保留
seeRecalled := oversee || s.canModerateRoomMessages(userID, roomID, false)
if !seeRecalled {
for i := range msgs {
if msgs[i].RecalledAt != nil {
msgs[i].Content = ""
@@ -918,7 +1066,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
return msgs, hasMore, nil
}
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。
// RecallMessage 撤回消息:本人;或群主/群管/站点消息管(群)/全站监管可撤他人。
// newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) {
if _, err := s.getRoom(roomID); err != nil {
@@ -934,19 +1082,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
if msg.RecalledAt != nil {
return &msg, false, nil // 幂等
}
if msg.SenderID == operatorID {
if !oversee {
m, err := s.membership(s.db, roomID, operatorID)
if err != nil {
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
return nil, false, err
}
// 群主/成员仅可撤回自己的消息;监管者可撤任意
if msg.SenderID != operatorID {
_ = m // 成员校验已通过
}
} else if !s.canModerateRoomMessages(operatorID, roomID, oversee) {
return nil, false, ErrChatRecallDenied
}
}
now := time.Now()
// 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content
if err := s.db.Model(&msg).Updates(map[string]interface{}{
"recalled_at": now,
"recalled_by": operatorID,
@@ -982,20 +1127,44 @@ type AdminChatMessageItem struct {
CreatedAt time.Time `json:"created_at"`
}
// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索
func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
if actor == nil || !s.CanAccessAdminMessages(actor) {
return nil, 0, ErrChatAdminAccess
}
if page < 1 {
page = 1
}
fullOversee := CanOverseeChat(actor.Role)
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
return nil, 0, errors.New("房间类型无效")
}
// 非全站监管不可看私聊
if roomType == model.ChatRoomTypeDirect && !fullOversee {
return []AdminChatMessageItem{}, 0, nil
}
const pageSize = 15
q := s.db.Model(&model.ChatMessage{}).
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
Joins("LEFT JOIN users ON users.id = chat_messages.sender_id").
Where("chat_rooms.deleted_at IS NULL AND chat_messages.deleted_at IS NULL").
Where("chat_rooms.room_type = ?", roomType)
if !fullOversee && !siteGroup {
ids, err := s.ListAdminRoomIDs(actor.ID)
if err != nil {
return nil, 0, err
}
if len(ids) == 0 {
return []AdminChatMessageItem{}, 0, nil
}
q = q.Where("chat_messages.room_id IN ?", ids)
} else if siteGroup {
q = q.Where("chat_rooms.room_type = ?", model.ChatRoomTypeGroup)
}
if kw := strings.TrimSpace(keyword); kw != "" {
like := "%" + kw + "%"
q = q.Where(
@@ -1037,7 +1206,6 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
roomMap[r.ID] = r
}
}
// 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条
participantsByRoom := map[uint][]AdminChatParticipant{}
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
var members []model.ChatRoomMember
@@ -1073,9 +1241,14 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
name = "私聊"
}
}
content := m.Content
// 后台列表:仅全站监管可见已撤回正文;群管/消息管理员在前台会话内可看
if !fullOversee && m.RecalledAt != nil {
content = ""
}
item := AdminChatMessageItem{
ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault,
SenderID: m.SenderID, Content: m.Content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
SenderID: m.SenderID, Content: content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
Participants: parts,
}
if m.Sender.ID > 0 {

View File

@@ -435,6 +435,13 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
var author model.User
if err := s.db.Select("role").First(&author, comment.UserID).Error; err != nil {
return ErrCommentNotFound
}
if !actor.CanModerateAuthor(author.Role) {
return ErrAuthorProtected
}
deleteType = strings.TrimSpace(opts.DeleteType)
if !model.ValidPostDeleteType(deleteType) {
return ErrCommentInvalidType
@@ -490,6 +497,13 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
return ErrCommentNotFound
}
if actor != nil && actor.CanModerateBoard(post.BoardID) {
var author model.User
if err := s.db.Select("role").First(&author, comment.UserID).Error; err != nil {
return ErrCommentNotFound
}
if !actor.CanModerateAuthor(author.Role) {
return ErrAuthorProtected
}
allowed = true
}
}
@@ -546,6 +560,9 @@ func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
if actor == nil || !actor.CanPurgeContent() {
return ErrPurgeOwnerOnly
}
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
@@ -554,7 +571,7 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
if !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
_ = userID

View File

@@ -12,6 +12,8 @@ import (
var (
ErrModerationForbidden = errors.New("无权审核该板块的内容")
ErrNotPending = errors.New("该内容不在待审核状态")
ErrAuthorProtected = errors.New("无权处置该作者的内容")
ErrPurgeOwnerOnly = errors.New("仅站长可彻底删除")
)
// ModerationService 帖子/评论审核队列

View File

@@ -15,7 +15,7 @@ import (
var (
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型")
)
@@ -1083,17 +1083,28 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO
if post.UserID == userID {
staffDeleted = false
} else if actor != nil && actor.CanModerateBoard(post.BoardID) {
var author model.User
if err := s.db.Select("role").First(&author, post.UserID).Error; err != nil {
return false, authorID, ErrPostNotFound
}
if !actor.CanModerateAuthor(author.Role) {
return false, authorID, ErrAuthorProtected
}
deleteType = strings.TrimSpace(opts.DeleteType)
deleteReason = strings.TrimSpace(opts.DeleteReason)
if !model.ValidPostDeleteType(deleteType) {
return false, authorID, ErrPostInvalidType
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return false, authorID, ErrPostDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return false, authorID, errors.New("删除理由不能超过 200 字")
}
} else {
deleteReason = ""
}
staffDeleted = true
} else {
return false, authorID, ErrPostForbidden

View File

@@ -41,6 +41,7 @@ import {
type AdminContentStatus,
type PublicSettings,
} from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
@@ -290,6 +291,8 @@ export default function ContentAdmin({
initialCounts,
initError,
canConfigureSettings,
canPurge,
viewerRole,
initialSettings,
}: {
initialKind: ContentKind;
@@ -301,6 +304,8 @@ export default function ContentAdmin({
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
canPurge: boolean;
viewerRole: string;
initialSettings: PublicSettings;
}) {
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
@@ -663,6 +668,7 @@ export default function ContentAdmin({
) : null}
{p.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
<ContentAction
label="恢复"
ok
@@ -683,6 +689,8 @@ export default function ContentAdmin({
)
}
/>
) : null}
{canPurge ? (
<ContentAction
label="清除"
danger
@@ -701,8 +709,9 @@ export default function ContentAdmin({
)
}
/>
) : null}
</>
) : (
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
<ContentAction
label="删除"
danger
@@ -710,7 +719,7 @@ export default function ContentAdmin({
icon={<Trash2 size={13} />}
onClick={() => setStaffDeletePost(p)}
/>
)}
) : null}
</div>
</div>
</div>
@@ -803,6 +812,7 @@ export default function ContentAdmin({
/>
{c.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
<ContentAction
label="恢复"
ok
@@ -823,6 +833,8 @@ export default function ContentAdmin({
)
}
/>
) : null}
{canPurge ? (
<ContentAction
label="清除"
danger
@@ -841,8 +853,9 @@ export default function ContentAdmin({
)
}
/>
) : null}
</>
) : (
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
<ContentAction
label="删除"
danger
@@ -850,7 +863,7 @@ export default function ContentAdmin({
icon={<Trash2 size={13} />}
onClick={() => setStaffDeleteComment(c)}
/>
)}
) : null}
</div>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import {
type AdminContentStatus,
} from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
@@ -93,6 +93,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
initialCounts={counts}
initError={initError}
canConfigureSettings={isSuperOrOwner(me.user.role)}
canPurge={isOwner(me.user.role)}
viewerRole={me.user.role}
initialSettings={settings}
/>
);

View File

@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import AdminShell from "@/components/admin/AdminShell";
// 整个 /admin 树不进入索引(子页面无需重复声明)
@@ -12,8 +12,8 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const cookie = authCookieHeader(cookieStore);
@@ -21,7 +21,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const settings = await getPublicSettingsCached();
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
if (!me.user || !isStaff(me.user.role)) {
const allowed =
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
if (!allowed || !me.user) {
return (
<div
data-admin-viewport
@@ -48,12 +51,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
);
}
const user = me.user;
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
return (
<AdminShell
user={me.user}
user={user}
siteName={settings.site_name || "姜十三论坛"}
initialTheme={theme}
initialUnread={unread}

View File

@@ -14,8 +14,10 @@ import {
apiAdminRecallChatMessage,
type AdminChatMessage,
type AdminChatRoomType,
type User,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { canOverseeChat } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
@@ -247,7 +249,8 @@ function useMessageColumn(roomType: MsgKind) {
}
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin() {
export default function MessagesAdmin({ me }: { me: User }) {
const canOversee = canOverseeChat(me.role);
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");
@@ -485,9 +488,14 @@ export default function MessagesAdmin() {
<AdminPageHeader
icon={MessagesSquare}
title="消息管理"
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
description={
canOversee
? "分栏监管群聊与私聊;可搜索并撤回违规内容"
: "监管授权范围内的群聊消息;可搜索并撤回违规内容(不含私聊)"
}
/>
{canOversee ? (
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
<button
type="button"
@@ -512,10 +520,11 @@ export default function MessagesAdmin() {
<span className="admin-content-kind-count">{direct.total}</span>
</button>
</div>
) : null}
<div className="admin-content-triptych">
<div className={canOversee ? "admin-content-triptych" : "admin-content-triptych admin-content-triptych--single"}>
{groupCol}
{directCol}
{canOversee ? directCol : null}
</div>
<ConfirmDialog

View File

@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import { canAccessAdminMessages } from "@/lib/roles";
import Forbidden from "../Forbidden";
import MessagesAdmin from "./MessagesAdmin";
@@ -10,13 +10,13 @@ export const metadata: Metadata = {
title: "消息管理",
};
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
/** 群聊(含大厅)与私聊分栏监管;鉴权与消息管理权限一致 */
export default async function AdminMessagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isStaff(me.user.role)) {
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
if (!me.user || !canAccessAdminMessages(me.user)) {
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
}
return <MessagesAdmin />;
return <MessagesAdmin me={me.user} />;
}

View File

@@ -21,7 +21,7 @@ import {
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove, ROLES } from "@/lib/roles";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import DashboardClient from "./DashboardClient";
@@ -115,9 +115,12 @@ export default async function AdminDashboardPage() {
const canMod = canModerateAny(me.user);
const canUsers = isSuperOrOwner(me.user?.role);
const canEconomy = isAdminOrAbove(me.user?.role);
const boardScoped = me.user?.role === ROLES.BOARD_ADMIN;
const [overview, pendingCounts, analytics] = await Promise.all([
fetchOverview(cookie).catch(() => null),
boardScoped
? Promise.resolve(null)
: fetchOverview(cookie).catch(() => null),
canMod
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
: Promise.resolve(null),
@@ -133,7 +136,15 @@ export default async function AdminDashboardPage() {
}).format(now);
const stats = overview?.stats;
const newUsers = overview?.new_users ?? [];
const newUsers = boardScoped ? [] : overview?.new_users ?? [];
// 板管:今日内容用授权板 analytics 序列今日点;隐藏全站注册/在线卡的误导数字
const todayPosts = boardScoped
? analytics.posts_series[analytics.posts_series.length - 1]?.count ?? 0
: stats?.today_posts ?? 0;
const todayComments = boardScoped
? analytics.comments_series[analytics.comments_series.length - 1]?.count ?? 0
: stats?.today_comments ?? 0;
return (
<div>
@@ -155,30 +166,34 @@ export default async function AdminDashboardPage() {
<AdminStatCard
label="今日 PV"
value={fmt(analytics.today_pv)}
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
hint={boardScoped ? "仅全站可见(板管不展示)" : `独立访客 UV ${fmt(analytics.today_uv)}`}
icon={<Eye size={16} />}
/>
<AdminStatCard
label="今日 UV"
value={fmt(analytics.today_uv)}
hint="独立访客"
hint={boardScoped ? "板管仪表盘不含全站 UV" : "独立访客"}
icon={<Users size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="今日内容"
value={fmt(stats?.today_posts ?? 0)}
value={fmt(todayPosts)}
sub="帖"
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
hint={
boardScoped
? `评论 ${fmt(todayComments)} · 授权板块`
: `评论 ${fmt(todayComments)} · 注册 ${fmt(stats?.today_users ?? 0)}`
}
icon={<FileText size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="在线"
value={fmt(stats?.online ?? 0)}
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
value={boardScoped ? "—" : fmt(stats?.online ?? 0)}
hint={boardScoped ? "板管不展示全站在线" : `峰值 ${fmt(stats?.peak_online ?? 0)}`}
icon={<Activity size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"

View File

@@ -27,6 +27,8 @@ import {
apiDissolveChatRoom,
apiUpdateChatRoom,
apiKickChatMember,
apiMuteChatMember,
apiSetChatMemberRole,
apiInviteChatMembers,
apiRecallChatMessage,
type ChatMessage,
@@ -44,7 +46,7 @@ import type {
RtChatMembershipData,
RtChatRecalledData,
} from "@/lib/realtime";
import { canOverseeChat } from "@/lib/roles";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { scrollBehavior } from "@/lib/motion";
import Avatar from "@/components/Avatar";
@@ -171,7 +173,7 @@ interface Props {
roomDescription: string;
ownerId: number;
memberCount: number;
myRole: "" | "owner" | "member" | "overseer";
myRole: "" | "owner" | "admin" | "member" | "overseer";
joined: boolean;
isPrivate: boolean;
isDefault: boolean;
@@ -179,6 +181,8 @@ interface Props {
peer: { id: number; username: string; nickname: string; avatar: string } | null;
meId: number;
meRole: string;
/** 站点消息管理 flag */
canManageMessages?: boolean;
meName: string;
meAvatar: string;
initialMessages: ChatMessage[];
@@ -315,6 +319,7 @@ export default function ChatRoomClient({
peer,
meId,
meRole,
canManageMessages = false,
initialMessages,
initialHasMore,
embedded = false,
@@ -353,11 +358,16 @@ export default function ChatRoomClient({
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const isOwner = myRole === "owner";
const isRoomAdmin = myRole === "admin";
const isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "member";
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
const canManageInvite = !isDirect && (isOwner || oversee);
const siteOwner = isSiteOwner(meRole);
const canModerateChat =
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
const canKickMembers = isOwner || isRoomAdmin || oversee;
const rememberReEdit = useCallback((id: number, text: string) => {
const t = text.trim();
@@ -472,7 +482,7 @@ export default function ChatRoomClient({
return {
...m,
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
content: oversee ? m.content : "",
content: canModerateChat ? m.content : "",
recalled_at: data.recalled_at || new Date().toISOString(),
recalled_by: data.recalled_by,
sender_id: data.sender_id || m.sender_id,
@@ -490,7 +500,7 @@ export default function ChatRoomClient({
);
});
return off;
}, [roomId, oversee, meId, rememberReEdit]);
}, [roomId, canModerateChat, meId, rememberReEdit]);
useEffect(() => {
const off = realtime.on<RtChatMembershipData>(
@@ -820,6 +830,7 @@ export default function ChatRoomClient({
};
const handleKick = (uid: number, nickname: string) => {
if (!canKickMembers) return;
setConfirm({
title: "移出成员",
message: `确定将 ${nickname} 移出该群?`,
@@ -834,9 +845,49 @@ export default function ChatRoomClient({
});
};
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
setConfirm({
title: muted ? "禁言成员" : "解除禁言",
message: muted
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
: `确定解除 ${nickname} 的禁言?`,
confirmLabel: muted ? "禁言" : "解禁",
danger: muted,
action: async () => {
await apiMuteChatMember(roomId, uid, muted);
setMembers((prev) =>
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
);
toast(muted ? "已禁言" : "已解禁", "ok");
},
});
};
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
if (!siteOwner) return;
setConfirm({
title: makeAdmin ? "设为群管理员" : "取消群管理员",
message: makeAdmin
? `确定将 ${nickname} 设为本群管理员?其可撤回消息、踢人与禁言。`
: `确定取消 ${nickname} 的群管理员身份?`,
confirmLabel: makeAdmin ? "任命" : "撤销",
danger: !makeAdmin,
action: async () => {
await apiSetChatMemberRole(roomId, uid, makeAdmin ? "admin" : "member");
setMembers((prev) =>
prev.map((m) =>
m.user_id === uid ? { ...m, role: makeAdmin ? "admin" : "member" } : m
)
);
toast(makeAdmin ? "已设为群管理员" : "已取消群管理员", "ok");
},
});
};
const canRecallMsg = (m: ChatMessage) => {
if (m.recalled_at) return false;
if (oversee) return true;
if (canModerateChat) return true;
return m.sender_id === meId && isRealMember;
};
@@ -858,7 +909,7 @@ export default function ChatRoomClient({
m.id === mid
? {
...m,
content: oversee ? body || m.content : "",
content: canModerateChat ? body || m.content : "",
recalled_at: message.recalled_at || new Date().toISOString(),
recalled_by: message.recalled_by,
recaller: message.recaller || m.recaller,
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
if (recalled && !(oversee && m.content)) {
if (recalled && !(canModerateChat && m.content)) {
return (
<div
key={m.id}
@@ -1160,7 +1211,7 @@ export default function ChatRoomClient({
}
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
if (recalled && oversee && m.content) {
if (recalled && canModerateChat && m.content) {
return (
<div
key={m.id}
@@ -1529,9 +1580,13 @@ export default function ChatRoomClient({
<MembersPanel
members={members}
meId={meId}
isOwner={isOwner}
canKick={canKickMembers}
isChatOwner={isOwner}
siteOwner={siteOwner}
onRefresh={refreshMembers}
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
/>
)}
{rightPanel === "invite" && (
@@ -1566,9 +1621,13 @@ export default function ChatRoomClient({
<MembersPanel
members={members}
meId={meId}
isOwner={isOwner}
canKick={canKickMembers}
isChatOwner={isOwner}
siteOwner={siteOwner}
onRefresh={refreshMembers}
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
showTitle
/>
</aside>
@@ -1634,16 +1693,24 @@ function JoinButton({
function MembersPanel({
members,
meId,
isOwner,
canKick,
isChatOwner,
siteOwner,
onRefresh,
onKick,
onMute,
onSetAdmin,
showTitle = false,
}: {
members: ChatRoomMember[];
meId: number;
isOwner: boolean;
canKick: boolean;
isChatOwner: boolean;
siteOwner: boolean;
onRefresh: () => void;
onKick: (uid: number, nickname: string) => void;
onMute: (uid: number, nickname: string, muted: boolean) => void;
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
showTitle?: boolean;
}) {
return (
@@ -1675,38 +1742,74 @@ function MembersPanel({
</div>
)}
<ul className="flex flex-col gap-1.5">
{members.map((m) => (
{members.map((m) => {
const name = m.user.nickname || m.user.username;
const isTargetOwner = m.role === "owner";
const isTargetAdmin = m.role === "admin";
const canActOn =
canKick &&
m.user_id !== meId &&
!isTargetOwner &&
(isChatOwner || !isTargetAdmin);
return (
<li
key={m.id}
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
>
<Avatar
name={m.user.nickname || m.user.username}
src={m.user.avatar || undefined}
size={28}
/>
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
<Link
href={`/u/${m.user.id}`}
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{m.user.nickname}
</Link>
{m.role === "owner" ? (
<Crown size={12} style={{ color: "var(--gold)" }} />
{name}
{m.muted ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
· 禁言
</span>
) : null}
{isOwner && m.user_id !== meId && (
</Link>
{isTargetOwner ? (
<Crown size={12} style={{ color: "var(--gold)" }} aria-label="群主" />
) : isTargetAdmin ? (
<span className="text-[10px] font-medium" style={{ color: "var(--accent)" }}>
管
</span>
) : null}
{siteOwner && !isTargetOwner && m.user_id !== meId && (
<button
onClick={() => onKick(m.user_id, m.user.nickname)}
aria-label={`移出 ${m.user.nickname}`}
type="button"
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
{isTargetAdmin ? "取消管" : "设管"}
</button>
)}
{canActOn && (
<>
<button
type="button"
onClick={() => onMute(m.user_id, name, !m.muted)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1"
style={{ color: "var(--ink-3)" }}
>
{m.muted ? "解禁" : "禁言"}
</button>
<button
type="button"
onClick={() => onKick(m.user_id, name)}
aria-label={`移出 ${name}`}
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
style={{ color: "var(--danger)" }}
>
<UserX size={12} />
</button>
</>
)}
</li>
))}
);
})}
</ul>
</div>
);

View File

@@ -88,6 +88,7 @@ export default async function ChatRoomPage({ params }: PageProps) {
peer={room.peer || null}
meId={me.user.id}
meRole={me.user.role}
canManageMessages={!!me.user.can_manage_messages}
meName={me.user.nickname || me.user.username}
meAvatar={me.user.avatar || ""}
initialMessages={initialMessages.messages}

View File

@@ -684,6 +684,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<PostActions
postId={id}
authorId={post.user.id}
authorRole={post.user.role}
boardId={post.board_id}
pinned={post.pinned}
recommended={post.recommended}

View File

@@ -28,7 +28,7 @@ import {
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import CommentsUnsupported from "@/components/CommentsUnsupported";
@@ -334,19 +334,25 @@ export default function CommentSection({
const canSoftDelete = (comment: CommentNode) =>
!comment.deleted &&
!!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId));
(user.id === comment.user?.id ||
canStaffSoftDelete(user, boardId, comment.user?.role));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canViewHistory = !!user && canModerateBoard(user, boardId);
const canPurge = (comment: CommentNode) =>
!!user && canModerateBoard(user, boardId);
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */
const canRestore = (comment: CommentNode) => {
if (!comment.deleted || !user) return false;
if (canModerateBoard(user, boardId)) return true;
if (
canModerateBoard(user, boardId) &&
canStaffSoftDelete(user, boardId, comment.user?.role)
) {
return true;
}
return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted;
};

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { POST_DELETE_TYPES } from "@/lib/api";
import StaffDeleteFields from "@/components/StaffDeleteFields";
export type DeleteCommentPayload = {
delete_type: string;
@@ -12,7 +12,7 @@ export type DeleteCommentPayload = {
/**
* 评论软删弹窗:
* - 评论者自删:必填理由(仅管理可见)
* - 管理删:必选类型;仅「其他」须填理由
* - 管理删:与删帖共用类型表单;仅「其他」须填理由
*/
export default function DeleteCommentModal({
open,
@@ -89,55 +89,31 @@ export default function DeleteCommentModal({
}
>
{isStaff ? (
<>
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
role="radiogroup"
aria-label="删除类型"
>
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => {
setDeleteType(t.value);
<StaffDeleteFields
deleteType={deleteType}
onTypeChange={(value) => {
setDeleteType(value);
setErr("");
if (t.value !== "other") setReason("");
if (value !== "other") setReason("");
}}
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
reason={reason}
onReasonChange={(value) => {
setReason(value);
setErr("");
}}
>
{t.label}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mb-4 leading-relaxed">
选「其他」时需补充理由;其余类型直接按标签展示给读者。
</p>
</>
) : null}
{reasonRequired ? (
reasonId="cmt-delete-reason"
reasonPlaceholder="向读者说明删除原因(将展示在占位中)"
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
reasonHint="将公开显示在评论占位旁。"
/>
) : (
<>
<label
className="block mb-1.5 text-[12.5px] font-medium"
style={{ color: "var(--ink)" }}
htmlFor="cmt-delete-reason"
>
{isStaff ? "补充理由" : "删除理由"}{" "}
<span style={{ color: "var(--danger)" }}>*</span>
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="cmt-delete-reason"
@@ -148,19 +124,13 @@ export default function DeleteCommentModal({
setReason(e.target.value);
setErr("");
}}
placeholder={
isStaff
? "向读者说明删除原因(将展示在占位中)"
: "例如:发错了 / 不想保留(仅管理员可见)"
}
placeholder="例如:发错了 / 不想保留(仅管理员可见)"
maxLength={200}
autoFocus={!isStaff}
autoFocus
/>
<p className="meta text-[11.5px] mt-1.5">
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
</p>
<p className="meta text-[11.5px] mt-1.5">普通读者看不到这条理由。</p>
</>
) : null}
)}
{err ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>

View File

@@ -21,7 +21,7 @@ import {
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { isStaff } from "@/lib/roles";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -396,7 +396,7 @@ export default function Header({
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user && isStaff(user.role) && (
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
@@ -560,7 +560,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{isStaff(user.role) && (
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -13,6 +13,7 @@ import StaffDeletePostModal from "@/components/StaffDeletePostModal";
interface PostActionsProps {
postId: string;
authorId: number;
authorRole?: string | null;
boardId: number;
pinned: number;
recommended: boolean;
@@ -25,6 +26,7 @@ interface PostActionsProps {
export default function PostActions({
postId,
authorId,
authorRole,
boardId,
pinned,
recommended,
@@ -64,12 +66,15 @@ export default function PostActions({
};
}, [open]);
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
const canManage =
!!user &&
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
const isAdmin = !!user && isAdminOrAbove(user.role);
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther =
!!user && user.id !== authorId && canModerateBoard(user, boardId);
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole);
const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
setDeleting(true);

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
@@ -19,6 +19,7 @@ export interface RoleAccountTarget {
role: string;
board_ids?: number[];
banned: boolean;
can_manage_messages?: boolean;
avatar?: string;
email?: string;
points?: number;
@@ -75,6 +76,7 @@ export default function RoleAccountModal({
const [role, setRole] = useState<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [manageMessages, setManageMessages] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
@@ -82,6 +84,7 @@ export default function RoleAccountModal({
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setManageMessages(!!target.can_manage_messages);
setErr("");
}
}, [target]);
@@ -97,6 +100,11 @@ export default function RoleAccountModal({
JSON.stringify([...boardIds].sort((a, b) => a - b));
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
const banChanged = banned !== !!target.banned;
const messagesChanged =
canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
@@ -105,7 +113,7 @@ export default function RoleAccountModal({
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged) {
if (!roleChanged && !banChanged && !messagesChanged) {
onClose();
return;
}
@@ -114,6 +122,7 @@ export default function RoleAccountModal({
let nextRole = target.role;
let nextBoards = target.board_ids ?? [];
let nextBanned = !!target.banned;
let nextMessages = !!target.can_manage_messages;
if (roleChanged) {
const res = await apiAdminSetUserRole(
target.id,
@@ -127,19 +136,26 @@ export default function RoleAccountModal({
const res = await apiAdminSetUserBan(target.id, banned);
nextBanned = !!res.user.banned;
}
if (messagesChanged) {
const res = await apiAdminSetUserMessages(target.id, manageMessages);
nextMessages = !!res.user.can_manage_messages;
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
toast(parts.join(" · ") || "已保存", "ok");
onSaved({
...target,
role: nextRole,
board_ids: nextBoards,
banned: nextBanned,
can_manage_messages: nextMessages,
});
onClose();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
setErr(e instanceof Error ? e.message : "保存失败");
} finally {
setActing(false);
}
@@ -279,6 +295,49 @@ export default function RoleAccountModal({
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
站点群消息管理
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
</span>
</span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
</span>
</button>
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
账号状态

View File

@@ -0,0 +1,82 @@
"use client";
import { POST_DELETE_TYPES } from "@/lib/api";
/** 管理软删共用:类型单选 + 仅「其他」时的补充理由 */
export default function StaffDeleteFields({
deleteType,
onTypeChange,
reason,
onReasonChange,
reasonId,
reasonPlaceholder,
typeHint,
reasonHint,
}: {
deleteType: string;
onTypeChange: (value: string) => void;
reason: string;
onReasonChange: (value: string) => void;
reasonId: string;
reasonPlaceholder: string;
typeHint: string;
reasonHint: string;
}) {
const reasonRequired = deleteType === "other";
return (
<>
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
role="radiogroup"
aria-label="删除类型"
>
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onTypeChange(t.value)}
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
}}
>
{t.label}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mb-4 leading-relaxed">{typeHint}</p>
{reasonRequired ? (
<>
<label
className="block mb-1.5 text-[12.5px] font-medium"
style={{ color: "var(--ink)" }}
htmlFor={reasonId}
>
补充理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id={reasonId}
className="field"
rows={3}
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder={reasonPlaceholder}
maxLength={200}
/>
<p className="meta text-[11.5px] mt-1.5">{reasonHint}</p>
</>
) : null}
</>
);
}

View File

@@ -2,10 +2,10 @@
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { POST_DELETE_TYPES } from "@/lib/api";
import StaffDeleteFields from "@/components/StaffDeleteFields";
/**
* Staff 软删帖:必填删除类型 + 理由。
* Staff 软删帖:必选删除类型;仅「其他」须填理由。
* 作者自删仍用 ConfirmDialog,不走此组件。
*/
export default function StaffDeletePostModal({
@@ -36,8 +36,11 @@ export default function StaffDeletePostModal({
if (!open) return null;
const reasonRequired = deleteType === "other";
const submit = () => {
const r = reason.trim();
if (reasonRequired) {
if (!r) {
setErr("请填写删除理由");
return;
@@ -46,7 +49,8 @@ export default function StaffDeletePostModal({
setErr("删除理由不能超过 200 字");
return;
}
onConfirm({ delete_type: deleteType, delete_reason: r });
}
onConfirm({ delete_type: deleteType, delete_reason: reasonRequired ? r : "" });
};
return (
@@ -55,8 +59,8 @@ export default function StaffDeletePostModal({
title="删除帖子"
description={
title
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
: "列表不再展示;原链接会显示删除类型与理由。"
? `将删除「${title}」。列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。`
: "列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。"
}
onClose={onClose}
busy={busy}
@@ -74,51 +78,28 @@ export default function StaffDeletePostModal({
</>
}
>
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => setDeleteType(t.value)}
className="text-left rounded-lg px-3 py-2 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
<StaffDeleteFields
deleteType={deleteType}
onTypeChange={(value) => {
setDeleteType(value);
setErr("");
if (value !== "other") setReason("");
}}
>
{t.label}
</button>
);
})}
</div>
<label className="block mb-1.5 text-[12.5px]" style={{ color: "var(--ink)" }} htmlFor="delete-reason">
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="delete-reason"
className="field"
rows={3}
value={reason}
onChange={(e) => {
setReason(e.target.value);
reason={reason}
onReasonChange={(value) => {
setReason(value);
setErr("");
}}
placeholder="将展示在帖子专用页与作者通知中"
maxLength={200}
reasonId="post-delete-reason"
reasonPlaceholder="向读者说明删除原因(将展示在帖子页)"
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
reasonHint="将公开显示在帖子专用页与作者通知中。"
/>
{err && (
{err ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
) : null}
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
canAccessAdminMessages,
isAdminOrAbove,
isStaff,
isSuperOrOwner,
@@ -76,7 +77,7 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/messages",
label: "消息管理",
icon: MessageCircle,
visible: (u) => isStaff(u.role),
visible: (u) => canAccessAdminMessages(u),
},
{
href: "/admin/announcements",

View File

@@ -44,6 +44,10 @@ export interface User {
role: string;
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
can_manage_messages?: boolean;
// 作为群管理员的群数量(/me;用于后台消息入口)
chat_admin_room_count?: number;
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
email?: string;
signature?: string;
@@ -1900,6 +1904,7 @@ export interface AdminUser {
role: string; // user | board_admin | admin | super_admin | owner
board_ids: number[]; // 板块管理员的授权板块
banned: boolean;
can_manage_messages?: boolean;
points?: number;
post_count: number;
comment_count: number;
@@ -2026,6 +2031,21 @@ export async function apiAdminSetUserRole(
return data as { user: AdminUser };
}
// 站长授予/撤销站点消息管理权限
export async function apiAdminSetUserMessages(
id: number,
canManageMessages: boolean
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/messages`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ can_manage_messages: canManageMessages }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "消息权限更新失败");
return data as { user: AdminUser };
}
// 某用户的登录历史(IP/UA/成败)
export async function apiAdminUserLoginLogs(
id: number,
@@ -2546,7 +2566,7 @@ export interface ChatRoom {
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
export interface ChatRoomView extends ChatRoom {
joined: boolean;
my_role: "" | "owner" | "member" | "overseer";
my_role: "" | "owner" | "admin" | "member" | "overseer";
unread_count: number;
pinned?: boolean;
pin_forced?: boolean;
@@ -2558,7 +2578,7 @@ export interface ChatRoomMember {
id: number;
room_id: number;
user_id: number;
role: "owner" | "member";
role: "owner" | "admin" | "member";
last_read_message_id: number;
muted: boolean;
created_at: string;
@@ -2824,7 +2844,7 @@ export async function apiListChatMembers(
return data as ChatMembersResponse;
}
// 客户端:群主踢人
// 客户端:群主/群管踢人
export async function apiKickChatMember(
roomId: number,
userId: number
@@ -2838,7 +2858,45 @@ export async function apiKickChatMember(
return data as { ok: boolean };
}
// 客户端:邀请成员(用户名列表,群主/监管)
// 客户端:禁言/解禁
export async function apiMuteChatMember(
roomId: number,
userId: number,
muted: boolean
): Promise<{ ok: boolean; muted: boolean }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
{
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ muted }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "禁言操作失败");
return data as { ok: boolean; muted: boolean };
}
// 客户端:站长任命/撤销群管理员
export async function apiSetChatMemberRole(
roomId: number,
userId: number,
role: "admin" | "member"
): Promise<{ ok: boolean; role: string }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/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 { ok: boolean; role: string };
}
// 客户端:邀请成员(用户名列表,群主/群管/监管)
export async function apiInviteChatMembers(
roomId: number,
usernames: string[]

View File

@@ -44,15 +44,23 @@ export function isOwner(role?: string | null): boolean {
return role === ROLES.OWNER;
}
/** 站长/超管:群聊监管旁路(可见全部群、撤回任意消息) */
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
export function canOverseeChat(role?: string | null): boolean {
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
}
/** 携带板块授权的最小用户形状(/me、帖子详情作者等均满足) */
/** 彻底删除帖/评:仅站长 */
export function canPurgeContent(role?: string | null): boolean {
return role === ROLES.OWNER;
}
/** 携带板块授权与消息权限的最小用户形状 */
export interface RoleUserLike {
role?: string | null;
board_ids?: number[] | null;
can_manage_messages?: boolean | null;
/** 是否至少管理一个群(/me 可带;后台入口用) */
chat_admin_room_count?: number | null;
}
/**
@@ -69,6 +77,29 @@ export function canModerateBoard(
return (user.board_ids ?? []).includes(boardId);
}
/**
* 板管不可软删管理员及以上作者的内容;作者本人不受此限(由调用方先判断自删)
*/
export function canModerateAuthor(
actor: RoleUserLike | null | undefined,
authorRole?: string | null
): boolean {
if (!actor) return false;
if (actor.role === ROLES.BOARD_ADMIN) {
return roleLevel(authorRole) < ROLE_LEVEL[ROLES.ADMIN];
}
return true;
}
/** 是否可软删他人内容(板块范围 + 作者角色保护) */
export function canStaffSoftDelete(
actor: RoleUserLike | null | undefined,
boardId: number | null | undefined,
authorRole?: string | null
): boolean {
return canModerateBoard(actor, boardId) && canModerateAuthor(actor, authorRole);
}
/** 是否拥有任意板块的内容审核权(后台「内容管理」入口) */
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
if (!user) return false;
@@ -76,6 +107,20 @@ export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0;
}
/** 站点级消息管理(站长/超管固有,或 can_manage_messages) */
export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean {
if (!user) return false;
if (canOverseeChat(user.role)) return true;
return !!user.can_manage_messages;
}
/** 后台「消息管理」入口:站点消息权或至少是一个群的管理员 */
export function canAccessAdminMessages(user: RoleUserLike | null | undefined): boolean {
if (!user) return false;
if (hasSiteMessagePerm(user)) return true;
return (user.chat_admin_room_count ?? 0) > 0;
}
/** 角色图标 key(RoleBadge 负责映射到具体图标,集中配置便于替换) */
export type RoleIconKey = "crown" | "shield-star" | "shield" | "shield-board" | "user";
@@ -102,7 +147,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--gold",
softToken: "--gold-soft",
grantDesc:
"站点所有者:全部后台能力、群聊监管、用户内容档案;身份不可授予或降级。",
"站点所有者:全部后台能力、群聊监管、用户内容档案、彻底删除内容;身份不可授予或降级。",
},
[ROLES.SUPER_ADMIN]: {
key: ROLES.SUPER_ADMIN,
@@ -111,7 +156,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--accent",
softToken: "--accent-soft",
grantDesc:
"用户与角色管理、封禁、外观主题、公告;全站内容审核与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可动站长账号,亦无站长专属的用户内容档案。",
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
},
[ROLES.ADMIN]: {
key: ROLES.ADMIN,
@@ -120,7 +165,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--accent",
softToken: "--accent-soft",
grantDesc:
"公告管理;全站帖子/评论审核;前台置顶与加精。不含用户权限、外观设置与群聊监管旁路。",
"公告管理;全站帖子/评论审核与软删;前台置顶与加精。不含用户权限、外观设置、彻底删除与默认的群聊监管(可由站长另授消息管理或任命群管理员)。",
},
[ROLES.BOARD_ADMIN]: {
key: ROLES.BOARD_ADMIN,
@@ -129,7 +174,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--accent",
softToken: "--accent-soft",
grantDesc:
"仅审核并管理被授权板块内的帖子与评论(含删除违规内容)。无公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
"仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
},
[ROLES.USER]: {
key: ROLES.USER,