3 Commits

48 changed files with 1274 additions and 240 deletions

View File

@@ -150,9 +150,9 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致
## 四级 RBAC + 内容审核(2026-09) ## 四级 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 按授权板过滤 - 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;其余管理员仅软删+恢复 - 彻底删除帖/评:仅 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、ErrAuthorProtected/ErrPurgeOwnerOnly→403 - 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403

View File

@@ -1 +1 @@
0.2.10 0.2.11

View File

@@ -21,8 +21,10 @@ func main() {
log.Fatalf("数据库初始化失败: %v", err) log.Fatalf("数据库初始化失败: %v", err)
} }
// 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置) // 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)。
service.ConfigureCookieNames(!cfg.DevMode) // 以前是 !DevMode 硬绑定,现在解耦:根据 SITE_URL scheme 或 FORCE_HTTPS 判断,
// 内网 HTTP 部署零配置即可跑,反代 TLS 但后端只看到 HTTP 时设 FORCE_HTTPS=true。
service.ConfigureCookieNames(cfg.IsSecureOrigin())
// 过期/已吊销 refresh token 定期清理 // 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB) service.StartRefreshTokenCleanup(model.DB)
// 悬赏过期退回 / 抽奖到期开奖 // 悬赏过期退回 / 抽奖到期开奖

View File

@@ -24,6 +24,7 @@ type Config struct {
DevMode bool DevMode bool
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用 SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量) CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
ForceHTTPS bool // 强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀;反代 TLS 但后端只看到 HTTP 时设 true
} }
// appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。 // appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。
@@ -43,7 +44,8 @@ var appIniSchema = []appIniKey{
{section: "paths", key: "DATA", value: "data"}, {section: "paths", key: "DATA", value: "data"},
{section: "app", key: "DEV_MODE", value: "true"}, {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: "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 > 默认值。 // Parse 解析配置:环境变量 > app.ini > 默认值。
@@ -98,6 +100,9 @@ func Parse() (*Config, error) {
if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" { if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
cfg.CORSOrigins = splitCSVOrigins(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 != "" { if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
abs, err := filepath.Abs(v) abs, err := filepath.Abs(v)
if err != nil { if err != nil {
@@ -179,6 +184,9 @@ func applyIni(cfg *Config, f *ini.File) {
cfg.CORSOrigins = splitCSVOrigins(v) 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 新建;已存在则只补缺失键。 // loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。
@@ -227,6 +235,19 @@ func loadAndEnsureAppIni(path string) (*ini.File, error) {
return f, nil 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。 // AllowOrigin 供 CORS 中间件判断浏览器 Origin。
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致); // 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。 // 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。

View File

@@ -164,7 +164,7 @@ func (h *Handlers) Register(c *gin.Context) {
}) })
return 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) h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"user": 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) h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
// dev 模式不设 Secure,生产环境需 HTTPS // dev 模式不设 Secure,生产环境需 HTTPS
h.Ops.ClearFailure(req.Username) h.Ops.ClearFailure(req.Username)
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode) setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin())
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧 // 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等 // 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
// access token 过期或硬刷新才发现) // access token 过期或硬刷新才发现)
@@ -244,13 +244,13 @@ func (h *Handlers) Refresh(c *gin.Context) {
if err != nil { if err != nil {
if errors.Is(err, service.ErrAccountBanned) { if errors.Is(err, service.ErrAccountBanned) {
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线 // 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
clearAuthCookies(c, !h.Cfg.DevMode) clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"}) c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
return return
} }
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) { if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效") // 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
clearAuthCookies(c, !h.Cfg.DevMode) clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return return
} }
@@ -259,7 +259,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
return return
} }
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode) setAuthCookies(c, accessToken, newRefresh, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"user": gin.H{ "user": gin.H{
"id": user.ID, "id": user.ID,
@@ -279,7 +279,7 @@ func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
_ = h.Auth.RevokeRefreshToken(refreshToken) _ = h.Auth.RevokeRefreshToken(refreshToken)
} }
clearAuthCookies(c, !h.Cfg.DevMode) clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{"message": "已登出"}) c.JSON(http.StatusOK, gin.H{"message": "已登出"})
} }
@@ -302,7 +302,7 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
return return
} }
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录 // 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c, !h.Cfg.DevMode) clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"}) c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
} }

View File

@@ -69,7 +69,7 @@ func (h *Handlers) chatOversee(userID uint) bool {
if err != nil || actor == nil { if err != nil || actor == nil {
return false return false
} }
return service.CanOverseeChat(actor.Role) return actor.CanOverseeChat()
} }
// RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。 // RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。
@@ -673,7 +673,7 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) {
"messages": msgs, "messages": msgs,
"total": total, "total": total,
"page": page, "page": page,
"can_oversee": service.CanOverseeChat(actor.Role), "can_oversee": actor.CanOverseeChat(),
"can_recall": true, "can_recall": true,
"site_messages": actor.HasSiteMessagePerm(), "site_messages": actor.HasSiteMessagePerm(),
}) })
@@ -697,7 +697,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 := service.CanOverseeChat(actor.Role) oversee := actor.CanOverseeChat()
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()})

View File

@@ -246,3 +246,55 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint)
h.broadcastModerationChanged() h.broadcastModerationChanged()
c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) 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": "已保存"})
}

View File

@@ -550,9 +550,12 @@ func (h *Handlers) DeletePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "已删除"}) c.JSON(http.StatusOK, gin.H{"message": "已删除"})
} }
// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限) // TogglePin 切换帖子置顶:管理员及以上切全站置顶,板块管理员切本板板块置顶
func (h *Handlers) TogglePin(c *gin.Context) { 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 return
} }
id, err := strconv.ParseUint(c.Param("id"), 10, 64) 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return 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 { if err != nil {
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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return return
} }
c.JSON(http.StatusOK, gin.H{"pinned": pinned}) c.JSON(http.StatusOK, gin.H{"pinned": pinned})

View File

