From 47f87a011c77a8eac751e7ac56c5db7782668fc0 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 15 Oct 2026 23:35:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B8=96=E5=AD=90=E5=88=86=E7=BA=A7?= =?UTF-8?q?=E7=BD=AE=E9=A1=B6=EF=BC=88=E6=9D=BF=E5=9D=97/=E5=85=A8?= =?UTF-8?q?=E7=AB=99=EF=BC=89=E4=B8=8E=E6=9D=BF=E5=9D=97=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=BD=AE=E9=A1=B6=E6=9D=83=E9=99=90=E3=80=81=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=AE=A1=E7=90=86=E6=9D=83=E9=99=90=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/project_memory.md | 4 +- backend/cmd/jiang13/main.go | 6 +- backend/config/config.go | 23 +- backend/handler/auth.go | 14 +- backend/handler/chat.go | 6 +- backend/handler/moderation.go | 52 ++++ backend/handler/post.go | 19 +- backend/handler/realtime.go | 4 +- backend/middleware/security.go | 14 +- backend/model/db.go | 24 ++ backend/model/models.go | 14 +- backend/router/router.go | 12 +- backend/service/actor.go | 21 +- backend/service/admin_content.go | 103 ++++++++ backend/service/admin_user.go | 6 +- backend/service/chat.go | 11 +- backend/service/legacyimport.go | 10 +- backend/service/operations.go | 6 +- backend/service/post.go | 53 +++-- docs/about.md | 2 +- frontend/app/admin/messages/MessagesAdmin.tsx | 2 +- frontend/app/admin/messages/page.tsx | 2 +- frontend/app/admin/pinned/PinnedAdmin.tsx | 224 ++++++++++++++++++ frontend/app/admin/pinned/page.tsx | 42 ++++ frontend/app/announcement/[id]/page.tsx | 2 +- frontend/app/announcements/page.tsx | 4 +- frontend/app/chat/[id]/ChatRoomClient.tsx | 2 +- frontend/app/globals.css | 37 ++- frontend/app/post/[id]/page.tsx | 11 +- frontend/app/snippets/page.tsx | 10 +- frontend/app/u/[id]/UserProfileContent.tsx | 10 +- frontend/components/PinnedPosts.tsx | 16 +- frontend/components/PostActions.tsx | 72 ++++-- frontend/components/PostRow.tsx | 11 +- frontend/components/RoleAccountModal.tsx | 77 +++--- frontend/components/SnippetExplorer.tsx | 32 +-- frontend/components/SnippetHero.tsx | 10 +- frontend/components/admin/AdminShell.tsx | 7 + frontend/lib/api.ts | 38 ++- frontend/lib/roles.ts | 16 +- 40 files changed, 822 insertions(+), 207 deletions(-) create mode 100644 frontend/app/admin/pinned/PinnedAdmin.tsx create mode 100644 frontend/app/admin/pinned/page.tsx diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md index 839596f..10801c0 100644 --- a/.cursor/project_memory.md +++ b/.cursor/project_memory.md @@ -150,9 +150,9 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致 ## 四级 RBAC + 内容审核(2026-09) -- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台+群聊全站监管,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `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+can_manage_messages);RequirePerm(announcements/users/settings/messages);板块隔离按 BoardIDs;板管仪表盘 analytics 按授权板过滤 -- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);站长可在群内任命 `ChatRoleAdmin`;站长/超管 `CanOverseeChat` 含私聊;禁言 API 已接 +- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);超管亦需授予才进入后台消息管理并获全站监管(含私聊),站长固有;站长可在群内任命 `ChatRoleAdmin`;`Actor.CanOverseeChat()` = 站长 || (超管 && can_manage_messages);禁言 API 已接 - 彻底删除帖/评:仅 owner;其余管理员仅软删+恢复 - 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible) - 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403 diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 8e55be4..5943706 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -21,8 +21,10 @@ func main() { log.Fatalf("数据库初始化失败: %v", err) } - // 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置) - service.ConfigureCookieNames(!cfg.DevMode) + // 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)。 + // 以前是 !DevMode 硬绑定,现在解耦:根据 SITE_URL scheme 或 FORCE_HTTPS 判断, + // 内网 HTTP 部署零配置即可跑,反代 TLS 但后端只看到 HTTP 时设 FORCE_HTTPS=true。 + service.ConfigureCookieNames(cfg.IsSecureOrigin()) // 过期/已吊销 refresh token 定期清理 service.StartRefreshTokenCleanup(model.DB) // 悬赏过期退回 / 抽奖到期开奖 diff --git a/backend/config/config.go b/backend/config/config.go index 9c5d3c1..8dccf77 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -24,6 +24,7 @@ type Config struct { DevMode bool SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用 CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量) + ForceHTTPS bool // 强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀;反代 TLS 但后端只看到 HTTP 时设 true } // appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。 @@ -43,7 +44,8 @@ var appIniSchema = []appIniKey{ {section: "paths", key: "DATA", value: "data"}, {section: "app", key: "DEV_MODE", value: "true"}, {section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"}, - {section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"}, + {section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。"}, + {section: "app", key: "FORCE_HTTPS", value: "false", comment: "强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀。反代 TLS 但后端只看到 HTTP 时设 true。\n默认自动根据 SITE_URL scheme 判断。环境变量 FORCE_HTTPS 优先。"}, } // Parse 解析配置:环境变量 > app.ini > 默认值。 @@ -98,6 +100,9 @@ func Parse() (*Config, error) { if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" { cfg.CORSOrigins = splitCSVOrigins(v) } + if v := strings.TrimSpace(os.Getenv("FORCE_HTTPS")); v != "" { + cfg.ForceHTTPS = strings.EqualFold(v, "true") || v == "1" + } if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" { abs, err := filepath.Abs(v) if err != nil { @@ -179,6 +184,9 @@ func applyIni(cfg *Config, f *ini.File) { cfg.CORSOrigins = splitCSVOrigins(v) } } + if s := f.Section("app"); s.HasKey("FORCE_HTTPS") { + cfg.ForceHTTPS = s.Key("FORCE_HTTPS").MustBool(false) + } } // loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。 @@ -227,6 +235,19 @@ func loadAndEnsureAppIni(path string) (*ini.File, error) { return f, nil } +// IsSecureOrigin 判断对外站点是否走 HTTPS,用于控制 cookie Secure 标志、HSTS 头、__Host- cookie 前缀。 +// 优先级:FORCE_HTTPS 显式设置 > SITE_URL scheme 推断。 +// 本地 dev 未填 SITE_URL 时按 http 处理,不会误开安全策略。 +func (c *Config) IsSecureOrigin() bool { + if c.ForceHTTPS { + return true + } + if c.SiteURL == "" { + return false + } + return strings.HasPrefix(c.SiteURL, "https://") +} + // AllowOrigin 供 CORS 中间件判断浏览器 Origin。 // 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致); // 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。 diff --git a/backend/handler/auth.go b/backend/handler/auth.go index bb501b9..e99233b 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -164,7 +164,7 @@ func (h *Handlers) Register(c *gin.Context) { }) return } - setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode) + setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin()) h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true) c.JSON(http.StatusOK, gin.H{ "user": gin.H{ @@ -212,7 +212,7 @@ func (h *Handlers) Login(c *gin.Context) { h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true) // dev 模式不设 Secure,生产环境需 HTTPS h.Ops.ClearFailure(req.Username) - setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode) + setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin()) // 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧 // 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等 // access token 过期或硬刷新才发现) @@ -244,13 +244,13 @@ func (h *Handlers) Refresh(c *gin.Context) { if err != nil { if errors.Is(err, service.ErrAccountBanned) { // 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线 - clearAuthCookies(c, !h.Cfg.DevMode) + clearAuthCookies(c, h.Cfg.IsSecureOrigin()) c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"}) return } if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) { // 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效") - clearAuthCookies(c, !h.Cfg.DevMode) + clearAuthCookies(c, h.Cfg.IsSecureOrigin()) c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) return } @@ -259,7 +259,7 @@ func (h *Handlers) Refresh(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"}) return } - setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode) + setAuthCookies(c, accessToken, newRefresh, h.Cfg.IsSecureOrigin()) c.JSON(http.StatusOK, gin.H{ "user": gin.H{ "id": user.ID, @@ -279,7 +279,7 @@ func (h *Handlers) Logout(c *gin.Context) { if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { _ = h.Auth.RevokeRefreshToken(refreshToken) } - clearAuthCookies(c, !h.Cfg.DevMode) + clearAuthCookies(c, h.Cfg.IsSecureOrigin()) c.JSON(http.StatusOK, gin.H{"message": "已登出"}) } @@ -302,7 +302,7 @@ func (h *Handlers) ChangePassword(c *gin.Context) { return } // 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录 - clearAuthCookies(c, !h.Cfg.DevMode) + clearAuthCookies(c, h.Cfg.IsSecureOrigin()) c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"}) } diff --git a/backend/handler/chat.go b/backend/handler/chat.go index f13d0a9..d0fbda8 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -69,7 +69,7 @@ func (h *Handlers) chatOversee(userID uint) bool { if err != nil || actor == nil { return false } - return service.CanOverseeChat(actor.Role) + return actor.CanOverseeChat() } // RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。 @@ -673,7 +673,7 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) { "messages": msgs, "total": total, "page": page, - "can_oversee": service.CanOverseeChat(actor.Role), + "can_oversee": actor.CanOverseeChat(), "can_recall": true, "site_messages": actor.HasSiteMessagePerm(), }) @@ -697,7 +697,7 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"}) return } - oversee := service.CanOverseeChat(actor.Role) + oversee := actor.CanOverseeChat() msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index e32aa05..fc165ef 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -246,3 +246,55 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint) h.broadcastModerationChanged() c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } + +// ===== 置顶顺序配置 ===== + +// AdminListPinnedPosts 列出指定层级(level=1 板块 / 2 全站)的置顶帖,供后台排序 +func (h *Handlers) AdminListPinnedPosts(c *gin.Context) { + level, _ := strconv.Atoi(c.Query("level")) + boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) + if level != model.PinBoard && level != model.PinGlobal { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"}) + return + } + items, err := h.Moderation.ListPinnedPosts(middleware.CurrentActor(c), level, uint(boardID)) + if err != nil { + if errors.Is(err, service.ErrPinOrderForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取置顶列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(items)}) +} + +// AdminSavePinOrderRequest 置顶顺序保存请求 +type AdminSavePinOrderRequest struct { + Level int `json:"level" binding:"required"` + BoardID uint `json:"board_id"` + IDs []uint `json:"ids" binding:"required"` +} + +// AdminSavePinOrder 保存置顶帖显示顺序(ids 数组顺序即展示顺序) +func (h *Handlers) AdminSavePinOrder(c *gin.Context) { + var req AdminSavePinOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if req.Level != model.PinBoard && req.Level != model.PinGlobal { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"}) + return + } + if err := h.Moderation.SavePinOrder(middleware.CurrentActor(c), req.Level, req.BoardID, req.IDs); err != nil { + if errors.Is(err, service.ErrPinOrderForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.broadcastFeedChanged("post", 0, req.BoardID, middleware.CurrentActor(c).ID) + c.JSON(http.StatusOK, gin.H{"message": "已保存"}) +} diff --git a/backend/handler/post.go b/backend/handler/post.go index 25eef0e..d1d028e 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -550,9 +550,12 @@ func (h *Handlers) DeletePost(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "已删除"}) } -// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限) +// TogglePin 切换帖子置顶:管理员及以上切全站置顶,板块管理员切本板板块置顶 func (h *Handlers) TogglePin(c *gin.Context) { - if !h.requireAdminOrAbove(c) { + claims := middleware.CurrentUser(c) + actor, err := h.Auth.LoadActor(claims.ID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -560,9 +563,17 @@ func (h *Handlers) TogglePin(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } - pinned, err := h.Post.TogglePin(uint(id)) + level, _ := strconv.Atoi(c.Query("level")) + pinned, err := h.Post.TogglePin(uint(id), actor, level) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + switch { + case errors.Is(err, service.ErrPostNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrPostForbidden), errors.Is(err, service.ErrPinGlobalOnly): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } return } c.JSON(http.StatusOK, gin.H{"pinned": pinned}) diff --git a/backend/handler/realtime.go b/backend/handler/realtime.go index 3c73d98..b6d70e4 100644 --- a/backend/handler/realtime.go +++ b/backend/handler/realtime.go @@ -61,7 +61,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) { canOverseeChat := false if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil { isStaff = actor.IsStaff() - canOverseeChat = service.CanOverseeChat(actor.Role) + canOverseeChat = actor.CanOverseeChat() } client := realtime.NewClient( @@ -74,7 +74,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) { return verr == nil }, func() { h.Auth.TouchLastSeen(claims.ID) }, - // 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群 + // 动态房间订阅鉴权:成员可订阅;站长/已授权超管旁路可订阅任意群 func(room string) bool { roomID, ok := parseWSChatRoom(room) if !ok { diff --git a/backend/middleware/security.go b/backend/middleware/security.go index aa9f0ad..5f7ece9 100644 --- a/backend/middleware/security.go +++ b/backend/middleware/security.go @@ -5,8 +5,9 @@ import ( ) // SecurityHeaders 安全响应头中间件 -// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险 -func SecurityHeaders() gin.HandlerFunc { +// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险。 +// secure=true 时额外下发 HSTS(仅 HTTPS origin 合法,HTTP 环境发 HSTS 属于误导)。 +func SecurityHeaders(secure bool) gin.HandlerFunc { return func(c *gin.Context) { // CSP:限制资源加载来源,降低 XSS 危害 c.Header("Content-Security-Policy", @@ -20,8 +21,10 @@ func SecurityHeaders() gin.HandlerFunc { "base-uri 'self'; "+ "form-action 'self'") - // HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略) - c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + // HSTS:仅 HTTPS origin 下发。HTTP 响应发 HSTS 是无效且误导的。 + if secure { + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + } // 点击劫持防护 c.Header("X-Frame-Options", "DENY") @@ -32,9 +35,6 @@ func SecurityHeaders() gin.HandlerFunc { // 控制 Referer 信息泄露 c.Header("Referrer-Policy", "strict-origin-when-cross-origin") - // 禁止浏览器缓存敏感页面(可按需覆盖) - // c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - c.Next() } } diff --git a/backend/model/db.go b/backend/model/db.go index 9c659d3..5146613 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -69,6 +69,11 @@ func InitDB(dsn string) error { return fmt.Errorf("自动迁移失败: %w", err) } + // 帖子置顶分级:历史全站置顶(pinned=1)升为 2,避免存量置顶帖被降级为板块置顶 + if err := preparePostPinLevels(db); err != nil { + return fmt.Errorf("帖子置顶分级迁移失败: %w", err) + } + // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // 已存在 owner 时不动数据,保证幂等 if err := ensureOwnerRole(db); err != nil { @@ -180,6 +185,25 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error { return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error } +// preparePostPinLevels 一次性迁移:历史置顶(pinned=1,旧语义=全站置顶)升为 2。 +// 新语义下 1=板块置顶、2=全站置顶,若不迁移,存量全站置顶会被降级为板块置顶。 +// 用 site_settings 内部键做幂等标记,避免每次启动把新建的板块置顶(=1)误升为全站。 +func preparePostPinLevels(db *gorm.DB) error { + const markerKey = "internal_pin_levels_migrated" + var marker int64 + if err := db.Model(&SiteSetting{}).Where("key = ?", markerKey).Count(&marker).Error; err != nil { + return err + } + if marker > 0 { + return nil + } + if err := db.Exec(`UPDATE posts SET pinned = 2 WHERE pinned = 1`).Error; err != nil { + return err + } + return db.Exec(`INSERT INTO site_settings (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, markerKey, "1").Error +} + // prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等) func prepareSiteSettingValueText(db *gorm.DB) error { var tableCount int64 diff --git a/backend/model/models.go b/backend/model/models.go index c927ac3..1480f4b 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -11,7 +11,7 @@ type Role string const ( RoleUser Role = "user" // 普通用户:发帖/评论需审核 - RoleBoardAdmin Role = "board_admin" // 板块管理员:仅管理被授权板块的帖子与评论 + RoleBoardAdmin Role = "board_admin" // 板块管理员:管理被授权板块的帖子、评论与板块内置顶 RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核 RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长 RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁 @@ -121,7 +121,7 @@ type User struct { Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名 Role Role `gorm:"size:16;default:user" json:"role"` Banned bool `gorm:"default:false" json:"banned"` - CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管) + CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;超管亦需授予,站长固有全站监管) PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过 Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减) @@ -207,6 +207,13 @@ type ImportRecord struct { CreatedAt time.Time `json:"created_at"` } +// 帖子置顶级别:同一 pinned 字段分级,数值越大范围越大 +const ( + PinNone = 0 // 不置顶 + PinBoard = 1 // 板块置顶:仅所属板块列表靠前(板块管理员可操作) + PinGlobal = 2 // 全站置顶:首页与所有列表靠前(管理员及以上可操作) +) + // Post 帖子 type Post struct { ID uint `gorm:"primaryKey" json:"id"` @@ -219,7 +226,8 @@ type Post struct { ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points|password|mixed(由正文隐藏块派生) AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 积分隐藏块价格之和 TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) - Pinned int `gorm:"default:0" json:"pinned"` + Pinned int `gorm:"default:0" json:"pinned"` // 置顶级别:0=无 1=板块 2=全站 + PinOrder int `gorm:"default:0" json:"pin_order"` // 同层级置顶帖的显示顺序:越小越靠前 Recommended bool `gorm:"default:false;index" json:"recommended"` Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) Status string `gorm:"size:16;default:published;index" json:"status"` diff --git a/backend/router/router.go b/backend/router/router.go index f1e486f..510d5e0 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -65,8 +65,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.Use(gin.Recovery()) r.Use(gin.Logger()) - // 全局安全响应头 - r.Use(middleware.SecurityHeaders()) + // 全局安全响应头(HSTS 仅 HTTPS origin 下发) + r.Use(middleware.SecurityHeaders(cfg.IsSecureOrigin())) // CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域) r.Use(cors.New(cors.Config{ @@ -159,7 +159,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Chat: chatSvc, Visit: visitSvc, Analytics: analyticsSvc, - HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), + HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, cfg.IsSecureOrigin()), Ads: adSvc, Sidebar: service.NewSidebarService(model.DB), FriendLink: service.NewFriendLinkService(model.DB), @@ -203,7 +203,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/api/ws", h.RealtimeWS) // 公开 API(可选登录);EnsureCSRF 让游客也能拿到双提交 token(密码解锁等) - pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(!cfg.DevMode)) + pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(cfg.IsSecureOrigin())) { pubAPI.GET("/me", h.Me) pubAPI.GET("/boards", h.Boards) @@ -380,6 +380,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment) staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment) + // 置顶帖顺序配置(板块置顶:板管限授权板块;全站置顶:管理员及以上) + staffAPI.GET("/pinned-posts", h.AdminListPinnedPosts) + staffAPI.PUT("/pinned-posts/order", h.AdminSavePinOrder) + // 积分与类型帖概览(管理员及以上) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) diff --git a/backend/service/actor.go b/backend/service/actor.go index 06a6270..3adbff9 100644 --- a/backend/service/actor.go +++ b/backend/service/actor.go @@ -18,7 +18,7 @@ const ( PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予) PermSettings = "settings" // 站点外观设置(超管/站长) PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限) - PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行) + PermMessages = "messages" // 后台消息管理(站长固有;超管及其他角色需站点消息 flag;群管另有业务层放行) PermBoards = "boards" // 板块管理(仅站长) ) @@ -105,7 +105,7 @@ func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages for _, p := range ParsePermOverrides(permOverridesRaw) { set[p] = true } - if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages { + if role == model.RoleOwner || canManageMessages { set[PermMessages] = true } out := make([]string, 0, len(set)) @@ -123,7 +123,7 @@ type Actor struct { Username string Role model.Role BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 - CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段 + CanManageMessages bool // 站点级消息管理 flag(仅站长可授,超管亦需此授予);站长固有 ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量) } @@ -151,15 +151,22 @@ func (a *Actor) HasPerm(p string) bool { return false } -// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages +// 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.Role == model.RoleOwner || a.CanManageMessages +} + +// CanOverseeChat 全站消息监管(可见全部群含私聊、撤回任意消息、旁路订阅任意房间): +// 站长固有;超管需先被授予站点消息管理 +func (a *Actor) CanOverseeChat() bool { + if a == nil { + return false } - return a.CanManageMessages + return a.Role == model.RoleOwner || + (a.Role == model.RoleSuperAdmin && a.CanManageMessages) } // CanPurgeContent 彻底删除帖/评:仅站长 diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index a32bf8f..6dccffd 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -13,6 +13,7 @@ import ( var ( ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除") ErrContentNotFound = errors.New("内容不存在") + ErrPinOrderForbidden = errors.New("无权配置该层级置顶顺序") ) func (s *ModerationService) authorRole(userID uint) (model.Role, error) { @@ -511,3 +512,105 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error { return tx.Unscoped().Delete(&model.Comment{}, id).Error }) } + +// ===== 置顶顺序配置(后台可拖拽排序,同层级 pin_order 越小越靠前) ===== + +// PinnedPostItem 置顶帖排序列表项 +type PinnedPostItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Pinned int `json:"pinned"` + PinOrder int `json:"pin_order"` + BoardID uint `json:"board_id"` + BoardName string `json:"board_name"` + CreatedAt time.Time `json:"created_at"` +} + +// assertPinScopePermission 校验 actor 对指定置顶层级/板块的排序权限: +// 全局置顶仅管理员及以上;板块置顶允许管理员或管辖该板块的板管(board_id 必填)。 +func (s *ModerationService) assertPinScopePermission(actor *Actor, level int, boardID uint) error { + if actor == nil { + return ErrPinOrderForbidden + } + if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) { + return nil + } + if level == model.PinGlobal { + return ErrPinOrderForbidden + } + if level == model.PinBoard { + if boardID == 0 { + return ErrPinOrderForbidden // 板管必须指定具体板块 + } + if actor.CanModerateBoard(boardID) { + return nil + } + } + return ErrPinOrderForbidden +} + +// ListPinnedPosts 列出指定层级的置顶帖(当前顺序:pin_order 升序,未配置按发帖时间倒序兜底)。 +// level=2 全站置顶;level=1 需带 board_id(管理员可省=全部板块的板块置顶)。 +func (s *ModerationService) ListPinnedPosts(actor *Actor, level int, boardID uint) ([]PinnedPostItem, error) { + if err := s.assertPinScopePermission(actor, level, boardID); err != nil { + return nil, err + } + q := s.db.Model(&model.Post{}). + Where("pinned = ? AND deleted_at IS NULL AND status = ?", level, model.ContentStatusPublished) + if boardID > 0 { + q = q.Where("board_id = ?", boardID) + } + var posts []model.Post + if err := q.Order("(pin_order = 0), pin_order ASC, created_at DESC"). + Preload("Board").Find(&posts).Error; err != nil { + return nil, err + } + items := make([]PinnedPostItem, 0, len(posts)) + for _, p := range posts { + items = append(items, PinnedPostItem{ + ID: p.ID, + Title: p.Title, + Pinned: p.Pinned, + PinOrder: p.PinOrder, + BoardID: p.BoardID, + BoardName: p.Board.Name, + CreatedAt: p.CreatedAt, + }) + } + return items, nil +} + +// SavePinOrder 按传入 ID 顺序重写 pin_order(从 1 递增)。 +// 安全约束:ids 必须全部属于该层级且在权限范围内,否则整体拒绝,避免半截写入。 +func (s *ModerationService) SavePinOrder(actor *Actor, level int, boardID uint, ids []uint) error { + if err := s.assertPinScopePermission(actor, level, boardID); err != nil { + return err + } + if len(ids) == 0 { + return nil + } + if len(ids) > 200 { + return errors.New("单次最多排序 200 条") + } + var count int64 + q := s.db.Model(&model.Post{}). + Where("id IN ? AND pinned = ? AND deleted_at IS NULL", ids, level) + if boardID > 0 { + q = q.Where("board_id = ?", boardID) + } + if err := q.Count(&count).Error; err != nil { + return err + } + if count != int64(len(ids)) { + return ErrPinOrderForbidden // 含越权/已取消置顶/已删除的 ID + } + return s.db.Transaction(func(tx *gorm.DB) error { + for i, id := range ids { + if err := tx.Model(&model.Post{}).Where("id = ?", id). + UpdateColumn("pin_order", i+1).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index b08647b..9e733f9 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -470,7 +470,7 @@ func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perm return s.getItem(s.db, targetID) } -// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己) +// SetCanManageMessages 站长授予/撤销站点消息管理权限(超管亦需授予;不可操作站长账号与自己) func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) { if operator == nil || operator.Role != model.RoleOwner { return nil, ErrOwnerOnly @@ -486,10 +486,6 @@ func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, if u.Role == model.RoleOwner { return ErrProtectedOwner } - // 超管固有全站监管,flag 无意义;强制保持 false 以免误导 - if u.Role == model.RoleSuperAdmin { - enabled = false - } if u.CanManageMessages == enabled { return nil } diff --git a/backend/service/chat.go b/backend/service/chat.go index 5acebdc..af39dd0 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -42,11 +42,6 @@ var ( ErrChatAdminAccess = errors.New("无权管理消息") ) -// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息 -func CanOverseeChat(role model.Role) bool { - return role == model.RoleOwner || role == model.RoleSuperAdmin -} - const ( chatNameMinRune = 2 chatNameMaxRune = 32 @@ -929,7 +924,7 @@ func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool { return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin } -// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee) +// hasSiteGroupMessagePerm 站点消息管理 flag(不含站长,超管经 oversee 已放行;调用方通常已判 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 { @@ -1219,7 +1214,7 @@ type AdminChatMessageItem struct { } // AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。 -// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。 +// 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 @@ -1227,7 +1222,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp if page < 1 { page = 1 } - fullOversee := CanOverseeChat(actor.Role) + fullOversee := actor.CanOverseeChat() siteGroup := actor.HasSiteMessagePerm() && !fullOversee if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect { return nil, 0, errors.New("房间类型无效") diff --git a/backend/service/legacyimport.go b/backend/service/legacyimport.go index 6701289..0616758 100644 --- a/backend/service/legacyimport.go +++ b/backend/service/legacyimport.go @@ -226,6 +226,14 @@ type legacyPostRow struct { func (legacyPostRow) TableName() string { return "posts" } +// legacyPinLevel 旧库 pinned(0/1,非零=全站置顶)映射到新置顶分级:板块置顶为本站新能力,旧数据无此语义 +func legacyPinLevel(v int) int { + if v > 0 { + return model.PinGlobal + } + return model.PinNone +} + type legacyCommentRow struct { ID uint `gorm:"primaryKey"` PostID uint `gorm:"column:post_id"` @@ -757,7 +765,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint Content: content, Tags: p.Tags, PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion - Pinned: p.Pinned, + Pinned: legacyPinLevel(p.Pinned), Recommended: p.Featured, Status: model.ContentStatusPublished, LikeCount: p.LikeCount, diff --git a/backend/service/operations.go b/backend/service/operations.go index c044b94..9dbed7b 100644 --- a/backend/service/operations.go +++ b/backend/service/operations.go @@ -368,7 +368,7 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error { return e } origin, originErr := url.Parse(o.cfg.SiteURL) - if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) { + if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && o.cfg.IsSecureOrigin()) { return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件") } } @@ -430,8 +430,8 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error { } if c.Backend == "s3" { u, e := url.Parse(c.Endpoint) - if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) { - return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)") + if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && o.cfg.IsSecureOrigin()) { + return bad("endpoint", "须为 HTTPS origin(HTTP origin 部署可放宽)") } if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") { return bad("bucket", "桶名称无效") diff --git a/backend/service/post.go b/backend/service/post.go index e8de543..ff24dff 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -19,6 +19,8 @@ var ( ErrPostInvalidType = errors.New("无效的删除类型") ErrPostLocked = errors.New("帖子已被锁定,无法编辑") ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑") + ErrPinGlobalOnly = errors.New("全站置顶仅管理员可操作") + ErrPinInvalidLevel = errors.New("无效的置顶级别") ) // editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用 @@ -247,18 +249,25 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { var posts []model.Post offset := (q.Page - 1) * q.Size + // 置顶排序前缀按范围区分:板块视图里全站置顶(2)在板块置顶(1)之上; + // 全站信息流只让全站置顶(2)浮顶,板块置顶(1)不影响首页与跨板列表。 + // 同层级置顶帖之间按后台配置的 pin_order 升序(pin_order=0 视为未配置,排在已配置的后面,再按活跃序兜底)。 + pinFirst := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC" + if q.BoardID > 0 { + pinFirst = "pinned DESC, (pin_order = 0), pin_order ASC" + } // latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。 // 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引 - order := `pinned DESC, GREATEST(created_at, COALESCE(( + order := pinFirst + `, GREATEST(created_at, COALESCE(( SELECT MAX(c.created_at) FROM comments c WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL ), created_at)) DESC` if q.Sort == "recommended" { - order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC" + order = pinFirst + ", (like_count + comment_count * 2) DESC, created_at DESC" } // new:纯按发帖时间(新帖子) if q.Sort == "new" { - order = "pinned DESC, created_at DESC" + order = pinFirst + ", created_at DESC" } if err := query.Order(order).Offset(offset).Limit(q.Size). Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil { @@ -311,7 +320,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size } var posts []model.Post offset := (page - 1) * size - order := "pinned DESC, created_at DESC" + order := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC, created_at DESC" if sort == "hot" { order = "like_count + comment_count DESC, view_count DESC, created_at DESC" } @@ -349,24 +358,36 @@ func (s *PostService) CountByUser(userID uint) (int64, error) { return total, err } -// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限) -func (s *PostService) TogglePin(id uint) (int, error) { +// TogglePin 设置置顶级别:level 0=取消置顶,1=板块置顶,2=全站置顶。 +// 板块管理员只能操作自己授权板块且只能设置/取消板块置顶;管理员可设置任意级别。 +func (s *PostService) TogglePin(id uint, actor *Actor, level int) (int, error) { + if level < model.PinNone || level > model.PinGlobal { + return 0, ErrPinInvalidLevel + } var post model.Post - if err := s.db.First(&post, id).Error; err != nil { + if err := s.db.Select("id", "board_id", "pinned").First(&post, id).Error; err != nil { return 0, err } - var newPinned int - if post.Pinned > 0 { - newPinned = 0 - } else { - newPinned = 1 + isGlobalAdmin := actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) + isBoardModerator := actor != nil && actor.CanModerateBoard(post.BoardID) + + // 权限校验 + if level == model.PinGlobal && !isGlobalAdmin { + return 0, ErrPinGlobalOnly } + if level > model.PinNone && !isGlobalAdmin && !isBoardModerator { + return 0, ErrPostForbidden + } + // 板块管理员不能取消全局置顶(只能取消自己设置的板块置顶) + if !isGlobalAdmin && level == model.PinNone && post.Pinned == model.PinGlobal { + return 0, ErrPinGlobalOnly + } + // UpdateColumn:置顶非内容编辑,不刷新 updated_at - result := s.db.Model(&post).UpdateColumn("pinned", newPinned) - if result.Error != nil { - return 0, result.Error + if err := s.db.Model(&post).UpdateColumn("pinned", level).Error; err != nil { + return 0, err } - return newPinned, nil + return level, nil } // ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。 diff --git a/docs/about.md b/docs/about.md index 753cc43..a0a366d 100644 --- a/docs/about.md +++ b/docs/about.md @@ -11,7 +11,7 @@ - **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。 - **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。 - **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。 -- **置顶与推荐**:管理员可对帖子进行置顶、推荐,引导社区焦点。 +- **置顶与推荐**:管理员可对帖子进行全站置顶、推荐,引导社区焦点;板块管理员可在被授权板块内单独置顶帖子。 ### 🔔 实时通知 - 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。 diff --git a/frontend/app/admin/messages/MessagesAdmin.tsx b/frontend/app/admin/messages/MessagesAdmin.tsx index 9b84ec6..763b192 100644 --- a/frontend/app/admin/messages/MessagesAdmin.tsx +++ b/frontend/app/admin/messages/MessagesAdmin.tsx @@ -250,7 +250,7 @@ function useMessageColumn(roomType: MsgKind) { /** 群聊(含大厅)| 私聊 双栏监管 */ export default function MessagesAdmin({ me }: { me: User }) { - const canOversee = canOverseeChat(me.role); + const canOversee = canOverseeChat(me); const [mobileKind, setMobileKind] = useState("group"); const group = useMessageColumn("group"); const direct = useMessageColumn("direct"); diff --git a/frontend/app/admin/messages/page.tsx b/frontend/app/admin/messages/page.tsx index 966fabe..749831e 100644 --- a/frontend/app/admin/messages/page.tsx +++ b/frontend/app/admin/messages/page.tsx @@ -15,7 +15,7 @@ export default async function AdminMessagesPage() { const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); if (!me.user || !canAccessAdminMessages(me.user)) { - return ; + return ; } return ; diff --git a/frontend/app/admin/pinned/PinnedAdmin.tsx b/frontend/app/admin/pinned/PinnedAdmin.tsx new file mode 100644 index 0000000..1bbe17a --- /dev/null +++ b/frontend/app/admin/pinned/PinnedAdmin.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { ChevronDown, ChevronUp, Pin } from "lucide-react"; +import { + apiAdminPinnedPosts, + apiAdminSavePinOrder, + type AdminPinnedPost, +} from "@/lib/api"; +import AdminPageHeader from "@/components/admin/AdminPageHeader"; +import AdminEmpty from "@/components/admin/AdminEmpty"; +import { toast } from "@/lib/toast"; + +interface BoardOpt { + id: number; + name: string; +} + +interface PinnedAdminProps { + boards: BoardOpt[]; + /** 管理员及以上:可排全站置顶 */ + canGlobal: boolean; +} + +type Level = 1 | 2; + +export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) { + const [level, setLevel] = useState(canGlobal ? 2 : 1); + const [boardId, setBoardId] = useState(boards[0]?.id ?? 0); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async (lv: Level, bid: number) => { + setLoading(true); + setError(""); + try { + const r = await apiAdminPinnedPosts(lv, lv === 1 ? bid : undefined); + setPosts(r.posts ?? []); + } catch (e) { + setPosts([]); + setError(e instanceof Error ? e.message : "获取置顶列表失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(level, boardId); + }, [level, boardId, load]); + + const switchLevel = (lv: Level) => { + if (lv === level) return; + setLevel(lv); + setPosts([]); + }; + + // 上移/下移:乐观交换后整体保存顺序,失败回滚 + const move = async (idx: number, dir: -1 | 1) => { + const target = idx + dir; + if (target < 0 || target >= posts.length || busy) return; + const prev = posts; + const next = [...posts]; + const tmp = next[idx]; + next[idx] = next[target]; + next[target] = tmp; + setPosts(next); + setBusy(true); + setError(""); + try { + await apiAdminSavePinOrder(level, level === 1 ? boardId : 0, next.map((p) => p.id)); + } catch (e) { + setPosts(prev); + setError(e instanceof Error ? e.message : "保存顺序失败"); + } finally { + setBusy(false); + } + }; + + const tabCls = (active: boolean) => + `px-3.5 py-2 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors ${ + active ? "" : "hover:bg-[var(--panel-2)]" + }`; + + return ( +
+ + + {error && ( +
+ {error} +
+ )} + + {/* 层级切换 + 板块选择 */} +
+
+ {canGlobal && ( + + )} + +
+ + {level === 1 && ( + + )} +
+ +
+ {loading ? ( +

加载中…

+ ) : posts.length === 0 ? ( + } + title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"} + description="在帖子页的「管理」菜单中可将帖子设为置顶。" + /> + ) : ( +
    + {posts.map((p, i) => ( +
  • + + {i + 1} + +
    + + +
    +
    + + {p.title} + +

    + {p.board_name} · {new Date(p.created_at).toLocaleDateString("zh-CN")} +

    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/admin/pinned/page.tsx b/frontend/app/admin/pinned/page.tsx new file mode 100644 index 0000000..f04a667 --- /dev/null +++ b/frontend/app/admin/pinned/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { authCookieHeader } from "@/lib/cookies"; +import { fetchBoards, type BoardWithCount } from "@/lib/api"; +import { getMeCached } from "@/lib/serverData"; +import { canModerateAny, isAdminOrAbove } from "@/lib/roles"; +import Forbidden from "../Forbidden"; +import PinnedAdmin from "./PinnedAdmin"; + +export const metadata: Metadata = { + title: "置顶管理", +}; + +export default async function AdminPinnedPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + + // 管理员及以上排全站置顶;板块管理员排自己授权板块的板块置顶 + if (!me.user || !canModerateAny(me.user)) { + return ; + } + + // 板块管理员只能看到自己被授权的板块 + let boards: BoardWithCount[] = []; + try { + const r = await fetchBoards(); + boards = r.boards ?? []; + } catch { + boards = []; + } + if (!isAdminOrAbove(me.user.role)) { + const ids = new Set(me.user.board_ids ?? []); + boards = boards.filter((b) => ids.has(b.id)); + } + + return ( + ({ id: b.id, name: b.name }))} + canGlobal={isAdminOrAbove(me.user.role)} + /> + ); +} diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx index 562aa0d..2e6457d 100644 --- a/frontend/app/announcement/[id]/page.tsx +++ b/frontend/app/announcement/[id]/page.tsx @@ -118,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) { <> {a.pinned ? ( - + ) : null} {a.tag} diff --git a/frontend/app/announcements/page.tsx b/frontend/app/announcements/page.tsx index 9fca982..df9a3d5 100644 --- a/frontend/app/announcements/page.tsx +++ b/frontend/app/announcements/page.tsx @@ -59,7 +59,7 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS {a.pinned ? ( - + ) : null} {a.tag} @@ -93,7 +93,7 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
{a.pinned ? ( - + ) : null} {a.tag} diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index 44eb6ba..f6a55e1 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -688,7 +688,7 @@ export default function ChatRoomClient({ const isDirect = roomType === "direct"; const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member"; const canView = joined; - const oversee = canOverseeChat(meRole); + const oversee = canOverseeChat({ role: meRole, can_manage_messages: canManageMessages }); const siteOwner = isSiteOwner(meRole); const canModerateChat = oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2d267e0..936203e 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -30,6 +30,14 @@ --gold: #825700; --gold-soft: rgba(130, 87, 0, 0.13); + /* 置顶 / 推荐徽章专用色:与 --clay/--gold(还被文档类型、图表等复用)解耦,单独可调 */ + --pin: #c2410c; /* 置顶:焦橙,明度高、不浑浊 */ + --pin-soft: rgba(194, 65, 12, 0.1); + --pin-line: rgba(194, 65, 12, 0.26); + --rec: #b7791f; /* 推荐:琥珀金,偏 saffron 而非暗橄榄 */ + --rec-soft: rgba(183, 121, 31, 0.13); + --rec-line: rgba(183, 121, 31, 0.3); + --danger: #c8281e; --danger-soft: rgba(200, 40, 30, 0.09); --ok: #137333; @@ -154,6 +162,13 @@ --gold: #e0b259; --gold-soft: rgba(224, 178, 89, 0.16); + --pin: #ff9a6b; + --pin-soft: rgba(255, 154, 107, 0.14); + --pin-line: rgba(255, 154, 107, 0.3); + --rec: #e8bd63; + --rec-soft: rgba(232, 189, 99, 0.14); + --rec-line: rgba(232, 189, 99, 0.3); + --danger: #ff6b60; --danger-soft: rgba(255, 107, 96, 0.15); --ok: #58d18a; @@ -6592,23 +6607,33 @@ a.j13-mention-token:hover { background: var(--accent-soft); color: var(--accent); } - /* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */ + /* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间。 + 形态:圆角方块 + 极淡底 + 同色发丝边 + 线性图标,避免实心图标在小尺寸下发糊。 */ .badge-pin, .badge-rec { display: inline-flex; align-items: center; justify-content: center; + box-sizing: border-box; width: 20px; height: 20px; - border-radius: 6px; + border-radius: 7px; + border: 1px solid; + line-height: 0; } .badge-pin { - background: var(--clay-soft); - color: var(--clay); + color: var(--pin); + background: var(--pin-soft); + border-color: var(--pin-line); + } + /* 板块置顶:去掉底色只留描边,弱一档,与全站置顶拉开层级(不换色相,避免多一套语义色) */ + .badge-pin[data-scope="board"] { + background: transparent; } .badge-rec { - background: var(--gold-soft); - color: var(--gold); + color: var(--rec); + background: var(--rec-soft); + border-color: var(--rec-line); } .badge-pending { display: inline-flex; diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 2abf407..f5686d5 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -491,13 +491,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps 使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
{post.pinned > 0 && ( - - + = 2 ? "global" : "board"} + title={post.pinned >= 2 ? "置顶" : "板块置顶"} + aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"} + > + )} {post.recommended && ( - + )}

{ export default async function SnippetsPage() { const settings = await getPublicSettingsCached(); + // 登录态由服务端判定后下发:客户端组件内再读 cookie 会造成 hydration 不一致。 + // getMeCached 与 layout 同参调用,命中 React cache 不额外请求后端。 + const cookie = authCookieHeader(await cookies()); + const isLoggedIn = !!(await getMeCached(cookie || undefined)).user; + let initialItems: Awaited>["items"] = []; let initialTotal = 0; let languages: string[] = []; @@ -43,6 +50,7 @@ export default async function SnippetsPage() { languages={languages} urlStyle={settings.url_style} stats={stats} + isLoggedIn={isLoggedIn} />

); diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx index 305f18e..df78b62 100644 --- a/frontend/app/u/[id]/UserProfileContent.tsx +++ b/frontend/app/u/[id]/UserProfileContent.tsx @@ -30,13 +30,14 @@ const SORTS: { key: string; label: string }[] = [ /** 帖子卡(发帖列表与收藏列表共用) */ async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) { const urlStyle = (await getPublicSettingsCached()).url_style; - const pinned = post.pinned > 0; + // 用户主页为全站语境:仅全站置顶(2)高亮,板块置顶(1)在主页不作置顶展示 + const pinned = post.pinned >= 2; return ( - + )}

{post.recommended && ( - + )}

diff --git a/frontend/components/PinnedPosts.tsx b/frontend/components/PinnedPosts.tsx index ba83cf2..dd7cde2 100644 --- a/frontend/components/PinnedPosts.tsx +++ b/frontend/components/PinnedPosts.tsx @@ -15,7 +15,7 @@ function formatTime(dateStr: string) { /** * 管理员置顶区:独立于普通帖子列表。 - * 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。 + * 焦橙语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。 */ export default async function PinnedPosts({ posts, @@ -32,11 +32,11 @@ export default async function PinnedPosts({
- + -

+

置顶讨论

管理员置顶 · {posts.length} @@ -78,7 +78,7 @@ export default async function PinnedPosts({ title="推荐" aria-label="推荐" > - + )}
@@ -94,9 +94,9 @@ export default async function PinnedPosts({ ))} diff --git a/frontend/components/PostActions.tsx b/frontend/components/PostActions.tsx index 229f47b..5e28165 100644 --- a/frontend/components/PostActions.tsx +++ b/frontend/components/PostActions.tsx @@ -46,7 +46,7 @@ export default function PostActions({ const [user, setUser] = useState(null); const [open, setOpen] = useState(false); const [deleting, setDeleting] = useState(false); - const [isPinned, setIsPinned] = useState(pinned > 0); + const [pinLevel, setPinLevel] = useState(pinned); const [isRecommended, setIsRecommended] = useState(recommended); const [isLocked, setIsLocked] = useState(locked); const [acting, setActing] = useState(false); @@ -89,6 +89,13 @@ export default function PostActions({ const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked; const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId); + // 置顶分级:管理员及以上可设全局置顶(2)与板块置顶(1);板块管理员只在其授权板块做板块置顶。 + // 已是全站置顶的帖子,板块管理员不可操作,故不展示入口。 + const canGlobalPin = isAdmin; + const canBoardPin = !!user && canModerateBoard(user, boardId); + const showGlobalPin = canGlobalPin; + const showBoardPin = canBoardPin && (isAdmin || pinLevel < 2); + const showPin = showGlobalPin || showBoardPin; // staff 删他人帖:走类型+理由;作者自删:简单确认 const staffDeletingOther = !!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole); @@ -126,16 +133,24 @@ export default function PostActions({ } }; - const handlePin = async () => { + const handlePin = async (level: 1 | 2) => { setActing(true); setOpen(false); try { - const res = await apiTogglePin(postId); - setIsPinned(res.pinned > 0); - toast(res.pinned > 0 ? "已置顶" : "已取消置顶", "ok"); + // 再次点击当前级别 = 取消置顶 + const next = pinLevel === level ? 0 : level; + const res = await apiTogglePin(postId, next as 0 | 1 | 2); + setPinLevel(res.pinned); + const okMsg = + res.pinned <= 0 + ? "已取消置顶" + : res.pinned === 2 + ? "已全局置顶" + : "已在本板块置顶"; + toast(okMsg, "ok"); router.refresh(); - } catch { - toast("操作失败,请稍后重试"); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败,请稍后重试"); } finally { setActing(false); } @@ -220,25 +235,44 @@ export default function PostActions({
)} + {showPin && ( + <> + {showGlobalPin && ( + + )} + {showBoardPin && ( + + )} + + )} {isAdmin && ( <> - -
- )} + + 可在后台管理全部群聊消息(不含私聊);超管授予后另获全站会话(含私聊)监管;站长固有无需此项。 + + + + + + + + )}

diff --git a/frontend/components/SnippetExplorer.tsx b/frontend/components/SnippetExplorer.tsx index a02337f..50ff0cb 100644 --- a/frontend/components/SnippetExplorer.tsx +++ b/frontend/components/SnippetExplorer.tsx @@ -22,7 +22,6 @@ import { snippetPath, type UrlStyle } from "@/lib/urlStyle"; import { formatRelative } from "@/lib/format"; import Avatar from "@/components/Avatar"; import { toast } from "@/lib/toast"; -import { hasAuthCookieHint } from "@/lib/cookies"; import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter"; import SnippetLangSelect from "@/components/SnippetLangSelect"; import SnippetHero from "@/components/SnippetHero"; @@ -278,36 +277,20 @@ const SORT_OPTIONS: { value: SnippetSort; label: string }[] = [ { value: "fork", label: "Fork" }, ]; -function CardSkeleton() { - return ( -

-
-
-
-
-
-
-
-
-
- ); -} - export default function SnippetExplorer({ initialItems, initialTotal, languages, urlStyle, stats, + isLoggedIn, }: { initialItems: SnippetListItem[]; initialTotal: number; languages: string[]; urlStyle: UrlStyle; stats: SnippetStats | null; + isLoggedIn: boolean; }) { const [items, setItems] = useState(initialItems); const [total, setTotal] = useState(initialTotal); @@ -317,7 +300,6 @@ export default function SnippetExplorer({ const [lang, setLang] = useState(""); const [sort, setSort] = useState("latest"); const [scope, setScope] = useState<"public" | "mine">("public"); - const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false; const pageSize = 12; const totalPages = Math.max(1, Math.ceil(total / pageSize)); @@ -360,7 +342,7 @@ export default function SnippetExplorer({ return (
{/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */} - + {/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */}
{/* 列表 / 空态 */} - {loading && items.length === 0 ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : items.length === 0 && !loading ? ( + {items.length === 0 && loading ? null : items.length === 0 && !loading ? ( filtering ? (
canModerateAny(u), pendingBadge: true, }, + { + href: "/admin/pinned", + label: "置顶管理", + icon: Pin, + visible: (u) => canModerateAny(u), + }, { href: "/admin/messages", label: "消息管理", diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 54f5144..73c9876 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -81,7 +81,7 @@ export interface User { role: string; // 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮) board_ids?: number[]; - // 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段) + // 站点消息管理(仅站长可授;超管亦需授予,站长固有监管不必依赖此字段) can_manage_messages?: boolean; // 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐) perms?: string[]; @@ -2261,8 +2261,8 @@ export async function apiDeletePost( return res.json(); } -export async function apiTogglePin(postId: string): Promise<{ pinned: number }> { - const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, { +export async function apiTogglePin(postId: string, level: 0 | 1 | 2): Promise<{ pinned: number }> { + const res = await fetchWithRefresh(`/api/posts/${postId}/pin?level=${level}`, { method: "PUT", headers: clientHeaders(), }); @@ -4059,6 +4059,38 @@ export const apiAdminRestoreComment = (id: number) => export const apiAdminPurgeComment = (id: number) => contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE"); +// ===== 置顶顺序配置 ===== +export interface AdminPinnedPost { + id: number; + title: string; + pinned: number; + pin_order: number; + board_id: number; + board_name: string; + created_at: string; +} + +export async function apiAdminPinnedPosts( + level: 1 | 2, + boardId?: number +): Promise<{ posts: AdminPinnedPost[] }> { + const params = new URLSearchParams({ level: String(level) }); + if (boardId) params.set("board_id", String(boardId)); + const res = await fetchWithRefresh(`/api/admin/pinned-posts?${params}`, { + headers: clientHeaders(), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "获取置顶列表失败"); + return data; +} + +export const apiAdminSavePinOrder = (level: 1 | 2, boardId: number, ids: number[]) => + contentAction(`/api/admin/pinned-posts/order`, "PUT", { + level, + board_id: boardId, + ids, + }); + export interface CommentEditHistoryItem { id: number; editor: { diff --git a/frontend/lib/roles.ts b/frontend/lib/roles.ts index 134d07a..c55d9d8 100644 --- a/frontend/lib/roles.ts +++ b/frontend/lib/roles.ts @@ -76,9 +76,11 @@ export const PERM_META: Record< [PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" }, }; -/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */ -export function canOverseeChat(role?: string | null): boolean { - return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN; +/** 全站消息监管(可见全部群含私聊、撤回任意消息):站长固有;超管需先被授予「消息管理」 */ +export function canOverseeChat(user?: RoleUserLike | null): boolean { + if (!user) return false; + if (isOwner(user.role)) return true; + return user.role === ROLES.SUPER_ADMIN && !!user.can_manage_messages; } /** 彻底删除帖/评:仅站长 */ @@ -152,10 +154,10 @@ export function canModerateAny(user: RoleUserLike | null | undefined): boolean { return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0; } -/** 站点级消息管理(站长/超管固有,或 can_manage_messages) */ +/** 站点级消息管理:站长固有;超管及其他角色需站长授予 can_manage_messages */ export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean { if (!user) return false; - if (canOverseeChat(user.role)) return true; + if (isOwner(user.role)) return true; return !!user.can_manage_messages; } @@ -201,7 +203,7 @@ export const ROLE_META: Record = { colorToken: "--accent", softToken: "--accent-soft", grantDesc: - "用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。", + "用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;站长授予「消息管理」后方可进入后台消息管理并全站会话监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。", }, [ROLES.ADMIN]: { key: ROLES.ADMIN, @@ -219,7 +221,7 @@ export const ROLE_META: Record = { colorToken: "--accent", softToken: "--accent-soft", grantDesc: - "仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。", + "仅审核并软删被授权板块内普通用户的帖子与评论;可在被授权板块内单独置顶帖子(板块置顶)。不可处置管理员/站长发布的内容,无消息管理、公告、全站置顶/加精、用户管理与外观设置;须至少勾选一个板块。", }, [ROLES.USER]: { key: ROLES.USER,