完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -150,14 +150,16 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致
|
|||||||
|
|
||||||
## 四级 RBAC + 内容审核(2026-09)
|
## 四级 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 及以上
|
- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台+群聊全站监管,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
|
||||||
- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids);RequirePerm(PermAnnouncements/PermUsers/PermSettings) 挂在 staffAPI 子组;板块隔离在 service 层按 Actor.BoardIDs
|
- 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)
|
- 发帖/评论: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 列
|
- 登录历史 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`
|
- 用户安全设置「最近登录设备」: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
|
- 角色变更事务内 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 是字符串不是数组
|
- 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ func (h *Handlers) AdminUpdateUserRole(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
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 封禁 / 解封用户
|
// AdminSetUserBan 封禁 / 解封用户
|
||||||
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
||||||
id, ok := parseAdminUserID(c)
|
id, ok := parseAdminUserID(c)
|
||||||
|
|||||||
@@ -301,14 +301,15 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
|||||||
boardIDs = []uint{}
|
boardIDs = []uint{}
|
||||||
}
|
}
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
"signature": user.Signature,
|
"signature": user.Signature,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
|
"can_manage_messages": user.CanManageMessages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +347,15 @@ func (h *Handlers) Me(c *gin.Context) {
|
|||||||
if user.Role == model.RoleBoardAdmin {
|
if user.Role == model.RoleBoardAdmin {
|
||||||
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": meUserBody(user, boardIDs),
|
"user": body,
|
||||||
"unread_count": unread,
|
"unread_count": unread,
|
||||||
"chat_unread_count": chatUnread,
|
"chat_unread_count": chatUnread,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ func chatErrToStatus(err error) int {
|
|||||||
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
|
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
|
||||||
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
|
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
|
||||||
errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve),
|
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
|
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
|
return http.StatusBadRequest
|
||||||
default:
|
default:
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
@@ -307,7 +310,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
|
||||||
return
|
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()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -319,6 +322,63 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
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 历史消息
|
// ListChatMessages 历史消息
|
||||||
func (h *Handlers) ListChatMessages(c *gin.Context) {
|
func (h *Handlers) ListChatMessages(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
@@ -474,11 +534,21 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
|
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
|
||||||
func (h *Handlers) AdminChatMessages(c *gin.Context) {
|
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"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
roomType := c.Query("room_type")
|
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 err != nil {
|
||||||
|
if errors.Is(err, service.ErrChatAdminAccess) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err.Error() == "房间类型无效" {
|
if err.Error() == "房间类型无效" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -486,12 +556,24 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"})
|
||||||
return
|
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) {
|
func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
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)
|
roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64)
|
||||||
if err != nil || roomID == 0 {
|
if err != nil || roomID == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"})
|
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 无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oversee := h.chatOversee(claims.ID)
|
oversee := service.CanOverseeChat(actor.Role)
|
||||||
// 非监管 staff:仅允许撤回自己发送的(与前台一致)
|
|
||||||
msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
|
msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ func (h *Handlers) DeleteComment(c *gin.Context) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrCommentNotFound):
|
case errors.Is(err, service.ErrCommentNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
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()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrCommentDeleteMeta),
|
case errors.Is(err, service.ErrCommentDeleteMeta),
|
||||||
errors.Is(err, service.ErrCommentInvalidType):
|
errors.Is(err, service.ErrCommentInvalidType):
|
||||||
@@ -206,7 +207,8 @@ func (h *Handlers) RestoreComment(c *gin.Context) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrCommentNotFound):
|
case errors.Is(err, service.ErrCommentNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
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": "无权限恢复此评论"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"})
|
||||||
case errors.Is(err, service.ErrCommentNotDeleted):
|
case errors.Is(err, service.ErrCommentNotDeleted):
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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": "已恢复"})
|
c.JSON(http.StatusOK, gin.H{"message": "已恢复"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeComment 硬删评论及子树(仅版主;不占位)
|
// PurgeComment 硬删评论及子树(仅站长;不占位)
|
||||||
func (h *Handlers) PurgeComment(c *gin.Context) {
|
func (h *Handlers) PurgeComment(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||||
@@ -230,7 +232,8 @@ func (h *Handlers) PurgeComment(c *gin.Context) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrCommentNotFound):
|
case errors.Is(err, service.ErrCommentNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
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()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ func (h *Handlers) writeModerationError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, service.ErrContentNotFound):
|
case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, service.ErrContentNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
|
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()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrNotPending),
|
case errors.Is(err, service.ErrNotPending),
|
||||||
errors.Is(err, service.ErrContentNotDeleted):
|
errors.Is(err, service.ErrContentNotDeleted):
|
||||||
|
|||||||
@@ -536,7 +536,8 @@ func respondPostModError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrPostNotFound):
|
case errors.Is(err, service.ErrPostNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
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()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrPollOptionsLocked),
|
case errors.Is(err, service.ErrPollOptionsLocked),
|
||||||
errors.Is(err, service.ErrLotteryPrizesLocked):
|
errors.Is(err, service.ErrLotteryPrizesLocked):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -47,7 +48,13 @@ func (h *Handlers) TelemetryPageView(c *gin.Context) {
|
|||||||
// GET /api/admin/analytics/overview?range=7d|30d
|
// GET /api/admin/analytics/overview?range=7d|30d
|
||||||
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
|
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
|
||||||
rangeQ := c.DefaultQuery("range", "7d")
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -112,21 +112,22 @@ func NormalizeContentAccess(a string) string {
|
|||||||
|
|
||||||
// User 用户表
|
// User 用户表
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||||
Email string `gorm:"index;size:128;default:''" json:"-"`
|
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||||
Password string `gorm:"size:128;not null" json:"-"`
|
Password string `gorm:"size:128;not null" json:"-"`
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
Nickname string `gorm:"size:64" json:"nickname"`
|
||||||
Avatar string `gorm:"size:512" json:"avatar"`
|
Avatar string `gorm:"size:512" json:"avatar"`
|
||||||
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
|
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
|
||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||||
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
|
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
|
||||||
@@ -483,9 +484,19 @@ type LoginLog struct {
|
|||||||
// 群聊成员角色
|
// 群聊成员角色
|
||||||
const (
|
const (
|
||||||
ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言
|
ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言
|
||||||
|
ChatRoleAdmin = "admin" // 群管理员(仅站长可任命):本群撤回他人消息/踢人/禁言
|
||||||
ChatRoleMember = "member" // 普通成员
|
ChatRoleMember = "member" // 普通成员
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ValidChatMemberRole 群内角色白名单(任命接口仅允许 admin/member)
|
||||||
|
func ValidChatMemberRole(r string) bool {
|
||||||
|
switch r {
|
||||||
|
case ChatRoleOwner, ChatRoleAdmin, ChatRoleMember:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅
|
// ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅
|
||||||
type ChatRoom struct {
|
type ChatRoom struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
chatAPI.GET("/rooms/:id/members", h.ListChatMembers)
|
chatAPI.GET("/rooms/:id/members", h.ListChatMembers)
|
||||||
chatAPI.POST("/rooms/:id/members", h.InviteChatMembers)
|
chatAPI.POST("/rooms/:id/members", h.InviteChatMembers)
|
||||||
chatAPI.DELETE("/rooms/:id/members/:uid", h.KickChatMember)
|
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.GET("/rooms/:id/messages", h.ListChatMessages)
|
||||||
chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage)
|
chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage)
|
||||||
chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage)
|
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)
|
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
|
||||||
|
|
||||||
// 站点分析概览(任意 staff)
|
// 站点分析概览(任意 staff;板管按授权板过滤)
|
||||||
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
|
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
|
||||||
|
|
||||||
// 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则)
|
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||||
staffAPI.GET("/chat/messages", h.AdminChatMessages)
|
// (见下方 msgAPI)
|
||||||
staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage)
|
|
||||||
|
|
||||||
// 站点公告文章管理(管理员及以上)
|
// 站点公告文章管理(管理员及以上)
|
||||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
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 := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
|
||||||
usersAPI.GET("/users", h.AdminListUsers)
|
usersAPI.GET("/users", h.AdminListUsers)
|
||||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||||
|
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||||
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||||
// 站长内容档案(handler 内再校验 owner)
|
// 站长内容档案(handler 内再校验 owner)
|
||||||
@@ -284,6 +286,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
|
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) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
c.JSON(404, gin.H{"error": "not found"})
|
c.JSON(404, gin.H{"error": "not found"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ const (
|
|||||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
||||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||||
|
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||||
type Actor struct {
|
type Actor struct {
|
||||||
ID uint
|
ID uint
|
||||||
Username string
|
Username string
|
||||||
Role model.Role
|
Role model.Role
|
||||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||||
|
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStaff 是否管理团队成员
|
// IsStaff 是否管理团队成员
|
||||||
@@ -41,10 +43,28 @@ func (a *Actor) HasPerm(p string) bool {
|
|||||||
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
||||||
case PermModeration:
|
case PermModeration:
|
||||||
return a.IsStaff()
|
return a.IsStaff()
|
||||||
|
case PermMessages:
|
||||||
|
return a.HasSiteMessagePerm()
|
||||||
}
|
}
|
||||||
return false
|
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 是否可管理指定板块的内容:
|
// CanModerateBoard 是否可管理指定板块的内容:
|
||||||
// 管理员及以上不限板块;板块管理员仅限被授权板块
|
// 管理员及以上不限板块;板块管理员仅限被授权板块
|
||||||
func (a *Actor) CanModerateBoard(boardID uint) bool {
|
func (a *Actor) CanModerateBoard(boardID uint) bool {
|
||||||
@@ -65,6 +85,17 @@ func (a *Actor) CanModerateBoard(boardID uint) bool {
|
|||||||
return false
|
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 操作者能否把他人设置为目标角色(站长不可被授予,仅站长可授予超管)
|
// CanAssignRole 操作者能否把他人设置为目标角色(站长不可被授予,仅站长可授予超管)
|
||||||
func (a *Actor) CanAssignRole(target model.Role) bool {
|
func (a *Actor) CanAssignRole(target model.Role) bool {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
@@ -82,10 +113,13 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
|||||||
// LoadActor 读取用户实时角色与板块授权
|
// LoadActor 读取用户实时角色与板块授权
|
||||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||||
var u model.User
|
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
|
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 {
|
if u.Role == model.RoleBoardAdmin {
|
||||||
var ids []uint
|
var ids []uint
|
||||||
if err := s.db.Model(&model.UserBoard{}).
|
if err := s.db.Model(&model.UserBoard{}).
|
||||||
@@ -99,6 +133,15 @@ func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
|||||||
return actor, nil
|
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 等场景)
|
// GetUserBoardIDs 读取用户被授权的板块 ID(供 /me 等场景)
|
||||||
func (s *AuthService) GetUserBoardIDs(id uint) ([]uint, error) {
|
func (s *AuthService) GetUserBoardIDs(id uint) ([]uint, error) {
|
||||||
var ids []uint
|
var ids []uint
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ var (
|
|||||||
ErrContentNotFound = errors.New("内容不存在")
|
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
|
const adminContentPageSize = 15
|
||||||
|
|
||||||
// AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除
|
// AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除
|
||||||
@@ -303,12 +322,15 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C
|
|||||||
return &cm, post.BoardID, nil
|
return &cm, post.BoardID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者
|
// SoftDeletePost 软删帖子(未删除时);须选类型,「其他」才须填理由,并通知作者
|
||||||
func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) {
|
func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) {
|
||||||
post, err := s.loadPostUnscoped(actor, id)
|
post, err := s.loadPostUnscoped(actor, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
if post.DeletedAt.Valid {
|
if post.DeletedAt.Valid {
|
||||||
return 0, errors.New("帖子已在回收站")
|
return 0, errors.New("帖子已在回收站")
|
||||||
}
|
}
|
||||||
@@ -317,11 +339,15 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de
|
|||||||
if !model.ValidPostDeleteType(deleteType) {
|
if !model.ValidPostDeleteType(deleteType) {
|
||||||
return 0, errors.New("无效的删除类型")
|
return 0, errors.New("无效的删除类型")
|
||||||
}
|
}
|
||||||
if deleteReason == "" {
|
if deleteType == model.PostDeleteTypeOther {
|
||||||
return 0, errors.New("请填写删除理由")
|
if deleteReason == "" {
|
||||||
}
|
return 0, errors.New("请填写删除理由")
|
||||||
if len([]rune(deleteReason)) > 200 {
|
}
|
||||||
return 0, errors.New("删除理由不能超过 200 字")
|
if len([]rune(deleteReason)) > 200 {
|
||||||
|
return 0, errors.New("删除理由不能超过 200 字")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteReason = ""
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"delete_type": deleteType,
|
"delete_type": deleteType,
|
||||||
@@ -343,14 +369,20 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !post.DeletedAt.Valid {
|
if !post.DeletedAt.Valid {
|
||||||
return errors.New("帖子未被删除")
|
return errors.New("帖子未被删除")
|
||||||
}
|
}
|
||||||
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
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 {
|
func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
|
||||||
|
if !actor.CanPurgeContent() {
|
||||||
|
return ErrPurgeOwnerOnly
|
||||||
|
}
|
||||||
post, err := s.loadPostUnscoped(actor, id)
|
post, err := s.loadPostUnscoped(actor, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -372,6 +404,9 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if cm.DeletedAt.Valid {
|
if cm.DeletedAt.Valid {
|
||||||
return errors.New("评论已在回收站")
|
return errors.New("评论已在回收站")
|
||||||
}
|
}
|
||||||
@@ -414,6 +449,9 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !cm.DeletedAt.Valid {
|
if !cm.DeletedAt.Valid {
|
||||||
return errors.New("评论未被删除")
|
return errors.New("评论未被删除")
|
||||||
}
|
}
|
||||||
@@ -433,8 +471,11 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeComment 彻底删除已软删评论
|
// PurgeComment 彻底删除已软删评论(仅站长)
|
||||||
func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
|
func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
|
||||||
|
if !actor.CanPurgeContent() {
|
||||||
|
return ErrPurgeOwnerOnly
|
||||||
|
}
|
||||||
cm, _, err := s.loadCommentUnscoped(actor, id)
|
cm, _, err := s.loadCommentUnscoped(actor, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -44,23 +44,24 @@ func NewAdminUserService(db *gorm.DB) *AdminUserService {
|
|||||||
// AdminUserItem 后台用户列表项:email / last_seen / 登录 IP 在 User 模型上 json:"-",
|
// AdminUserItem 后台用户列表项:email / last_seen / 登录 IP 在 User 模型上 json:"-",
|
||||||
// 仅管理员接口通过此 DTO 显式带出
|
// 仅管理员接口通过此 DTO 显式带出
|
||||||
type AdminUserItem struct {
|
type AdminUserItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Signature string `json:"signature"`
|
Signature string `json:"signature"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
BoardIDs []uint `json:"board_ids"`
|
BoardIDs []uint `json:"board_ids"`
|
||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
PostCount int64 `json:"post_count"`
|
CanManageMessages bool `json:"can_manage_messages"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
Points int `json:"points"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Points int `json:"points"`
|
||||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Online bool `json:"online"`
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||||
LastLoginIP string `json:"last_login_ip"`
|
Online bool `json:"online"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginIP string `json:"last_login_ip"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUserSummary 列表顶部汇总
|
// AdminUserSummary 列表顶部汇总
|
||||||
@@ -222,18 +223,19 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
ids = append(ids, u.ID)
|
ids = append(ids, u.ID)
|
||||||
item := AdminUserItem{
|
item := AdminUserItem{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Nickname: u.Nickname,
|
Nickname: u.Nickname,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
Signature: u.Signature,
|
Signature: u.Signature,
|
||||||
Role: string(u.Role),
|
Role: string(u.Role),
|
||||||
BoardIDs: []uint{},
|
BoardIDs: []uint{},
|
||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
Points: u.Points,
|
CanManageMessages: u.CanManageMessages,
|
||||||
CreatedAt: u.CreatedAt,
|
Points: u.Points,
|
||||||
LastSeenAt: u.LastSeenAt,
|
CreatedAt: u.CreatedAt,
|
||||||
|
LastSeenAt: u.LastSeenAt,
|
||||||
}
|
}
|
||||||
if u.LastSeenAt != nil && now.Sub(*u.LastSeenAt) <= onlineThreshold {
|
if u.LastSeenAt != nil && now.Sub(*u.LastSeenAt) <= onlineThreshold {
|
||||||
item.Online = true
|
item.Online = true
|
||||||
@@ -407,6 +409,38 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return s.getItem(s.db, targetID)
|
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 封禁/解封用户。站长账号不可封禁;封禁时同事务使登录态立即失效
|
// SetBanned 封禁/解封用户。站长账号不可封禁;封禁时同事务使登录态立即失效
|
||||||
func (s *AdminUserService) SetBanned(operator *Actor, targetID uint, banned bool) (*AdminUserItem, error) {
|
func (s *AdminUserService) SetBanned(operator *Actor, targetID uint, banned bool) (*AdminUserItem, error) {
|
||||||
if operator == nil {
|
if operator == nil {
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overview 返回近 N 日访问与内容趋势
|
// Overview 返回近 N 日访问与内容趋势。
|
||||||
func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
// scoped=true 时按 boardIDs 过滤内容类指标,并隐藏全站 PV/UV/注册。
|
||||||
|
func (s *AnalyticsService) Overview(rangeQ string, boardIDs []uint, scoped bool) (*AnalyticsOverview, error) {
|
||||||
days := parseRangeDays(rangeQ)
|
days := parseRangeDays(rangeQ)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
@@ -97,22 +98,38 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
|||||||
TopViewed: make([]TopViewPost, 0),
|
TopViewed: make([]TopViewPost, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
var stats []model.SiteDailyStats
|
if scoped && len(boardIDs) == 0 {
|
||||||
if err := s.db.Where("date >= ? AND date <= ?", start, end).
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||||
Order("date ASC").Find(&stats).Error; err != nil {
|
out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)})
|
||||||
return nil, err
|
}
|
||||||
|
out.PostsSeries = fillDaySeries(start, end, nil)
|
||||||
|
out.CommentsSeries = fillDaySeries(start, end, nil)
|
||||||
|
out.RegistersSeries = fillDaySeries(start, end, nil)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
visitMap := map[string]model.SiteDailyStats{}
|
|
||||||
for _, st := range stats {
|
if !scoped {
|
||||||
visitMap[dateKey(st.Date)] = st
|
var stats []model.SiteDailyStats
|
||||||
}
|
if err := s.db.Where("date >= ? AND date <= ?", start, end).
|
||||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
Order("date ASC").Find(&stats).Error; err != nil {
|
||||||
k := dateKey(d)
|
return nil, err
|
||||||
st := visitMap[k]
|
}
|
||||||
out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV})
|
visitMap := map[string]model.SiteDailyStats{}
|
||||||
if k == dateKey(today) {
|
for _, st := range stats {
|
||||||
out.TodayPV = st.PV
|
visitMap[dateKey(st.Date)] = st
|
||||||
out.TodayUV = st.UV
|
}
|
||||||
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||||
|
k := dateKey(d)
|
||||||
|
st := visitMap[k]
|
||||||
|
out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV})
|
||||||
|
if k == dateKey(today) {
|
||||||
|
out.TodayPV = st.PV
|
||||||
|
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)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,38 +149,56 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
|||||||
return m, nil
|
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(`
|
postMap, err := scanDayCounts(`
|
||||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||||
FROM posts
|
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`,
|
GROUP BY day ORDER BY day`,
|
||||||
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
postArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out.PostsSeries = fillDaySeries(start, end, postMap)
|
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(`
|
commentMap, err := scanDayCounts(`
|
||||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||||
FROM comments
|
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`,
|
GROUP BY day ORDER BY day`,
|
||||||
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
commentArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out.CommentsSeries = fillDaySeries(start, end, commentMap)
|
out.CommentsSeries = fillDaySeries(start, end, commentMap)
|
||||||
|
|
||||||
regMap, err := scanDayCounts(`
|
if !scoped {
|
||||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
regMap, err := scanDayCounts(`
|
||||||
FROM users
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||||
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?
|
FROM users
|
||||||
GROUP BY day ORDER BY day`,
|
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?
|
||||||
start, end.AddDate(0, 0, 1))
|
GROUP BY day ORDER BY day`,
|
||||||
if err != nil {
|
start, end.AddDate(0, 0, 1))
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.RegistersSeries = fillDaySeries(start, end, regMap)
|
||||||
|
} else {
|
||||||
|
out.RegistersSeries = fillDaySeries(start, end, nil)
|
||||||
}
|
}
|
||||||
out.RegistersSeries = fillDaySeries(start, end, regMap)
|
|
||||||
|
|
||||||
type boardRow struct {
|
type boardRow struct {
|
||||||
ID uint
|
ID uint
|
||||||
@@ -171,13 +206,18 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
|||||||
PostCount int64
|
PostCount int64
|
||||||
}
|
}
|
||||||
var boards []boardRow
|
var boards []boardRow
|
||||||
if err := s.db.Raw(`
|
boardSQL := `
|
||||||
SELECT b.id, b.name, count(p.id) AS post_count
|
SELECT b.id, b.name, count(p.id) AS post_count
|
||||||
FROM boards b
|
FROM boards b
|
||||||
LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ?
|
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
|
WHERE b.deleted_at IS NULL`
|
||||||
GROUP BY b.id, b.name
|
boardArgs := []interface{}{model.ContentStatusPublished}
|
||||||
ORDER BY post_count DESC`, model.ContentStatusPublished).Scan(&boards).Error; err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, b := range boards {
|
for _, b := range boards {
|
||||||
@@ -192,11 +232,14 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
|||||||
PostType string
|
PostType string
|
||||||
Cnt int64
|
Cnt int64
|
||||||
}
|
}
|
||||||
var types []typeRow
|
typeQ := s.db.Model(&model.Post{}).
|
||||||
if err := s.db.Model(&model.Post{}).
|
|
||||||
Select("post_type, count(*) as cnt").
|
Select("post_type, count(*) as cnt").
|
||||||
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
|
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished)
|
||||||
Group("post_type").Scan(&types).Error; err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, t := range types {
|
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
|
var tops []model.Post
|
||||||
if err := s.db.Select("id, title, view_count, board_id").
|
if err := topQ.Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
|
||||||
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
|
|
||||||
Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, p := range tops {
|
for _, p := range tops {
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ var (
|
|||||||
ErrChatDMDissolve = errors.New("私聊会话不可解散")
|
ErrChatDMDissolve = errors.New("私聊会话不可解散")
|
||||||
ErrChatUserGone = errors.New("用户不存在或已封禁")
|
ErrChatUserGone = errors.New("用户不存在或已封禁")
|
||||||
ErrChatHallPinFixed = 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 {
|
func CanOverseeChat(role model.Role) bool {
|
||||||
return role == model.RoleOwner || role == model.RoleSuperAdmin
|
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
|
return &m, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID
|
// InviteMembers 群主/群管(或监管)拉人入群;返回实际新加入的用户 ID
|
||||||
func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) {
|
func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) {
|
||||||
room, err := s.getRoom(roomID)
|
room, err := s.getRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -544,7 +549,7 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if op.Role != model.ChatRoleOwner {
|
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||||
return nil, ErrChatOwnerOnly
|
return nil, ErrChatOwnerOnly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -702,7 +707,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
|
|||||||
}
|
}
|
||||||
var list []model.ChatRoomMember
|
var list []model.ChatRoomMember
|
||||||
err := s.db.Where("room_id = ?", roomID).
|
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").
|
Preload("User").
|
||||||
Find(&list).Error
|
Find(&list).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -711,18 +716,11 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick 群主踢人(不能踢群主)
|
// Kick 群主/群管/全站监管踢人(不能踢群主;群管不能踢其他群管)
|
||||||
func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
|
func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) error {
|
||||||
if _, err := s.getRoom(roomID); err != nil {
|
if _, err := s.getRoom(roomID); err != nil {
|
||||||
return err
|
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)
|
target, err := s.membership(s.db, roomID, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -730,9 +728,158 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
|
|||||||
if target.Role == model.ChatRoleOwner {
|
if target.Role == model.ChatRoleOwner {
|
||||||
return ErrChatOwnerOnly
|
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)
|
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 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。
|
// SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。
|
||||||
func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) {
|
func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) {
|
||||||
content = strings.TrimSpace(content)
|
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 {
|
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||||
}
|
}
|
||||||
// 已撤回正文:仅监管者(站长/超管)保留,供聊天界面审计展示;普通成员清空
|
// 已撤回正文:监管者 / 站点消息管(群)/ 群主群管保留
|
||||||
if !oversee {
|
seeRecalled := oversee || s.canModerateRoomMessages(userID, roomID, false)
|
||||||
|
if !seeRecalled {
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
if msgs[i].RecalledAt != nil {
|
if msgs[i].RecalledAt != nil {
|
||||||
msgs[i].Content = ""
|
msgs[i].Content = ""
|
||||||
@@ -918,7 +1066,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
|||||||
return msgs, hasMore, nil
|
return msgs, hasMore, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。
|
// RecallMessage 撤回消息:本人;或群主/群管/站点消息管(群)/全站监管可撤他人。
|
||||||
// newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。
|
// newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。
|
||||||
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) {
|
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) {
|
||||||
if _, err := s.getRoom(roomID); err != nil {
|
if _, err := s.getRoom(roomID); err != nil {
|
||||||
@@ -934,19 +1082,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
if msg.RecalledAt != nil {
|
if msg.RecalledAt != nil {
|
||||||
return &msg, false, nil // 幂等
|
return &msg, false, nil // 幂等
|
||||||
}
|
}
|
||||||
if !oversee {
|
if msg.SenderID == operatorID {
|
||||||
m, err := s.membership(s.db, roomID, operatorID)
|
if !oversee {
|
||||||
if err != nil {
|
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
// 群主/成员仅可撤回自己的消息;监管者可撤任意
|
|
||||||
if msg.SenderID != operatorID {
|
|
||||||
_ = m // 成员校验已通过
|
|
||||||
return nil, false, ErrChatRecallDenied
|
|
||||||
}
|
}
|
||||||
|
} else if !s.canModerateRoomMessages(operatorID, roomID, oversee) {
|
||||||
|
return nil, false, ErrChatRecallDenied
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content
|
|
||||||
if err := s.db.Model(&msg).Updates(map[string]interface{}{
|
if err := s.db.Model(&msg).Updates(map[string]interface{}{
|
||||||
"recalled_at": now,
|
"recalled_at": now,
|
||||||
"recalled_by": operatorID,
|
"recalled_by": operatorID,
|
||||||
@@ -982,20 +1127,44 @@ type AdminChatMessageItem struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索
|
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
|
||||||
func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
// 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 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
fullOversee := CanOverseeChat(actor.Role)
|
||||||
|
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
|
||||||
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
||||||
return nil, 0, errors.New("房间类型无效")
|
return nil, 0, errors.New("房间类型无效")
|
||||||
}
|
}
|
||||||
|
// 非全站监管不可看私聊
|
||||||
|
if roomType == model.ChatRoomTypeDirect && !fullOversee {
|
||||||
|
return []AdminChatMessageItem{}, 0, nil
|
||||||
|
}
|
||||||
const pageSize = 15
|
const pageSize = 15
|
||||||
q := s.db.Model(&model.ChatMessage{}).
|
q := s.db.Model(&model.ChatMessage{}).
|
||||||
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
|
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
|
||||||
Joins("LEFT JOIN users ON users.id = chat_messages.sender_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.deleted_at IS NULL AND chat_messages.deleted_at IS NULL").
|
||||||
Where("chat_rooms.room_type = ?", roomType)
|
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 != "" {
|
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||||
like := "%" + kw + "%"
|
like := "%" + kw + "%"
|
||||||
q = q.Where(
|
q = q.Where(
|
||||||
@@ -1037,7 +1206,6 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
|
|||||||
roomMap[r.ID] = r
|
roomMap[r.ID] = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条
|
|
||||||
participantsByRoom := map[uint][]AdminChatParticipant{}
|
participantsByRoom := map[uint][]AdminChatParticipant{}
|
||||||
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
||||||
var members []model.ChatRoomMember
|
var members []model.ChatRoomMember
|
||||||
@@ -1073,9 +1241,14 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
|
|||||||
name = "私聊"
|
name = "私聊"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
content := m.Content
|
||||||
|
// 后台列表:仅全站监管可见已撤回正文;群管/消息管理员在前台会话内可看
|
||||||
|
if !fullOversee && m.RecalledAt != nil {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
item := AdminChatMessageItem{
|
item := AdminChatMessageItem{
|
||||||
ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault,
|
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,
|
Participants: parts,
|
||||||
}
|
}
|
||||||
if m.Sender.ID > 0 {
|
if m.Sender.ID > 0 {
|
||||||
|
|||||||
@@ -435,6 +435,13 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet
|
|||||||
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
|
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
|
||||||
return ErrCommentForbidden
|
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)
|
deleteType = strings.TrimSpace(opts.DeleteType)
|
||||||
if !model.ValidPostDeleteType(deleteType) {
|
if !model.ValidPostDeleteType(deleteType) {
|
||||||
return ErrCommentInvalidType
|
return ErrCommentInvalidType
|
||||||
@@ -490,6 +497,13 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
|
|||||||
return ErrCommentNotFound
|
return ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if actor != nil && actor.CanModerateBoard(post.BoardID) {
|
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
|
allowed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,6 +560,9 @@ func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
|
|||||||
|
|
||||||
// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
|
// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
|
||||||
func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
|
func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
|
||||||
|
if actor == nil || !actor.CanPurgeContent() {
|
||||||
|
return ErrPurgeOwnerOnly
|
||||||
|
}
|
||||||
var comment model.Comment
|
var comment model.Comment
|
||||||
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
|
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
|
||||||
return ErrCommentNotFound
|
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 {
|
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
||||||
return ErrCommentNotFound
|
return ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
|
if !actor.CanModerateBoard(post.BoardID) {
|
||||||
return ErrCommentForbidden
|
return ErrCommentForbidden
|
||||||
}
|
}
|
||||||
_ = userID
|
_ = userID
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrModerationForbidden = errors.New("无权审核该板块的内容")
|
ErrModerationForbidden = errors.New("无权审核该板块的内容")
|
||||||
ErrNotPending = errors.New("该内容不在待审核状态")
|
ErrNotPending = errors.New("该内容不在待审核状态")
|
||||||
|
ErrAuthorProtected = errors.New("无权处置该作者的内容")
|
||||||
|
ErrPurgeOwnerOnly = errors.New("仅站长可彻底删除")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModerationService 帖子/评论审核队列
|
// ModerationService 帖子/评论审核队列
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrPostNotFound = errors.New("帖子不存在")
|
ErrPostNotFound = errors.New("帖子不存在")
|
||||||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||||||
ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由")
|
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
|
||||||
ErrPostInvalidType = errors.New("无效的删除类型")
|
ErrPostInvalidType = errors.New("无效的删除类型")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1083,16 +1083,27 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO
|
|||||||
if post.UserID == userID {
|
if post.UserID == userID {
|
||||||
staffDeleted = false
|
staffDeleted = false
|
||||||
} else if actor != nil && actor.CanModerateBoard(post.BoardID) {
|
} 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)
|
deleteType = strings.TrimSpace(opts.DeleteType)
|
||||||
deleteReason = strings.TrimSpace(opts.DeleteReason)
|
deleteReason = strings.TrimSpace(opts.DeleteReason)
|
||||||
if !model.ValidPostDeleteType(deleteType) {
|
if !model.ValidPostDeleteType(deleteType) {
|
||||||
return false, authorID, ErrPostInvalidType
|
return false, authorID, ErrPostInvalidType
|
||||||
}
|
}
|
||||||
if deleteReason == "" {
|
if deleteType == model.PostDeleteTypeOther {
|
||||||
return false, authorID, ErrPostDeleteMeta
|
if deleteReason == "" {
|
||||||
}
|
return false, authorID, ErrPostDeleteMeta
|
||||||
if len([]rune(deleteReason)) > 200 {
|
}
|
||||||
return false, authorID, errors.New("删除理由不能超过 200 字")
|
if len([]rune(deleteReason)) > 200 {
|
||||||
|
return false, authorID, errors.New("删除理由不能超过 200 字")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteReason = ""
|
||||||
}
|
}
|
||||||
staffDeleted = true
|
staffDeleted = true
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type AdminContentStatus,
|
type AdminContentStatus,
|
||||||
type PublicSettings,
|
type PublicSettings,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { canModerateAuthor } from "@/lib/roles";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||||
@@ -290,6 +291,8 @@ export default function ContentAdmin({
|
|||||||
initialCounts,
|
initialCounts,
|
||||||
initError,
|
initError,
|
||||||
canConfigureSettings,
|
canConfigureSettings,
|
||||||
|
canPurge,
|
||||||
|
viewerRole,
|
||||||
initialSettings,
|
initialSettings,
|
||||||
}: {
|
}: {
|
||||||
initialKind: ContentKind;
|
initialKind: ContentKind;
|
||||||
@@ -301,6 +304,8 @@ export default function ContentAdmin({
|
|||||||
initialCounts: AdminContentCounts;
|
initialCounts: AdminContentCounts;
|
||||||
initError: string;
|
initError: string;
|
||||||
canConfigureSettings: boolean;
|
canConfigureSettings: boolean;
|
||||||
|
canPurge: boolean;
|
||||||
|
viewerRole: string;
|
||||||
initialSettings: PublicSettings;
|
initialSettings: PublicSettings;
|
||||||
}) {
|
}) {
|
||||||
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
||||||
@@ -663,46 +668,50 @@ export default function ContentAdmin({
|
|||||||
) : null}
|
) : null}
|
||||||
{p.deleted ? (
|
{p.deleted ? (
|
||||||
<>
|
<>
|
||||||
<ContentAction
|
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||||
label="恢复"
|
<ContentAction
|
||||||
ok
|
label="恢复"
|
||||||
disabled={busy}
|
ok
|
||||||
icon={
|
disabled={busy}
|
||||||
busy ? (
|
icon={
|
||||||
<Loader2 size={13} className="animate-spin" />
|
busy ? (
|
||||||
) : (
|
<Loader2 size={13} className="animate-spin" />
|
||||||
<RotateCcw size={13} />
|
) : (
|
||||||
)
|
<RotateCcw size={13} />
|
||||||
}
|
)
|
||||||
onClick={() =>
|
}
|
||||||
runAction(
|
onClick={() =>
|
||||||
key,
|
runAction(
|
||||||
() => apiAdminRestorePost(p.id),
|
key,
|
||||||
"帖子已恢复",
|
() => apiAdminRestorePost(p.id),
|
||||||
() => reloadPosts()
|
"帖子已恢复",
|
||||||
)
|
() => reloadPosts()
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<ContentAction
|
/>
|
||||||
label="清除"
|
) : null}
|
||||||
danger
|
{canPurge ? (
|
||||||
disabled={busy}
|
<ContentAction
|
||||||
icon={<Trash2 size={13} />}
|
label="清除"
|
||||||
onClick={() =>
|
danger
|
||||||
askConfirm(
|
disabled={busy}
|
||||||
"彻底删除帖子",
|
icon={<Trash2 size={13} />}
|
||||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
onClick={() =>
|
||||||
() =>
|
askConfirm(
|
||||||
apiAdminPurgePost(p.id).then(() => {
|
"彻底删除帖子",
|
||||||
toast("帖子已彻底删除", "ok");
|
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||||
reloadPosts();
|
() =>
|
||||||
}),
|
apiAdminPurgePost(p.id).then(() => {
|
||||||
true
|
toast("帖子已彻底删除", "ok");
|
||||||
)
|
reloadPosts();
|
||||||
}
|
}),
|
||||||
/>
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||||
<ContentAction
|
<ContentAction
|
||||||
label="删除"
|
label="删除"
|
||||||
danger
|
danger
|
||||||
@@ -710,7 +719,7 @@ export default function ContentAdmin({
|
|||||||
icon={<Trash2 size={13} />}
|
icon={<Trash2 size={13} />}
|
||||||
onClick={() => setStaffDeletePost(p)}
|
onClick={() => setStaffDeletePost(p)}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -803,46 +812,50 @@ export default function ContentAdmin({
|
|||||||
/>
|
/>
|
||||||
{c.deleted ? (
|
{c.deleted ? (
|
||||||
<>
|
<>
|
||||||
<ContentAction
|
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||||
label="恢复"
|
<ContentAction
|
||||||
ok
|
label="恢复"
|
||||||
disabled={busy}
|
ok
|
||||||
icon={
|
disabled={busy}
|
||||||
busy ? (
|
icon={
|
||||||
<Loader2 size={13} className="animate-spin" />
|
busy ? (
|
||||||
) : (
|
<Loader2 size={13} className="animate-spin" />
|
||||||
<RotateCcw size={13} />
|
) : (
|
||||||
)
|
<RotateCcw size={13} />
|
||||||
}
|
)
|
||||||
onClick={() =>
|
}
|
||||||
runAction(
|
onClick={() =>
|
||||||
key,
|
runAction(
|
||||||
() => apiAdminRestoreComment(c.id),
|
key,
|
||||||
"评论已恢复",
|
() => apiAdminRestoreComment(c.id),
|
||||||
() => reloadComments()
|
"评论已恢复",
|
||||||
)
|
() => reloadComments()
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<ContentAction
|
/>
|
||||||
label="清除"
|
) : null}
|
||||||
danger
|
{canPurge ? (
|
||||||
disabled={busy}
|
<ContentAction
|
||||||
icon={<Trash2 size={13} />}
|
label="清除"
|
||||||
onClick={() =>
|
danger
|
||||||
askConfirm(
|
disabled={busy}
|
||||||
"彻底删除评论",
|
icon={<Trash2 size={13} />}
|
||||||
"该评论将永久删除且不可恢复。",
|
onClick={() =>
|
||||||
() =>
|
askConfirm(
|
||||||
apiAdminPurgeComment(c.id).then(() => {
|
"彻底删除评论",
|
||||||
toast("评论已彻底删除", "ok");
|
"该评论将永久删除且不可恢复。",
|
||||||
reloadComments();
|
() =>
|
||||||
}),
|
apiAdminPurgeComment(c.id).then(() => {
|
||||||
true
|
toast("评论已彻底删除", "ok");
|
||||||
)
|
reloadComments();
|
||||||
}
|
}),
|
||||||
/>
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||||
<ContentAction
|
<ContentAction
|
||||||
label="删除"
|
label="删除"
|
||||||
danger
|
danger
|
||||||
@@ -850,7 +863,7 @@ export default function ContentAdmin({
|
|||||||
icon={<Trash2 size={13} />}
|
icon={<Trash2 size={13} />}
|
||||||
onClick={() => setStaffDeleteComment(c)}
|
onClick={() => setStaffDeleteComment(c)}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type AdminContentStatus,
|
type AdminContentStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||||
|
|
||||||
@@ -93,6 +93,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
|||||||
initialCounts={counts}
|
initialCounts={counts}
|
||||||
initError={initError}
|
initError={initError}
|
||||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||||
|
canPurge={isOwner(me.user.role)}
|
||||||
|
viewerRole={me.user.role}
|
||||||
initialSettings={settings}
|
initialSettings={settings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
|
||||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||||
@@ -12,8 +12,8 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
|
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||||
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
|
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = authCookieHeader(cookieStore);
|
const cookie = authCookieHeader(cookieStore);
|
||||||
@@ -21,7 +21,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-admin-viewport
|
data-admin-viewport
|
||||||
@@ -48,12 +51,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = me.user;
|
||||||
|
|
||||||
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
||||||
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
|
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
user={me.user}
|
user={user}
|
||||||
siteName={settings.site_name || "姜十三论坛"}
|
siteName={settings.site_name || "姜十三论坛"}
|
||||||
initialTheme={theme}
|
initialTheme={theme}
|
||||||
initialUnread={unread}
|
initialUnread={unread}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
apiAdminRecallChatMessage,
|
apiAdminRecallChatMessage,
|
||||||
type AdminChatMessage,
|
type AdminChatMessage,
|
||||||
type AdminChatRoomType,
|
type AdminChatRoomType,
|
||||||
|
type User,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { canOverseeChat } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import AutolinkText from "@/components/AutolinkText";
|
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 [mobileKind, setMobileKind] = useState<MsgKind>("group");
|
||||||
const group = useMessageColumn("group");
|
const group = useMessageColumn("group");
|
||||||
const direct = useMessageColumn("direct");
|
const direct = useMessageColumn("direct");
|
||||||
@@ -485,37 +488,43 @@ export default function MessagesAdmin() {
|
|||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={MessagesSquare}
|
icon={MessagesSquare}
|
||||||
title="消息管理"
|
title="消息管理"
|
||||||
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
|
description={
|
||||||
|
canOversee
|
||||||
|
? "分栏监管群聊与私聊;可搜索并撤回违规内容"
|
||||||
|
: "监管授权范围内的群聊消息;可搜索并撤回违规内容(不含私聊)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
{canOversee ? (
|
||||||
<button
|
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={mobileKind === "group"}
|
role="tab"
|
||||||
className="admin-content-kind-btn"
|
aria-selected={mobileKind === "group"}
|
||||||
onClick={() => setMobileKind("group")}
|
className="admin-content-kind-btn"
|
||||||
>
|
onClick={() => setMobileKind("group")}
|
||||||
<Users size={14} aria-hidden />
|
>
|
||||||
群聊
|
<Users size={14} aria-hidden />
|
||||||
<span className="admin-content-kind-count">{group.total}</span>
|
群聊
|
||||||
</button>
|
<span className="admin-content-kind-count">{group.total}</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={mobileKind === "direct"}
|
role="tab"
|
||||||
className="admin-content-kind-btn"
|
aria-selected={mobileKind === "direct"}
|
||||||
onClick={() => setMobileKind("direct")}
|
className="admin-content-kind-btn"
|
||||||
>
|
onClick={() => setMobileKind("direct")}
|
||||||
<MessageCircle size={14} aria-hidden />
|
>
|
||||||
私聊
|
<MessageCircle size={14} aria-hidden />
|
||||||
<span className="admin-content-kind-count">{direct.total}</span>
|
私聊
|
||||||
</button>
|
<span className="admin-content-kind-count">{direct.total}</span>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="admin-content-triptych">
|
<div className={canOversee ? "admin-content-triptych" : "admin-content-triptych admin-content-triptych--single"}>
|
||||||
{groupCol}
|
{groupCol}
|
||||||
{directCol}
|
{canOversee ? directCol : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import MessagesAdmin from "./MessagesAdmin";
|
import MessagesAdmin from "./MessagesAdmin";
|
||||||
|
|
||||||
@@ -10,13 +10,13 @@ export const metadata: Metadata = {
|
|||||||
title: "消息管理",
|
title: "消息管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
|
/** 群聊(含大厅)与私聊分栏监管;鉴权与消息管理权限一致 */
|
||||||
export default async function AdminMessagesPage() {
|
export default async function AdminMessagesPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isStaff(me.user.role)) {
|
if (!me.user || !canAccessAdminMessages(me.user)) {
|
||||||
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
|
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MessagesAdmin />;
|
return <MessagesAdmin me={me.user} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
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 { AdminPageHeader, AdminStatCard } from "@/components/admin";
|
||||||
import DashboardClient from "./DashboardClient";
|
import DashboardClient from "./DashboardClient";
|
||||||
|
|
||||||
@@ -115,9 +115,12 @@ export default async function AdminDashboardPage() {
|
|||||||
const canMod = canModerateAny(me.user);
|
const canMod = canModerateAny(me.user);
|
||||||
const canUsers = isSuperOrOwner(me.user?.role);
|
const canUsers = isSuperOrOwner(me.user?.role);
|
||||||
const canEconomy = isAdminOrAbove(me.user?.role);
|
const canEconomy = isAdminOrAbove(me.user?.role);
|
||||||
|
const boardScoped = me.user?.role === ROLES.BOARD_ADMIN;
|
||||||
|
|
||||||
const [overview, pendingCounts, analytics] = await Promise.all([
|
const [overview, pendingCounts, analytics] = await Promise.all([
|
||||||
fetchOverview(cookie).catch(() => null),
|
boardScoped
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: fetchOverview(cookie).catch(() => null),
|
||||||
canMod
|
canMod
|
||||||
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
@@ -133,7 +136,15 @@ export default async function AdminDashboardPage() {
|
|||||||
}).format(now);
|
}).format(now);
|
||||||
|
|
||||||
const stats = overview?.stats;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -155,30 +166,34 @@ export default async function AdminDashboardPage() {
|
|||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="今日 PV"
|
label="今日 PV"
|
||||||
value={fmt(analytics.today_pv)}
|
value={fmt(analytics.today_pv)}
|
||||||
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
|
hint={boardScoped ? "仅全站可见(板管不展示)" : `独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||||
icon={<Eye size={16} />}
|
icon={<Eye size={16} />}
|
||||||
/>
|
/>
|
||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="今日 UV"
|
label="今日 UV"
|
||||||
value={fmt(analytics.today_uv)}
|
value={fmt(analytics.today_uv)}
|
||||||
hint="独立访客"
|
hint={boardScoped ? "板管仪表盘不含全站 UV" : "独立访客"}
|
||||||
icon={<Users size={16} />}
|
icon={<Users size={16} />}
|
||||||
color="var(--ok)"
|
color="var(--ok)"
|
||||||
soft="var(--ok-soft)"
|
soft="var(--ok-soft)"
|
||||||
/>
|
/>
|
||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="今日内容"
|
label="今日内容"
|
||||||
value={fmt(stats?.today_posts ?? 0)}
|
value={fmt(todayPosts)}
|
||||||
sub="帖"
|
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} />}
|
icon={<FileText size={16} />}
|
||||||
color="var(--gold)"
|
color="var(--gold)"
|
||||||
soft="var(--gold-soft)"
|
soft="var(--gold-soft)"
|
||||||
/>
|
/>
|
||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="在线"
|
label="在线"
|
||||||
value={fmt(stats?.online ?? 0)}
|
value={boardScoped ? "—" : fmt(stats?.online ?? 0)}
|
||||||
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
hint={boardScoped ? "板管不展示全站在线" : `峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||||
icon={<Activity size={16} />}
|
icon={<Activity size={16} />}
|
||||||
color="var(--clay)"
|
color="var(--clay)"
|
||||||
soft="var(--clay-soft)"
|
soft="var(--clay-soft)"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
apiDissolveChatRoom,
|
apiDissolveChatRoom,
|
||||||
apiUpdateChatRoom,
|
apiUpdateChatRoom,
|
||||||
apiKickChatMember,
|
apiKickChatMember,
|
||||||
|
apiMuteChatMember,
|
||||||
|
apiSetChatMemberRole,
|
||||||
apiInviteChatMembers,
|
apiInviteChatMembers,
|
||||||
apiRecallChatMessage,
|
apiRecallChatMessage,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
@@ -44,7 +46,7 @@ import type {
|
|||||||
RtChatMembershipData,
|
RtChatMembershipData,
|
||||||
RtChatRecalledData,
|
RtChatRecalledData,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import { canOverseeChat } from "@/lib/roles";
|
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { scrollBehavior } from "@/lib/motion";
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
@@ -171,7 +173,7 @@ interface Props {
|
|||||||
roomDescription: string;
|
roomDescription: string;
|
||||||
ownerId: number;
|
ownerId: number;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
myRole: "" | "owner" | "member" | "overseer";
|
myRole: "" | "owner" | "admin" | "member" | "overseer";
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
@@ -179,6 +181,8 @@ interface Props {
|
|||||||
peer: { id: number; username: string; nickname: string; avatar: string } | null;
|
peer: { id: number; username: string; nickname: string; avatar: string } | null;
|
||||||
meId: number;
|
meId: number;
|
||||||
meRole: string;
|
meRole: string;
|
||||||
|
/** 站点消息管理 flag */
|
||||||
|
canManageMessages?: boolean;
|
||||||
meName: string;
|
meName: string;
|
||||||
meAvatar: string;
|
meAvatar: string;
|
||||||
initialMessages: ChatMessage[];
|
initialMessages: ChatMessage[];
|
||||||
@@ -315,6 +319,7 @@ export default function ChatRoomClient({
|
|||||||
peer,
|
peer,
|
||||||
meId,
|
meId,
|
||||||
meRole,
|
meRole,
|
||||||
|
canManageMessages = false,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
initialHasMore,
|
initialHasMore,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
@@ -353,11 +358,16 @@ export default function ChatRoomClient({
|
|||||||
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
const isOwner = myRole === "owner";
|
const isOwner = myRole === "owner";
|
||||||
|
const isRoomAdmin = myRole === "admin";
|
||||||
const isDirect = roomType === "direct";
|
const isDirect = roomType === "direct";
|
||||||
const isRealMember = myRole === "owner" || myRole === "member";
|
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
|
||||||
const canView = joined;
|
const canView = joined;
|
||||||
const oversee = canOverseeChat(meRole);
|
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 rememberReEdit = useCallback((id: number, text: string) => {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
@@ -472,7 +482,7 @@ export default function ChatRoomClient({
|
|||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
|
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
|
||||||
content: oversee ? m.content : "",
|
content: canModerateChat ? m.content : "",
|
||||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||||
recalled_by: data.recalled_by,
|
recalled_by: data.recalled_by,
|
||||||
sender_id: data.sender_id || m.sender_id,
|
sender_id: data.sender_id || m.sender_id,
|
||||||
@@ -490,7 +500,7 @@ export default function ChatRoomClient({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return off;
|
return off;
|
||||||
}, [roomId, oversee, meId, rememberReEdit]);
|
}, [roomId, canModerateChat, meId, rememberReEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = realtime.on<RtChatMembershipData>(
|
const off = realtime.on<RtChatMembershipData>(
|
||||||
@@ -820,6 +830,7 @@ export default function ChatRoomClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKick = (uid: number, nickname: string) => {
|
const handleKick = (uid: number, nickname: string) => {
|
||||||
|
if (!canKickMembers) return;
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: "移出成员",
|
title: "移出成员",
|
||||||
message: `确定将 ${nickname} 移出该群?`,
|
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) => {
|
const canRecallMsg = (m: ChatMessage) => {
|
||||||
if (m.recalled_at) return false;
|
if (m.recalled_at) return false;
|
||||||
if (oversee) return true;
|
if (canModerateChat) return true;
|
||||||
return m.sender_id === meId && isRealMember;
|
return m.sender_id === meId && isRealMember;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -858,7 +909,7 @@ export default function ChatRoomClient({
|
|||||||
m.id === mid
|
m.id === mid
|
||||||
? {
|
? {
|
||||||
...m,
|
...m,
|
||||||
content: oversee ? body || m.content : "",
|
content: canModerateChat ? body || m.content : "",
|
||||||
recalled_at: message.recalled_at || new Date().toISOString(),
|
recalled_at: message.recalled_at || new Date().toISOString(),
|
||||||
recalled_by: message.recalled_by,
|
recalled_by: message.recalled_by,
|
||||||
recaller: message.recaller || m.recaller,
|
recaller: message.recaller || m.recaller,
|
||||||
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
|
|||||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||||
|
|
||||||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||||||
if (recalled && !(oversee && m.content)) {
|
if (recalled && !(canModerateChat && m.content)) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -1160,7 +1211,7 @@ export default function ChatRoomClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
||||||
if (recalled && oversee && m.content) {
|
if (recalled && canModerateChat && m.content) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -1529,9 +1580,13 @@ export default function ChatRoomClient({
|
|||||||
<MembersPanel
|
<MembersPanel
|
||||||
members={members}
|
members={members}
|
||||||
meId={meId}
|
meId={meId}
|
||||||
isOwner={isOwner}
|
canKick={canKickMembers}
|
||||||
|
isChatOwner={isOwner}
|
||||||
|
siteOwner={siteOwner}
|
||||||
onRefresh={refreshMembers}
|
onRefresh={refreshMembers}
|
||||||
onKick={handleKick}
|
onKick={handleKick}
|
||||||
|
onMute={handleMute}
|
||||||
|
onSetAdmin={handleSetAdmin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rightPanel === "invite" && (
|
{rightPanel === "invite" && (
|
||||||
@@ -1566,9 +1621,13 @@ export default function ChatRoomClient({
|
|||||||
<MembersPanel
|
<MembersPanel
|
||||||
members={members}
|
members={members}
|
||||||
meId={meId}
|
meId={meId}
|
||||||
isOwner={isOwner}
|
canKick={canKickMembers}
|
||||||
|
isChatOwner={isOwner}
|
||||||
|
siteOwner={siteOwner}
|
||||||
onRefresh={refreshMembers}
|
onRefresh={refreshMembers}
|
||||||
onKick={handleKick}
|
onKick={handleKick}
|
||||||
|
onMute={handleMute}
|
||||||
|
onSetAdmin={handleSetAdmin}
|
||||||
showTitle
|
showTitle
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1634,16 +1693,24 @@ function JoinButton({
|
|||||||
function MembersPanel({
|
function MembersPanel({
|
||||||
members,
|
members,
|
||||||
meId,
|
meId,
|
||||||
isOwner,
|
canKick,
|
||||||
|
isChatOwner,
|
||||||
|
siteOwner,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onKick,
|
onKick,
|
||||||
|
onMute,
|
||||||
|
onSetAdmin,
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
}: {
|
}: {
|
||||||
members: ChatRoomMember[];
|
members: ChatRoomMember[];
|
||||||
meId: number;
|
meId: number;
|
||||||
isOwner: boolean;
|
canKick: boolean;
|
||||||
|
isChatOwner: boolean;
|
||||||
|
siteOwner: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onKick: (uid: number, nickname: string) => 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;
|
showTitle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1675,38 +1742,74 @@ function MembersPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{members.map((m) => (
|
{members.map((m) => {
|
||||||
<li
|
const name = m.user.nickname || m.user.username;
|
||||||
key={m.id}
|
const isTargetOwner = m.role === "owner";
|
||||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
const isTargetAdmin = m.role === "admin";
|
||||||
>
|
const canActOn =
|
||||||
<Avatar
|
canKick &&
|
||||||
name={m.user.nickname || m.user.username}
|
m.user_id !== meId &&
|
||||||
src={m.user.avatar || undefined}
|
!isTargetOwner &&
|
||||||
size={28}
|
(isChatOwner || !isTargetAdmin);
|
||||||
/>
|
return (
|
||||||
<Link
|
<li
|
||||||
href={`/u/${m.user.id}`}
|
key={m.id}
|
||||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
>
|
||||||
{m.user.nickname}
|
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||||
</Link>
|
<Link
|
||||||
{m.role === "owner" ? (
|
href={`/u/${m.user.id}`}
|
||||||
<Crown size={12} style={{ color: "var(--gold)" }} />
|
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||||
) : null}
|
style={{ color: "var(--ink)" }}
|
||||||
{isOwner && m.user_id !== meId && (
|
|
||||||
<button
|
|
||||||
onClick={() => onKick(m.user_id, m.user.nickname)}
|
|
||||||
aria-label={`移出 ${m.user.nickname}`}
|
|
||||||
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} />
|
{name}
|
||||||
</button>
|
{m.muted ? (
|
||||||
)}
|
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
</li>
|
· 禁言
|
||||||
))}
|
</span>
|
||||||
|
) : null}
|
||||||
|
</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
|
||||||
|
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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export default async function ChatRoomPage({ params }: PageProps) {
|
|||||||
peer={room.peer || null}
|
peer={room.peer || null}
|
||||||
meId={me.user.id}
|
meId={me.user.id}
|
||||||
meRole={me.user.role}
|
meRole={me.user.role}
|
||||||
|
canManageMessages={!!me.user.can_manage_messages}
|
||||||
meName={me.user.nickname || me.user.username}
|
meName={me.user.nickname || me.user.username}
|
||||||
meAvatar={me.user.avatar || ""}
|
meAvatar={me.user.avatar || ""}
|
||||||
initialMessages={initialMessages.messages}
|
initialMessages={initialMessages.messages}
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<PostActions
|
<PostActions
|
||||||
postId={id}
|
postId={id}
|
||||||
authorId={post.user.id}
|
authorId={post.user.id}
|
||||||
|
authorRole={post.user.role}
|
||||||
boardId={post.board_id}
|
boardId={post.board_id}
|
||||||
pinned={post.pinned}
|
pinned={post.pinned}
|
||||||
recommended={post.recommended}
|
recommended={post.recommended}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
type CommentNode,
|
type CommentNode,
|
||||||
type User,
|
type User,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateBoard } from "@/lib/roles";
|
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||||
@@ -334,19 +334,25 @@ export default function CommentSection({
|
|||||||
const canSoftDelete = (comment: CommentNode) =>
|
const canSoftDelete = (comment: CommentNode) =>
|
||||||
!comment.deleted &&
|
!comment.deleted &&
|
||||||
!!user &&
|
!!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 canEdit = (comment: CommentNode) => canSoftDelete(comment);
|
||||||
|
|
||||||
const canViewHistory = !!user && canModerateBoard(user, boardId);
|
const canViewHistory = !!user && canModerateBoard(user, boardId);
|
||||||
|
|
||||||
const canPurge = (comment: CommentNode) =>
|
const canPurge = (comment: CommentNode) =>
|
||||||
!!user && canModerateBoard(user, boardId);
|
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
|
||||||
|
|
||||||
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
|
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */
|
||||||
const canRestore = (comment: CommentNode) => {
|
const canRestore = (comment: CommentNode) => {
|
||||||
if (!comment.deleted || !user) return false;
|
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;
|
return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
import StaffDeleteFields from "@/components/StaffDeleteFields";
|
||||||
|
|
||||||
export type DeleteCommentPayload = {
|
export type DeleteCommentPayload = {
|
||||||
delete_type: string;
|
delete_type: string;
|
||||||
@@ -12,7 +12,7 @@ export type DeleteCommentPayload = {
|
|||||||
/**
|
/**
|
||||||
* 评论软删弹窗:
|
* 评论软删弹窗:
|
||||||
* - 评论者自删:必填理由(仅管理可见)
|
* - 评论者自删:必填理由(仅管理可见)
|
||||||
* - 管理删:必选类型;仅「其他」须填理由
|
* - 管理删:与删帖共用类型表单;仅「其他」须填理由
|
||||||
*/
|
*/
|
||||||
export default function DeleteCommentModal({
|
export default function DeleteCommentModal({
|
||||||
open,
|
open,
|
||||||
@@ -89,55 +89,31 @@ export default function DeleteCommentModal({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isStaff ? (
|
{isStaff ? (
|
||||||
<>
|
<StaffDeleteFields
|
||||||
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
|
deleteType={deleteType}
|
||||||
删除类型
|
onTypeChange={(value) => {
|
||||||
</p>
|
setDeleteType(value);
|
||||||
<div
|
setErr("");
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
|
if (value !== "other") setReason("");
|
||||||
role="radiogroup"
|
}}
|
||||||
aria-label="删除类型"
|
reason={reason}
|
||||||
>
|
onReasonChange={(value) => {
|
||||||
{POST_DELETE_TYPES.map((t) => {
|
setReason(value);
|
||||||
const active = deleteType === t.value;
|
setErr("");
|
||||||
return (
|
}}
|
||||||
<button
|
reasonId="cmt-delete-reason"
|
||||||
key={t.value}
|
reasonPlaceholder="向读者说明删除原因(将展示在占位中)"
|
||||||
type="button"
|
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
|
||||||
role="radio"
|
reasonHint="将公开显示在评论占位旁。"
|
||||||
aria-checked={active}
|
/>
|
||||||
onClick={() => {
|
) : (
|
||||||
setDeleteType(t.value);
|
|
||||||
setErr("");
|
|
||||||
if (t.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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="meta text-[11.5px] mb-4 leading-relaxed">
|
|
||||||
选「其他」时需补充理由;其余类型直接按标签展示给读者。
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{reasonRequired ? (
|
|
||||||
<>
|
<>
|
||||||
<label
|
<label
|
||||||
className="block mb-1.5 text-[12.5px] font-medium"
|
className="block mb-1.5 text-[12.5px] font-medium"
|
||||||
style={{ color: "var(--ink)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
htmlFor="cmt-delete-reason"
|
htmlFor="cmt-delete-reason"
|
||||||
>
|
>
|
||||||
{isStaff ? "补充理由" : "删除理由"}{" "}
|
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
|
||||||
<span style={{ color: "var(--danger)" }}>*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="cmt-delete-reason"
|
id="cmt-delete-reason"
|
||||||
@@ -148,19 +124,13 @@ export default function DeleteCommentModal({
|
|||||||
setReason(e.target.value);
|
setReason(e.target.value);
|
||||||
setErr("");
|
setErr("");
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder="例如:发错了 / 不想保留(仅管理员可见)"
|
||||||
isStaff
|
|
||||||
? "向读者说明删除原因(将展示在占位中)"
|
|
||||||
: "例如:发错了 / 不想保留(仅管理员可见)"
|
|
||||||
}
|
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
autoFocus={!isStaff}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="meta text-[11.5px] mt-1.5">
|
<p className="meta text-[11.5px] mt-1.5">普通读者看不到这条理由。</p>
|
||||||
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{err ? (
|
{err ? (
|
||||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
import { isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||||
@@ -396,7 +396,7 @@ export default function Header({
|
|||||||
</MobileLink>
|
</MobileLink>
|
||||||
)}
|
)}
|
||||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||||
{user && isStaff(user.role) && (
|
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||||
<MobileLink href="/admin">
|
<MobileLink href="/admin">
|
||||||
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
|
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
|
||||||
</MobileLink>
|
</MobileLink>
|
||||||
@@ -560,7 +560,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{isStaff(user.role) && (
|
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||||
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
|
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
|
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
|
||||||
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
|
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 { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||||
@@ -13,6 +13,7 @@ import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
|||||||
interface PostActionsProps {
|
interface PostActionsProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
authorId: number;
|
authorId: number;
|
||||||
|
authorRole?: string | null;
|
||||||
boardId: number;
|
boardId: number;
|
||||||
pinned: number;
|
pinned: number;
|
||||||
recommended: boolean;
|
recommended: boolean;
|
||||||
@@ -25,6 +26,7 @@ interface PostActionsProps {
|
|||||||
export default function PostActions({
|
export default function PostActions({
|
||||||
postId,
|
postId,
|
||||||
authorId,
|
authorId,
|
||||||
|
authorRole,
|
||||||
boardId,
|
boardId,
|
||||||
pinned,
|
pinned,
|
||||||
recommended,
|
recommended,
|
||||||
@@ -64,12 +66,15 @@ export default function PostActions({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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 isAdmin = !!user && isAdminOrAbove(user.role);
|
||||||
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
|
const canApprove =
|
||||||
|
!!user && status === "pending" && canModerateBoard(user, boardId);
|
||||||
// staff 删他人帖:走类型+理由;作者自删:简单确认
|
// staff 删他人帖:走类型+理由;作者自删:简单确认
|
||||||
const staffDeletingOther =
|
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 }) => {
|
const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
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 { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -19,6 +19,7 @@ export interface RoleAccountTarget {
|
|||||||
role: string;
|
role: string;
|
||||||
board_ids?: number[];
|
board_ids?: number[];
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
|
can_manage_messages?: boolean;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
points?: number;
|
points?: number;
|
||||||
@@ -75,6 +76,7 @@ export default function RoleAccountModal({
|
|||||||
const [role, setRole] = useState<string>(ROLES.USER);
|
const [role, setRole] = useState<string>(ROLES.USER);
|
||||||
const [boardIds, setBoardIds] = useState<number[]>([]);
|
const [boardIds, setBoardIds] = useState<number[]>([]);
|
||||||
const [banned, setBanned] = useState(false);
|
const [banned, setBanned] = useState(false);
|
||||||
|
const [manageMessages, setManageMessages] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,6 +84,7 @@ export default function RoleAccountModal({
|
|||||||
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
|
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
|
||||||
setBoardIds(target.board_ids ?? []);
|
setBoardIds(target.board_ids ?? []);
|
||||||
setBanned(!!target.banned);
|
setBanned(!!target.banned);
|
||||||
|
setManageMessages(!!target.can_manage_messages);
|
||||||
setErr("");
|
setErr("");
|
||||||
}
|
}
|
||||||
}, [target]);
|
}, [target]);
|
||||||
@@ -97,6 +100,11 @@ export default function RoleAccountModal({
|
|||||||
JSON.stringify([...boardIds].sort((a, b) => a - b));
|
JSON.stringify([...boardIds].sort((a, b) => a - b));
|
||||||
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
|
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
|
||||||
const banChanged = banned !== !!target.banned;
|
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 willBan = banChanged && banned;
|
||||||
const willUnban = banChanged && !banned;
|
const willUnban = banChanged && !banned;
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ export default function RoleAccountModal({
|
|||||||
setErr("请至少选择一个授权板块");
|
setErr("请至少选择一个授权板块");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roleChanged && !banChanged) {
|
if (!roleChanged && !banChanged && !messagesChanged) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,6 +122,7 @@ export default function RoleAccountModal({
|
|||||||
let nextRole = target.role;
|
let nextRole = target.role;
|
||||||
let nextBoards = target.board_ids ?? [];
|
let nextBoards = target.board_ids ?? [];
|
||||||
let nextBanned = !!target.banned;
|
let nextBanned = !!target.banned;
|
||||||
|
let nextMessages = !!target.can_manage_messages;
|
||||||
if (roleChanged) {
|
if (roleChanged) {
|
||||||
const res = await apiAdminSetUserRole(
|
const res = await apiAdminSetUserRole(
|
||||||
target.id,
|
target.id,
|
||||||
@@ -127,19 +136,26 @@ export default function RoleAccountModal({
|
|||||||
const res = await apiAdminSetUserBan(target.id, banned);
|
const res = await apiAdminSetUserBan(target.id, banned);
|
||||||
nextBanned = !!res.user.banned;
|
nextBanned = !!res.user.banned;
|
||||||
}
|
}
|
||||||
|
if (messagesChanged) {
|
||||||
|
const res = await apiAdminSetUserMessages(target.id, manageMessages);
|
||||||
|
nextMessages = !!res.user.can_manage_messages;
|
||||||
|
}
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (roleChanged) parts.push("角色已更新");
|
if (roleChanged) parts.push("角色已更新");
|
||||||
if (willBan) parts.push("已封禁");
|
if (willBan) parts.push("已封禁");
|
||||||
if (willUnban) parts.push("已解封");
|
if (willUnban) parts.push("已解封");
|
||||||
|
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
|
||||||
toast(parts.join(" · ") || "已保存", "ok");
|
toast(parts.join(" · ") || "已保存", "ok");
|
||||||
onSaved({
|
onSaved({
|
||||||
...target,
|
...target,
|
||||||
role: nextRole,
|
role: nextRole,
|
||||||
board_ids: nextBoards,
|
board_ids: nextBoards,
|
||||||
banned: nextBanned,
|
banned: nextBanned,
|
||||||
|
can_manage_messages: nextMessages,
|
||||||
});
|
});
|
||||||
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "操作失败");
|
setErr(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
setActing(false);
|
setActing(false);
|
||||||
}
|
}
|
||||||
@@ -279,6 +295,49 @@ export default function RoleAccountModal({
|
|||||||
</div>
|
</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)" }}>
|
<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 className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
|
||||||
账号状态
|
账号状态
|
||||||
|
|||||||
82
frontend/components/StaffDeleteFields.tsx
Normal file
82
frontend/components/StaffDeleteFields.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
import StaffDeleteFields from "@/components/StaffDeleteFields";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff 软删帖:必填删除类型 + 理由。
|
* Staff 软删帖:必选删除类型;仅「其他」须填理由。
|
||||||
* 作者自删仍用 ConfirmDialog,不走此组件。
|
* 作者自删仍用 ConfirmDialog,不走此组件。
|
||||||
*/
|
*/
|
||||||
export default function StaffDeletePostModal({
|
export default function StaffDeletePostModal({
|
||||||
@@ -36,17 +36,21 @@ export default function StaffDeletePostModal({
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const reasonRequired = deleteType === "other";
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const r = reason.trim();
|
const r = reason.trim();
|
||||||
if (!r) {
|
if (reasonRequired) {
|
||||||
setErr("请填写删除理由");
|
if (!r) {
|
||||||
return;
|
setErr("请填写删除理由");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([...r].length > 200) {
|
||||||
|
setErr("删除理由不能超过 200 字");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ([...r].length > 200) {
|
onConfirm({ delete_type: deleteType, delete_reason: reasonRequired ? r : "" });
|
||||||
setErr("删除理由不能超过 200 字");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirm({ delete_type: deleteType, delete_reason: r });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,8 +59,8 @@ export default function StaffDeletePostModal({
|
|||||||
title="删除帖子"
|
title="删除帖子"
|
||||||
description={
|
description={
|
||||||
title
|
title
|
||||||
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
|
? `将删除「${title}」。列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。`
|
||||||
: "列表不再展示;原链接会显示删除类型与理由。"
|
: "列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。"
|
||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -74,51 +78,28 @@ export default function StaffDeletePostModal({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
<StaffDeleteFields
|
||||||
删除类型
|
deleteType={deleteType}
|
||||||
</p>
|
onTypeChange={(value) => {
|
||||||
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
|
setDeleteType(value);
|
||||||
{POST_DELETE_TYPES.map((t) => {
|
setErr("");
|
||||||
const active = deleteType === t.value;
|
if (value !== "other") setReason("");
|
||||||
return (
|
}}
|
||||||
<button
|
reason={reason}
|
||||||
key={t.value}
|
onReasonChange={(value) => {
|
||||||
type="button"
|
setReason(value);
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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);
|
|
||||||
setErr("");
|
setErr("");
|
||||||
}}
|
}}
|
||||||
placeholder="将展示在帖子专用页与作者通知中"
|
reasonId="post-delete-reason"
|
||||||
maxLength={200}
|
reasonPlaceholder="向读者说明删除原因(将展示在帖子页)"
|
||||||
|
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
|
||||||
|
reasonHint="将公开显示在帖子专用页与作者通知中。"
|
||||||
/>
|
/>
|
||||||
{err && (
|
{err ? (
|
||||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||||
{err}
|
{err}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { User } from "@/lib/api";
|
|||||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
canModerateAny,
|
canModerateAny,
|
||||||
|
canAccessAdminMessages,
|
||||||
isAdminOrAbove,
|
isAdminOrAbove,
|
||||||
isStaff,
|
isStaff,
|
||||||
isSuperOrOwner,
|
isSuperOrOwner,
|
||||||
@@ -76,7 +77,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
href: "/admin/messages",
|
href: "/admin/messages",
|
||||||
label: "消息管理",
|
label: "消息管理",
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
visible: (u) => isStaff(u.role),
|
visible: (u) => canAccessAdminMessages(u),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/announcements",
|
href: "/admin/announcements",
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface User {
|
|||||||
role: string;
|
role: string;
|
||||||
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
||||||
board_ids?: number[];
|
board_ids?: number[];
|
||||||
|
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
|
||||||
|
can_manage_messages?: boolean;
|
||||||
|
// 作为群管理员的群数量(/me;用于后台消息入口)
|
||||||
|
chat_admin_room_count?: number;
|
||||||
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||||||
email?: string;
|
email?: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
@@ -1900,6 +1904,7 @@ export interface AdminUser {
|
|||||||
role: string; // user | board_admin | admin | super_admin | owner
|
role: string; // user | board_admin | admin | super_admin | owner
|
||||||
board_ids: number[]; // 板块管理员的授权板块
|
board_ids: number[]; // 板块管理员的授权板块
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
|
can_manage_messages?: boolean;
|
||||||
points?: number;
|
points?: number;
|
||||||
post_count: number;
|
post_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
@@ -2026,6 +2031,21 @@ export async function apiAdminSetUserRole(
|
|||||||
return data as { user: AdminUser };
|
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/成败)
|
// 某用户的登录历史(IP/UA/成败)
|
||||||
export async function apiAdminUserLoginLogs(
|
export async function apiAdminUserLoginLogs(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -2546,7 +2566,7 @@ export interface ChatRoom {
|
|||||||
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
|
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
|
||||||
export interface ChatRoomView extends ChatRoom {
|
export interface ChatRoomView extends ChatRoom {
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
my_role: "" | "owner" | "member" | "overseer";
|
my_role: "" | "owner" | "admin" | "member" | "overseer";
|
||||||
unread_count: number;
|
unread_count: number;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
pin_forced?: boolean;
|
pin_forced?: boolean;
|
||||||
@@ -2558,7 +2578,7 @@ export interface ChatRoomMember {
|
|||||||
id: number;
|
id: number;
|
||||||
room_id: number;
|
room_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
role: "owner" | "member";
|
role: "owner" | "admin" | "member";
|
||||||
last_read_message_id: number;
|
last_read_message_id: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -2824,7 +2844,7 @@ export async function apiListChatMembers(
|
|||||||
return data as ChatMembersResponse;
|
return data as ChatMembersResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端:群主踢人
|
// 客户端:群主/群管踢人
|
||||||
export async function apiKickChatMember(
|
export async function apiKickChatMember(
|
||||||
roomId: number,
|
roomId: number,
|
||||||
userId: number
|
userId: number
|
||||||
@@ -2838,7 +2858,45 @@ export async function apiKickChatMember(
|
|||||||
return data as { ok: boolean };
|
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(
|
export async function apiInviteChatMembers(
|
||||||
roomId: number,
|
roomId: number,
|
||||||
usernames: string[]
|
usernames: string[]
|
||||||
|
|||||||
@@ -44,15 +44,23 @@ export function isOwner(role?: string | null): boolean {
|
|||||||
return role === ROLES.OWNER;
|
return role === ROLES.OWNER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站长/超管:群聊监管旁路(可见全部群、撤回任意消息) */
|
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
|
||||||
export function canOverseeChat(role?: string | null): boolean {
|
export function canOverseeChat(role?: string | null): boolean {
|
||||||
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
|
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 携带板块授权的最小用户形状(/me、帖子详情作者等均满足) */
|
/** 彻底删除帖/评:仅站长 */
|
||||||
|
export function canPurgeContent(role?: string | null): boolean {
|
||||||
|
return role === ROLES.OWNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 携带板块授权与消息权限的最小用户形状 */
|
||||||
export interface RoleUserLike {
|
export interface RoleUserLike {
|
||||||
role?: string | null;
|
role?: string | null;
|
||||||
board_ids?: number[] | 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);
|
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 {
|
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||||
if (!user) return false;
|
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;
|
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 负责映射到具体图标,集中配置便于替换) */
|
/** 角色图标 key(RoleBadge 负责映射到具体图标,集中配置便于替换) */
|
||||||
export type RoleIconKey = "crown" | "shield-star" | "shield" | "shield-board" | "user";
|
export type RoleIconKey = "crown" | "shield-star" | "shield" | "shield-board" | "user";
|
||||||
|
|
||||||
@@ -102,7 +147,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
|||||||
colorToken: "--gold",
|
colorToken: "--gold",
|
||||||
softToken: "--gold-soft",
|
softToken: "--gold-soft",
|
||||||
grantDesc:
|
grantDesc:
|
||||||
"站点所有者:全部后台能力、群聊监管、用户内容档案;身份不可授予或降级。",
|
"站点所有者:全部后台能力、群聊监管、用户内容档案、彻底删除内容;身份不可授予或降级。",
|
||||||
},
|
},
|
||||||
[ROLES.SUPER_ADMIN]: {
|
[ROLES.SUPER_ADMIN]: {
|
||||||
key: ROLES.SUPER_ADMIN,
|
key: ROLES.SUPER_ADMIN,
|
||||||
@@ -111,7 +156,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
|||||||
colorToken: "--accent",
|
colorToken: "--accent",
|
||||||
softToken: "--accent-soft",
|
softToken: "--accent-soft",
|
||||||
grantDesc:
|
grantDesc:
|
||||||
"用户与角色管理、封禁、外观主题、公告;全站内容审核与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可动站长账号,亦无站长专属的用户内容档案。",
|
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
|
||||||
},
|
},
|
||||||
[ROLES.ADMIN]: {
|
[ROLES.ADMIN]: {
|
||||||
key: ROLES.ADMIN,
|
key: ROLES.ADMIN,
|
||||||
@@ -120,7 +165,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
|||||||
colorToken: "--accent",
|
colorToken: "--accent",
|
||||||
softToken: "--accent-soft",
|
softToken: "--accent-soft",
|
||||||
grantDesc:
|
grantDesc:
|
||||||
"公告管理;全站帖子/评论审核;前台置顶与加精。不含用户权限、外观设置与群聊监管旁路。",
|
"公告管理;全站帖子/评论审核与软删;前台置顶与加精。不含用户权限、外观设置、彻底删除与默认的群聊监管(可由站长另授消息管理或任命群管理员)。",
|
||||||
},
|
},
|
||||||
[ROLES.BOARD_ADMIN]: {
|
[ROLES.BOARD_ADMIN]: {
|
||||||
key: ROLES.BOARD_ADMIN,
|
key: ROLES.BOARD_ADMIN,
|
||||||
@@ -129,7 +174,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
|||||||
colorToken: "--accent",
|
colorToken: "--accent",
|
||||||
softToken: "--accent-soft",
|
softToken: "--accent-soft",
|
||||||
grantDesc:
|
grantDesc:
|
||||||
"仅审核并管理被授权板块内的帖子与评论(含删除违规内容)。无公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
"仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||||
},
|
},
|
||||||
[ROLES.USER]: {
|
[ROLES.USER]: {
|
||||||
key: ROLES.USER,
|
key: ROLES.USER,
|
||||||
|
|||||||
Reference in New Issue
Block a user