@@ -61,7 +61,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
canOverseeChat := false canOverseeChat := false
if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil { if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil {
isStaff = actor.IsStaff() isStaff = actor.IsStaff()
canOverseeChat = service.CanOverseeChat(actor.Role) canOverseeChat = actor.CanOverseeChat()
} }
client := realtime.NewClient( client := realtime.NewClient(
@@ -74,7 +74,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
return verr == nil return verr == nil
}, },
func() { h.Auth.TouchLastSeen(claims.ID) }, func() { h.Auth.TouchLastSeen(claims.ID) },
// 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群 // 动态房间订阅鉴权:成员可订阅;站长/已授权超管旁路可订阅任意群
func(room string) bool { func(room string) bool {
roomID, ok := parseWSChatRoom(room) roomID, ok := parseWSChatRoom(room)
if !ok { if !ok {

View File

@@ -5,8 +5,9 @@ import (
) )
// SecurityHeaders 安全响应头中间件 // SecurityHeaders 安全响应头中间件
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险 // 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险。
func SecurityHeaders() gin.HandlerFunc { // secure=true 时额外下发 HSTS(仅 HTTPS origin 合法,HTTP 环境发 HSTS 属于误导)。
func SecurityHeaders(secure bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// CSP:限制资源加载来源,降低 XSS 危害 // CSP:限制资源加载来源,降低 XSS 危害
c.Header("Content-Security-Policy", c.Header("Content-Security-Policy",
@@ -20,8 +21,10 @@ func SecurityHeaders() gin.HandlerFunc {
"base-uri 'self'; "+ "base-uri 'self'; "+
"form-action 'self'") "form-action 'self'")
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略) // HSTS:仅 HTTPS origin 下发。HTTP 响应发 HSTS 是无效且误导的。
if secure {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// 点击劫持防护 // 点击劫持防护
c.Header("X-Frame-Options", "DENY") c.Header("X-Frame-Options", "DENY")
@@ -32,9 +35,6 @@ func SecurityHeaders() gin.HandlerFunc {
// 控制 Referer 信息泄露 // 控制 Referer 信息泄露
c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// 禁止浏览器缓存敏感页面(可按需覆盖)
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Next() c.Next()
} }
} }

View File

@@ -69,6 +69,11 @@ func InitDB(dsn string) error {
return fmt.Errorf("自动迁移失败: %w", err) return fmt.Errorf("自动迁移失败: %w", err)
} }
// 帖子置顶分级:历史全站置顶(pinned=1)升为 2,避免存量置顶帖被降级为板块置顶
if err := preparePostPinLevels(db); err != nil {
return fmt.Errorf("帖子置顶分级迁移失败: %w", err)
}
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
// 已存在 owner 时不动数据,保证幂等 // 已存在 owner 时不动数据,保证幂等
if err := ensureOwnerRole(db); err != nil { 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 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(幂等) // prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等)
func prepareSiteSettingValueText(db *gorm.DB) error { func prepareSiteSettingValueText(db *gorm.DB) error {
var tableCount int64 var tableCount int64

View File

@@ -11,7 +11,7 @@ type Role string
const ( const (
RoleUser Role = "user" // 普通用户:发帖/评论需审核 RoleUser Role = "user" // 普通用户:发帖/评论需审核
RoleBoardAdmin Role = "board_admin" // 板块管理员:仅管理被授权板块的帖子与评论 RoleBoardAdmin Role = "board_admin" // 板块管理员:管理被授权板块的帖子、评论与板块内置顶
RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核 RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核
RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长 RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长
RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁 RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁
@@ -121,7 +121,7 @@ type User struct {
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"`
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;仅后台权限服务读取,序列化跳过 PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减) TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
@@ -207,6 +207,13 @@ type ImportRecord struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// 帖子置顶级别:同一 pinned 字段分级,数值越大范围越大
const (
PinNone = 0 // 不置顶
PinBoard = 1 // 板块置顶:仅所属板块列表靠前(板块管理员可操作)
PinGlobal = 2 // 全站置顶:首页与所有列表靠前(管理员及以上可操作)
)
// Post 帖子 // Post 帖子
type Post struct { type Post struct {
ID uint `gorm:"primaryKey" json:"id"` 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(由正文隐藏块派生) 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"` // 积分隐藏块价格之和 AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 积分隐藏块价格之和
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) 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"` Recommended bool `gorm:"default:false;index" json:"recommended"`
Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `gorm:"size:16;default:published;index" json:"status"` Status string `gorm:"size:16;default:published;index" json:"status"`

View File

@@ -65,8 +65,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(gin.Logger()) r.Use(gin.Logger())
// 全局安全响应头 // 全局安全响应头(HSTS 仅 HTTPS origin 下发)
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders(cfg.IsSecureOrigin()))
// CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域) // CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域)
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
@@ -159,7 +159,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Chat: chatSvc, Chat: chatSvc,
Visit: visitSvc, Visit: visitSvc,
Analytics: analyticsSvc, Analytics: analyticsSvc,
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, cfg.IsSecureOrigin()),
Ads: adSvc, Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB), Sidebar: service.NewSidebarService(model.DB),
FriendLink: service.NewFriendLinkService(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) r.GET("/api/ws", h.RealtimeWS)
// 公开 API(可选登录);EnsureCSRF 让游客也能拿到双提交 token(密码解锁等) // 公开 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("/me", h.Me)
pubAPI.GET("/boards", h.Boards) 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.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment) 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) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)

View File

@@ -18,7 +18,7 @@ const (
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予) PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
PermSettings = "settings" // 站点外观设置(超管/站长) PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限) PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行) PermMessages = "messages" // 后台消息管理(站长固有;超管及其他角色需站点消息 flag;群管另有业务层放行)
PermBoards = "boards" // 板块管理(仅站长) PermBoards = "boards" // 板块管理(仅站长)
) )
@@ -105,7 +105,7 @@ func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages
for _, p := range ParsePermOverrides(permOverridesRaw) { for _, p := range ParsePermOverrides(permOverridesRaw) {
set[p] = true set[p] = true
} }
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages { if role == model.RoleOwner || canManageMessages {
set[PermMessages] = true set[PermMessages] = true
} }
out := make([]string, 0, len(set)) out := make([]string, 0, len(set))
@@ -123,7 +123,7 @@ type Actor struct {
Username string Username string
Role model.Role Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段 CanManageMessages bool // 站点级消息管理 flag(仅站长可授,超管亦需此授予);站长固有
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量) ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
} }
@@ -151,15 +151,22 @@ func (a *Actor) HasPerm(p string) bool {
return false return false
} }
// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages // HasSiteMessagePerm 站点级消息管理:站长固有;超管及其他角色需被授予 CanManageMessages
func (a *Actor) HasSiteMessagePerm() bool { func (a *Actor) HasSiteMessagePerm() bool {
if a == nil { if a == nil {
return false return false
} }
if a.Role == model.RoleOwner || a.Role == model.RoleSuperAdmin { return a.Role == model.RoleOwner || a.CanManageMessages
return true
} }
return a.CanManageMessages
// CanOverseeChat 全站消息监管(可见全部群含私聊、撤回任意消息、旁路订阅任意房间):
// 站长固有;超管需先被授予站点消息管理
func (a *Actor) CanOverseeChat() bool {
if a == nil {
return false
}
return a.Role == model.RoleOwner ||
(a.Role == model.RoleSuperAdmin && a.CanManageMessages)
} }
// CanPurgeContent 彻底删除帖/评:仅站长 // CanPurgeContent 彻底删除帖/评:仅站长

View File

@@ -13,6 +13,7 @@ import (
var ( var (
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除") ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
ErrContentNotFound = errors.New("内容不存在") ErrContentNotFound = errors.New("内容不存在")
ErrPinOrderForbidden = errors.New("无权配置该层级置顶顺序")
) )
func (s *ModerationService) authorRole(userID uint) (model.Role, error) { 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 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
})
}

View File

@@ -470,7 +470,7 @@ func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perm
return s.getItem(s.db, targetID) return s.getItem(s.db, targetID)
} }
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己) // SetCanManageMessages 站长授予/撤销站点消息管理权限(超管亦需授予;不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) { func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner { if operator == nil || operator.Role != model.RoleOwner {
return nil, ErrOwnerOnly return nil, ErrOwnerOnly
@@ -486,10 +486,6 @@ func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint,
if u.Role == model.RoleOwner { if u.Role == model.RoleOwner {
return ErrProtectedOwner return ErrProtectedOwner
} }
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
if u.Role == model.RoleSuperAdmin {
enabled = false
}
if u.CanManageMessages == enabled { if u.CanManageMessages == enabled {
return nil return nil
} }

View File

@@ -42,11 +42,6 @@ var (
ErrChatAdminAccess = errors.New("无权管理消息") ErrChatAdminAccess = errors.New("无权管理消息")
) )
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
func CanOverseeChat(role model.Role) bool {
return role == model.RoleOwner || role == model.RoleSuperAdmin
}
const ( const (
chatNameMinRune = 2 chatNameMinRune = 2
chatNameMaxRune = 32 chatNameMaxRune = 32
@@ -929,7 +924,7 @@ func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
} }
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee) // hasSiteGroupMessagePerm 站点消息管理 flag(不含站长,超管经 oversee 已放行;调用方通常已判 oversee)
func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool { func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool {
var u model.User var u model.User
if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil { if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil {
@@ -1219,7 +1214,7 @@ type AdminChatMessageItem struct {
} }
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。 // AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。 // actor 为站长或已授权超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) { func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
if actor == nil || !s.CanAccessAdminMessages(actor) { if actor == nil || !s.CanAccessAdminMessages(actor) {
return nil, 0, ErrChatAdminAccess return nil, 0, ErrChatAdminAccess
@@ -1227,7 +1222,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp
if page < 1 { if page < 1 {
page = 1 page = 1
} }
fullOversee := CanOverseeChat(actor.Role) fullOversee := actor.CanOverseeChat()
siteGroup := actor.HasSiteMessagePerm() && !fullOversee 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("房间类型无效")

View File

@@ -226,6 +226,14 @@ type legacyPostRow struct {
func (legacyPostRow) TableName() string { return "posts" } 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 { type legacyCommentRow struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
PostID uint `gorm:"column:post_id"` PostID uint `gorm:"column:post_id"`
@@ -757,7 +765,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
Content: content, Content: content,
Tags: p.Tags, Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned, Pinned: legacyPinLevel(p.Pinned),
Recommended: p.Featured, Recommended: p.Featured,
Status: model.ContentStatusPublished, Status: model.ContentStatusPublished,
LikeCount: p.LikeCount, LikeCount: p.LikeCount,

View File

@@ -368,7 +368,7 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
return e return e
} }
origin, originErr := url.Parse(o.cfg.SiteURL) 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 后才能开启账号邮件") 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" { if c.Backend == "s3" {
u, e := url.Parse(c.Endpoint) 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")) { 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)") 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, "..") { if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
return bad("bucket", "桶名称无效") return bad("bucket", "桶名称无效")

View File

@@ -19,6 +19,8 @@ var (
ErrPostInvalidType = errors.New("无效的删除类型") ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑") ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑") ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
ErrPinGlobalOnly = errors.New("全站置顶仅管理员可操作")
ErrPinInvalidLevel = errors.New("无效的置顶级别")
) )
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用 // editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
@@ -247,18 +249,25 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
var posts []model.Post var posts []model.Post
offset := (q.Page - 1) * q.Size 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:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。 // latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引 // 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
order := `pinned DESC, GREATEST(created_at, COALESCE(( order := pinFirst + `, GREATEST(created_at, COALESCE((
SELECT MAX(c.created_at) FROM comments c SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC` ), created_at)) DESC`
if q.Sort == "recommended" { 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:纯按发帖时间(新帖子) // new:纯按发帖时间(新帖子)
if q.Sort == "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). if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil { 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 var posts []model.Post
offset := (page - 1) * size 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" { if sort == "hot" {
order = "like_count + comment_count DESC, view_count DESC, created_at DESC" 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 return total, err
} }
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限) // TogglePin 设置置顶级别:level 0=取消置顶,1=板块置顶,2=全站置顶。
func (s *PostService) TogglePin(id uint) (int, error) { // 板块管理员只能操作自己授权板块且只能设置/取消板块置顶;管理员可设置任意级别。
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 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 return 0, err
} }
var newPinned int isGlobalAdmin := actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)
if post.Pinned > 0 { isBoardModerator := actor != nil && actor.CanModerateBoard(post.BoardID)
newPinned = 0
} else { // 权限校验
newPinned = 1 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 // UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned) if err := s.db.Model(&post).UpdateColumn("pinned", level).Error; err != nil {
if result.Error != nil { return 0, err
return 0, result.Error
} }
return newPinned, nil return level, nil
} }
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。 // ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。

View File

@@ -124,15 +124,18 @@ const (
DefaultUrlStyle = "default" DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长) // MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5 MaxMobileTabs = 6
) )
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致) // MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是管理面板排序基准,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"} var MobileTabKeys = []string{"home", "library", "snippets", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置 // DefaultMobileTabKeys 底栏缺省启用集合(白名单子集:snippets 默认关闭,需管理员手动开启)
var DefaultMobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认启用配置
func DefaultMobileTabs() []string { func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...) return append([]string(nil), DefaultMobileTabKeys...)
} }
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -645,7 +648,7 @@ func (s *SettingService) SetMobileTabs(tabs []string) error {
if !ok || len(out) == 0 { if !ok || len(out) == 0 {
return ErrInvalidSiteSetting return ErrInvalidSiteSetting
} }
if mobileTabsEqual(out, MobileTabKeys) { if mobileTabsEqual(out, DefaultMobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs) return s.deleteKey(SettingKeyMobileTabs)
} }
b, err := json.Marshal(out) b, err := json.Marshal(out)

View File

@@ -11,7 +11,7 @@
- **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。 - **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。
- **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。 - **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。
- **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。 - **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。
- **置顶与推荐**:管理员可对帖子进行置顶、推荐,引导社区焦点。 - **置顶与推荐**:管理员可对帖子进行全站置顶、推荐,引导社区焦点;板块管理员可在被授权板块内单独置顶帖子。
### 🔔 实时通知 ### 🔔 实时通知
- 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。 - 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。

View File

@@ -250,7 +250,7 @@ function useMessageColumn(roomType: MsgKind) {
/** 群聊(含大厅)| 私聊 双栏监管 */ /** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin({ me }: { me: User }) { export default function MessagesAdmin({ me }: { me: User }) {
const canOversee = canOverseeChat(me.role); const canOversee = canOverseeChat(me);
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");

View File

@@ -15,7 +15,7 @@ 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 || !canAccessAdminMessages(me.user)) { if (!me.user || !canAccessAdminMessages(me.user)) {
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />; return <Forbidden text="消息管理需站长授予权限(超管亦需授权),或被任命为群管理员。" />;
} }
return <MessagesAdmin me={me.user} />; return <MessagesAdmin me={me.user} />;

View File

@@ -0,0 +1,553 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
Check,
ChevronDown,
ChevronUp,
FolderOpen,
Globe,
GripVertical,
Loader2,
Pin,
} from "lucide-react";
import {
apiAdminPinnedPosts,
apiAdminSavePinOrder,
type AdminPinnedPost,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
} from "@/components/admin";
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<Level>(canGlobal ? 2 : 1);
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
/** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
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([]);
setError("");
};
/** 统一保存:乐观更新新顺序,失败回滚并提示 */
const saveOrder = useCallback(
async (next: AdminPinnedPost[], prev: AdminPinnedPost[]) => {
setPosts(next);
setSaving(true);
try {
await apiAdminSavePinOrder(
level,
level === 1 ? boardId : 0,
next.map((p) => p.id)
);
toast("置顶顺序已更新", "ok");
} catch (e) {
setPosts(prev);
toast(e instanceof Error ? e.message : "保存顺序失败");
} finally {
setSaving(false);
}
},
[level, boardId]
);
// 上移/下移按钮与键盘共用
const move = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= posts.length || saving) return;
const prev = posts;
const next = [...posts];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
void saveOrder(next, prev);
};
const resetDrag = () => {
setDragIndex(null);
setDropIndex(null);
};
const onRowDragOver = (e: DragEvent<HTMLLIElement>, i: number) => {
if (dragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// 指针过行中线则插到行后,否则插到行前
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
setDropIndex(i + (after ? 1 : 0));
};
const onRowDrop = (e: DragEvent<HTMLLIElement>) => {
e.preventDefault();
if (dragIndex === null || dropIndex === null || saving) {
resetDrag();
return;
}
// 落在原位置前后:无变化
if (dropIndex === dragIndex || dropIndex === dragIndex + 1) {
resetDrag();
return;
}
const prev = posts;
const next = [...posts];
const [moved] = next.splice(dragIndex, 1);
next.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved);
resetDrag();
void saveOrder(next, prev);
};
/** 插入指示线:仅当落点确实改变位置时显示 */
const showLineAt = (pos: number) =>
dragIndex !== null && dropIndex === pos && pos !== dragIndex && pos !== dragIndex + 1;
const segOptions = [
...(canGlobal
? [{ key: "2", label: "全站置顶", icon: <Globe size={13} aria-hidden /> }]
: []),
{ key: "1", label: "板块置顶", icon: <FolderOpen size={13} aria-hidden /> },
];
return (
<div>
<AdminPageHeader
icon={Pin}
title="置顶管理"
description="拖拽手柄或上下箭头调整置顶帖顺序,改动自动保存,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{/* 层级切换 + 板块选择 */}
<div className="mb-4 flex items-center gap-3 flex-wrap">
<AdminSegmented
options={segOptions}
value={String(level)}
onChange={(k) => switchLevel(Number(k) as Level)}
ariaLabel="置顶层级"
/>
{level === 1 && (
<BoardSelect
boards={boards}
value={boardId}
onChange={setBoardId}
disabled={loading || saving}
/>
)}
</div>
<div className="panel overflow-hidden">
{loading ? (
<PinListSkeleton />
) : posts.length === 0 ? (
<AdminEmpty
icon={<Pin size={20} />}
title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"}
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
/>
) : (
<>
{/* 列表状态条:数量 + 保存状态/操作提示 */}
<div
className="flex items-center justify-between gap-3 px-4 py-2.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="text-[12.5px] font-semibold inline-flex items-center gap-1"
style={{ color: "var(--ink-2)" }}
>
共
<b className="tabular-nums" style={{ color: "var(--accent)" }}>
{posts.length}
</b>
篇置顶
</span>
<span className="meta text-[12px] inline-flex items-center gap-1.5">
{saving ? (
<>
<Loader2 size={12} className="animate-spin" aria-hidden />
保存中…
</>
) : (
"拖拽手柄或上下按钮调整,自动保存"
)}
</span>
</div>
<ul
onDragLeave={(e) => {
// 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropIndex(null);
}
}}
>
{posts.map((p, i) => (
<li
key={p.id}
className={`pin-row relative flex items-center gap-3 px-4 py-3${
dragIndex === i ? " is-dragging" : ""
}`}
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
onDragOver={(e) => onRowDragOver(e, i)}
onDrop={onRowDrop}
>
{/* 拖拽插入位置指示线 */}
{showLineAt(i) && (
<span
className="absolute left-3 right-3 -top-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{i === posts.length - 1 && showLineAt(posts.length) && (
<span
className="absolute left-3 right-3 -bottom-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{/* 拖拽手柄:键盘上下方向键同样可排序 */}
<span
role="button"
tabIndex={0}
aria-label={`拖拽调整「${p.title}」的顺序,也可使用上下方向键`}
className="pin-drag-handle w-5 h-8 -ml-1 inline-flex items-center justify-center rounded-md shrink-0"
draggable={!saving}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(i));
setDragIndex(i);
}}
onDragEnd={resetDrag}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
move(i, -1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(i, 1);
}
}}
>
<GripVertical size={14} aria-hidden />
</span>
{/* 名次徽章:前三名高亮 */}
<span
className="w-7 h-7 rounded-lg inline-flex items-center justify-center text-[12.5px] font-bold tabular-nums shrink-0"
style={
i < 3
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { background: "var(--panel-2)", color: "var(--ink-3)" }
}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<a
href={`/post/${p.id}`}
target="_blank"
rel="noreferrer"
className="text-[13.5px] font-semibold truncate block hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title}
</a>
<p className="meta text-[12px] mt-0.5 flex items-center gap-1.5 truncate">
{level === 2 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-px rounded-full shrink-0"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<FolderOpen size={10} aria-hidden />
{p.board_name}
</span>
)}
<span className="shrink-0">{formatRelative(p.created_at)}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<AdminIconButton
ariaLabel={`上移「${p.title}」`}
tip="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ChevronUp size={15} />
</AdminIconButton>
<AdminIconButton
ariaLabel={`下移「${p.title}」`}
tip="下移"
disabled={i === posts.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ChevronDown size={15} />
</AdminIconButton>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 select */
function BoardSelect({
boards,
value,
onChange,
disabled,
}: {
boards: BoardOpt[];
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
/** 键盘/悬停高亮下标 */
const [hi, setHi] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const current = boards.find((b) => b.id === value);
useEffect(() => {
if (!open) return;
setHi(Math.max(0, boards.findIndex((b) => b.id === value)));
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open, boards, value]);
// 高亮项保持在可视区内
useEffect(() => {
if (!open) return;
const el = listRef.current?.children[hi];
el?.scrollIntoView({ block: "nearest" });
}, [hi, open]);
const close = (refocus = false) => {
setOpen(false);
if (refocus) triggerRef.current?.focus();
};
const select = (id: number) => {
onChange(id);
close(true);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
close(true);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHi((v) => Math.min(boards.length - 1, v + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((v) => Math.max(0, v - 1));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (boards[hi]) select(boards[hi].id);
} else if (e.key === "Tab") {
close();
}
};
if (boards.length === 0) {
return <span className="meta text-[13px] py-2">无可用板块</span>;
}
return (
<div ref={ref} className="relative" onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="选择板块"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="pin-board-trigger h-9 pl-3 pr-2.5 rounded-lg inline-flex items-center gap-2 text-[13px] font-medium transition-[border-color,box-shadow] disabled:opacity-60 disabled:cursor-not-allowed"
style={{
minWidth: 176,
background: "var(--panel)",
border: `1px solid ${open ? "var(--accent)" : "var(--line-2)"}`,
color: "var(--ink)",
boxShadow: open ? "0 0 0 3px var(--accent-soft)" : "var(--shadow-sm)",
}}
>
<FolderOpen
size={14}
className="shrink-0 transition-colors"
style={{ color: open ? "var(--accent)" : "var(--ink-3)" }}
aria-hidden
/>
<span className="truncate max-w-44">{current?.name ?? "选择板块"}</span>
<ChevronDown
size={13}
className={`ml-auto shrink-0 transition-transform duration-150${
open ? " rotate-180" : ""
}`}
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
</button>
{open && (
<div
className="absolute z-30 mt-1.5 left-0 w-full min-w-52 rounded-xl overflow-hidden"
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul
ref={listRef}
role="listbox"
aria-label="板块列表"
className="max-h-60 overflow-y-auto p-1.5"
>
{boards.map((b, i) => {
const active = b.id === value;
return (
<li key={b.id}>
<button
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
onClick={() => select(b.id)}
onMouseEnter={() => setHi(i)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-[13px] transition-colors"
style={{
background: active
? "var(--nav-active-bg)"
: i === hi
? "var(--panel-2)"
: undefined,
color: active ? "var(--nav-active-ink)" : "var(--ink-2)",
}}
>
<span className="flex-1 truncate font-medium">{b.name}</span>
{active && <Check size={13} className="shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */
function PinListSkeleton() {
return (
<ul aria-hidden>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex items-center gap-3 px-4 py-3"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<span
className="w-5 h-7 -ml-1 rounded-md animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<span
className="w-7 h-7 rounded-lg animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<span
className="h-3.5 rounded animate-pulse"
style={{ background: "var(--panel-2)", width: `${52 - i * 9}%` }}
/>
<span
className="h-3 w-28 rounded animate-pulse"
style={{ background: "var(--panel-2)" }}
/>
</div>
</li>
))}
</ul>
);
}

View File

@@ -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 <Forbidden text="你所在的角色没有置顶管理权限,或尚未被授权任何板块。" />;
}
// 板块管理员只能看到自己被授权的板块
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 (
<PinnedAdmin
boards={boards.map((b) => ({ id: b.id, name: b.name }))}
canGlobal={isAdminOrAbove(me.user.role)}
/>
);
}

View File

@@ -11,6 +11,7 @@ import { toast } from "@/lib/toast";
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = { const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
home: { label: "首页", hint: "站点首页" }, home: { label: "首页", hint: "站点首页" },
library: { label: "书库", hint: "书库列表页入口" }, library: { label: "书库", hint: "书库列表页入口" },
snippets: { label: "片段", hint: "代码片段列表页入口(默认关闭,开启后显示在底栏)" },
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" }, compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" }, messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
me: { label: "我的", hint: "个人主页;未登录指向登录页" }, me: { label: "我的", hint: "个人主页;未登录指向登录页" },
@@ -22,6 +23,7 @@ function switchesOf(tabs: MobileTabKey[]): TabSwitches {
return { return {
home: tabs.includes("home"), home: tabs.includes("home"),
library: tabs.includes("library"), library: tabs.includes("library"),
snippets: tabs.includes("snippets"),
compose: tabs.includes("compose"), compose: tabs.includes("compose"),
messages: tabs.includes("messages"), messages: tabs.includes("messages"),
me: tabs.includes("me"), me: tabs.includes("me"),
@@ -166,7 +168,7 @@ export default function MobileTabBarPanel({
<ul className="admin-url-style-notes meta mt-1 text-[12px]"> <ul className="admin-url-style-notes meta mt-1 text-[12px]">
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li> <li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
<li>至少保留一个入口;全部关闭将无法保存。</li> <li>至少保留一个入口;全部关闭将无法保存。</li>
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li> <li>片段、排行榜未放进底栏时,移动端仍可从首页 ☰ 导航抽屉进入(片段默认关闭)。</li>
</ul> </ul>
</div> </div>
</AdminSettingsGroup> </AdminSettingsGroup>

View File

@@ -118,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<> <>
{a.pinned ? ( {a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶"> <span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
) : null} ) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span> <span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -59,7 +59,7 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
</span> </span>
{a.pinned ? ( {a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶"> <span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
) : null} ) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span> <span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
@@ -93,7 +93,7 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{a.pinned ? ( {a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶"> <span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
) : null} ) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span> <span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -688,7 +688,7 @@ export default function ChatRoomClient({
const isDirect = roomType === "direct"; const isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member"; const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
const canView = joined; const canView = joined;
const oversee = canOverseeChat(meRole); const oversee = canOverseeChat({ role: meRole, can_manage_messages: canManageMessages });
const siteOwner = isSiteOwner(meRole); const siteOwner = isSiteOwner(meRole);
const canModerateChat = const canModerateChat =
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect); oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);

View File

@@ -30,6 +30,14 @@
--gold: #825700; --gold: #825700;
--gold-soft: rgba(130, 87, 0, 0.13); --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: #c8281e;
--danger-soft: rgba(200, 40, 30, 0.09); --danger-soft: rgba(200, 40, 30, 0.09);
--ok: #137333; --ok: #137333;
@@ -154,6 +162,13 @@
--gold: #e0b259; --gold: #e0b259;
--gold-soft: rgba(224, 178, 89, 0.16); --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: #ff6b60;
--danger-soft: rgba(255, 107, 96, 0.15); --danger-soft: rgba(255, 107, 96, 0.15);
--ok: #58d18a; --ok: #58d18a;
@@ -5265,6 +5280,36 @@ a.j13-mention-token:hover {
outline-offset: 2px; outline-offset: 2px;
} }
/* 置顶管理:行悬停、拖拽手柄、板块下拉触发器焦点 */
.pin-row {
transition: background 0.15s ease, opacity 0.15s ease;
}
.pin-row:hover {
background: var(--item-hover-bg);
}
.pin-row.is-dragging {
opacity: 0.45;
}
.pin-drag-handle {
cursor: grab;
color: var(--ink-3);
transition: color 0.15s ease;
}
.pin-row:hover .pin-drag-handle {
color: var(--ink-2);
}
.pin-drag-handle:active {
cursor: grabbing;
}
.pin-drag-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.pin-board-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */ /* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
.admin-doc-page { .admin-doc-page {
width: 100%; width: 100%;
@@ -6592,23 +6637,33 @@ a.j13-mention-token:hover {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent); color: var(--accent);
} }
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */ /* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间。
形态:圆角方块 + 极淡底 + 同色发丝边 + 线性图标,避免实心图标在小尺寸下发糊。 */
.badge-pin, .badge-pin,
.badge-rec { .badge-rec {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box;
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 6px; border-radius: 7px;
border: 1px solid;
line-height: 0;
} }
.badge-pin { .badge-pin {
background: var(--clay-soft); color: var(--pin);
color: var(--clay); background: var(--pin-soft);
border-color: var(--pin-line);
}
/* 板块置顶:去掉底色只留描边,弱一档,与全站置顶拉开层级(不换色相,避免多一套语义色) */
.badge-pin[data-scope="board"] {
background: transparent;
} }
.badge-rec { .badge-rec {
background: var(--gold-soft); color: var(--rec);
color: var(--gold); background: var(--rec-soft);
border-color: var(--rec-line);
} }
.badge-pending { .badge-pending {
display: inline-flex; display: inline-flex;

View File

@@ -491,13 +491,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */} 使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0"> <div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && ( {post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶"> <span
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden /> className="badge-pin shrink-0 mt-2 sm:mt-3"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={13} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
{post.recommended && ( {post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐"> <span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Star size={13} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
<h1 <h1

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api"; import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetsPath } from "@/lib/urlStyle"; import { snippetsPath } from "@/lib/urlStyle";
import SnippetExplorer from "@/components/SnippetExplorer"; import SnippetExplorer from "@/components/SnippetExplorer";
@@ -17,6 +19,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function SnippetsPage() { export default async function SnippetsPage() {
const settings = await getPublicSettingsCached(); 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<ReturnType<typeof fetchSnippets>>["items"] = []; let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
let initialTotal = 0; let initialTotal = 0;
let languages: string[] = []; let languages: string[] = [];
@@ -43,6 +50,7 @@ export default async function SnippetsPage() {
languages={languages} languages={languages}
urlStyle={settings.url_style} urlStyle={settings.url_style}
stats={stats} stats={stats}
isLoggedIn={isLoggedIn}
/> />
</div> </div>
); );

View File

@@ -30,13 +30,14 @@ const SORTS: { key: string; label: string }[] = [
/** 帖子卡(发帖列表与收藏列表共用) */ /** 帖子卡(发帖列表与收藏列表共用) */
async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) { async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style; const urlStyle = (await getPublicSettingsCached()).url_style;
const pinned = post.pinned > 0; // 用户主页为全站语境:仅全站置顶(2)高亮,板块置顶(1)在主页不作置顶展示
const pinned = post.pinned >= 2;
return ( return (
<Link <Link
href={postPath(urlStyle, post.id)} href={postPath(urlStyle, post.id)}
className={`${ITEM_BASE} ${ className={`${ITEM_BASE} ${
pinned pinned
? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]" ? "bg-[color-mix(in_srgb,var(--pin)_9%,var(--panel))] border-[color-mix(in_srgb,var(--pin)_30%,var(--line))] hover:bg-[color-mix(in_srgb,var(--pin)_14%,var(--panel))]"
: "bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]" : "bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
}`} }`}
style={{ boxShadow: CARD_SHADOW }} style={{ boxShadow: CARD_SHADOW }}
@@ -45,10 +46,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
{pinned && ( {pinned && (
<span <span
className="badge-pin shrink-0 mt-0.5" className="badge-pin shrink-0 mt-0.5"
data-scope="global"
title="置顶" title="置顶"
aria-label="置顶" aria-label="置顶"
> >
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
<h2 <h2
@@ -59,7 +61,7 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
</h2> </h2>
{post.recommended && ( {post.recommended && (
<span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐"> <span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ import { Fragment, useEffect, useState } from "react";
import { import {
Home, Home,
BookOpen, BookOpen,
CodeXml,
PenSquare, PenSquare,
MessageSquare, MessageSquare,
User as UserIcon, User as UserIcon,
@@ -14,7 +15,7 @@ import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider"; import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider"; import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { userPath, libraryPath } from "@/lib/urlStyle"; import { userPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
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";
import type { MobileTabKey } from "@/lib/mobileTabs"; import type { MobileTabKey } from "@/lib/mobileTabs";
@@ -36,6 +37,7 @@ export default function MobileTabBar({
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand(); const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle); const libHref = libraryPath(urlStyle);
const snipHref = snippetsPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread); const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标 // 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -100,6 +102,16 @@ export default function MobileTabBar({
active={isActive((p) => p === libHref)} active={isActive((p) => p === libHref)}
/> />
), ),
snippets: (
<TabLink
href={snipHref}
icon={<CodeXml size={20} />}
label="片段"
active={isActive(
(p) => p === snipHref || p.startsWith("/snippets") || p.startsWith("/snippet-"),
)}
/>
),
compose: ( compose: (
<TabLink <TabLink
href={user ? "/compose" : "/login"} href={user ? "/compose" : "/login"}

View File

@@ -15,7 +15,7 @@ function formatTime(dateStr: string) {
/** /**
* 管理员置顶区:独立于普通帖子列表。 * 管理员置顶区:独立于普通帖子列表。
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。 * 焦橙语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
*/ */
export default async function PinnedPosts({ export default async function PinnedPosts({
posts, posts,
@@ -32,11 +32,11 @@ export default async function PinnedPosts({
<div className="flex items-center gap-2 mb-2.5 px-1"> <div className="flex items-center gap-2 mb-2.5 px-1">
<span <span
className="w-6 h-6 rounded-full flex items-center justify-center" className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ background: "var(--clay-soft)", color: "var(--clay)" }} style={{ background: "var(--pin-soft)", color: "var(--pin)" }}
> >
<Pin size={12} fill="currentColor" strokeWidth={2.4} /> <Pin size={12} fill="none" strokeWidth={2} />
</span> </span>
<h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--clay)" }}> <h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--pin)" }}>
置顶讨论 置顶讨论
</h2> </h2>
<span className="meta text-xs">管理员置顶 · {posts.length}</span> <span className="meta text-xs">管理员置顶 · {posts.length}</span>
@@ -78,7 +78,7 @@ export default async function PinnedPosts({
title="推荐" title="推荐"
aria-label="推荐" aria-label="推荐"
> >
<Star size={10} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Star size={10} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
</div> </div>
@@ -94,9 +94,9 @@ export default async function PinnedPosts({
<Pin <Pin
size={13} size={13}
className="shrink-0 hidden sm:block" className="shrink-0 hidden sm:block"
style={{ color: "var(--clay)" }} style={{ color: "var(--pin)" }}
fill="currentColor" fill="none"
strokeWidth={2.2} strokeWidth={2}
/> />
</Link> </Link>
))} ))}

View File

@@ -46,7 +46,7 @@ export default function PostActions({
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0); const [pinLevel, setPinLevel] = useState(pinned);
const [isRecommended, setIsRecommended] = useState(recommended); const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked); const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -89,6 +89,13 @@ export default function PostActions({
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked; const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove = const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId); !!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 删他人帖:走类型+理由;作者自删:简单确认 // staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther = const staffDeletingOther =
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole); !!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); setActing(true);
setOpen(false); setOpen(false);
try { try {
const res = await apiTogglePin(postId); // 再次点击当前级别 = 取消置顶
setIsPinned(res.pinned > 0); const next = pinLevel === level ? 0 : level;
toast(res.pinned > 0 ? "已置顶" : "已取消置顶", "ok"); 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(); router.refresh();
} catch { } catch (e) {
toast("操作失败,请稍后重试"); toast(e instanceof Error ? e.message : "操作失败,请稍后重试");
} finally { } finally {
setActing(false); setActing(false);
} }
@@ -220,25 +235,44 @@ export default function PostActions({
<div style={{ height: 1, background: "var(--line)" }} /> <div style={{ height: 1, background: "var(--line)" }} />
</> </>
)} )}
{showPin && (
<>
{showGlobalPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(2)}
className={itemCls}
style={pinLevel === 2 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">全局置顶</span>
{pinLevel === 2 && <Check size={14} className="shrink-0" />}
</button>
)}
{showBoardPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(1)}
className={itemCls}
style={pinLevel === 1 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">板块置顶</span>
{pinLevel === 1 && <Check size={14} className="shrink-0" />}
</button>
)}
</>
)}
{isAdmin && ( {isAdmin && (
<> <>
<button
type="button"
role="menuitem"
onClick={handlePin}
className={itemCls}
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
{isPinned && <Check size={14} className="shrink-0" />}
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
onClick={handleRecommend} onClick={handleRecommend}
className={itemCls} className={itemCls}
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }} style={isRecommended ? { color: "var(--rec)" } : { color: "var(--ink)" }}
> >
<Star size={14} className="shrink-0" /> <Star size={14} className="shrink-0" />
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span> <span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>

View File

@@ -73,13 +73,18 @@ export default async function PostRow({
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */} {/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && ( {post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶"> <span
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> className="badge-pin shrink-0"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
{post.recommended && ( {post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐"> <span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden /> <Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span> </span>
)} )}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden"> <div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">

View File

@@ -149,7 +149,6 @@ export default function RoleAccountModal({
const messagesChanged = const messagesChanged =
canGrantSuper && canGrantSuper &&
target.role !== ROLES.OWNER && target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages; manageMessages !== !!target.can_manage_messages;
/** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */ /** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */
const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN]; const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN];
@@ -462,9 +461,7 @@ export default function RoleAccountModal({
</div> </div>
)} )}
{canGrantSuper && {canGrantSuper && target.role !== ROLES.OWNER && (
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<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)" }}>
消息管理 消息管理
@@ -488,7 +485,7 @@ export default function RoleAccountModal({
站点群消息管理 站点群消息管理
</span> </span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}> <span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。 可在后台管理全部群聊消息(不含私聊);超管授予后另获全站会话(含私聊)监管;站长固有无需此项。
</span> </span>
</span> </span>
<span <span

View File

@@ -7,7 +7,7 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop"; import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar"; import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api"; import type { User } from "@/lib/api";
import { libraryPath, userPath } from "@/lib/urlStyle"; import { libraryPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider"; import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) { function pathIsAdmin(pathname: string | null) {
@@ -88,6 +88,7 @@ export default function SiteChrome({
const tabBarRoots: string[] = []; const tabBarRoots: string[] = [];
if (enabledTabs.has("home")) tabBarRoots.push("/"); if (enabledTabs.has("home")) tabBarRoots.push("/");
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style)); if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
if (enabledTabs.has("snippets")) tabBarRoots.push(snippetsPath(brand.url_style));
if (enabledTabs.has("messages")) tabBarRoots.push("/chat"); if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref); if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar = const showTabBar =

View File

@@ -11,7 +11,6 @@ import {
GitFork, GitFork,
Plus, Plus,
Lock, Lock,
Loader2,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
RotateCcw, RotateCcw,
@@ -22,7 +21,6 @@ import { snippetPath, type UrlStyle } from "@/lib/urlStyle";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { hasAuthCookieHint } from "@/lib/cookies";
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter"; import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
import SnippetLangSelect from "@/components/SnippetLangSelect"; import SnippetLangSelect from "@/components/SnippetLangSelect";
import SnippetHero from "@/components/SnippetHero"; import SnippetHero from "@/components/SnippetHero";
@@ -278,36 +276,20 @@ const SORT_OPTIONS: { value: SnippetSort; label: string }[] = [
{ value: "fork", label: "Fork" }, { value: "fork", label: "Fork" },
]; ];
function CardSkeleton() {
return (
<div
className="rounded-xl overflow-hidden animate-pulse"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="h-2.5 w-16 rounded" style={{ background: "var(--panel-2)" }} />
<div className="mt-2.5 h-3.5 w-3/4 rounded" style={{ background: "var(--panel-2)" }} />
</div>
<div className="mx-3.5 rounded-lg h-[128px]" style={{ background: "var(--panel-2)" }} />
<div className="px-3.5 py-3 mt-1">
<div className="h-2.5 w-2/3 rounded" style={{ background: "var(--panel-2)" }} />
</div>
</div>
);
}
export default function SnippetExplorer({ export default function SnippetExplorer({
initialItems, initialItems,
initialTotal, initialTotal,
languages, languages,
urlStyle, urlStyle,
stats, stats,
isLoggedIn,
}: { }: {
initialItems: SnippetListItem[]; initialItems: SnippetListItem[];
initialTotal: number; initialTotal: number;
languages: string[]; languages: string[];
urlStyle: UrlStyle; urlStyle: UrlStyle;
stats: SnippetStats | null; stats: SnippetStats | null;
isLoggedIn: boolean;
}) { }) {
const [items, setItems] = useState(initialItems); const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(initialTotal); const [total, setTotal] = useState(initialTotal);
@@ -317,7 +299,6 @@ export default function SnippetExplorer({
const [lang, setLang] = useState(""); const [lang, setLang] = useState("");
const [sort, setSort] = useState<SnippetSort>("latest"); const [sort, setSort] = useState<SnippetSort>("latest");
const [scope, setScope] = useState<"public" | "mine">("public"); const [scope, setScope] = useState<"public" | "mine">("public");
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
const pageSize = 12; const pageSize = 12;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -360,7 +341,7 @@ export default function SnippetExplorer({
return ( return (
<div> <div>
{/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */} {/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */}
<SnippetHero stats={stats} /> <SnippetHero stats={stats} isLoggedIn={isLoggedIn} />
{/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */} {/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */}
<div <div
@@ -472,30 +453,19 @@ export default function SnippetExplorer({
)} )}
</div> </div>
{/* 结果统计 */} {/* 结果统计:纯文本行,切换范围时数据原地更新,无任何加载指示 */}
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}> <div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<span className="tabular-nums" style={{ fontFamily: MONO }}> <span className="tabular-nums" style={{ fontFamily: MONO }}>
<span style={{ color: "var(--accent)" }}>$</span>{" "} <span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"} {scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
</span> </span>
)}
<span> <span>
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段 共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
{loading ? ",加载中…" : ""}
</span> </span>
</div> </div>
{/* 列表 / 空态 */} {/* 列表 / 空态:请求期间保留现有内容,新数据返回后原地替换,不闪空态 */}
{loading && items.length === 0 ? ( {items.length === 0 ? (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : items.length === 0 && !loading ? (
filtering ? ( filtering ? (
<div <div
className="mt-4 rounded-xl px-6 py-12 text-center" className="mt-4 rounded-xl px-6 py-12 text-center"

View File

@@ -8,7 +8,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { SnippetStats } from "@/lib/api"; import type { SnippetStats } from "@/lib/api";
import { snippetLangColor } from "@/lib/snippetLangColors"; import { snippetLangColor } from "@/lib/snippetLangColors";
import { hasAuthCookieHint } from "@/lib/cookies";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)"; const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
@@ -151,8 +150,13 @@ function LangTicker({ langs }: { langs: string[] }) {
); );
} }
export default function SnippetHero({ stats }: { stats: SnippetStats | null }) { export default function SnippetHero({
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false; stats,
isLoggedIn,
}: {
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const s = stats; const s = stats;
const total = useRollIn(s?.total_snippets ?? 0); const total = useRollIn(s?.total_snippets ?? 0);
const copies = useRollIn(s?.total_copies ?? 0); const copies = useRollIn(s?.total_copies ?? 0);

View File

@@ -32,6 +32,7 @@ import {
Images, Images,
Link2, Link2,
CodeXml, CodeXml,
Pin,
} from "lucide-react"; } from "lucide-react";
import type { User } from "@/lib/api"; import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api"; import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -95,6 +96,12 @@ const NAV_GROUPS: NavGroup[] = [
visible: (u) => canModerateAny(u), visible: (u) => canModerateAny(u),
pendingBadge: true, pendingBadge: true,
}, },
{
href: "/admin/pinned",
label: "置顶管理",
icon: Pin,
visible: (u) => canModerateAny(u),
},
{ {
href: "/admin/messages", href: "/admin/messages",
label: "消息管理", label: "消息管理",

View File

@@ -1,14 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react"; import { Bookmark, CodeXml, Home, LayoutGrid, Menu, ShieldCheck, Trophy } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api"; import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles"; import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon"; import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider"; import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle"; import { leaderboardPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers"; import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 // 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -26,9 +26,16 @@ export function WorkspaceNavPanel({
user?: User | null; user?: User | null;
}) { }) {
const params = useSearchParams(); const params = useSearchParams();
const pathname = usePathname();
const urlStyle = useUrlStyle(); const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0; const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role); const showAdmin = !!user && isStaff(user.role);
const snipHref = snippetsPath(urlStyle);
const lbHref = leaderboardPath(urlStyle);
// 与 Header 高亮规则一致:覆盖标准路径与伪静态(/snippet-*.html、/rank.html 等)
const snippetsActive =
!!pathname && (pathname.startsWith("/snippets") || pathname.startsWith("/snippet-"));
const leaderboardActive = !!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`));
return ( return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录"> <section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
@@ -49,6 +56,37 @@ export function WorkspaceNavPanel({
<span className="j13-bcard-name flex-1 truncate">全部讨论</span> <span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link> </Link>
{/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径),
桌面端由顶部导航承担,左栏不重复 */}
<Link
href={snipHref}
className="j13-bcard lg:hidden"
aria-current={snippetsActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<CodeXml size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">代码片段</span>
</Link>
<Link
href={lbHref}
className="j13-bcard lg:hidden"
aria-current={leaderboardActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Trophy size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">排行榜</span>
</Link>
{user ? ( {user ? (
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard"> <Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
<span <span

View File

@@ -81,7 +81,7 @@ export interface User {
role: string; role: string;
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮) // 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
board_ids?: number[]; board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段) // 站点消息管理(仅站长可授;超管亦需授予,站长固有监管不必依赖此字段)
can_manage_messages?: boolean; can_manage_messages?: boolean;
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐) // 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
perms?: string[]; perms?: string[];
@@ -2261,8 +2261,8 @@ export async function apiDeletePost(
return res.json(); return res.json();
} }
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> { export async function apiTogglePin(postId: string, level: 0 | 1 | 2): Promise<{ pinned: number }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, { const res = await fetchWithRefresh(`/api/posts/${postId}/pin?level=${level}`, {
method: "PUT", method: "PUT",
headers: clientHeaders(), headers: clientHeaders(),
}); });
@@ -4059,6 +4059,38 @@ export const apiAdminRestoreComment = (id: number) =>
export const apiAdminPurgeComment = (id: number) => export const apiAdminPurgeComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE"); 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 { export interface CommentEditHistoryItem {
id: number; id: number;
editor: { editor: {

View File

@@ -7,6 +7,10 @@ describe("normalizeMobileTabs", () => {
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]); assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]); assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
}); });
it("snippets 为白名单合法项,显式启用时保留", () => {
assert.deepEqual(normalizeMobileTabs(["snippets"]), ["snippets"]);
assert.deepEqual(normalizeMobileTabs(["home", "snippets", "me"]), ["home", "snippets", "me"]);
});
it("去重与去空白", () => { it("去重与去空白", () => {
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]); assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
}); });
@@ -16,15 +20,18 @@ describe("normalizeMobileTabs", () => {
it("非法元素被剔除", () => { it("非法元素被剔除", () => {
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]); assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
}); });
it("空数组/非法输入回落默认全量", () => { it("空数组/非法输入回落默认集合", () => {
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
}); });
it("默认全量与固定顺序一致", () => { it("白名单与默认集合:snippets 在白名单内但默认不启用", () => {
assert.equal(MOBILE_TABS.length, 6);
assert.ok(MOBILE_TABS.includes("snippets"));
assert.equal(DEFAULT_MOBILE_TABS.length, 5); assert.equal(DEFAULT_MOBILE_TABS.length, 5);
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]); assert.ok(!DEFAULT_MOBILE_TABS.includes("snippets"));
assert.deepEqual(DEFAULT_MOBILE_TABS, MOBILE_TABS.filter((k) => k !== "snippets"));
}); });
}); });

View File

@@ -1,17 +1,20 @@
/** /**
* 移动端底栏配置的单一事实源: * 移动端底栏配置的单一事实源:
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。 * 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。 * 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认集合。
*
* MOBILE_TABS = 白名单(同时是管理面板排序基准),含全部可选 key;
* DEFAULT_MOBILE_TABS = 缺省启用集合,是白名单的子集(snippets 默认关闭,需管理员手动开启)。
*/ */
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const; export const MOBILE_TABS = ["home", "library", "snippets", "compose", "messages", "me"] as const;
export type MobileTabKey = (typeof MOBILE_TABS)[number]; export type MobileTabKey = (typeof MOBILE_TABS)[number];
/** 底栏默认全量配置 */ /** 底栏默认启用配置(白名单子集:snippets 默认不启用,移动端入口由首页导航抽屉承担) */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS]; export const DEFAULT_MOBILE_TABS: MobileTabKey[] = ["home", "library", "compose", "messages", "me"];
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */ /** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认集合 */
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] { export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS]; if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
const seen = new Set<string>(); const seen = new Set<string>();

View File

@@ -76,9 +76,11 @@ export const PERM_META: Record<
[PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" }, [PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" },
}; };
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */ /** 全站消息监管(可见全部群含私聊、撤回任意消息):站长固有;超管需先被授予「消息管理」 */
export function canOverseeChat(role?: string | null): boolean { export function canOverseeChat(user?: RoleUserLike | null): boolean {
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN; 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; 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 { export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean {
if (!user) return false; if (!user) return false;
if (canOverseeChat(user.role)) return true; if (isOwner(user.role)) return true;
return !!user.can_manage_messages; return !!user.can_manage_messages;
} }
@@ -201,7 +203,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,
@@ -219,7 +221,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,