feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ out/
.vercel/
*.tsbuildinfo
next-debug.log*
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
frontend/public/fonts/
# ===== Go 构建产物 =====
*.exe

View File

@@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
func TestParseSiteURLFromIni(t *testing.T) {
work := t.TempDir()
body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
body := "[security]\nJWT_SECRET = " + testJWTSecret + "\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
t.Fatal(err)
}

View File

@@ -119,7 +119,7 @@ func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bo
return "", true // 字段不存在,视为未上传
}
if fh.Size > service.LegacyZipMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"})
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label + "不能超过 128MB"})
return "", false
}
path, err := saveMultipartToTemp(fh, pattern)

View File

@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": user})
}
// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用)
func (h *Handlers) AdminGetUserPerms(c *gin.Context) {
id, ok := parseAdminUserID(c)
if !ok {
return
}
perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id)
if err != nil {
respondAdminUserError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"perm_overrides": perms})
}
// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长)
func (h *Handlers) AdminSetUserPerms(c *gin.Context) {
id, ok := parseAdminUserID(c)
if !ok {
return
}
var body struct {
Perms []string `json:"perms"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms)
if err != nil {
respondAdminUserError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
}
// AdminSetUserBan 封禁 / 解封用户
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
id, ok := parseAdminUserID(c)

View File

@@ -382,8 +382,11 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
"role": user.Role,
"board_ids": boardIDs,
"can_manage_messages": user.CanManageMessages,
"level": user.Level,
"total_points": user.TotalPoints,
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
// 最终权限以后端 RequirePerm 校验为准
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
"level": user.Level,
"total_points": user.TotalPoints,
}
if badges != nil {
body["badges"] = badges

View File

@@ -51,11 +51,11 @@ func (h *Handlers) BoardSidebar(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"board": data.Board,
"moderators": data.Moderators,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"checkin": data.Checkin,
"board": data.Board,
"moderators": data.Moderators,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"checkin": data.Checkin,
})
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
@@ -42,6 +43,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
return uint(id), true
}
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
func formatMuteDurationCN(minutes int) string {
switch {
case minutes >= 30*24*60:
return "30 天"
case minutes >= 7*24*60:
return "7 天"
case minutes >= 3*24*60:
return "3 天"
case minutes >= 24*60:
return "1 天"
case minutes >= 12*60:
return "12 小时"
case minutes >= 60:
return "1 小时"
default:
return strconv.Itoa(minutes) + " 分钟"
}
}
func (h *Handlers) chatOversee(userID uint) bool {
actor, err := h.Auth.LoadActor(userID)
if err != nil || actor == nil {
@@ -322,7 +343,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SetChatMemberMute 禁言/解禁成员
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
@@ -335,17 +356,59 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
return
}
var body struct {
Muted bool `json:"muted"`
Muted bool `json:"muted"`
DurationMinutes int `json:"duration_minutes"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
duration := time.Duration(body.DurationMinutes) * time.Minute
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
var mutedUntil *time.Time
if body.Muted && body.DurationMinutes > 0 {
t := time.Now().Add(duration)
mutedUntil = &t
}
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
var sysContent string
if body.Muted {
if body.DurationMinutes > 0 {
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
} else {
sysContent = targetName + " 已被禁言 永久"
}
} else {
sysContent = targetName + " 的禁言已被解除"
}
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染)
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: gin.H{
"message": sysMsg,
},
})
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMemberMuted,
Data: gin.H{
"room_id": roomID,
"user_id": uint(targetID),
"muted": body.Muted,
"muted_until": mutedUntil,
"duration_minutes": body.DurationMinutes,
"target_name": targetName,
"operator_id": claims.ID,
},
})
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
}
// SetChatMemberRole 站长任命/撤销群管理员

View File

@@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) {
})
}
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
func (h *Handlers) CommentLocation(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
// 待审/被拒帖子的评论不对公众开放
var viewerID uint
var loadActor func() *service.Actor
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
floor := h.Comment.FloorNumber(uint(id), uint(cid))
if floor <= 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"floor": floor})
}
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000"`

View File

@@ -10,36 +10,36 @@ import (
// Handlers 聚合所有服务引用
type Handlers struct {
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Favorite *service.FavoriteService
Follow *service.FollowService
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
SitePage *service.SitePageService
Upload *service.UploadService
PostFile *service.PostFileService
Points *service.PointsService
Setting *service.SettingService
AdminUser *service.AdminUserService
LegacyImport *service.LegacyImportService
Moderation *service.ModerationService
Chat *service.ChatService
Visit *service.VisitStatsService
Analytics *service.AnalyticsService
HidePwd *service.HidePasswordCookie
Ads *service.AdService
Sidebar *service.SidebarService
FriendLink *service.FriendLinkService
Badge *service.BadgeService
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Favorite *service.FavoriteService
Follow *service.FollowService
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
SitePage *service.SitePageService
Upload *service.UploadService
PostFile *service.PostFileService
Points *service.PointsService
Setting *service.SettingService
AdminUser *service.AdminUserService
LegacyImport *service.LegacyImportService
Moderation *service.ModerationService
Chat *service.ChatService
Visit *service.VisitStatsService
Analytics *service.AnalyticsService
HidePwd *service.HidePasswordCookie
Ads *service.AdService
Sidebar *service.SidebarService
FriendLink *service.FriendLinkService
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
LibrarySvc *service.LibraryService
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/url"
"os"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
@@ -91,7 +92,11 @@ func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
d, err := h.LibrarySvc.Create(&in)
var creatorID uint
if claims := middleware.CurrentUser(c); claims != nil {
creatorID = claims.ID
}
d, err := h.LibrarySvc.Create(&in, creatorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -242,6 +247,109 @@ func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ---------- 管理端:导出(迁移 / 备份) ----------
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
if err := exp.WriteZip(c.Writer); err != nil {
_ = c.Error(err)
}
}
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
exp, err := h.LibrarySvc.BuildAllExport()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
const overhead = 64 << 10 // multipart 边界开销
limit := int64(service.LibraryImportMaxBytes) + overhead
if c.Request.ContentLength > limit {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
return
}
if fh.Size > service.LibraryImportMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
return
}
mode := c.PostForm("mode")
if mode == "" {
mode = service.LibraryImportModeCreate
}
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
return
}
defer os.Remove(tmpPath)
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
if err != nil {
switch {
case errors.Is(err, service.ErrLibraryImportBadZip),
errors.Is(err, service.ErrLibraryImportFormat),
errors.Is(err, service.ErrLibraryImportVer),
errors.Is(err, service.ErrLibraryImportEmpty),
errors.Is(err, service.ErrLibraryImportMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"report": rep})
}
// ---------- 管理端:章节 ----------
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)

View File

@@ -5,9 +5,18 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
// notificationView 通知列表项:附带关联评论的楼层相对锚点,
// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。
type notificationView struct {
*model.Notification
CommentFloor uint `json:"comment_floor"`
CommentIsRoot bool `json:"comment_is_root"`
}
// Notifications 获取当前用户的通知列表(分页)
func (h *Handlers) Notifications(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
commentIDs := make([]uint, 0, len(list))
for _, n := range list {
if n.CommentID > 0 {
commentIDs = append(commentIDs, n.CommentID)
}
}
anchors := h.Comment.AnchorsByCommentIDs(commentIDs)
views := make([]notificationView, 0, len(list))
for i := range list {
v := notificationView{Notification: &list[i]}
if a, ok := anchors[list[i].CommentID]; ok {
v.CommentFloor = a.Floor
v.CommentIsRoot = a.IsRoot
}
views = append(views, v)
}
c.JSON(http.StatusOK, gin.H{
"notifications": nonNilSlice(list),
"notifications": nonNilSlice(views),
"total": total,
"page": page,
"size": size,

View File

@@ -313,7 +313,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
defer r.Close()
c.Header("Content-Type", m)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "private, no-store")
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
c.Header("Cache-Control", "public, max-age=86400")
c.Status(200)
_, _ = io.Copy(c.Writer, r)
}

View File

@@ -29,20 +29,20 @@ func (h *Handlers) Overview(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"announcements_total": data.AnnouncementsTotal,
"sidebar_pages": data.SidebarPages,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"ads": data.Ads,
"ads_panel_title": data.AdsPanelTitle,
"ads_enabled": data.AdsEnabled,
"sponsors": data.Sponsors,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"announcements_total": data.AnnouncementsTotal,
"sidebar_pages": data.SidebarPages,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"ads": data.Ads,
"ads_panel_title": data.AdsPanelTitle,
"ads_enabled": data.AdsEnabled,
"sponsors": data.Sponsors,
"sponsors_panel_title": data.SponsorsPanelTitle,
"sponsors_enabled": data.SponsorsEnabled,
"sponsors_enabled": data.SponsorsEnabled,
})
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -102,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
}
defer f.Close()
att, err := h.Upload.SaveImage(claims.ID, f)
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
@@ -314,7 +315,9 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
var req struct {
AttachmentID uint `json:"attachment_id"`
@@ -328,7 +331,14 @@ func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID)
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -404,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
c.Data(http.StatusOK, "image/webp", data)
}
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
func (h *Handlers) ImageVariant(c *gin.Context) {
w, err := strconv.Atoi(c.Query("w"))
if err != nil || !service.IsVariantWidth(w) {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
return
}
data, m, err := h.Upload.Variant(c.Query("u"), w)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
return
}
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, m, data)
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"

View File

@@ -144,11 +144,11 @@ func (h *Handlers) UserProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"user": userPayload,
"stats": gin.H{
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"favorite_count": favoriteCount,
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"favorite_count": favoriteCount,
"following_count": followingCount,
},
"posts": nonNilSlice(posts),

View File

@@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc {
}
}
// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后
// RequireActor 必须登录并加载实时 Actor(不要求管理角色),
// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用;
// 功能点鉴权由后续 RequirePerm 完成
func (m *AuthMiddleware) RequireActor() gin.HandlerFunc {
return func(c *gin.Context) {
claims, ok := m.parseToken(c)
if !ok {
if c.GetBool(AccountBannedKey) {
bannedJSON(c)
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
actor, err := m.auth.LoadActor(claims.ID)
if err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"})
return
}
c.Set("user", claims)
c.Set(ActorKey, actor)
c.Next()
}
}
// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
return func(c *gin.Context) {
actor := CurrentActor(c)

View File

@@ -0,0 +1,19 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、
// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全
// immutable。PageSpeed「缓存生命周期」修复项。
func UploadStaticCache() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
c.Next()
}
}

View File

@@ -19,26 +19,26 @@ const (
// Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏)
type Ad struct {
ID uint `gorm:"primaryKey" json:"id"`
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
LinkURL string `gorm:"size:512;not null" json:"link_url"`
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
Title string `gorm:"size:64" json:"title"` // 文字广告
TextColor string `gorm:"size:16" json:"text_color"`
BgColor string `gorm:"size:16" json:"bg_color"`
DurationDays int `gorm:"not null" json:"duration_days"`
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
BuyerNote string `gorm:"size:200" json:"buyer_note"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
RejectReason string `gorm:"size:200" json:"reject_reason"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
SubmitIP string `gorm:"size:45" json:"-"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewedBy *uint `json:"reviewed_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
LinkURL string `gorm:"size:512;not null" json:"link_url"`
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
Title string `gorm:"size:64" json:"title"` // 文字广告
TextColor string `gorm:"size:16" json:"text_color"`
BgColor string `gorm:"size:16" json:"bg_color"`
DurationDays int `gorm:"not null" json:"duration_days"`
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
BuyerNote string `gorm:"size:200" json:"buyer_note"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
RejectReason string `gorm:"size:200" json:"reject_reason"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
SubmitIP string `gorm:"size:45" json:"-"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewedBy *uint `json:"reviewed_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

View File

@@ -78,6 +78,9 @@ func InitDB(dsn string) error {
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
backfillUserTotalPoints(db)
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
backfillLibraryCreator(db)
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
@@ -146,6 +149,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
pairs := []struct{ table, constraint string }{
{"chat_rooms", "fk_chat_rooms_last_message"},
{"notifications", "fk_notifications_room"},
{"chat_messages", "fk_chat_messages_sender"},
}
for _, p := range pairs {
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
@@ -368,6 +372,29 @@ func backfillUserTotalPoints(db *gorm.DB) {
}
}
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
// 站长不存在(空库)时跳过,等下次启动自愈。
func backfillLibraryCreator(db *gorm.DB) {
var owner User
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
if err != nil {
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
return
}
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
if res.Error != nil {
log.Printf("[model] 回填书库创建者失败: %v", res.Error)
return
}
if res.RowsAffected > 0 {
log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected)
}
}
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。

View File

@@ -122,6 +122,7 @@ type User struct {
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
@@ -323,9 +324,9 @@ const (
PointReasonBountyEscrow = "bounty_escrow"
PointReasonBountyRefund = "bounty_refund"
PointReasonBountyAward = "bounty_award"
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
@@ -486,7 +487,7 @@ type Notification struct {
IsRead bool `gorm:"default:false;index" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
@@ -524,19 +525,25 @@ type SitePage struct {
// LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费)
type LibraryDoc struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
Published bool `gorm:"not null;index" json:"published"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测)
CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测)
Published bool `gorm:"not null;index" json:"published"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用
Author string `gorm:"size:100;not null;default:''" json:"author"`
// CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变
CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录)
@@ -590,20 +597,20 @@ type SidebarWidget struct {
// FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见
type FriendLink struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
URL string `gorm:"size:512;not null" json:"url"`
Description string `gorm:"size:255" json:"description"`
LogoURL string `gorm:"size:512" json:"logo_url"`
Category string `gorm:"size:32;index" json:"category"`
ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected
ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil
RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
URL string `gorm:"size:512;not null" json:"url"`
Description string `gorm:"size:255" json:"description"`
LogoURL string `gorm:"size:512" json:"logo_url"`
Category string `gorm:"size:32;index" json:"category"`
ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected
ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil
RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
@@ -652,11 +659,19 @@ const (
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
)
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
const (
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
URL string `gorm:"size:512;not null" json:"url"`
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
@@ -736,6 +751,7 @@ type ChatRoomMember struct {
Role string `gorm:"size:16;not null;default:member" json:"role"`
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -747,17 +763,18 @@ type ChatRoomMember struct {
type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
SenderID uint `gorm:"index;not null" json:"sender_id"`
SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true)
Content string `gorm:"type:varchar(2000);not null" json:"content"`
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
RecalledAt *time.Time `json:"recalled_at,omitempty"`
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
}

View File

@@ -18,18 +18,19 @@ import (
// 事件类型:前后端共享的单层 JSON 协议契约,禁止手拼 JSON / 双重编码
const (
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
EventPong = "pong" // 应用层心跳应答
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
EventPong = "pong" // 应用层心跳应答
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
)
// RoomStaff 管理团队房间(板块管理员及以上)

View File

@@ -101,7 +101,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
postFileSvc.WithOperations(ops)
pointsSvc := service.NewPointsService(model.DB)
leaderboardSvc := service.NewLeaderboardService(model.DB)
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).WithSetting(settingSvc)
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
WithSetting(settingSvc).
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -183,6 +185,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 挂在 OptionalAuth 之后可顺带拿到登录用户
r.Use(middleware.VisitCapture(visitSvc))
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
r.Use(middleware.UploadStaticCache())
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
@@ -209,7 +214,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
pubAPI.GET("/posts/:id/comments", h.PostComments)
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
pubAPI.GET("/users/:id", h.UserProfile)
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
@@ -226,6 +230,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/library/:slug", h.LibraryDetail)
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
pubAPI.GET("/site-state", h.SiteState)
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
pubAPI.GET("/img", h.ImageVariant)
pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
@@ -376,23 +382,34 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
// (见下方 msgAPI)
// 站点公告文章管理(管理员及以上)
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
// 站点公告文章管理(PermAnnouncements)
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
announceAPI.GET("/announcements", h.AdminListAnnouncements)
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
announceAPI.GET("/pages", h.AdminListSitePages)
announceAPI.POST("/pages", h.AdminCreateSitePage)
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
announceAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
// 书库管理(管理员及以上,同公告/单页权限)
libraryAPI := staffAPI.Group("/library", authMW.RequirePerm(service.PermAnnouncements))
// 站点单页管理(PermPages)
pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages))
pagesAPI.GET("/pages", h.AdminListSitePages)
pagesAPI.POST("/pages", h.AdminCreateSitePage)
pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
// 书库管理(PermLibrary)
libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary))
{
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
libraryAPI.GET("/export", h.AdminExportAllLibrary)
libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary)
libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc)
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
@@ -409,8 +426,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
}
// 广告位管理(管理员及以上)
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
// 广告位管理(PermAds)
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
adsAPI.GET("/ads", h.AdminListAds)
@@ -477,6 +494,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users", h.AdminListUsers)
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms)
usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms)
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页

View File

@@ -1,6 +1,8 @@
package service
import (
"encoding/json"
"sort"
"strings"
"github.com/freefire/jiang13-bbs/model"
@@ -10,21 +12,119 @@ import (
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
const (
PermUsers = "users" // 用户与权限管理(超管/站长)
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
PermBoards = "boards" // 板块管理(仅站长)
)
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
// ValidPermOverride 权限码是否可被账号级授予
func ValidPermOverride(p string) bool {
for _, g := range GrantablePerms {
if p == g {
return true
}
}
return false
}
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
func NormalizePermOverrides(perms []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(perms))
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] || !ValidPermOverride(p) {
continue
}
seen[p] = true
out = append(out, p)
}
sort.Strings(out)
return out
}
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
func ParsePermOverrides(raw string) []string {
if raw == "" {
return []string{}
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return []string{}
}
return NormalizePermOverrides(arr)
}
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
func SerializePermOverrides(perms []string) string {
norm := NormalizePermOverrides(perms)
b, err := json.Marshal(norm)
if err != nil {
return "[]"
}
return string(b)
}
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
func roleDefaultPerm(role model.Role, p string) bool {
switch p {
case PermBoards:
return role == model.RoleOwner
case PermUsers, PermSettings:
return role == model.RoleSuperAdmin || role == model.RoleOwner
case PermAnnouncements, PermPages, PermLibrary, PermAds:
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
case PermModeration:
return model.IsStaff(role)
}
return false
}
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
set := map[string]bool{}
all := append([]string{
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
PermSettings, PermModeration, PermBoards,
}, GrantablePerms...)
for _, p := range all {
if roleDefaultPerm(role, p) {
set[p] = true
}
}
for _, p := range ParsePermOverrides(permOverridesRaw) {
set[p] = true
}
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
set[PermMessages] = true
}
out := make([]string, 0, len(set))
for p := range set {
out = append(out, p)
}
sort.Strings(out)
return out
}
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
type Actor struct {
ID uint
Username string
Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
}
// IsStaff 是否管理团队成员
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
return a != nil && model.IsStaff(a.Role)
}
// HasPerm 是否拥有某后台功能点
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
func (a *Actor) HasPerm(p string) bool {
if a == nil {
return false
}
switch p {
case PermBoards:
return a.Role == model.RoleOwner
case PermUsers, PermSettings:
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
case PermAnnouncements:
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
case PermModeration:
return a.IsStaff()
case PermMessages:
if roleDefaultPerm(a.Role, p) {
return true
}
if p == PermMessages {
return a.HasSiteMessagePerm()
}
for _, e := range a.ExtraPerms {
if e == p {
return true
}
}
return false
}
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
return a.HasPerm(PermUsers)
}
// LoadActor 读取用户实时角色与板块授权
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
var u model.User
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
return nil, err
}
actor := &Actor{
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
CanManageMessages: u.CanManageMessages,
ExtraPerms: ParsePermOverrides(u.PermOverrides),
}
if u.Role == model.RoleBoardAdmin {
var ids []uint

View File

@@ -17,25 +17,25 @@ import (
const (
SettingKeyAdsConfig = "ads_config"
AdMaxTitleRunes = 24
AdMaxNoteRunes = 100
AdMaxImageShow = 5
AdMaxTextShow = 6
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
AdDefaultTitle = "自助推广"
AdMaxTitleRunes = 24
AdMaxNoteRunes = 100
AdMaxImageShow = 5
AdMaxTextShow = 6
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
AdDefaultTitle = "自助推广"
)
var (
ErrAdNotFound = errors.New("广告不存在")
ErrAdForbidden = errors.New("无权操作")
ErrAdInvalid = errors.New("广告参数无效")
ErrAdCaptcha = errors.New("验证码错误或已过期")
ErrAdDisabled = errors.New("自助推广暂未开放")
ErrAdBadPayment = errors.New("请选择有效的支付方式")
ErrAdBadDuration = errors.New("请选择有效的投放时长")
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
ErrAdNotFound = errors.New("广告不存在")
ErrAdForbidden = errors.New("无权操作")
ErrAdInvalid = errors.New("广告参数无效")
ErrAdCaptcha = errors.New("验证码错误或已过期")
ErrAdDisabled = errors.New("自助推广暂未开放")
ErrAdBadPayment = errors.New("请选择有效的支付方式")
ErrAdBadDuration = errors.New("请选择有效的投放时长")
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
)
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)

View File

@@ -49,11 +49,11 @@ const (
// AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab)
type AdminContentCounts struct {
PostsLive int64 `json:"posts_live"`
PostsPending int64 `json:"posts_pending"`
PostsRejected int64 `json:"posts_rejected"`
PostsDeleted int64 `json:"posts_deleted"`
CommentsLive int64 `json:"comments_live"`
PostsLive int64 `json:"posts_live"`
PostsPending int64 `json:"posts_pending"`
PostsRejected int64 `json:"posts_rejected"`
PostsDeleted int64 `json:"posts_deleted"`
CommentsLive int64 `json:"comments_live"`
CommentsPending int64 `json:"comments_pending"`
CommentsRejected int64 `json:"comments_rejected"`
CommentsDeleted int64 `json:"comments_deleted"`
@@ -61,32 +61,34 @@ type AdminContentCounts struct {
// AdminContentPost 后台帖子列表项
type AdminContentPost struct {
ID uint `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CommentCount int `json:"comment_count"`
BoardID uint `json:"board_id"`
ID uint `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CommentCount int `json:"comment_count"`
BoardID uint `json:"board_id"`
Board model.Board `json:"board"`
User model.User `json:"user"`
User model.User `json:"user"`
}
// AdminContentComment 后台评论列表项
type AdminContentComment struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
BoardID uint `json:"board_id"`
Content string `json:"content"`
Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
BoardID uint `json:"board_id"`
Content string `json:"content"`
Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Floor uint `json:"floor"` // 所属楼层相对序号
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
Board model.Board `json:"board"`
User model.User `json:"user"`
User model.User `json:"user"`
}
func normalizeAdminStatus(raw string) AdminContentStatus {
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
}
items := make([]AdminContentComment, 0, len(rows))
commentIDs := make([]uint, 0, len(rows))
for _, r := range rows {
commentIDs = append(commentIDs, r.ID)
}
anchors := CommentAnchors(s.db, commentIDs)
for _, r := range rows {
it := AdminContentComment{
ID: r.ID,
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
Board: boards[r.BoardID],
User: users[r.UserID],
}
if a, ok := anchors[r.ID]; ok {
it.Floor = a.Floor
it.IsRoot = a.IsRoot
}
if r.DeletedAt.Valid {
it.Deleted = true
t := r.DeletedAt.Time

View File

@@ -54,6 +54,7 @@ type AdminUserItem struct {
BoardIDs []uint `json:"board_ids"`
Banned bool `json:"banned"`
CanManageMessages bool `json:"can_manage_messages"`
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
Points int `json:"points"`
@@ -71,7 +72,7 @@ type AdminUserItem struct {
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
type AdminUserSummary struct {
Online int64 `json:"online"`
InCooldown int64 `json:"in_cooldown"`
InCooldown int64 `json:"in_cooldown"`
Active7d int64 `json:"active_7d"`
FailedLogins24h int64 `json:"failed_logins_24h"`
Total int64 `json:"total"`
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
BoardIDs: []uint{},
Banned: u.Banned,
CanManageMessages: u.CanManageMessages,
PermOverrides: ParsePermOverrides(u.PermOverrides),
Points: u.Points,
TotalPoints: u.TotalPoints,
Level: u.Level,
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
return err
}
}
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
return err
}
}
return nil
})
if err != nil {
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
return s.getItem(s.db, targetID)
}
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
if operator == nil || !operator.HasPerm(PermUsers) {
return nil, ErrCannotAssignRole
}
var u model.User
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
return nil, err
}
return ParsePermOverrides(u.PermOverrides), nil
}
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
if operator == nil || !operator.HasPerm(PermUsers) {
return nil, ErrCannotAssignRole
}
if operator.ID == targetID {
return nil, ErrAdminSelfAction
}
norm := NormalizePermOverrides(perms)
err := s.db.Transaction(func(tx *gorm.DB) error {
var u model.User
if err := tx.First(&u, targetID).Error; err != nil {
return err
}
if u.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
norm = []string{}
}
raw := SerializePermOverrides(norm)
if u.PermOverrides == raw {
return nil
}
return tx.Model(&u).Update("perm_overrides", raw).Error
})
if err != nil {
return nil, err
}
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
@@ -564,12 +619,12 @@ func normalizeAuditPage(page, size int) (int, int) {
// AdminUserAuditProfile 站长查看用户档案摘要
type AdminUserAuditProfile struct {
User AdminUserItem `json:"user"`
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
PublishedPosts int64 `json:"published_posts"`
PublishedComments int64 `json:"published_comments"`
User AdminUserItem `json:"user"`
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
PublishedPosts int64 `json:"published_posts"`
PublishedComments int64 `json:"published_comments"`
}
// AdminAuditPostItem 审计帖子行
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
Floor uint `json:"floor"`
IsRoot bool `json:"is_root"`
}
// AdminAuditMessageItem 审计聊天消息行
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
return nil, 0, page, err
}
commentIDs := make([]uint, 0, len(comments))
for _, c := range comments {
commentIDs = append(commentIDs, c.ID)
}
anchors := CommentAnchors(s.db, commentIDs)
items := make([]AdminAuditCommentItem, 0, len(comments))
for _, c := range comments {
it := AdminAuditCommentItem{
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
Status: c.Status,
CreatedAt: c.CreatedAt,
}
if a, ok := anchors[c.ID]; ok {
it.Floor = a.Floor
it.IsRoot = a.IsRoot
}
if c.DeletedAt.Valid {
it.Deleted = true
t := c.DeletedAt.Time

View File

@@ -75,8 +75,8 @@ type RoomView struct {
Joined bool `json:"joined"`
MyRole string `json:"my_role"`
UnreadCount int64 `json:"unread_count"`
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct)
}
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
if err != nil {
return nil, err
}
// 过期禁言自动解除:惰性清理,不依赖定时任务
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
m.Muted = false
m.MutedUntil = nil
_ = tx.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, userID).
Select("muted", "muted_until").
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
}
return &m, nil
}
@@ -758,44 +767,85 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
return s.removeMember(roomID, targetID)
}
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true),
// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
msg := &model.ChatMessage{
RoomID: roomID,
SenderID: 0,
Content: content,
IsSystem: true,
CreatedAt: time.Now(),
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(msg).Error; err != nil {
return err
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
Updates(map[string]interface{}{
"last_message_id": msg.ID,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return msg, nil
}
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
// 返回目标用户昵称(供广播系统提示使用)。
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
room, err := s.getRoom(roomID)
if err != nil {
return err
return "", err
}
if room.RoomType == model.ChatRoomTypeDirect {
return ErrChatOwnerOnly
return "", ErrChatOwnerOnly
}
target, err := s.membership(s.db, roomID, targetID)
if err != nil {
return err
return "", err
}
if target.Role == model.ChatRoleOwner {
return ErrChatCannotMuteOwner
return "", ErrChatCannotMuteOwner
}
var targetUser model.User
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
return ErrChatUserGone
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
return "", ErrChatUserGone
}
if targetUser.Role == model.RoleOwner {
return ErrChatCannotMuteOwner
return "", ErrChatCannotMuteOwner
}
if !oversee {
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return err
return "", err
}
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
return ErrChatMuteDenied
return "", ErrChatMuteDenied
}
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
return ErrChatMuteDenied
return "", ErrChatMuteDenied
}
}
return s.db.Model(&model.ChatRoomMember{}).
var mutedUntil *time.Time
if muted && duration > 0 {
t := time.Now().Add(duration)
mutedUntil = &t
}
if err := s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, targetID).
Select("muted").Update("muted", muted).Error
Select("muted", "muted_until").
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
return "", err
}
targetName := targetUser.Nickname
if targetName == "" {
targetName = targetUser.Username
}
return targetName, nil
}
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主

View File

@@ -44,7 +44,7 @@ func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
db *gorm.DB
setting *SettingService
}
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
return FloorNumber(s.db, postID, commentID)
}
// CommentAnchor 评论在所属帖子内的相对锚点:
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
type CommentAnchor struct {
Floor uint `json:"floor"`
IsRoot bool `json:"is_root"`
}
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
out := make(map[uint]CommentAnchor, len(ids))
if db == nil || len(ids) == 0 {
return out
}
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
var targets []model.Comment
if err := db.Unscoped().
Select("id", "post_id", "parent_id", "root_id", "created_at").
Where("id IN ?", ids).Find(&targets).Error; err != nil {
return out
}
rootIDSet := make(map[uint]struct{}, len(targets))
for _, c := range targets {
rid := c.ID
if c.ParentID != nil && c.RootID != nil {
rid = *c.RootID
}
rootIDSet[rid] = struct{}{}
}
rootIDs := make([]uint, 0, len(rootIDSet))
for rid := range rootIDSet {
rootIDs = append(rootIDs, rid)
}
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
type aheadRow struct {
RootID uint
Ahead int64
}
var aheadRows []aheadRow
if err := db.Unscoped().Table("comments AS f").
Select(`f.id AS root_id,
(SELECT COUNT(*) FROM comments c
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
AND (c.created_at < f.created_at
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
Scan(&aheadRows).Error; err != nil {
return out
}
floorByRoot := make(map[uint]uint, len(aheadRows))
for _, r := range aheadRows {
floorByRoot[r.RootID] = uint(r.Ahead) + 1
}
for _, c := range targets {
isRoot := c.ParentID == nil
rid := c.ID
if !isRoot && c.RootID != nil {
rid = *c.RootID
}
floor := floorByRoot[rid]
if floor == 0 {
continue
}
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
}
return out
}
// AnchorsByCommentIDs 见包级 CommentAnchors
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
return CommentAnchors(s.db, ids)
}
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
@@ -442,6 +516,8 @@ type UserCommentItem struct {
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Content string `json:"content"`
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
CreatedAt time.Time `json:"created_at"`
}
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
if items == nil {
items = []UserCommentItem{}
}
ids := make([]uint, 0, len(items))
for _, it := range items {
ids = append(ids, it.ID)
}
anchors := s.AnchorsByCommentIDs(ids)
for i := range items {
if a, ok := anchors[items[i].ID]; ok {
items[i].Floor = a.Floor
items[i].IsRoot = a.IsRoot
}
}
return items, total, nil
}

View File

@@ -9,7 +9,7 @@ import (
// FollowService 关注服务(仅用户;关注时给对方发通知)
type FollowService struct {
db *gorm.DB
db *gorm.DB
notif *NotificationService
}

View File

@@ -268,8 +268,8 @@ func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string)
return err
}
updates := map[string]interface{}{
"status": status,
"updated_at": time.Now(),
"status": status,
"updated_at": time.Now(),
"reject_reason": "",
}
switch status {

View File

@@ -16,8 +16,8 @@ import (
)
const (
hidePwdCookiePrefix = "j13_hp_"
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
hidePwdCookiePrefix = "j13_hp_"
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
hidePwdCookieVersion = "1"
)

View File

@@ -0,0 +1,276 @@
package service
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
)
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
const VariantWebPQuality = 78
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
func IsVariantWidth(w int) bool {
for _, v := range variantWidths {
if v == w {
return true
}
}
return false
}
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
var variantGenLocks sync.Map
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
func extMIME(ext string) string {
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/x-icon"
}
return ""
}
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
func (s *UploadService) variantCacheFile(url string, w int) string {
sum := sha1.Sum([]byte(url))
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
return filepath.Join(s.dir, ".thumbs", "v", name)
}
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
func variantCacheFresh(cache string, modTime time.Time) bool {
ti, err := os.Stat(cache)
if err != nil || ti.IsDir() {
return false
}
return modTime.IsZero() || !ti.ModTime().Before(modTime)
}
// Variant 返回源图指定宽度的变体字节与 MIME。
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL)
if len(rawURL) == 0 || len(rawURL) > 512 {
return nil, "", errors.New("无效地址")
}
if !IsVariantWidth(width) {
return nil, "", errors.New("不支持的尺寸")
}
var (
srcData []byte // 远程源预读字节(本地源在锁内读取)
srcMIME string
srcPath string // 本地源绝对路径
modTime time.Time
remote bool
cache = s.variantCacheFile(rawURL, width)
)
switch {
case strings.HasPrefix(rawURL, "/uploads/"):
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
return nil, "", errors.New("无效地址")
}
dir := filepath.Dir(rel)
catOK := false
for _, cat := range mediaLibraryCategories {
if cat.Dir == dir {
catOK = true
break
}
}
if !catOK {
return nil, "", errors.New("无效地址")
}
ext := strings.ToLower(filepath.Ext(rel))
mime := extMIME(ext)
if mime == "" {
return nil, "", errors.New("非图片")
}
srcPath = filepath.Join(s.dir, rel)
info, err := os.Stat(srcPath)
if err != nil || info.IsDir() {
return nil, "", errors.New("文件不存在")
}
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
data, err := os.ReadFile(srcPath)
return data, mime, err
}
srcMIME = mime
modTime = info.ModTime()
case strings.HasPrefix(rawURL, "/api/media/"):
if s.ops == nil {
return nil, "", errors.New("无效地址")
}
id := strings.TrimPrefix(rawURL, "/api/media/")
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
return nil, "", errors.New("无效地址")
}
remote = true
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
r, mime, e := s.ops.OpenObject(ctx, id, true)
if e != nil {
return nil, "", errors.New("文件暂不可用")
}
defer r.Close()
switch mime {
case "image/jpeg", "image/png", "image/webp":
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
data, err := io.ReadAll(r)
return data, mime, err
default:
return nil, "", errors.New("非图片")
}
srcData, e = io.ReadAll(r)
if e != nil {
return nil, "", e
}
srcMIME = mime
default:
return nil, "", errors.New("无效地址")
}
// 快速路径:缓存已新鲜直接返回(无锁)
if variantCacheFresh(cache, modTime) {
if data, err := os.ReadFile(cache); err == nil {
return data, "image/webp", nil
}
}
// 并发生成去重:同 url+w 只生成一次
sum := sha1.Sum([]byte(rawURL))
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
lk := mu.(*sync.Mutex)
lk.Lock()
defer lk.Unlock()
// double-check:等锁期间可能已由他人生成
if variantCacheFresh(cache, modTime) {
if data, err := os.ReadFile(cache); err == nil {
return data, "image/webp", nil
}
}
if !remote {
data, err := os.ReadFile(srcPath)
if err != nil {
return nil, "", err
}
srcData = data
}
out, m, err := variantFromBytes(srcData, srcMIME, width)
if err != nil {
return nil, "", err // 如动图 WebP,调用方回退原图
}
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
if m == "image/webp" {
_ = atomicWriteFile(cache, out)
}
return out, m, nil
}
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
if err != nil {
return nil, "", err
}
b := img.Bounds()
if b.Dx() <= width {
return srcData, srcMIME, nil
}
nh := max(1, b.Dy()*width/b.Dx())
dst := scaleImage(img, width, nh)
out, err := encodeLossyWebP(dst, VariantWebPQuality)
if err != nil {
return nil, "", err
}
return out, "image/webp", nil
}
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
if isWebP {
return xwebp.Decode(bytes.NewReader(data))
}
img, _, err := image.Decode(bytes.NewReader(data))
return img, err
}
// scaleImage CatmullRom 高质量缩放到指定宽高
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
return dst
}
// encodeLossyWebP 有损 WebP 编码
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// atomicWriteFile 临时文件 + rename 原子落盘
func atomicWriteFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".partial"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

View File

@@ -0,0 +1,282 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/freefire/jiang13-bbs/config"
webpenc "github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
// solidImg 生成渐变纯色测试图
func solidImg(w, h int) *image.RGBA {
src := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
return src
}
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
t.Helper()
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abs)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
if err := encode(f); err != nil {
t.Fatalf("encode: %v", err)
}
}
func encodeJPEG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
}
}
func encodePNG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return png.Encode(w, img)
}
}
func encodeWebP(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
}
}
func encodeGIF(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return gif.Encode(w, img, nil)
}
}
// decodeVariant 解码返回的 WebP 变体
func decodeVariant(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := xwebp.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode variant webp: %v", err)
}
return img
}
func TestIsVariantWidth(t *testing.T) {
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
t.Fatal("whitelist endpoints should be valid")
}
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
t.Fatal("non-whitelist width should be invalid")
}
}
func TestVariantRejectsInvalidInput(t *testing.T) {
s, _ := newUploadTestService(t)
cases := []struct {
name string
url string
w int
}{
{"非法尺寸", "/uploads/images/a.webp", 100},
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
{"非白名单目录", "/uploads/secret/a.webp", 96},
{"非图片扩展名", "/uploads/images/a.txt", 96},
{"外部地址", "https://evil.com/a.webp", 96},
{"空地址", "", 96},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
t.Fatal("want error, got nil")
}
})
}
}
func TestVariantMissingFile(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
t.Fatal("missing source should error")
}
}
func TestVariantPassthroughSmallImage(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/small.webp"
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
data, m, err := s.Variant(url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp mime, got %s", m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
if !bytes.Equal(data, src) {
t.Fatal("small image should be returned byte-for-byte")
}
}
func TestVariantResizesByWidthLandscape(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/wide.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
data, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 384 || b.Dy() != 192 {
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantResizesByWidthPortrait(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/tall.png"
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
data, _, err := s.Variant(url, 256)
if err != nil {
t.Fatalf("variant: %v", err)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 256 || b.Dy() != 512 {
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
s, dir := newUploadTestService(t)
cases := []struct {
name string
url string
mime string
body []byte
}{
{"gif", "/uploads/images/a.gif", "image/gif", nil},
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.body != nil {
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
_, err := w.Write(tc.body)
return err
})
} else {
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
}
data, m, err := s.Variant(tc.url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != tc.mime {
t.Fatalf("want %s, got %s", tc.mime, m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
if !bytes.Equal(data, src) {
t.Fatalf("%s should pass through unchanged", tc.name)
}
})
}
}
func TestVariantCachedAndReused(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/cache.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
first, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("first variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
cache := s.variantCacheFile(url, 384)
if info, err := os.Stat(cache); err != nil || info.IsDir() {
t.Fatalf("cache file should exist: %v", err)
}
second, _, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("second variant: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached variant should be byte-identical")
}
}
func TestVariantCacheFreshness(t *testing.T) {
_, dir := newUploadTestService(t)
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
// 远程对象:modTime 零值,存在即新鲜
if !variantCacheFresh(cache, time.Time{}) {
t.Fatal("remote cache should be fresh when file exists")
}
// 本地源:mtime 新于缓存 → 不新鲜
future := time.Now().Add(1 * time.Hour)
if variantCacheFresh(cache, future) {
t.Fatal("cache should be stale vs newer source mtime")
}
}
func TestVariantRemoteRejectsBadID(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
t.Fatal("id with slash should be rejected")
}
}
func TestVariantRemoteUnavailable(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
t.Fatal("missing remote object should error")
}
}
func TestVariantRemoteWithoutOps(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
t.Fatal("remote url without ops wired should error")
}
}

View File

@@ -38,9 +38,9 @@ func TestEarnedPointsWindowAndExclusions(t *testing.T) {
{UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now},
{UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now},
{UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)},
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
{UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
}
if err := db.Create(&ledgers).Error; err != nil {

View File

@@ -102,7 +102,7 @@ type LegacyUserPreview struct {
Nickname string `json:"nickname"`
Posts int `json:"posts"`
Comments int `json:"comments"`
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
HasAvatar bool `json:"has_avatar"`
}
@@ -142,8 +142,8 @@ type LegacyUserReport struct {
// LegacyBoardReport 板块导入明细
type LegacyBoardReport struct {
Created int `json:"created"` // 新建(预检时为「将新建」)
Reused int `json:"reused"` // 复用同名已有板块
Created int `json:"created"` // 新建(预检时为「将新建」)
Reused int `json:"reused"` // 复用同名已有板块
Names []string `json:"names,omitempty"` // 新建板块名
}
@@ -151,7 +151,7 @@ type LegacyBoardReport struct {
type LegacyPostReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
Skipped int `json:"skipped"` // 已导入过(去重记录)
Excluded int `json:"excluded"` // 手动排除
ExcludedDetail []string `json:"excluded_detail,omitempty"`
PollsSkipped int `json:"polls_skipped"`
@@ -751,19 +751,19 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
continue
}
post := model.Post{
BoardID: boardID,
UserID: authorID,
Title: truncateRunesN(title, 256),
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,
ViewCount: p.ViewCount,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
BoardID: boardID,
UserID: authorID,
Title: truncateRunesN(title, 256),
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,
ViewCount: p.ViewCount,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
if err := s.db.Create(&post).Error; err != nil {
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))

View File

@@ -136,9 +136,9 @@ func TestLegacyImportSelectiveRun(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
@@ -241,8 +241,8 @@ func TestLegacyImportSelectiveRun(t *testing.T) {
func TestLegacyImportSkipPostCascades(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {

View File

@@ -1,10 +1,12 @@
package service
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
@@ -44,7 +46,16 @@ func LibraryExtAllowed(ext string) bool {
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
var libraryDocWriteFields = []string{
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
}
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
type LibraryCreator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
@@ -53,27 +64,34 @@ type LibraryDocDetail struct {
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
}
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
type LibraryDocListItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
Creator *LibraryCreator `json:"creator,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"` // 书籍作者(自由文本,可空)
Description string `json:"description"`
CoverURL string `json:"cover_url"`
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
Published *bool `json:"published"`
SortOrder *int `json:"sort_order"`
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
@@ -99,6 +117,11 @@ func (in *LibraryInput) normalize() error {
return errors.New("标题不能超过 200 字")
}
in.Author = strings.TrimSpace(in.Author)
if utf8.RuneCountInString(in.Author) > 100 {
return errors.New("作者不能超过 100 字")
}
in.Description = strings.TrimSpace(in.Description)
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
return errors.New("介绍不能超过 20000 字")
@@ -115,8 +138,17 @@ func (in *LibraryInput) normalize() error {
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
d.Slug = in.Slug
d.Title = in.Title
d.Author = in.Author
d.Description = in.Description
d.CoverURL = in.CoverURL
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
if in.CoverURL == "" {
d.CoverWidth = 0
d.CoverHeight = 0
} else {
d.CoverWidth = in.CoverWidth
d.CoverHeight = in.CoverHeight
}
if in.Published != nil {
d.Published = *in.Published
}
@@ -130,9 +162,10 @@ func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
type LibraryService struct {
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
setting *SettingService
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
uploadsDir string // data/uploads:封面回填时解析本地封面图
setting *SettingService
}
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
@@ -145,6 +178,12 @@ func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
return s
}
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
s.uploadsDir = uploadsDir
return s
}
func (s *LibraryService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
@@ -224,6 +263,7 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
for _, sec := range sections {
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
}
creatorByID := s.creatorMap(docs)
for _, d := range docs {
fs := byDoc[d.ID]
if fs == nil {
@@ -233,12 +273,40 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
if ss == nil {
ss = []model.LibrarySection{}
}
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
out = append(out, LibraryDocDetail{
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
Creator: creatorByID[d.CreatorID],
})
}
return out
}
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
ids := make([]uint, 0, len(docs))
seen := map[uint]bool{}
for _, d := range docs {
if d.CreatorID > 0 && !seen[d.CreatorID] {
seen[d.CreatorID] = true
ids = append(ids, d.CreatorID)
}
}
if len(ids) == 0 {
return map[uint]*LibraryCreator{}
}
var users []model.User
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
return map[uint]*LibraryCreator{}
}
m := make(map[uint]*LibraryCreator, len(users))
for i := range users {
u := &users[i]
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
}
return m
}
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
if err := in.normalize(); err != nil {
return nil, err
}
@@ -253,6 +321,9 @@ func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
d := &model.LibraryDoc{Published: false, SortOrder: 0}
if len(creatorID) > 0 {
d.CreatorID = creatorID[0]
}
in.applyTo(d)
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return nil, err
@@ -279,12 +350,15 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
in.applyTo(&d)
// map 更新确保 false/空串写入(同 SitePage.Update)
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"author": d.Author,
"description": d.Description,
"cover_url": d.CoverURL,
"cover_width": d.CoverWidth,
"cover_height": d.CoverHeight,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
@@ -294,6 +368,77 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
return &d, nil
}
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
return "", false
}
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
root := filepath.Clean(s.uploadsDir)
clean := filepath.Clean(abs)
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", false
}
return clean, true
}
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
func measureCoverReader(r io.Reader) (w, h int, err error) {
data, err := io.ReadAll(r)
if err != nil {
return 0, 0, err
}
format, err := detectImageFormat(data)
if err != nil {
return 0, 0, err
}
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
}
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
return
}
url := strings.TrimSpace(d.CoverURL)
var reader io.ReadCloser
switch {
case strings.HasPrefix(url, "/uploads/"):
abs, ok := s.resolveCoverPath(url)
if !ok {
return
}
f, err := os.Open(abs)
if err != nil {
return
}
reader = f
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return
}
reader = resp.Body
default:
return
}
w, h, err := measureCoverReader(reader)
reader.Close()
if err != nil || w <= 0 || h <= 0 {
return
}
if err := s.db.Model(d).Updates(map[string]interface{}{
"cover_width": w,
"cover_height": h,
}).Error; err != nil {
return
}
d.CoverWidth, d.CoverHeight = w, h
}
func (s *LibraryService) Delete(id uint) error {
result := s.db.Delete(&model.LibraryDoc{}, id)
if result.Error != nil {
@@ -349,8 +494,9 @@ func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
out := make([]LibraryDocListItem, 0, len(details))
for _, d := range details {
out = append(out, LibraryDocListItem{
ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL,
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
Creator: d.Creator,
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
})
}
@@ -364,6 +510,8 @@ func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, err
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
return nil, ErrLibraryNotFound
}
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
s.ensureCoverDimensions(&d)
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}

View File

@@ -0,0 +1,369 @@
package service
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
//
// 单本 ZIP 布局:
//
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
// files/<stored-name> 附件原文件
//
// 全库 ZIP 布局:
//
// library.json 索引(每本书的目录位置)
// docs/<slug>/book.json 各书清单
// docs/<slug>/cover.<ext>
// docs/<slug>/files/<stored-name>
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
const (
libraryExportFormat = "jiang13-library" // 全库包
libraryExportBookFormat = "jiang13-library-book" // 单本书包
libraryExportVersion = 1
)
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
var libraryExportCoverExts = map[string]struct{}{
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
}
// libraryExportFile 附件清单条目
type libraryExportFile struct {
Name string `json:"name"` // 原始文件名
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
Ext string `json:"ext"`
MIME string `json:"mime"`
Size int `json:"size"`
DownloadCount int `json:"download_count"`
SortOrder int `json:"sort_order"`
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
}
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
type libraryExportSection struct {
Key string `json:"key"`
ParentKey string `json:"parent_key,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
}
// libraryExportBook 单本书的完整清单
type libraryExportBook struct {
ID uint `json:"id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
CoverWidth int `json:"cover_width"`
CoverHeight int `json:"cover_height"`
Published bool `json:"published"`
SortOrder int `json:"sort_order"`
EntriesAuto bool `json:"entries_auto"`
Files []libraryExportFile `json:"files"`
Sections []libraryExportSection `json:"sections"`
}
// libraryExportIndexEntry 全库索引中的单本概要
type libraryExportIndexEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Dir string `json:"dir"` // 相对 ZIP 根的目录
FileCount int `json:"file_count"`
SectionCount int `json:"section_count"`
}
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
type libraryExportManifest struct {
Format string `json:"format"`
FormatVersion int `json:"format_version"`
ExportedAt string `json:"exported_at"`
Doc *libraryExportBook `json:"doc,omitempty"`
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
}
// preparedFile 已打开、待流式拷入 ZIP 的附件
type preparedFile struct {
zipPath string
reader *os.File
}
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
type preparedBook struct {
meta libraryExportBook
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
coverPath string // ZIP 内封面路径(空 = 不内嵌)
coverReader *os.File
files []preparedFile // 仅磁盘存在的附件
}
func (b *preparedBook) closeReaders() {
if b.coverReader != nil {
_ = b.coverReader.Close()
}
for i := range b.files {
_ = b.files[i].reader.Close()
}
}
// LibraryExport 已完成装载、可直接流式写出的导出包
type LibraryExport struct {
Filename string
full bool
exportedAt time.Time
books []preparedBook
}
// Close 释放导出过程中打开的全部文件句柄
func (e *LibraryExport) Close() {
for i := range e.books {
e.books[i].closeReaders()
}
}
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
d, err := s.Get(id)
if err != nil {
return nil, err
}
b, err := s.prepareBook(d, "")
if err != nil {
return nil, err
}
name := d.Slug
if name == "" {
name = "book"
}
return &LibraryExport{
Filename: name + ".zip",
full: false,
exportedAt: time.Now(),
books: []preparedBook{*b},
}, nil
}
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
var docs []model.LibraryDoc
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
return nil, err
}
details := s.attachFiles(docs)
exp := &LibraryExport{
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
full: true,
exportedAt: time.Now(),
books: make([]preparedBook, 0, len(details)),
}
for i := range details {
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
dir := "docs/" + details[i].Slug + "/"
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
exp.Close()
return nil, errors.New("条目 slug 含非法字符,无法导出")
}
b, err := s.prepareBook(&details[i], dir)
if err != nil {
exp.Close()
return nil, err
}
exp.books = append(exp.books, *b)
}
return exp, nil
}
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
b := &preparedBook{dir: dir}
b.meta = libraryExportBook{
ID: d.ID,
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
Slug: d.Slug,
Title: d.Title,
Author: d.Author,
Description: d.Description,
CoverURL: d.CoverURL,
CoverWidth: d.CoverWidth,
CoverHeight: d.CoverHeight,
Published: d.Published,
SortOrder: d.SortOrder,
EntriesAuto: d.EntriesAuto,
Files: make([]libraryExportFile, 0, len(d.Files)),
Sections: make([]libraryExportSection, 0, len(d.Sections)),
}
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
keyByID := make(map[uint]string, len(d.Sections))
for i, sec := range d.Sections {
keyByID[sec.ID] = "s" + strconv.Itoa(i)
}
for _, sec := range d.Sections {
m := libraryExportSection{
Key: keyByID[sec.ID],
Title: sec.Title,
Content: sec.Content,
SortOrder: sec.SortOrder,
}
if sec.ParentID != nil {
m.ParentKey = keyByID[*sec.ParentID]
}
b.meta.Sections = append(b.meta.Sections, m)
}
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
for _, f := range d.Files {
m := libraryExportFile{
Name: f.Name,
Ext: f.Ext,
MIME: f.MIME,
Size: f.Size,
DownloadCount: f.DownloadCount,
SortOrder: f.SortOrder,
}
full := filepath.Join(s.dir, f.StoredName)
if r, err := os.Open(full); err == nil {
m.Stored = "files/" + f.StoredName
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
} else {
m.Missing = true
}
b.meta.Files = append(b.meta.Files, m)
}
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
if ext := coverExtOf(d.CoverURL); ext != "" {
if r, err := os.Open(rel); err == nil {
b.coverReader = r
b.coverPath = dir + "cover." + ext
b.meta.CoverFile = "cover." + ext
}
}
}
return b, nil
}
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
func (e *LibraryExport) WriteZip(w io.Writer) error {
defer e.Close()
zw := zip.NewWriter(w)
index := make([]libraryExportIndexEntry, 0, len(e.books))
for i := range e.books {
b := &e.books[i]
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
if e.full {
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
return err
}
}
if b.coverReader != nil {
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
return err
}
}
for j := range b.files {
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
return err
}
}
index = append(index, libraryExportIndexEntry{
Slug: b.meta.Slug,
Title: b.meta.Title,
Dir: b.dir,
FileCount: len(b.meta.Files),
SectionCount: len(b.meta.Sections),
})
}
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
if e.full {
root := libraryExportManifest{
Format: libraryExportFormat,
FormatVersion: libraryExportVersion,
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
Docs: index,
}
if err := writeZipJSON(zw, "library.json", root); err != nil {
return err
}
} else {
root := libraryExportManifest{
Format: libraryExportBookFormat,
FormatVersion: libraryExportVersion,
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
Doc: &e.books[0].meta,
}
if err := writeZipJSON(zw, "book.json", root); err != nil {
return err
}
}
return zw.Close()
}
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
func coverExtOf(coverURL string) string {
u := strings.TrimSpace(coverURL)
if i := strings.IndexByte(u, '?'); i >= 0 {
u = u[:i]
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
if _, ok := libraryExportCoverExts[ext]; !ok {
return ""
}
return ext
}
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
func writeZipJSON(zw *zip.Writer, name string, v any) error {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
hdr.SetMode(0o644)
f, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
_, err = f.Write(buf.Bytes())
return err
}
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
hdr.SetMode(0o644)
f, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
_, err = io.Copy(f, r)
return err
}

View File

@@ -0,0 +1,199 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
t.Helper()
for _, f := range zr.File {
if f.Name == name {
rc, err := f.Open()
if err != nil {
t.Fatalf("open zip entry %s: %v", name, err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read zip entry %s: %v", name, err)
}
return data, true
}
}
return nil, false
}
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
t.Helper()
var buf bytes.Buffer
if err := exp.WriteZip(&buf); err != nil {
t.Fatalf("write zip: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("read exported zip: %v", err)
}
return zr
}
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
t.Helper()
pub := true
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
if err != nil {
t.Fatalf("create doc: %v", err)
}
coverDir := filepath.Join(uploads, "images")
if err := os.MkdirAll(coverDir, 0o755); err != nil {
t.Fatalf("mkdir uploads: %v", err)
}
coverBytes := encodeJPEGBytes(t, 120, 160)
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
if _, err := s.Update(doc.ID, &LibraryInput{
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
Published: &pub,
}); err != nil {
t.Fatalf("update cover: %v", err)
}
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
if err != nil {
t.Fatalf("create chapter: %v", err)
}
if _, err := s.CreateSection(doc.ID, &SectionInput{
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
}); err != nil {
t.Fatalf("create section: %v", err)
}
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
if err != nil {
t.Fatalf("add file: %v", err)
}
return doc.ID, f.StoredName
}
func TestLibraryExportBook(t *testing.T) {
s, _ := newLibraryTestService(t)
uploads := t.TempDir()
s.WithUploadsDir(uploads)
docID, storedName := seedBookForExport(t, s, uploads)
exp, err := s.BuildBookExport(docID)
if err != nil {
t.Fatalf("build export: %v", err)
}
defer exp.Close()
if exp.Filename != "dao-jia.zip" {
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
}
zr := buildExportZip(t, exp)
raw, ok := readZipEntry(t, zr, "book.json")
if !ok {
t.Fatal("缺少 book.json")
}
var manifest libraryExportManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("parse book.json: %v", err)
}
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
}
b := manifest.Doc
if b == nil {
t.Fatal("book.json 缺少 doc")
}
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
t.Fatalf("元信息导出不正确: %+v", b)
}
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
t.Fatalf("封面信息导出不正确: %+v", b)
}
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
t.Fatalf("章节树导出不正确: %+v", b.Sections)
}
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
t.Fatalf("附件清单导出不正确: %+v", b.Files)
}
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
}
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
}
// 单本包不应出现全库索引
if _, ok := readZipEntry(t, zr, "library.json"); ok {
t.Fatal("单本包不应包含 library.json")
}
}
func TestLibraryExportAll(t *testing.T) {
s, _ := newLibraryTestService(t)
uploads := t.TempDir()
s.WithUploadsDir(uploads)
docID, storedName := seedBookForExport(t, s, uploads)
_ = docID
exp, err := s.BuildAllExport()
if err != nil {
t.Fatalf("build all export: %v", err)
}
defer exp.Close()
zr := buildExportZip(t, exp)
raw, ok := readZipEntry(t, zr, "library.json")
if !ok {
t.Fatal("缺少 library.json")
}
var manifest libraryExportManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("parse library.json: %v", err)
}
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
t.Fatalf("全库索引异常: %+v", manifest)
}
idx := manifest.Docs[0]
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
t.Fatalf("索引条目异常: %+v", idx)
}
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
if !ok {
t.Fatal("缺少 docs/dao-jia/book.json")
}
var book libraryExportBook
if err := json.Unmarshal(bookRaw, &book); err != nil {
t.Fatalf("parse nested book.json: %v", err)
}
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
t.Fatalf("嵌套书籍清单异常: %+v", book)
}
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
t.Fatal("缺少嵌套附件")
}
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
t.Fatal("缺少嵌套封面")
}
}
func TestLibraryExportMissingDoc(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
}
}

View File

@@ -0,0 +1,631 @@
package service
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
//
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
const LibraryImportMaxBytes = 512 << 20
const (
LibraryImportModeCreate = "create" // 冲突跳过
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
)
// 失败原因(前端据此区分冲突,可提示覆盖重导)
const (
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
libraryImportReasonInvalid = "invalid" // 清单数据非法
libraryImportReasonFailed = "failed" // 落盘/写库失败
)
var (
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
ErrLibraryImportVer = errors.New("导出版本不受支持")
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
ErrLibraryImportMode = errors.New("无效的导入模式")
)
// LibraryImportItem 成功导入的单本结果
type LibraryImportItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Action string `json:"action"` // created / overwritten
Sections int `json:"sections"`
Files int `json:"files"`
}
// LibraryImportFailure 单本书失败明细
type LibraryImportFailure struct {
Slug string `json:"slug"`
Title string `json:"title"`
Reason string `json:"reason"`
Error string `json:"error"`
}
// LibraryImportReport 导入报告
type LibraryImportReport struct {
Mode string `json:"mode"`
Total int `json:"total"`
Imported []LibraryImportItem `json:"imported"`
Failed []LibraryImportFailure `json:"failed"`
}
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
type bookImportPlan struct {
book libraryExportBook
dir string
}
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
type plannedSection struct {
key string
parentKey string
title string
content string
sort int
}
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
return nil, ErrLibraryImportMode
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, ErrLibraryImportBadZip
}
defer zr.Close()
entries := make(map[string]*zip.File, len(zr.File))
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
}
plans, err := parseImportPlans(entries)
if err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, ErrLibraryImportEmpty
}
rep := &LibraryImportReport{
Mode: mode,
Total: len(plans),
Imported: []LibraryImportItem{},
Failed: []LibraryImportFailure{},
}
imp := &bookImporter{s: s, entries: entries, userID: userID}
for _, p := range plans {
item, fail := imp.run(p, mode)
if fail != nil {
rep.Failed = append(rep.Failed, *fail)
} else {
rep.Imported = append(rep.Imported, *item)
}
}
return rep, nil
}
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
if rootRaw, ok := entries["library.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
return nil, ErrLibraryImportVer
}
plans := make([]bookImportPlan, 0, len(root.Docs))
for _, d := range root.Docs {
dir := strings.ReplaceAll(d.Dir, "\\", "/")
if !safeImportDir(dir) {
return nil, ErrLibraryImportFormat
}
raw, ok := entries[path.Clean(dir)+"/book.json"]
if !ok {
return nil, ErrLibraryImportFormat
}
var b libraryExportBook
if err := readZipJSON(raw, &b); err != nil {
return nil, ErrLibraryImportFormat
}
plans = append(plans, bookImportPlan{book: b, dir: dir})
}
return plans, nil
}
if rootRaw, ok := entries["book.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
return nil, ErrLibraryImportVer
}
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
}
return nil, ErrLibraryImportFormat
}
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
func safeImportDir(dir string) bool {
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
return false
}
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
return false
}
return sitePageSlugRe.MatchString(slug)
}
// bookImporter 携带一次批量导入的共享上下文
type bookImporter struct {
s *LibraryService
entries map[string]*zip.File
userID uint
}
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
}
// run 导入单本书;返回 item 或 failure(二者互斥)
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
b := p.book
in, err := buildImportInput(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
sections, err := buildImportSections(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
if err := imp.checkPlannedFiles(p); err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
coverEntry, err := imp.planCover(p)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
}
var docID uint
action := "created"
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
switch {
case active && mode != LibraryImportModeOverwrite:
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
case active:
var existing model.LibraryDoc
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
}
docID = existing.ID
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
backfillCreator = existing.CreatorID == 0
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
oldCoverDisk = disk
}
action = "overwritten"
case deleted:
return nil, imp.fail(p, libraryImportReasonDeleted,
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
}
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
coverDisk := ""
if coverEntry != nil {
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
if ferr != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
}
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
}
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
if action == "created" {
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
in.applyTo(d)
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return err
}
docID = d.ID
} else {
var d model.LibraryDoc
if err := tx.First(&d, docID).Error; err != nil {
return err
}
in.applyTo(&d)
updates := map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"author": d.Author,
"description": d.Description,
"cover_url": d.CoverURL,
"cover_width": d.CoverWidth,
"cover_height": d.CoverHeight,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}
if backfillCreator {
updates["creator_id"] = imp.userID
}
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
}
return imp.createSections(tx, docID, sections)
})
if txErr != nil {
if action == "created" && coverDisk != "" {
_ = os.Remove(coverDisk)
}
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
}
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
for i := range oldFiles {
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
}
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
_ = os.Remove(oldCoverDisk)
}
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
added := 0
var newFiles []model.LibraryFile
for _, mf := range b.Files {
if mf.Missing || mf.Stored == "" {
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
}
f, fail := imp.addPlannedFile(p, docID, mf)
if fail != nil {
if action == "created" {
imp.rollbackCreated(docID, newFiles, coverDisk)
}
return nil, fail
}
newFiles = append(newFiles, *f)
added++
}
return &LibraryImportItem{
Slug: b.Slug,
Title: b.Title,
Action: action,
Sections: len(sections),
Files: added,
}, nil
}
// ---------- 清单解析与预检 ----------
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
in := &LibraryInput{
Slug: b.Slug,
Title: b.Title,
Author: b.Author,
Description: b.Description,
CoverURL: b.CoverURL,
CoverWidth: b.CoverWidth,
CoverHeight: b.CoverHeight,
Published: &pub,
SortOrder: &sortOrder,
EntriesAuto: &entriesAuto,
}
if err := in.normalize(); err != nil {
return nil, err
}
return in, nil
}
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
if len(b.Sections) > MaxSectionsPerDoc {
return nil, errors.New("章节数量超过上限(最多 200)")
}
keySeen := map[string]bool{}
out := make([]plannedSection, 0, len(b.Sections))
chapterSort := map[string]int{}
order := 0
for _, sec := range b.Sections {
if sec.ParentKey != "" {
continue
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的章节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
if keySeen[sec.Key] {
return nil, errors.New("章节标识重复:" + sec.Key)
}
keySeen[sec.Key] = true
chapterSort[sec.Key] = order
out = append(out, plannedSection{
key: sec.Key, title: title, content: sec.Content, sort: order,
})
order++
}
childCount := map[string]int{}
for _, sec := range b.Sections {
if sec.ParentKey == "" {
continue
}
if _, ok := chapterSort[sec.ParentKey]; !ok {
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的小节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
out = append(out, plannedSection{
key: sec.Key,
parentKey: sec.ParentKey,
title: title,
content: sec.Content,
sort: childCount[sec.ParentKey],
})
childCount[sec.ParentKey]++
}
return out, nil
}
func validateSectionText(title, content string) error {
if len([]rune(title)) > 200 {
return errors.New("章节标题不能超过 200 字:" + title)
}
if len([]rune(content)) > MaxSectionContent {
return errors.New("章节正文不能超过 100000 字:" + title)
}
return nil
}
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
if len(p.book.Files) > MaxLibraryFilesPerDoc {
return errors.New("附件数量超过上限(最多 20 个)")
}
maxB := imp.s.maxBytes()
for _, f := range p.book.Files {
if f.Missing || f.Stored == "" {
continue
}
rel, ok := cleanZipRel(f.Stored)
if !ok || !strings.HasPrefix(rel, "files/") {
return errors.New("附件路径非法:" + f.Stored)
}
name := sanitizeFilename(f.Name)
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
return errors.New("附件格式不受支持:" + f.Name)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return errors.New("备份包缺少附件文件:" + f.Name)
}
if zf.UncompressedSize64 > uint64(maxB) {
return errors.New("附件超过大小上限:" + f.Name)
}
}
return nil
}
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
cf := strings.TrimSpace(p.book.CoverFile)
if cf == "" {
return nil, nil
}
rel, ok := cleanZipRel(cf)
if !ok || strings.Contains(rel, "/") {
return nil, errors.New("封面路径非法:" + cf)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, errors.New("备份包缺少封面文件")
}
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
return nil, errors.New("封面不能超过 5MB")
}
return zf, nil
}
// ---------- 落盘 ----------
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
keyToID := make(map[string]uint, len(sections))
for i := range sections {
ps := &sections[i]
if ps.parentKey != "" {
continue
}
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
if err := tx.Create(sec).Error; err != nil {
return err
}
keyToID[ps.key] = sec.ID
}
for i := range sections {
ps := &sections[i]
if ps.parentKey == "" {
continue
}
parentID, ok := keyToID[ps.parentKey]
if !ok {
return errors.New("小节找不到所属章节")
}
sec := &model.LibrarySection{
DocID: docID, ParentID: &parentID,
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
}
if err := tx.Create(sec).Error; err != nil {
return err
}
}
return nil
}
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
func (imp *bookImporter) addPlannedFile(
p bookImportPlan, docID uint, mf libraryExportFile,
) (*model.LibraryFile, *LibraryImportFailure) {
rel, _ := cleanZipRel(mf.Stored)
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
}
rc, err := zf.Open()
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
}
defer rc.Close()
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
}
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
UpdateColumns(map[string]interface{}{
"download_count": mf.DownloadCount,
"sort_order": mf.SortOrder,
}).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
}
return f, nil
}
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
if imp.s.uploadsDir == "" {
return "", 0, 0, "", errors.New("未配置上传目录")
}
rc, err := zf.Open()
if err != nil {
return "", 0, 0, "", err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
if err != nil {
return "", 0, 0, "", err
}
if int64(len(data)) > ImageMaxBytes {
return "", 0, 0, "", errors.New("封面不能超过 5MB")
}
format, err := detectImageFormat(data)
if err != nil {
return "", 0, 0, "", err
}
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
if err != nil || width < 1 || height < 1 {
return "", 0, 0, "", errors.New("无法解析封面图片")
}
if width > ImageMaxDim || height > ImageMaxDim {
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return "", 0, 0, "", err
}
filename := hex.EncodeToString(nameBytes) + format.ext
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
return "", 0, 0, "", err
}
full := filepath.Join(imagesDir, filename)
if err := os.WriteFile(full, data, 0o644); err != nil {
return "", 0, 0, "", err
}
return "/uploads/images/" + filename, width, height, full, nil
}
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
for i := range newFiles {
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
}
if coverDisk != "" {
_ = os.Remove(coverDisk)
}
}
// ---------- ZIP 工具 ----------
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
func cleanZipRel(rel string) (string, bool) {
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
return p, true
}
func readZipJSON(zf *zip.File, v any) error {
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
return json.NewDecoder(rc).Decode(v)
}

View File

@@ -0,0 +1,354 @@
package service
import (
"archive/zip"
"bytes"
"errors"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
)
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
t.Helper()
p := filepath.Join(t.TempDir(), "export.zip")
f, err := os.Create(p)
if err != nil {
t.Fatalf("create temp zip: %v", err)
}
if err := exp.WriteZip(f); err != nil {
t.Fatalf("write export zip: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close temp zip: %v", err)
}
return p
}
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
func writeZipFileMap(t *testing.T, entries map[string]string) string {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
p := filepath.Join(t.TempDir(), "in.zip")
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write temp zip: %v", err)
}
return p
}
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
t.Helper()
if rep == nil {
t.Fatal("报告为空")
}
if rep.Total != total {
t.Fatalf("total = %d, want %d", rep.Total, total)
}
if len(rep.Failed) != 0 {
t.Fatalf("存在失败记录: %+v", rep.Failed)
}
if len(rep.Imported) != total {
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
}
}
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
func TestLibraryImportBookRoundTrip(t *testing.T) {
// ---- 源库 ----
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
docID, storedName := seedBookForExport(t, src, srcUploads)
// 制造两次下载,验证计数随包保留
var srcFile model.LibraryFile
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
t.Fatalf("find source file: %v", err)
}
src.IncrDownload(srcFile.ID)
src.IncrDownload(srcFile.ID)
exp, err := src.BuildBookExport(docID)
if err != nil {
t.Fatalf("build export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
// ---- 目标库(空库)----
dst, _ := newLibraryTestService(t)
dstUploads := t.TempDir()
dst.WithUploadsDir(dstUploads)
// 1) create 导入
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("import: %v", err)
}
assertReportOK(t, rep, 1)
item := rep.Imported[0]
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
t.Fatalf("导入条目异常: %+v", item)
}
var doc model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
t.Fatalf("find imported doc: %v", err)
}
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
t.Fatalf("元信息恢复不正确: %+v", doc)
}
if doc.Author != "老子" {
t.Fatalf("作者恢复不正确: %q", doc.Author)
}
if doc.CreatorID != 7 {
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
}
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
}
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
}
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
if !ok {
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
}
coverData, err := os.ReadFile(coverDisk)
if err != nil {
t.Fatalf("封面未落盘: %v", err)
}
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
t.Fatal("封面内容不是 JPEG")
}
// 章节树:章 + 小节父子关系
secs, err := dst.ListSections(doc.ID)
if err != nil {
t.Fatalf("list sections: %v", err)
}
if len(secs) != 2 {
t.Fatalf("章节数 = %d, want 2", len(secs))
}
var chapter, child *model.LibrarySection
for i := range secs {
if secs[i].ParentID == nil {
chapter = &secs[i]
} else {
child = &secs[i]
}
}
if chapter == nil || child == nil {
t.Fatalf("章节层级不正确: %+v", secs)
}
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
t.Fatalf("章信息不正确: %+v", chapter)
}
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
t.Fatalf("小节信息不正确: %+v", child)
}
// 附件:重新随机落盘、内容与下载计数保留
var files []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
t.Fatalf("list files: %v", err)
}
if len(files) != 1 {
t.Fatalf("附件数 = %d, want 1", len(files))
}
nf := files[0]
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
t.Fatalf("附件恢复不正确: %+v", nf)
}
data, err := os.ReadFile(dst.FilePath(&nf))
if err != nil {
t.Fatalf("附件未落盘: %v", err)
}
if string(data) != "EPUB-FILE-BYTES" {
t.Fatalf("附件内容不正确: %q", data)
}
firstCoverURL := doc.CoverURL
// 2) 再次 create:冲突跳过
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
t.Fatalf("冲突报告异常: %+v", rep2)
}
if rep2.Failed[0].Reason != libraryImportReasonConflict {
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
}
var docCount int64
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
}
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
if err != nil {
t.Fatalf("add extra: %v", err)
}
extraPath := dst.FilePath(extra)
if _, err := os.Stat(extraPath); err != nil {
t.Fatalf("extra 未先落盘: %v", err)
}
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
if err != nil {
t.Fatalf("overwrite import: %v", err)
}
assertReportOK(t, rep3, 1)
if rep3.Imported[0].Action != "overwritten" {
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
}
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("覆盖后书库数量异常: %d", docCount)
}
var doc2 model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
t.Fatalf("find overwritten doc: %v", err)
}
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
}
if doc2.Author != "老子" || doc2.CreatorID != 7 {
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
}
var files2 []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
}
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
}
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
}
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
}
if doc2.CoverURL == firstCoverURL {
t.Fatal("覆盖后封面应重新落盘为新文件")
}
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
if _, err := os.ReadFile(newCoverDisk); err != nil {
t.Fatalf("新封面未落盘: %v", err)
}
}
// TestLibraryImportAll 全库包往返
func TestLibraryImportAll(t *testing.T) {
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
t.Fatalf("create second doc: %v", err)
}
seedBookForExport(t, src, srcUploads)
exp, err := src.BuildAllExport()
if err != nil {
t.Fatalf("build all export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
dst, _ := newLibraryTestService(t)
dst.WithUploadsDir(t.TempDir())
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
if err != nil {
t.Fatalf("import all: %v", err)
}
assertReportOK(t, rep, 2)
slugs := map[string]bool{}
for _, it := range rep.Imported {
slugs[it.Slug] = true
}
if !slugs["dao-jia"] || !slugs["second-book"] {
t.Fatalf("导入书集不正确: %+v", slugs)
}
}
// TestLibraryImportRejectBadPackage 非法包整体拒绝
func TestLibraryImportRejectBadPackage(t *testing.T) {
s, _ := newLibraryTestService(t)
s.WithUploadsDir(t.TempDir())
badPath := filepath.Join(t.TempDir(), "bad.zip")
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
}
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
}
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
}
}
// TestCleanZipRel 路径穿越校验
func TestCleanZipRel(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", false},
{".", false},
{"..", false},
{"../evil", false},
{"a/../../b", false},
{"/etc/passwd", false},
{`docs\..\..\x`, false},
{"files/a.epub", true},
{"docs/x/book.json", true},
{`docs\x/cover.jpg`, true},
{"a//b", true},
}
for _, tc := range cases {
_, ok := cleanZipRel(tc.in)
if ok != tc.want {
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
}
}
}

View File

@@ -18,7 +18,7 @@ func newLibraryTestService(t *testing.T) (*LibraryService, string) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
@@ -45,6 +45,7 @@ func TestLibraryNormalize(t *testing.T) {
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
{"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
{"合法", func(i *LibraryInput) {}, ""},
}
for _, tc := range cases {
@@ -63,6 +64,58 @@ func TestLibraryNormalize(t *testing.T) {
}
}
func TestLibraryAuthorAndCreator(t *testing.T) {
s, _ := newLibraryTestService(t)
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
if err := s.db.Create(user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
pub := true
doc, err := s.Create(&LibraryInput{
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
}, user.ID)
if err != nil {
t.Fatalf("create: %v", err)
}
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
}
list, err := s.ListPublished()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 || list[0].Author != "鲁迅" {
t.Fatalf("列表作者异常: %+v", list)
}
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
t.Fatalf("列表创建者摘要异常: %+v", c)
}
detail, err := s.GetPublishedBySlug("authored")
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Creator == nil || detail.Creator.Username != "curator" {
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
}
// 作者可清空;更新不改变创建者归属
if _, err := s.Update(doc.ID, &LibraryInput{
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
}); err != nil {
t.Fatalf("update: %v", err)
}
var again model.LibraryDoc
if err := s.db.First(&again, doc.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if again.Author != "" || again.CreatorID != user.ID {
t.Fatalf("更新后作者/创建者异常: %+v", again)
}
}
func TestLibrarySlugUnique(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {

View File

@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
{"brand", "brand", "品牌资源"},
}
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
const (
mediaCategoryBook = "book"
mediaCategoryBookName = "书籍"
)
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
// 其余按 kind 对应磁盘目录分类。
func mediaCategoryForAttachment(kind, source string) (key, name string) {
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
return mediaCategoryBook, mediaCategoryBookName
}
for _, cat := range mediaLibraryCategories {
if cat.Key == kind {
return cat.Key, cat.Name
}
}
return kind, kind
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
@@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
Source string
URL string
MIME string
Size int
@@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
}
var rows []attMeta
if err := s.db.Table("attachments").
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
@@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
categoryName := func(key string) string {
for _, cat := range mediaLibraryCategories {
if cat.Key == key {
return cat.Name
}
}
return key
}
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
}
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
URL: r.URL, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
@@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
// 书库封面 / 正文插图改归“书籍”分类
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
item.Category = mediaCategoryBook
item.CategoryName = mediaCategoryBookName
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
@@ -243,6 +262,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
floor uint
isRoot bool
}
commentByURL := map[string]commentRef{}
@@ -299,6 +320,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
}
}
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
commentIDs := make([]uint, 0, len(commentByURL))
for _, cm := range commentByURL {
commentIDs = append(commentIDs, cm.id)
}
commentAnchors := CommentAnchors(s.db, commentIDs)
for u, cm := range commentByURL {
if a, ok := commentAnchors[cm.id]; ok {
cm.floor, cm.isRoot = a.Floor, a.IsRoot
commentByURL[u] = cm
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
@@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
break
}
if cm, ok := commentByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
if cm.floor > 0 {
if cm.isRoot {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
} else {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
}
} else {
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
}
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"

View File

@@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
@@ -259,6 +258,63 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
}
}
func TestAdminMediaLibraryBookCategory(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
mkAtt := func(source, url string) {
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", source, err)
}
}
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 封面(磁盘)与正文插图(远程)都归“书籍”
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
it, ok := byURL[u]
if !ok {
t.Fatalf("%s missing", u)
}
if it.Category != "book" || it.CategoryName != "书籍" {
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
}
}
// 普通帖子插图仍归 image
if post := byURL["/api/media/post-image"]; post.Category != "image" {
t.Fatalf("post image should stay image category, got %q", post.Category)
}
if counts["book"] != 2 || counts["image"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)

View File

@@ -1,27 +1,19 @@
package service
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
)
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
const MediaThumbMaxSide = 480
@@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
if err != nil {
return nil, err
}
var img image.Image
if ext == ".webp" {
img, err = xwebp.Decode(bytes.NewReader(data))
} else {
img, _, err = image.Decode(bytes.NewReader(data))
}
img, err := decodeImageBytes(data, ext == ".webp")
if err != nil {
return nil, err // 如动图 WebP,调用方回退原图
}
@@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
nh = MediaThumbMaxSide
nw = max(1, w*MediaThumbMaxSide/h)
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
dst := scaleImage(img, nw, nh)
var buf bytes.Buffer
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
out, err := encodeLossyWebP(dst, VariantWebPQuality)
if err != nil {
return nil, err
}
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
tmp := thumbPath + ".partial"
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
if err := os.Rename(tmp, thumbPath); err != nil {
_ = os.Remove(tmp)
}
}
}
return buf.Bytes(), nil
_ = atomicWriteFile(thumbPath, out)
return out, nil
}

View File

@@ -402,6 +402,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
})
return 0, e
}
const (
codeVerifyMaxAttempts = 5
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致

View File

@@ -72,18 +72,18 @@ type NewUserItem struct {
// OverviewData 首页聚合数据
type OverviewData struct {
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
AnnouncementsTotal int64 `json:"announcements_total"`
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
Ads []PublicAdItem `json:"ads"`
AdsPanelTitle string `json:"ads_panel_title"`
AdsEnabled bool `json:"ads_enabled"`
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
AnnouncementsTotal int64 `json:"announcements_total"`
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
Ads []PublicAdItem `json:"ads"`
AdsPanelTitle string `json:"ads_panel_title"`
AdsEnabled bool `json:"ads_enabled"`
Sponsors []PublicSponsorItem `json:"sponsors"`
SponsorsPanelTitle string `json:"sponsors_panel_title"`
SponsorsEnabled bool `json:"sponsors_enabled"`

View File

@@ -457,37 +457,37 @@ type PostAttachmentDTO struct {
// PostDetail 帖子详情(含可见性裁剪与附件)
type PostDetail struct {
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
ContentAccess string `json:"content_access"`
AccessPoints int `json:"access_points"`
TypeMeta string `json:"type_meta"`
TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
ContentAccess string `json:"content_access"`
AccessPoints int `json:"access_points"`
TypeMeta string `json:"type_meta"`
TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
Attachments []PostAttachmentDTO `json:"attachments"`
Question *QuestionState `json:"question,omitempty"`
Poll *PollState `json:"poll,omitempty"`
@@ -620,7 +620,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
Board: post.Board, User: post.User,

View File

@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
type AcceptedAnswer struct {
ID uint `json:"id"`
Floor int `json:"floor"`
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted,omitempty"`
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
ans := &AcceptedAnswer{
ID: c.ID,
Floor: floor,
IsRoot: c.ParentID == nil,
CreatedAt: c.CreatedAt,
}
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID

View File

@@ -12,7 +12,7 @@ import (
)
var (
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
)

View File

@@ -90,7 +90,7 @@ func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGit
client := &http.Client{
Timeout: timelineGitHTTPTimeout,
Transport: &http.Transport{
DialContext: publicOnlyDial,
DialContext: publicOnlyDial,
TLSHandshakeTimeout: timelineGitHTTPTimeout,
ForceAttemptHTTP2: true,
},

View File

@@ -238,7 +238,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
}
@@ -354,6 +354,7 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
@@ -585,9 +586,20 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
return s.SaveBackground(f)
}
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
func normalizeImageSource(s string) string {
switch s {
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
return s
default:
return model.AttachmentSourcePost
}
}
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) {
// source 决定副本的来源标记(如 library_cover)
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return nil, errors.New("图片不存在或不属于你")
@@ -600,7 +612,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
return nil, e
}
defer r.Close()
return s.SaveImage(userID, r)
return s.SaveImage(userID, r, source)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
@@ -611,7 +623,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
return nil, errors.New("读取图片失败")
}
defer f.Close()
return s.SaveImage(userID, f)
return s.SaveImage(userID, f, source)
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
@@ -667,6 +679,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
return list, err
}
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
return nil, err
}
return &att, nil
}
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")

View File

@@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
t.Fatalf("encode jpeg: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
@@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
t.Fatalf("encode png: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
@@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) {
t.Fatalf("encode gif: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
@@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) {
t.Fatalf("encode webp: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}

View File

@@ -9,7 +9,7 @@ import {
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import {
@@ -94,7 +94,7 @@ export default async function AboutIndexPage() {
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
const right = <SiteDocAnnoNav items={announcements} />;

View File

@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
import { AdText } from "@/components/AdText";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
type SectionId = "overview" | "queue" | "promote" | "sponsors";
@@ -882,8 +883,14 @@ function AdCard({
{/* 预览缩略 */}
<div className="j13-ads-thumb shrink-0" aria-hidden>
{ad.kind === "image" && ad.image_url ? (
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
// eslint-disable-next-line @next/next/no-img-element
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
<img
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
alt=""
className="h-full w-full object-contain"
/>
) : ad.kind === "text" && ad.title ? (
<div className="flex h-full w-full items-center justify-center p-1.5">
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
@@ -990,8 +997,14 @@ function PaymentEditor({
{/* 二维码:点击即上传 */}
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
{payment.qr_url ? (
// 96×96 收款码位:本站图走 96/160 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
<img
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={16} />
@@ -1084,8 +1097,14 @@ function SponsorEditor({
{/* Logo:点击即上传 */}
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
{item.logo_url ? (
// 120×56 Logo 位:本站图走 160/256 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
<img
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
alt=""
className="h-full w-full rounded-lg object-contain"
/>
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={15} />

View File

@@ -12,7 +12,7 @@ import {
type SponsorsConfig,
} from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
export default async function AdminAdsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../../Forbidden";
import AnnouncementEditLoader from "./AnnouncementEditLoader";
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../Forbidden";
import AnnouncementComposeClient from "../AnnouncementComposeClient";
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default async function AdminAnnouncementNewPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "站点公告管理",
};
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
export default async function AdminAnnouncementsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
const SURFACE_LABEL: Record<BgSurface, string> = {
site: "前台背景",
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt={`${SURFACE_LABEL[surface]}候选图`}
className="w-full h-full object-cover"
loading="lazy"

View File

@@ -42,7 +42,7 @@ import {
} from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format";
import { postPath } from "@/lib/urlStyle";
import { postPath, commentAnchor } from "@/lib/urlStyle";
import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
@@ -692,7 +692,7 @@ export default function ContentAdmin({
c.post_title
) : (
<Link
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -22,6 +22,7 @@ import {
type FriendLinkStatus,
} from "@/lib/api";
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
initialLinks: FriendLink[];
@@ -237,9 +238,11 @@ export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
style={{ borderTop: "1px solid var(--line)" }}
>
{l.logo_url ? (
// 36px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
alt=""
width={36}
height={36}

View File

@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
import AdminShell from "@/components/admin/AdminShell";
import AdminSessionRecover from "./AdminSessionRecover";
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
};
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const cookie = authCookieHeader(cookieStore);
@@ -24,7 +24,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
const allowed =
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
!!me.user &&
(isStaff(me.user.role) || canAccessAdminMessages(me.user) || hasAnyChannelPerm(me.user));
if (!allowed || !me.user) {
// 已登录但无后台权限:保留提示卡片

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
BookOpen,
Download,
@@ -17,6 +17,7 @@ import {
apiAdminCreateLibraryDoc,
apiAdminDeleteLibraryDoc,
apiAdminDeleteLibraryFile,
apiAdminImportLibraryZip,
apiAdminListLibraryDocs,
apiAdminPurgeLibraryDoc,
apiAdminUpdateLibraryDoc,
@@ -25,17 +26,20 @@ import {
apiUploadPostImage,
type LibraryDoc,
type LibraryFileItem,
type LibraryImportReport,
type LibraryInput,
} from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { libraryDocPath } from "@/lib/urlStyle";
import { libraryDocPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import Avatar from "@/components/Avatar";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
import LibrarySectionsPanel from "./LibrarySectionsPanel";
import LibraryCoverPicker from "./LibraryCoverPicker";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
const ALLOWED_EXTS = new Set([
@@ -59,9 +63,12 @@ function formatSize(n: number): string {
interface DocForm {
title: string;
author: string;
slug: string;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto: boolean;
@@ -69,9 +76,12 @@ interface DocForm {
const EMPTY_FORM: DocForm = {
title: "",
author: "",
slug: "",
description: "",
cover_url: "",
cover_width: 0,
cover_height: 0,
published: false,
sort_order: 0,
entries_auto: false,
@@ -80,9 +90,12 @@ const EMPTY_FORM: DocForm = {
function formOfDoc(d: LibraryDoc): DocForm {
return {
title: d.title,
author: d.author ?? "",
slug: d.slug,
description: d.description ?? "",
cover_url: d.cover_url ?? "",
cover_width: d.cover_width ?? 0,
cover_height: d.cover_height ?? 0,
published: d.published,
sort_order: d.sort_order,
entries_auto: d.entries_auto ?? false,
@@ -145,6 +158,13 @@ export default function LibraryAdmin({
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
// 书库导入恢复(上传导出 ZIP)
const importInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
// 条目删除(软删)与彻底删除
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
@@ -170,6 +190,34 @@ export default function LibraryAdmin({
}
}
async function runImport(file: File, mode: "create" | "overwrite") {
setImporting(true);
setImportReport(null);
try {
const rep = await apiAdminImportLibraryZip(file, mode);
setImportReport(rep);
const okN = rep.imported.length;
const failN = rep.failed.length;
if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok");
else toast("没有书籍被导入,请查看失败明细", "error");
await reload();
if (mode === "create") {
const conflicts = rep.failed.filter((f) => f.reason === "conflict").length;
if (conflicts > 0) setImportOverwrite({ file, count: conflicts });
}
} catch (e) {
toast(e instanceof Error ? e.message : "导入失败");
} finally {
setImporting(false);
}
}
function onPickImportFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // 允许重选同一文件
if (file) void runImport(file, "create");
}
function openNew() {
setForm(EMPTY_FORM);
setSnapshot(JSON.stringify(EMPTY_FORM));
@@ -221,8 +269,11 @@ export default function LibraryAdmin({
const input: LibraryInput = {
slug,
title,
author: form.author.trim(),
description: form.description,
cover_url: form.cover_url.trim(),
cover_width: form.cover_width,
cover_height: form.cover_height,
published: form.published,
sort_order: form.sort_order,
entries_auto: form.entries_auto,
@@ -250,9 +301,13 @@ export default function LibraryAdmin({
if (!file) return;
setCoverBusy(true);
try {
const res = await apiUploadPostImage(file);
const res = await apiUploadPostImage(file, undefined, "library_cover");
if (res.url) {
patchForm({ cover_url: res.url });
patchForm({
cover_url: res.url,
cover_width: res.attachment?.width ?? 0,
cover_height: res.attachment?.height ?? 0,
});
} else {
setFormError(res.error || "封面上传失败");
}
@@ -267,8 +322,8 @@ export default function LibraryAdmin({
async function pickCoverFromMedia(attachmentId: number) {
setCoverBusy(true);
try {
const url = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url });
const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url, cover_width: width, cover_height: height });
} catch (e) {
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
} finally {
@@ -374,17 +429,103 @@ export default function LibraryAdmin({
title="书库管理"
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
actions={
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
<>
<a
href="/api/admin/library/export"
className="btn btn-ghost"
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
>
<Download size={15} aria-hidden />
导出全部
</a>
<button
type="button"
className="btn btn-ghost"
onClick={() => importInputRef.current?.click()}
disabled={importing}
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
>
{importing ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<Upload size={15} aria-hidden />
)}
{importing ? "导入中…" : "导入恢复"}
</button>
<input
ref={importInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onPickImportFile}
/>
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
</>
}
/>
{importReport ? (
<div
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
role="status"
style={{ borderColor: "var(--line)" }}
>
<div className="flex items-center gap-2">
<strong style={{ color: "var(--ink)" }}>
导入完成:成功 {importReport.imported.length} 本
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
{importReport.failed.length} 本
</strong>
<button
type="button"
className="ml-auto btn btn-ghost"
style={{ padding: "2px 6px" }}
onClick={() => setImportReport(null)}
aria-label="关闭导入结果"
>
<X size={14} aria-hidden />
</button>
</div>
{importReport.failed.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1">
{importReport.failed.map((f, i) => (
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
<span style={{ color: "var(--ink-2)" }}>
{f.title || f.slug}
{f.slug ? `(${f.slug})` : ""}
</span>
<span
style={{
color:
f.reason === "conflict" || f.reason === "deleted"
? "var(--warn, #b45309)"
: "var(--danger, #b91c1c)",
}}
>
{f.reason === "conflict"
? "同名书籍已存在,可覆盖导入"
: f.reason === "deleted"
? "地址被已删除条目占用,需先彻底删除"
: f.reason === "invalid"
? "备份数据不受支持"
: "导入失败"}
</span>
<span>{f.error}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
<div
className="admin-library-split"
data-view={editorOpen ? "editor" : "list"}
@@ -435,9 +576,11 @@ export default function LibraryAdmin({
}}
>
{d.cover_url ? (
// 36px 封面位:本站图走 48/96 变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={d.cover_url}
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
alt=""
className="w-9 h-9 rounded-lg object-cover shrink-0"
style={{ border: "1px solid var(--line)" }}
@@ -560,6 +703,16 @@ export default function LibraryAdmin({
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
: ""}
</span>
{typeof editingId === "number" ? (
<a
href={`/api/admin/library/docs/${editingId}/export`}
className="btn btn-line btn-sm shrink-0"
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
>
<Download size={13} aria-hidden />
导出备份
</a>
) : null}
</div>
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
@@ -586,6 +739,15 @@ export default function LibraryAdmin({
className="admin-doc-title"
/>
<AdminField
id="lib-author"
label="作者"
maxLength={100}
value={form.author}
onChange={(author) => patchForm({ author })}
placeholder="作者或编著者,多位可用逗号分隔(可选)"
/>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-slug">
访问地址
@@ -628,6 +790,7 @@ export default function LibraryAdmin({
placeholder="条目介绍,支持 Markdown…"
rows={12}
ariaLabel="条目介绍"
uploadSource="library_content"
/>
</div>
</div>
@@ -636,56 +799,58 @@ export default function LibraryAdmin({
<aside className="flex flex-col gap-4 lg:min-h-0">
<div className="admin-doc-field">
<p className="admin-doc-label">封面(可选)</p>
<div className="flex items-center gap-2.5">
<div className="flex flex-col gap-2">
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.cover_url}
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
alt="封面预览"
className="w-12 h-12 rounded-lg object-cover shrink-0"
className="w-full h-32 rounded-xl object-cover"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<button
type="button"
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
style={{ border: "1px dashed var(--control-border)" }}
aria-label="上传封面"
onClick={() => coverInputRef.current?.click()}
>
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
点击上传封面
</span>
</button>
)}
<div className="flex flex-col gap-1">
<div className="flex gap-1.5">
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
{form.cover_url ? (
<button
type="button"
className="admin-doc-textbtn"
onClick={() => patchForm({ cover_url: "" })}
className="admin-doc-textbtn ml-auto"
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
>
移除
</button>
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
<p className="admin-doc-hint">数字越小越靠前。</p>
</div>
{typeof editingId === "number" && editingDoc?.creator ? (
<div className="admin-doc-field">
<p className="admin-doc-label">创建者</p>
<a
href={userPath(urlStyle, editingDoc.creator.id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
style={{ color: "var(--ink)" }}
title={`@${editingDoc.creator.username}`}
>
<Avatar
size={22}
name={editingDoc.creator.nickname || editingDoc.creator.username}
src={editingDoc.creator.avatar}
/>
<span className="truncate">
{editingDoc.creator.nickname || editingDoc.creator.username}
</span>
</a>
<p className="admin-doc-hint">条目创建后归属不变。</p>
</div>
) : null}
{/* 附件管理:长列表列内滚动 */}
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
<p className="admin-doc-label shrink-0">
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
</section>
</div>
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
<ConfirmDialog
open={!!importOverwrite}
title="覆盖导入同名书籍?"
message={
importOverwrite
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
: ""
}
danger
busy={importing}
confirmLabel="覆盖导入"
cancelLabel="保留现有"
onCancel={() => !importing && setImportOverwrite(null)}
onConfirm={() => {
const pending = importOverwrite;
setImportOverwrite(null);
if (pending) void runImport(pending.file, "overwrite");
}}
/>
{/* 未保存关闭/切换确认 */}
<ConfirmDialog
open={!!pendingNav}

View File

@@ -4,8 +4,35 @@ import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
/** 来源标签映射 */
const SOURCE_LABELS: Record<string, string> = {
library_cover: "书籍封面",
library_content: "书籍内容",
post: "帖子插图",
};
function sourceLabel(source?: string): string {
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
}
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
const groups = new Map<string, MediaAttachment[]>();
for (const item of items) {
const key = item.source || "post";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 按优先级排序
const order = ["library_cover", "library_content", "post"];
return Array.from(groups.entries()).sort(
([a], [b]) => order.indexOf(a) - order.indexOf(b)
);
}
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
export default function LibraryCoverPicker({
open,
onClose,
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
};
}, [open]);
const groups = groupBySource(items);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
description="已选作封面的图片会单独分组,再次选择直接复用。"
onClose={onClose}
maxWidthClass="max-w-lg"
maxWidthClass="max-w-2xl"
dismissOnOverlay
>
{loading ? (
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
{groups.map(([source, groupItems]) => (
<section key={source}>
<h4
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={14} />
</span>
</button>
{sourceLabel(source)}
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
</h4>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{groupItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip={
item.source === "library_cover"
? "已作封面,点击直接复用"
: "复制一份用作封面"
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt="图库候选封面"
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
</section>
))}
</div>
)}

View File

@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
uploadSource="library_content"
/>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import LibraryAdmin from "./LibraryAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default async function AdminLibraryPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
import Modal from "@/components/Modal";
import AdminEmpty from "@/components/admin/AdminEmpty";
import type { MediaLibraryItem } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
return "—";
}
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
function thumbSrc(item: MediaLibraryItem): string {
if (item.url.startsWith("/uploads/")) {
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
// 网格缩略走统一变体端点(256 档);非本站图原样返回
if (isOwnImage(item.url)) {
return variantURL(item.url, 256);
}
return item.url;
}
@@ -143,6 +145,8 @@ export default function MediaLibrary({
<span className="block relative aspect-square overflow-hidden">
<img
src={thumbSrc(item)}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [160, 256, 384, 512]) : undefined}
sizes="(min-width:1280px) 16vw, (min-width:1024px) 25vw, (min-width:640px) 33vw, 50vw"
alt={item.name}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover"

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../../Forbidden";
import PageEditLoader from "./PageEditLoader";
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../Forbidden";
import PageComposeClient from "../PageComposeClient";
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default async function AdminPageNewPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default async function AdminPagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
const SLOT_LABEL: Record<BrandSlot, string> = {
logo_light: "浅色 Logo",
@@ -88,7 +89,14 @@ export default function BrandMediaPicker({
data-tip="用作草稿预览,点保存后才生效"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt={`${SLOT_LABEL[slot]}候选图`}
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}

View File

@@ -21,6 +21,7 @@ import {
type BrandSlot,
} from "@/lib/brand";
import { toast } from "@/lib/toast";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
import BrandMediaPicker from "./BrandMediaPicker";
type SlotDraft = {
@@ -233,8 +234,13 @@ export default function BrandSeoPanel({
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
<div className={`admin-brand-swatch ${swatchClass}`}>
{preview ? (
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" />
<img
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
alt=""
/>
) : (
<span>未设置</span>
)}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { toast } from "@/lib/toast";
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
type Side = "left" | "right";
type SectionId = SidebarScope | "widgets";
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
export default function SidebarAdmin({
initialConfig,
initialWidgets,
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
const [draftName, setDraftName] = useState("");
const [mixed, setMixed] = useState("");
const [deleteId, setDeleteId] = useState<number | null>(null);
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
setEditingId(null);
setDraftName("");
setMixed("");
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
setEditorOpen(true);
};
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
setEditingId(w.id);
setDraftName(w.name);
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
setEditorOpen(true);
};
const submitWidget = async () => {
if (!draftOk) return;
const doSubmitWidget = async (code: WidgetDraftCode) => {
setBusy(true);
try {
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
if (editingId == null) {
await apiAdminCreateSidebarWidget(code);
toast("工具已创建", "ok");
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
toast("工具已保存", "ok");
}
setEditorOpen(false);
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
await reloadAll();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
}
};
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
const submitWidget = () => {
if (!draftOk || busy) return;
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
const issues = lintWidgetCode(code);
setLintIssues(issues.length ? issues : null);
if (issues.some((i) => i.level === "error")) return;
const warns = issues.filter((i) => i.level === "warn");
if (warns.length > 0) {
pendingCodeRef.current = code;
setWarnConfirmOpen(true);
return;
}
void doSubmitWidget(code);
};
const confirmWarnSubmit = () => {
const code = pendingCodeRef.current;
pendingCodeRef.current = null;
setWarnConfirmOpen(false);
if (code) void doSubmitWidget(code);
};
const cancelWarnSubmit = () => {
pendingCodeRef.current = null;
setWarnConfirmOpen(false);
};
const toggleWidget = async (w: SidebarWidgetRecord) => {
setBusy(true);
try {
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
<SectionHead
icon={<Code2 size={16} />}
title="自定义工具"
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
/>
<div className="p-4 sm:p-5">
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
footer={
<>
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
</button>
</>
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
<textarea
className="field w-full min-h-[240px] font-mono text-[12.5px]"
value={mixed}
onChange={(e) => setMixed(e.target.value)}
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
onChange={(e) => {
setMixed(e.target.value);
setLintIssues(null);
}}
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
/>
</label>
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
<p><code>J13.theme.get()</code> 返回 &quot;light&quot; | &quot;dark&quot;;<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
</div>
</details>
<p className="meta text-[11.5px]">
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
</p>
</div>
</Modal>
{/* 仅 warn 级问题时的二次确认 */}
<ConfirmDialog
open={warnConfirmOpen}
title="代码存在风险提醒"
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
confirmLabel="仍要保存"
busy={busy}
onConfirm={confirmWarnSubmit}
onCancel={cancelWarnSubmit}
/>
{/* 删除工具确认 */}
<ConfirmDialog
open={deleteId != null}
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
);
}
/* ─── 工具代码静态扫描结果面板 ─── */
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
js: "JS",
css: "CSS",
html: "HTML",
};
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
const errors = issues.filter((i) => i.level === "error");
const warns = issues.filter((i) => i.level === "warn");
return (
<div className="space-y-2">
{errors.length > 0 && (
<div
className="rounded-xl border p-2.5"
style={{
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
background: "var(--danger-soft)",
}}
>
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
<AlertTriangle size={13} aria-hidden />
{errors.length} 个错误,修复后才能保存
</p>
<ul className="space-y-1">
{errors.map((issue, idx) => (
<LintIssueRow key={`e-${idx}`} issue={issue} />
))}
</ul>
</div>
)}
{warns.length > 0 && (
<div
className="rounded-xl border p-2.5"
style={{
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
background: "var(--gold-soft)",
}}
>
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
<Info size={13} aria-hidden />
{warns.length} 个提醒,确认风险后仍可保存
</p>
<ul className="space-y-1">
{warns.map((issue, idx) => (
<LintIssueRow key={`w-${idx}`} issue={issue} />
))}
</ul>
</div>
)}
</div>
);
}
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
return (
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
<span
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
>
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
</span>
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
</li>
);
}
/* ─── 分区卡片头 ─── */
function SectionHead({
icon,

View File

@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import RoleBadge from "@/components/RoleBadge";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import {
AdminEmpty,
AdminPager,
@@ -356,7 +356,7 @@ export default function UserAuditClient({
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
) : (
<Link
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="text-[11.5px] hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -29,6 +29,7 @@ import {
AD_IMAGE_WIDTH,
AD_TEXT_COL_WIDTH,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type Kind = "image" | "text";
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(160)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 160) : undefined}
alt="收款码"
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
style={{ borderColor: "var(--line)" }}
@@ -381,7 +383,8 @@ export default function AdsBuyClient() {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(144)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 144) : undefined}
alt={`${selectedPay.name}收款码`}
className="h-36 w-36 object-contain"
/>

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { Megaphone } from "lucide-react";
import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(a.content);
@@ -116,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
showTimeOfDay
badges={
<>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
</>
}

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
>
置顶公告
</span>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
@@ -87,7 +91,11 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
@@ -133,7 +141,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;

View File

@@ -18,6 +18,7 @@ import {
Copy,
ChevronsDown,
Smile,
VolumeX,
} from "lucide-react";
import {
apiListChatMessages,
@@ -41,11 +42,13 @@ import {
RT_CHAT_MESSAGE,
RT_CHAT_MEMBERSHIP,
RT_CHAT_RECALLED,
RT_CHAT_MEMBER_MUTED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
RtChatMemberMutedData,
} from "@/lib/realtime";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
@@ -55,6 +58,7 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import EmojiPicker from "@/components/EmojiPicker";
@@ -591,8 +595,21 @@ export default function ChatRoomClient({
y: number;
message: ChatMessage;
} | null>(null);
/** 头像右键菜单:对成员的管理操作 */
const [userCtxMenu, setUserCtxMenu] = useState<{
x: number;
y: number;
user: User;
member?: ChatRoomMember;
} | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
/** 禁言弹窗(含时长选择,QQ 风格) */
const [muteTarget, setMuteTarget] = useState<{
uid: number;
nickname: string;
} | null>(null);
const [muteBusy, setMuteBusy] = useState(false);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
@@ -684,7 +701,7 @@ export default function ChatRoomClient({
const openUserCard = useCallback(
(u: User) => {
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
},
[members]
);
@@ -856,6 +873,40 @@ export default function ChatRoomClient({
return off;
}, [roomId, router]);
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
useEffect(() => {
const off = realtime.on<RtChatMemberMutedData>(
RT_CHAT_MEMBER_MUTED,
(data) => {
if (!data || data.room_id !== roomId) return;
setMembers((prev) =>
prev.map((m) =>
m.user_id === data.user_id
? { ...m, muted: data.muted, muted_until: data.muted_until }
: m
)
);
}
);
return off;
}, [roomId, members, meId]);
// 禁言/解禁系统提示(居中展示,类似撤回提示)
const mutedNotice = useMemo(() => {
const mem = members.find((m) => m.user_id === meId);
if (!mem?.muted) return null;
return mem;
}, [members, meId]);
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
setMembers((prev) =>
prev.map((m) =>
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
)
);
});
const loadMore = useCallback(async () => {
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
setLoadingMore(true);
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
const handleRichInput = () => {
const el = inputRef.current;
if (!el) return;
if (!el || mutedNotice) return;
const text = domToText(el);
if (text === lastRichText.current) return;
if (text.length > 2000) {
@@ -1153,6 +1204,10 @@ export default function ChatRoomClient({
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// IME 组合中不拦截按键
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (mutedNotice) {
e.preventDefault();
return;
}
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -1196,6 +1251,10 @@ export default function ChatRoomClient({
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
if (mutedNotice) {
e.preventDefault();
return;
}
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
@@ -1309,23 +1368,55 @@ export default function ChatRoomClient({
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
if (muted) {
// 打开时长选择弹窗(QQ 风格)
setMuteTarget({ uid, nickname });
return;
}
setConfirm({
title: muted ? "禁言成员" : "解除禁言",
message: muted
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
: `确定解除 ${nickname} 的禁言?`,
confirmLabel: muted ? "禁言" : "解禁",
danger: muted,
title: "解除禁言",
message: `确定解除 ${nickname} 的禁言?`,
confirmLabel: "解禁",
action: async () => {
await apiMuteChatMember(roomId, uid, muted);
await apiMuteChatMember(roomId, uid, false);
setMembers((prev) =>
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
prev.map((m) =>
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
)
);
toast(muted ? "已禁言" : "已解禁", "ok");
toast("已解禁", "ok");
},
});
};
const submitMute = async (durationMinutes: number) => {
if (!muteTarget) return;
setMuteBusy(true);
try {
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
const until =
durationMinutes > 0
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
: null;
setMembers((prev) =>
prev.map((m) =>
m.user_id === muteTarget.uid
? { ...m, muted: true, muted_until: until }
: m
)
);
toast(
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
"ok"
);
setMuteTarget(null);
} catch (e) {
toast(e instanceof Error ? e.message : "禁言失败");
} finally {
setMuteBusy(false);
}
};
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
if (!siteOwner) return;
setConfirm({
@@ -1454,6 +1545,62 @@ export default function ChatRoomClient({
return items;
};
/** 用户头像右键菜单:按权限展示成员管理项 */
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
const close = () => setUserCtxMenu(null);
const items: CtxMenuItem[] = [
{
key: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => {
close();
openUserCard(u);
},
},
];
if (u.id === meId || isDirect) return items;
const isTargetOwner = mem?.role === "owner";
const isTargetAdmin = mem?.role === "admin";
if (canKickMembers && !isTargetOwner) {
const muted = mem?.muted ?? false;
items.push({
key: "mute",
label: muted ? "解禁" : "禁言",
icon: <VolumeX size={14} />,
danger: !muted,
onClick: () => {
close();
handleMute(u.id, u.nickname || u.username, !muted);
},
});
items.push({
key: "kick",
label: "移出群聊",
icon: <UserX size={14} />,
danger: true,
onClick: () => {
close();
handleKick(u.id, u.nickname || u.username);
},
});
}
if (siteOwner && !isTargetOwner) {
items.push({
key: "setAdmin",
label: isTargetAdmin ? "取消管理员" : "设为管理员",
icon: <Crown size={14} />,
onClick: () => {
close();
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
},
});
}
return items;
};
// 点击空白关闭右键菜单 — ContextMenu 自带
const shellClass = embedded
@@ -1650,6 +1797,23 @@ export default function ChatRoomClient({
5 * 60_000;
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 系统消息(如禁言通知):居中灰字
if (m.sender_id === 0) {
return (
<div
key={m.id}
className="flex justify-center px-2 py-0.5"
>
<p
className="text-[12px] leading-relaxed text-center max-w-[90%]"
style={{ color: "var(--ink-3)" }}
>
{m.content}
</p>
</div>
);
}
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
if (recalled && !(canModerateChat && m.content)) {
return (
@@ -1704,10 +1868,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1715,7 +1895,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -1784,10 +1964,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1795,7 +1991,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -2028,18 +2224,32 @@ export default function ChatRoomClient({
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
<button
type="button"
disabled={!!mutedNotice}
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
emojiOpen ? " bg-[var(--accent-soft)]" : " hover:bg-[var(--panel-2)]"
mutedNotice
? " opacity-40 cursor-not-allowed"
: emojiOpen
? " bg-[var(--accent-soft)]"
: " hover:bg-[var(--panel-2)]"
}`}
style={{ color: emojiOpen ? "var(--accent)" : "var(--ink-2)" }}
aria-label="表情"
style={{
color: mutedNotice
? "var(--ink-3)"
: emojiOpen
? "var(--accent)"
: "var(--ink-2)",
}}
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
aria-expanded={emojiOpen}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setEmojiOpen((v) => !v)}
onClick={() => {
if (mutedNotice) return;
setEmojiOpen((v) => !v);
}}
>
<Smile size={19} />
</button>
{emojiOpen && (
{emojiOpen && !mutedNotice && (
<div className="j13-chat-emoji-panel">
<EmojiPicker onPick={insertEmoji} />
</div>
@@ -2047,17 +2257,28 @@ export default function ChatRoomClient({
</div>
<div
ref={inputRef}
contentEditable
contentEditable={!mutedNotice}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="输入消息"
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
aria-label={mutedNotice ? "禁言中" : "输入消息"}
data-placeholder={
mutedNotice
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
: isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
onInput={handleRichInput}
onKeyDown={handleRichKeyDown}
onPaste={handleRichPaste}
onFocus={(e) => {
if (mutedNotice) {
e.currentTarget.blur();
return;
}
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
@@ -2067,7 +2288,7 @@ export default function ChatRoomClient({
<button
type="button"
onClick={send}
disabled={sending || !content.trim()}
disabled={sending || !content.trim() || !!mutedNotice}
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
aria-label="发送"
>
@@ -2156,6 +2377,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
/>
@@ -2204,6 +2426,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
showTitle
@@ -2220,6 +2443,15 @@ export default function ChatRoomClient({
/>
)}
{userCtxMenu && (
<ContextMenu
x={userCtxMenu.x}
y={userCtxMenu.y}
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
onClose={() => setUserCtxMenu(null)}
/>
)}
{userCard && (
<ChatUserCard
target={userCard}
@@ -2240,10 +2472,179 @@ export default function ChatRoomClient({
if (!confirmBusy) setConfirm(null);
}}
/>
<MuteModal
open={!!muteTarget}
nickname={muteTarget?.nickname ?? ""}
busy={muteBusy}
onConfirm={submitMute}
onClose={() => {
if (!muteBusy) setMuteTarget(null);
}}
/>
</div>
);
}
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
const MUTE_DURATIONS: { value: number; label: string }[] = [
{ value: 10, label: "10 分钟" },
{ value: 60, label: "1 小时" },
{ value: 12 * 60, label: "12 小时" },
{ value: 24 * 60, label: "1 天" },
{ value: 3 * 24 * 60, label: "3 天" },
{ value: 7 * 24 * 60, label: "7 天" },
{ value: 30 * 24 * 60, label: "30 天" },
{ value: 0, label: "永久" },
];
function formatMuteDuration(minutes: number): string {
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
}
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
function formatMuteCountdown(untilIso: string): string {
const until = new Date(untilIso).getTime();
const diff = until - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return `${days} 天 ${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
}
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
function formatMuteRemainShort(until?: string | null): string {
if (!until) return "";
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return ` ${days}d`;
if (hours > 0) return ` ${hours}h`;
return ` ${minutes}m`;
}
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
function useCountdown(
until?: string | null,
onExpire?: () => void
): string | null {
const [text, setText] = useState<string | null>(null);
const expiredRef = useRef(false);
useEffect(() => {
expiredRef.current = false;
if (!until) {
setText(null);
return;
}
const update = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
if (!expiredRef.current) {
expiredRef.current = true;
onExpire?.();
}
setText("已到期");
return;
}
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
const seconds = Math.floor((diff % 60_000) / 1000);
if (days > 0) setText(`${days} 天 ${hours} 小时`);
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
else setText(`${seconds} 秒`);
};
update();
const t = setInterval(update, 1000);
return () => clearInterval(t);
}, [until, onExpire]);
return text;
}
/** 禁言成员弹窗(QQ 风格:选择时长) */
function MuteModal({
open,
nickname,
busy,
onConfirm,
onClose,
}: {
open: boolean;
nickname: string;
busy: boolean;
onConfirm: (durationMinutes: number) => void;
onClose: () => void;
}) {
const [duration, setDuration] = useState<number>(24 * 60);
useEffect(() => {
if (open) setDuration(24 * 60);
}, [open]);
return (
<Modal
open={open}
title="禁言成员"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-sm"
role="alertdialog"
footer={
<>
<button
type="button"
onClick={onClose}
disabled={busy}
className="btn btn-line btn-sm"
>
取消
</button>
<button
type="button"
onClick={() => onConfirm(duration)}
disabled={busy}
className="btn btn-primary btn-sm"
>
{busy ? "禁言中..." : "禁言"}
</button>
</>
}
>
<div className="space-y-3">
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
</p>
<div className="grid grid-cols-2 gap-2">
{MUTE_DURATIONS.map((d) => {
const active = d.value === duration;
return (
<button
key={d.value}
type="button"
onClick={() => setDuration(d.value)}
className="h-9 rounded-lg text-[13px] transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--bg)",
color: active ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
}}
>
{d.label}
</button>
);
})}
</div>
</div>
</Modal>
);
}
function JoinButton({
roomId,
onJoined,
@@ -2361,7 +2762,7 @@ function MembersPanel({
{name}
{m.muted ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
· 禁言
· 禁言{formatMuteRemainShort(m.muted_until)}
</span>
) : null}
</Link>

View File

@@ -6,7 +6,7 @@ export default function ChatLayout({ children }: { children: React.ReactNode })
return (
<div
data-chat-viewport
className="flex flex-col flex-1 min-h-0 w-full max-w-6xl mx-auto"
className="flex flex-col flex-1 min-h-0 w-full"
>
{children}
</div>

View File

@@ -376,6 +376,11 @@ html[data-lv-fx="off"] .lv-text {
hsl(var(--cover-h) 28% 16%);
}
/* 书库页标题:书香气衬线(宋体/楷体回退) */
.j13-library-title {
font-family: "Songti SC", "STSong", "SimSun", "Noto Serif CJK SC", serif;
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
@@ -2421,14 +2426,23 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
height: 32px;
padding: 0 8px;
}
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用) */
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用)。
空间不足时允许「编写/预览」折到第二行,不能让固定宽按钮溢出后与模式切换重叠 */
.j13-md-editor.is-narrow .j13-md-toolbar {
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 2px;
row-gap: 6px;
padding: 2px 0 8px;
}
.j13-md-editor.is-narrow .j13-md-tool-groups {
flex-wrap: nowrap;
/* 默认 min-width:0 会让本组被压缩、内部按钮溢出到模式切换下方;
内容宽度作为最小宽,放不下时把右侧切换挤到下一行 */
min-width: max-content;
}
.j13-md-editor.is-narrow .j13-md-toolbar-end {
/* 单行时左侧 flex-1 已把它顶到右侧;折行后在第二行保持右对齐 */
margin-left: auto;
}
.j13-md-editor.is-narrow .j13-md-tool {
width: 30px;
@@ -6489,29 +6503,23 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
.badge-pin {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 4px 11px;
background: var(--clay-soft);
color: var(--clay);
border-radius: 4px;
white-space: nowrap;
}
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
.badge-pin,
.badge-rec {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 4px 11px;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
}
.badge-pin {
background: var(--clay-soft);
color: var(--clay);
}
.badge-rec {
background: var(--gold-soft);
color: var(--gold);
border-radius: 4px;
white-space: nowrap;
}
.badge-pending {
display: inline-flex;
@@ -8206,6 +8214,15 @@ a.j13-mention-token:hover {
}
}
/* 书库全书搜索:正文命中样式由 BookSearch 运行时注入 ::highlight() 规则
(构建器 CSS 压缩器不识别该伪元素);.j13-find-mark 仅用于搜索弹窗片段预览 */
.j13-find-mark {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: inherit;
border-radius: 3px;
padding: 0 0.1em;
}
/* ============================================================
Markdown 正文
============================================================ */
@@ -8763,6 +8780,14 @@ html.dark .prose-content pre[data-theme] span {
}
}
/* 移动端聚焦评论框(主评论/回复/编辑)时隐藏回顶浮钮:
虚拟键盘弹起后浮钮会贴近输入区造成遮挡与误触,失焦后自动恢复 */
@media (max-width: 1023px) {
body:has(#comments .j13-md-textarea:focus) .j13-back-top {
display: none;
}
}
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
@media (max-width: 1023px) {
body:has(.j13-fluid-home) #main {
@@ -9284,12 +9309,12 @@ button.cmt-edited {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
/* 评论内联回复表单:出现在目标评论正下方 */
/* 评论内联回复表单:与楼层块同级全宽显示(DOM 见 CommentSection 列表 map)。
前侧间距由 .cmt-item 的 padding-bottom 提供 */
.cmt-reply-form {
padding: 12px 14px;
border-radius: 14px;
background: var(--panel-2);
margin-top: 8px;
}
/* 评论区编辑器:矮输入区,底栏含提交 */
@@ -9312,12 +9337,32 @@ button.cmt-edited {
flex-wrap: wrap;
}
@container (max-width: 420px) {
/* 手机端评论栏:工具按钮收到 30px(与 is-narrow 一致),「编写/预览」收成图标,
保证核心工具与「更多」单行排开不与模式切换重叠;
极端窄宽(<340px)放不下时,模式切换整体换到第二行而不是压在「更多」上 */
.cmt-md-editor .j13-md-editor.is-comment .j13-md-toolbar {
flex-wrap: wrap;
row-gap: 6px;
}
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
width: 36px;
height: 36px;
width: 30px;
height: 30px;
}
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool-groups {
flex: 1 0 auto;
min-width: min-content;
}
.cmt-md-editor .j13-md-toolbar-end {
margin-left: auto;
}
.cmt-md-editor .j13-md-mode button {
padding: 0 6px;
width: 30px;
padding: 0;
gap: 0;
justify-content: center;
}
.cmt-md-editor .j13-md-mode-text {
display: none;
}
}
@@ -9943,15 +9988,12 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
outline-offset: -2px;
}
/* ── 侧栏管理:站长自定义工具容器 ──
/* ── 侧栏管理:站长自定义工具容器(Shadow DOM 宿主) ──
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
仅保证在侧栏列内不横向溢出。 */
仅保证在侧栏列内不横向溢出,工具内容隔离在 shadow 内(见 widgetRuntime)。 */
.j13-widget-body {
min-width: 0;
}
.j13-widget-body > .j13-widget-html {
min-width: 0;
}
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
.j13-emoji-picker {

View File

@@ -140,6 +140,18 @@ export default async function RootLayout({
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
>
<body className="min-h-screen flex flex-col">
{/* Noto Sans SC 字体声明异步加载(postbuild 抽到 public/fonts/noto-sans-sc.css):
动态插入的 stylesheet 不阻塞渲染;度量校正 fallback 面仍在阻塞 CSS 中,
首屏先用栈内系统字体(PingFang/雅黑),切片就绪后 swap */}
<script
dangerouslySetInnerHTML={{
__html:
'(function(){var l=document.createElement("link");l.rel="stylesheet";l.href="/fonts/noto-sans-sc.css";document.head.appendChild(l)})();',
}}
/>
<noscript>
<link rel="stylesheet" href="/fonts/noto-sans-sc.css" />
</noscript>
{accentCSS ? (
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
<style

View File

@@ -9,13 +9,14 @@ import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { SiteDocMasthead } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import { libraryDocPath, libraryDocReadPath, userPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
import { estimateReadingMinutes } from "@/lib/toc";
import { groupSections } from "@/lib/librarySections";
import LightboxImage from "@/components/LightboxImage";
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
import Avatar from "@/components/Avatar";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -64,23 +65,27 @@ function ExtChip({ ext }: { ext: string }) {
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
className="rounded-xl px-3 py-2.5"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
<div className="flex items-center gap-2 min-w-0">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
className="text-[13px] font-medium min-w-0 truncate"
style={{ color: "var(--ink)" }}
title={f.name}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="meta text-[11.5px] shrink-0">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
className="btn btn-sm btn-primary inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
@@ -159,12 +164,23 @@ export default async function LibraryDocPage({ params }: PageProps) {
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="landscape"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<div
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[768, 1080, 1920]}
sizes="(max-width:1280px) 92vw, 1080px"
/>
</div>
</AdaptiveCoverSlot>
) : null}
@@ -175,18 +191,40 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="portrait"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<div
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[384, 768]}
sizes="(max-width:1023px) 92vw, 340px"
/>
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
{d.author ? (
<div className="flex items-start justify-between gap-3">
<dt className="meta shrink-0">作者</dt>
<dd
className="font-semibold text-right min-w-0"
style={{ color: "var(--ink)" }}
>
{d.author}
</dd>
</div>
) : null}
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
@@ -199,6 +237,30 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.files.length}
</dd>
</div>
{d.creator ? (
<div className="flex items-center justify-between gap-2">
<dt className="meta shrink-0">创建者</dt>
<dd className="min-w-0">
<Link
href={userPath(urlStyle, d.creator.id)}
className="inline-flex items-center gap-1.5 min-w-0"
title={`@${d.creator.username}`}
>
<Avatar
size={20}
name={d.creator.nickname || d.creator.username}
src={d.creator.avatar}
/>
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{d.creator.nickname || d.creator.username}
</span>
</Link>
</dd>
</div>
) : null}
</dl>
{chapterCount > 0 ? (
<Link
@@ -208,9 +270,28 @@ export default async function LibraryDocPage({ params }: PageProps) {
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
{/* 附件下载紧跟阅读按钮:左栏桌面端 sticky 常驻,移动端排在介绍之前,
避免介绍过长时用户需要长距离滚动寻找下载入口 */}
{d.files.length > 0 ? (
<section id="files" className="mt-5 scroll-mt-24">
<h2
className="text-[13.5px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={14} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
<div className="mt-2.5 flex flex-col gap-2">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
</section>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
{/* 右栏:介绍(附件下载已移至左栏阅读按钮下方) */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
@@ -228,25 +309,6 @@ export default async function LibraryDocPage({ params }: PageProps) {
)}
</div>
</section>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>

View File

@@ -21,7 +21,9 @@ import { splitContentSegments } from "@/lib/directiveBlocks";
import { wrapImplicitEntries } from "@/lib/entryBlocks";
import PostToc, { type TocItem } from "@/components/PostToc";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import BookSearch from "@/components/BookSearch";
import { formatRelative } from "@/lib/format";
import { markdownToSearchText, type BookChapterIndex } from "@/lib/bookSearch";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -127,6 +129,18 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
);
// 全书搜索索引:详情接口已一次返回全部章节,这里把每章(含小节标题/正文)
// 剥成可见纯文本随 RSC 下发给 BookSearch,无需额外交互请求
const searchIndex: BookChapterIndex[] = groups.map((g, i) => ({
no: i + 1,
title: g.chapter.title,
plain: markdownToSearchText(
[g.chapter.title, g.chapter.content, ...g.subs.flatMap((s) => [s.title, s.content])]
.filter(Boolean)
.join("\n\n")
),
}));
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
const bookRail = (
<div className="flex flex-col gap-3">
@@ -247,11 +261,19 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
<div className="j13-doc-bleed min-w-0">
<article className="panel p-6 sm:p-8">
<article id="j13-book-article" className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<div className="flex items-center justify-between gap-3">
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<BookSearch
slug={d.slug}
urlStyle={urlStyle}
currentNo={nav.index + 1}
chapters={searchIndex}
/>
</div>
<h2
id="ch-top"
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"

View File

@@ -1,106 +1,9 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import Link from "next/link";
import { BookOpen, FileDown } from "lucide-react";
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
function TextCover({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 break-all"
>
{name}
</span>
</div>
);
}
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function DocCard({
doc,
href,
}: {
doc: LibraryDocListItem;
href: string;
}) {
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
return (
<Link
href={href}
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={doc.cover_url}
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<h2
className="text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
<div className="mt-auto flex flex-wrap items-center gap-1.5">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
</div>
</Link>
);
}
import { libraryPath } from "@/lib/urlStyle";
import LibrarySearchGrid from "@/components/LibrarySearchGrid";
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
@@ -113,7 +16,6 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function LibraryPage() {
const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
// 接口失败降级为空态,不让页面 500
let docs: LibraryDocListItem[] = [];
@@ -126,53 +28,52 @@ export default async function LibraryPage() {
return (
<div className="min-w-0">
{/* Hero:渐变洗底(与排行榜同语言) */}
{/* Hero:书香气——serif 大标题、两侧晕染、底部水纹线 */}
<section
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
className="relative overflow-hidden px-6 py-12 sm:px-12 sm:py-16 text-center"
style={{
background:
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
border: "1px solid var(--line)",
"radial-gradient(140% 180% at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%)," +
"radial-gradient(80% 120% at 8% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)," +
"radial-gradient(80% 120% at 92% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)",
}}
>
<div
aria-hidden
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full pointer-events-none"
style={{
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
filter: "blur(48px)",
background: "color-mix(in srgb, var(--accent) 10%, transparent)",
filter: "blur(72px)",
}}
/>
<div className="relative">
<p
className="text-[11px] sm:text-[12px] font-semibold tracking-[0.35em] uppercase mb-4"
style={{ color: "var(--accent)" }}
>
Library
</p>
<h1
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
className="j13-library-title text-[32px] sm:text-[44px] leading-tight font-bold tracking-wide"
style={{ color: "var(--ink)" }}
>
<BookOpen size={26} style={{ color: "var(--accent)" }} />
书库
</h1>
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
<div
aria-hidden
className="mx-auto mt-5 h-px w-24 sm:w-32"
style={{
background:
"linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 40%, var(--line)), transparent)",
}}
/>
<p className="meta mt-4 text-[13.5px] sm:text-[14px] tracking-wide">
书籍、文档与页面作品,在线阅读或下载
</p>
</div>
</section>
{docs.length === 0 ? (
<div
className="mt-6 rounded-[20px] px-6 py-14 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{docs.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
<LibrarySearchGrid docs={docs} urlStyle={settings.url_style} />
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
type FriendLinkStatus,
} from "@/lib/api";
import LinksClient from "./LinksClient";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
links: FriendLink[];
@@ -240,9 +241,11 @@ export default function LinksBoard({
{/* 头部:LOGO + 名称 + 状态 */}
<div className="flex items-center gap-3">
{edit.logo_url ? (
// 40px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={edit.logo_url}
src={isOwnImage(edit.logo_url) ? variantURL(edit.logo_url, pickVariantWidth(40)) : edit.logo_url}
srcSet={isOwnImage(edit.logo_url) ? densitySrcSet(edit.logo_url, 40) : undefined}
alt=""
width={40}
height={40}
@@ -475,9 +478,11 @@ export default function LinksBoard({
}}
>
{l.logo_url ? (
// 48px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(48)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 48) : undefined}
alt=""
loading="lazy"
width={48}

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
isLoggedIn: boolean;
@@ -155,9 +156,11 @@ export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
aria-hidden
>
{form.logo_url ? (
// 64px 预览位:本站图走 96/160 变体;外链地址保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logo_url}
src={isOwnImage(form.logo_url) ? variantURL(form.logo_url, pickVariantWidth(64)) : form.logo_url}
srcSet={isOwnImage(form.logo_url) ? densitySrcSet(form.logo_url, 64) : undefined}
alt=""
className="h-full w-full object-contain"
onError={(e) => {

View File

@@ -4,7 +4,7 @@ import { fetchFriendLinks, fetchSiteState, type FriendLink } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { brandAliasPath } from "@/lib/brand";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { requestCanonical } from "@/lib/canonicalMeta";
import { resolveSiteOrigin } from "@/lib/siteUrl";
import LinksBoard from "./LinksBoard";
@@ -34,7 +34,7 @@ export default async function FriendLinksPage() {
getMeCached(cookie || undefined),
]);
const isLoggedIn = !!me.user;
const canManage = !!me.user && isAdminOrAbove(me.user.role);
const canManage = !!me.user && hasPerm(me.user, PERMS.ANNOUNCEMENTS);
// 站点 LOGO 用固定对外地址(换图不变链,对方收藏的地址永不失效),补全为绝对地址供申请人直接复制
const siteBase = siteState.site_url?.trim() || resolveSiteOrigin() || "";
const logoAlias = settings.logo_light_url

View File

@@ -3,18 +3,28 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus, ChevronRight } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
* 布局:左栏 sticky 概览(未读数/全部已读)+ 右列紧凑列表(单条两行内)。
*/
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
export default function NotificationsClient({
initialData,
initialUnread,
children,
}: {
initialData: NotificationsResponse;
initialUnread: number;
children?: React.ReactNode;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const [unreadCount, setUnreadCount] = useState(initialUnread);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
@@ -22,6 +32,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
setUnreadCount((c) => Math.max(0, c - 1));
}
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
@@ -32,9 +43,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) {
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, n.post_id));
}
@@ -43,6 +56,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
setUnreadCount(0);
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
router.refresh();
};
@@ -57,157 +71,176 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return `${Math.floor(hour / 24)} 天前`;
};
const hasUnread = notifications.some((n) => !n.is_read);
const iconOf = (n: NotificationItem) => {
const t = n.type;
const node =
t === "approved" ? <CheckCircle2 size={15} />
: t === "rejected" || t === "deleted" ? <XCircle size={15} />
: t === "necro_reply" ? <AlertTriangle size={15} />
: t === "like" ? <Heart size={15} />
: t === "mention" ? <AtSign size={15} />
: t === "pending_review" ? <ClipboardList size={15} />
: t === "badge" ? <Award size={15} />
: t === "follow" ? <UserPlus size={15} />
: <MessageCircle size={15} />;
const color =
t === "approved" ? "var(--ok)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger)"
: t === "like" ? "var(--clay)"
: t === "badge" ? "var(--gold)"
: "var(--accent)";
const bg =
t === "approved" ? "var(--ok-soft)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger-soft)"
: t === "like" ? "var(--clay-soft)"
: t === "badge" ? "var(--gold-soft)"
: "var(--accent-soft)";
return { node, color, bg };
};
return (
<>
{hasUnread && (
<div className="flex justify-end mb-4">
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
{/* 左栏:概览 + 全部已读(lg 起 sticky) */}
<aside className="panel p-4 sm:p-5 lg:sticky lg:top-[var(--rail-top)]">
<p className="text-xs font-bold tracking-widest uppercase" style={{ color: "var(--ink-3)" }}>概览</p>
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className="text-[30px] leading-none font-extrabold"
style={{ color: unreadCount > 0 ? "var(--accent)" : "var(--ink)", fontVariantNumeric: "tabular-nums" }}
>
{unreadCount}
</span>
<span className="meta">条未读</span>
</div>
<p className="meta mt-1.5">共 {initialData.total} 条通知</p>
<div className="my-3.5" style={{ borderTop: "1px solid var(--line)" }} />
{unreadCount > 0 ? (
<button onClick={handleMarkAll} className="btn btn-line btn-sm w-full justify-center">
<CheckCheck size={14} /> 全部已读
</button>
</div>
)}
) : (
<p className="meta flex items-center gap-1.5" style={{ color: "var(--ok)" }}>
<CheckCircle2 size={14} /> 通知都已读完
</p>
)}
</aside>
{notifications.length === 0 ? (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Bell size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "necro_reply"
? <AlertTriangle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: n.type === "follow"
? <UserPlus size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: n.type === "badge"
? "var(--gold)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
: n.type === "badge"
? "var(--gold-soft)"
: "var(--accent-soft)";
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
{/* 右列:紧凑通知列表 + 分页 */}
<div className="min-w-0">
{notifications.length === 0 ? (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<span
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
style={{ color: iconColor, background: iconBg }}
>
{iconNode}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
{approval ? (
<Bell size={22} />
</span>
<p className="font-bold" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const icon = iconOf(n);
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className="relative w-full text-left pl-4 pr-3 sm:pr-4 py-2.5 flex items-center gap-3 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
>
{/* 未读:左侧主题色竖条(已读透明占位,文本基线对齐不跳) */}
<span
aria-hidden
className="absolute left-0 top-2 bottom-2 w-[3px] rounded-full"
style={{ background: n.is_read ? "transparent" : "var(--accent)" }}
/>
<span
className="w-[30px] h-[30px] shrink-0 rounded-[9px] flex items-center justify-center"
style={{ color: icon.color, background: icon.bg }}
>
{icon.node}
</span>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] leading-snug flex items-baseline gap-x-1.5" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-bold shrink-0"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : system ? (
<span className="font-bold shrink-0" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold shrink-0 max-w-[10em] truncate hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>
<span className="meta shrink-0">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在群中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
{n.type === "mention"
? n.room && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</span>
)
: n.post && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</span>
)}
<span className="meta text-xs shrink-0 ml-auto pl-2">{formatTime(n.created_at)}</span>
</p>
{n.content && (
<p className="text-[13px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
</div>
{n.type === "follow" && (
<span
className="font-bold"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
className="shrink-0 inline-flex items-center gap-0.5 text-xs font-semibold"
style={{ color: "var(--accent)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
查看主页 <ChevronRight size={13} />
</span>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>{" "}
<span className="meta">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
</p>
{n.type === "mention" ? (
n.room && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</p>
)
) : (
n.post && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</p>
)
)}
{n.content && (
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
</div>
{!n.is_read && (
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
)}
</button>
);
})}
</div>
)}
</button>
);
})}
</div>
)}
{children}
</div>
</>
);
}

View File

@@ -30,8 +30,8 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
<div className="w-full">
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight mb-4 sm:mb-5" style={{ color: "var(--ink)" }}>
通知中心
</h1>
@@ -42,15 +42,17 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
<Link href="/notifications" className="btn btn-line btn-sm">重新加载</Link>
</div>
) : (
<>
<NotificationsClient initialData={data} />
<Pagination
total={data.total}
page={data.page}
size={data.size}
basePath="/notifications"
/>
</>
<div className="grid gap-4 items-start lg:grid-cols-[248px_minmax(0,1fr)]">
{/* 左栏 sticky 概览 + 全部已读(客户端组件,行内未读数点击后同步刷新);右列列表 + 分页 */}
<NotificationsClient initialData={data} initialUnread={me.unread_count}>
<Pagination
total={data.total}
page={data.page}
size={data.size}
basePath="/notifications"
/>
</NotificationsClient>
</div>
)}
</div>
);

View File

@@ -5,7 +5,7 @@ import { Megaphone } from "lucide-react";
import { fetchSitePageBySlug, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -58,7 +58,7 @@ export default async function SitePagePublic({ params }: PageProps) {
const p = data.page;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(p.content);

View File

@@ -18,7 +18,7 @@ import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/Si
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { siteDocumentTitle } from "@/lib/brand";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
@@ -127,8 +127,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
const canManagePages = canManageAnnouncements;
const canManageAnnouncements = !!user && hasPerm(user, PERMS.ANNOUNCEMENTS);
const canManagePages = !!user && hasPerm(user, PERMS.PAGES);
const sidebarPages = overview?.sidebar_pages ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;

View File

@@ -8,6 +8,8 @@ import {
MessageCircle,
Flame,
ChevronRight,
Pin,
Star,
} from "lucide-react";
import {
fetchPostDetail,
@@ -358,22 +360,31 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
absoluteCanonical(postPath(urlStyle, post.id), commentPage > 1 ? { page: commentPage } : undefined) ||
entityUrl;
const authorName = post.user.nickname || post.user.username;
// 楼层号 = 跨页连续的主评论序号(含被过滤掉的软删/空占位),故按未过滤索引计算
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
? comments
.filter((c) => !c.deleted && (c.content || "").trim())
.map((c) => ({
id: c.id,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}))
.map((c, i): JsonLdVisibleComment | null =>
!c.deleted && (c.content || "").trim()
? {
id: c.id,
floor: (commentPage - 1) * 20 + i + 1,
is_root: true,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}
: null
)
.filter((x): x is JsonLdVisibleComment => x !== null)
: [];
const acceptedForLd: JsonLdVisibleComment | null =
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
? {
id: acceptedAnswer.id,
floor: acceptedAnswer.floor,
is_root: acceptedAnswer.is_root,
content: acceptedAnswer.content,
created_at: acceptedAnswer.created_at,
author: acceptedAnswer.user
@@ -475,15 +486,27 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<div className="panel p-4 sm:p-6 lg:p-8 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<header className="mb-5 sm:mb-7">
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
{/* 置顶/推荐与标题包在同一不可折行的 flex item 内:外层 flex-wrap 收集行时只看
已在行内元素的收缩能力,shrink-0 徽章会让超长 h1 整体掉到第二行;内层 min-w-0
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶">
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
</div>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{postType !== "discussion" && postType !== "question" && (
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import {
FileText, MessageCircle, AlertCircle, Bookmark, UserPlus,
FileText, MessageCircle, AlertCircle, Bookmark, UserPlus, Pin, Star,
} from "lucide-react";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
@@ -12,7 +12,7 @@ import { formatRelative } from "@/lib/format";
import type {
UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
} from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
@@ -44,10 +44,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
<div className="flex items-start gap-2">
{pinned && (
<span
className="mt-0.5 inline-flex shrink-0 items-center rounded px-1.5 py-px text-[11px] font-semibold leading-[1.5] text-white"
style={{ background: "#f59e0b" }}
className="badge-pin shrink-0 mt-0.5"
title="置顶"
aria-label="置顶"
>
置顶
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<h2
@@ -56,7 +57,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
>
{post.title}
</h2>
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
{post.recommended && (
<span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]"
@@ -313,7 +318,7 @@ export default async function UserProfileContent({
{(commentData.comments ?? []).map((c) => (
<Link
key={c.id}
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="group block px-4 sm:px-5 py-4 border-t transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{ borderColor: "var(--line)" }}
>

View File

@@ -258,9 +258,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
/>
</div>
{/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */}
{/* 本人操作按钮 / 访客私聊:移动端独占顶部一行(避免遮挡徽章/签名),sm 起绝对定位右上角 */}
{isOwner ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<div className="relative z-20 flex items-center justify-end gap-2 px-5 sm:px-6 pt-3 sm:absolute sm:top-3 sm:right-3 sm:px-0 sm:pt-0">
<Link href={`${userPath(urlStyle, Number(id))}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span>
@@ -276,7 +276,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</Link>
</div>
) : currentUser ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<div className="relative z-20 flex items-center justify-end gap-2 px-5 sm:px-6 pt-3 sm:absolute sm:top-3 sm:right-3 sm:px-0 sm:pt-0">
<FollowButton userId={u.id} following={u.is_following === true} />
<StaffUserMenu
target={{
@@ -295,10 +295,10 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</div>
) : null}
{/* 身份 + 行内统计:垂直居中,高度约 120px */}
{/* 身份 + 行内统计:垂直居中,高度约 120px;移动端按钮已独占一行,无需右侧预留 */}
<div
className={`relative z-10 flex min-h-[120px] items-center gap-3.5 px-5 sm:px-6 py-3.5 ${
hasHeroActions ? "pr-24 sm:pr-32" : ""
hasHeroActions ? "sm:pr-32" : ""
}`}
>
<ProfileHeroAvatar
@@ -374,7 +374,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</p>
{u.signature && (
<p
className="mt-1 text-[13px] leading-snug line-clamp-1"
className="mt-1 text-[13px] leading-snug whitespace-pre-wrap break-words"
style={{ color: "var(--ink-2)" }}
>
{u.signature}

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api";
import { userPath } from "@/lib/urlStyle";
import { userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody";
@@ -74,7 +74,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
@@ -95,7 +95,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -1,51 +1,92 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { variantURL } from "@/lib/responsiveImage";
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
const MIN_TOP_WIDTH = 600;
/** 由自然宽高判定当前槽位是否该显示(与 measure() 内逻辑保持一致) */
function slotMatches(
orientation: "portrait" | "landscape",
w: number,
h: number
): boolean {
const landscape = w > h;
// 仅"横版且足够大"才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && w >= MIN_TOP_WIDTH;
return orientation === "landscape" ? topWorthy : !topWorthy;
}
/**
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
* 组件按方向决定是否渲染自身内容。
*
* 已知尺寸(naturalWidth/naturalHeight > 0)时同步判定,SSR 首帧即正确方向——
* 避免"先竖版占位、hydration 后探测再切横版"的闪烁与布局抖动。
* 尺寸未知(外链/存量未存尺寸)时回退到隐藏探测图运行时测量。
*/
export default function AdaptiveCoverSlot({
src,
orientation,
naturalWidth,
naturalHeight,
children,
}: {
src: string;
orientation: "portrait" | "landscape";
/** 封面自然宽;>0 时跳过探测直接判定 */
naturalWidth?: number;
/** 封面自然高;>0 时跳过探测直接判定 */
naturalHeight?: number;
children: ReactNode;
}) {
const known =
typeof naturalWidth === "number" &&
naturalWidth > 0 &&
typeof naturalHeight === "number" &&
naturalHeight > 0;
const ref = useRef<HTMLImageElement>(null);
const [match, setMatch] = useState(orientation === "portrait");
const measure = (img: HTMLImageElement) => {
if (!img.naturalWidth || !img.naturalHeight) return;
const landscape = img.naturalWidth > img.naturalHeight;
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
};
// 已知尺寸 → 同步初值;未知 → 仍默认竖版显示(书封多为竖版),探测后纠正
const [match, setMatch] = useState(
known ? slotMatches(orientation, naturalWidth!, naturalHeight!) : orientation === "portrait"
);
// 仅未知尺寸时需要探测
useEffect(() => {
if (known) return;
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
if (ref.current?.complete) measure(ref.current);
}, []);
if (ref.current?.complete) {
const img = ref.current;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}
}, [known, orientation]);
// 已知尺寸时不渲染探测图:省一次请求,且 SSR 输出即稳定
if (known) {
return <>{match ? children : null}</>;
}
return (
<>
{/* 探测只需尺寸信息:取 768 档变体而非原图(600~767 宽的源后端透传原尺寸,
≥768 的源探测宽 768 仍 ≥MIN_TOP_WIDTH,横竖版与门槛判定均不失真) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={ref}
src={src}
src={variantURL(src, 768)}
alt=""
aria-hidden
className="hidden"
onLoad={(e) => measure(e.currentTarget)}
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}}
/>
{match ? children : null}
</>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { densitySrcSet, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色
function hash(s: string) {
@@ -30,6 +31,8 @@ export default function Avatar({
// 切换到新的图片地址时重置失败状态
useEffect(() => setImgFailed(false), [src]);
const showImg = !!src && !imgFailed;
// showImg 为真时 src 必为字符串;统一收敛类型供变体 helper 使用
const imgSrc = src ?? "";
return (
<span
@@ -40,11 +43,15 @@ export default function Avatar({
aria-hidden="true"
>
{showImg ? (
// 源图 ≤512px,按显示尺寸取 1x/2x 变体(PageSpeed:38px 位不再下 512 原图)
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
src={variantURL(imgSrc, pickVariantWidth(size))}
srcSet={densitySrcSet(imgSrc, size)}
alt={name.trim() || "用户头像"}
className="w-full h-full object-cover"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setImgFailed(true)}
/>
) : (

View File

@@ -0,0 +1,327 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Search, X } from "lucide-react";
import Modal from "@/components/Modal";
import { libraryDocReadPath, type UrlStyle } from "@/lib/urlStyle";
import { searchBook, type BookChapterIndex, type BookSnippet } from "@/lib/bookSearch";
/** 阅读页正文章节容器 id(read/page.tsx 的 <article>),高亮作用域 */
const ARTICLE_ID = "j13-book-article";
/** 关键词长度上限(避免超长输入卡顿) */
const MAX_QUERY = 64;
/** CSS Custom Highlight 注册名(对应 globals.css 的 ::highlight(...)) */
const HL_ALL = "j13-find";
const HL_CURRENT = "j13-find-current";
/** 特性检测:CSS Custom Highlight API(Chrome105+/Safari17.2+/FF140+),不支持时只跳转不高亮 */
function highlightsSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.Highlight !== "undefined" &&
typeof CSS !== "undefined" &&
!!CSS.highlights
);
}
function clearHighlights(): void {
if (!highlightsSupported()) return;
CSS.highlights!.delete(HL_ALL);
CSS.highlights!.delete(HL_CURRENT);
}
/**
* 运行时注入高亮样式:构建器的 CSS 压缩器不识别 ::highlight() 会丢规则,
* 且 highlight 伪元素不继承自定义属性,这里从 :root 读出 --accent/--accent-on
* 的实际色值拼规则。hex 转带透明度的 rgba;非 hex 值原样使用。
*/
let highlightStyleInjected = false;
function ensureHighlightStyle(): void {
if (highlightStyleInjected || !highlightsSupported()) return;
const css = getComputedStyle(document.documentElement);
const accent = (css.getPropertyValue("--accent") || "").trim();
const accentOn = (css.getPropertyValue("--accent-on") || "").trim() || "#fff";
let h = accent;
if (h.length === 4) h = "#" + h.slice(1).split("").map((c) => c + c).join("");
const soft =
/^#[0-9a-f]{6}$/i.test(h)
? `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},0.24)`
: accent;
const style = document.createElement("style");
style.dataset.j13 = "find-highlight";
style.textContent =
`::highlight(${HL_ALL}){background-color:${soft}}` +
`::highlight(${HL_CURRENT}){background-color:${accent};color:${accentOn}}`;
document.head.appendChild(style);
highlightStyleInjected = true;
}
/**
* 收集正文内命中 Range:按文本节点逐个匹配(大小写不敏感子串),不改 DOM。
* 用 Range 而非包 <mark>:正文是 server 组件渲染的,直接替换文本节点会让
* React 在章节切换后的原地复用错位;Range 随章节重建自然失效,无副作用。
* 跨标签边界的词匹配不到——可接受,绝大多数中文命中落在同一文本节点。
*/
function collectRanges(root: HTMLElement, query: string): Range[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const targets: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const el = node.parentElement;
if (!el || !node.nodeValue) return NodeFilter.FILTER_REJECT;
const tag = el.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.toLowerCase().includes(q)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) targets.push(n as Text);
const ranges: Range[] = [];
for (const textNode of targets) {
const tv = textNode.nodeValue ?? "";
const low = tv.toLowerCase();
let from = 0;
for (;;) {
const idx = low.indexOf(q, from);
if (idx < 0) break;
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, idx + q.length);
ranges.push(range);
from = idx + q.length;
}
}
return ranges;
}
/** 注册高亮(全部命中 + 当前命中两组);返回文档序命中 Range 列表 */
function paintRanges(query: string, currentOrdinal: number): Range[] {
const root = document.getElementById(ARTICLE_ID);
if (!root) return [];
const ranges = collectRanges(root, query);
if (!highlightsSupported()) return ranges;
ensureHighlightStyle();
clearHighlights();
if (ranges.length > 0) {
const all = new Highlight(...ranges);
const cur = new Highlight();
const curRange = ranges[Math.min(Math.max(currentOrdinal, 0), ranges.length - 1)];
if (curRange) cur.add(curRange);
CSS.highlights!.set(HL_ALL, all);
CSS.highlights!.set(HL_CURRENT, cur);
}
return ranges;
}
/** 滚动到命中 Range(视口垂直居中,留出吸顶头空间) */
function scrollToRange(range: Range, smooth: boolean): void {
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const top =
window.scrollY + rect.top - window.innerHeight / 2 + (rect.height || 16) / 2;
window.scrollTo({ top: Math.max(0, top), behavior: smooth ? "smooth" : "auto" });
}
/** 重新高亮当前章并定位到指定命中(ordinal 为章内 0 基命中序号) */
function highlightAndScroll(query: string, ordinal: number, smooth: boolean): void {
const ranges = paintRanges(query, ordinal);
const target = ranges[Math.min(Math.max(ordinal, 0), ranges.length - 1)];
if (!target) return;
// 双层 rAF:等章节切换后的布局与图片占位稳定再定位
requestAnimationFrame(() =>
requestAnimationFrame(() => scrollToRange(target, smooth))
);
}
function SnippetText({ snip }: { snip: BookSnippet }) {
return (
<span className="leading-relaxed">
{snip.before ? <span className="meta">…{snip.before}</span> : null}
<mark className="j13-find-mark">{snip.match}</mark>
{snip.after ? <span className="meta">{snip.after}…</span> : null}
</span>
);
}
export interface BookSearchProps {
slug: string;
urlStyle: UrlStyle;
/** 当前章序号(1 基) */
currentNo: number;
chapters: BookChapterIndex[];
}
export default function BookSearch({ slug, urlStyle, currentNo, chapters }: BookSearchProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
/**
* 跨章跳转待执行的高亮。组件跨章导航不重挂载(位置稳定),query 状态保留,
* 仅在 currentNo 变化、新章 DOM 提交后执行一次,因此无需把关键词写进 URL。
*/
const pendingRef = useRef<{ q: string; ordinal: number } | null>(null);
const trimmed = query.trim();
const results = useMemo(() => searchBook(chapters, trimmed), [chapters, trimmed]);
const totalHits = useMemo(() => results.reduce((acc, h) => acc + h.count, 0), [results]);
useEffect(() => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
highlightAndScroll(pending.q, pending.ordinal, false);
}, [currentNo]);
// 关键词清空或离开读页时撤掉正文高亮
useEffect(() => {
if (!trimmed) clearHighlights();
}, [trimmed]);
useEffect(() => () => clearHighlights(), []);
const focusHit = (no: number, ordinal: number) => {
if (no === currentNo) {
highlightAndScroll(trimmed, ordinal, true);
setOpen(false);
return;
}
pendingRef.current = { q: trimmed, ordinal };
setOpen(false);
router.push(libraryDocReadPath(urlStyle, slug, no));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && results[0]) {
const first = results[0];
focusHit(first.no, first.snippets[0]?.ordinal ?? 0);
}
};
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="inline-flex items-center gap-2 rounded-full pl-3.5 pr-4 py-2 text-[13px] transition-colors hover:bg-[var(--panel-2)] hover:border-[color-mix(in_srgb,var(--accent)_40%,var(--line))] shrink-0 w-36 sm:w-52"
style={{ color: "var(--ink-3)", border: "1px solid var(--line)", background: "var(--panel)" }}
aria-label="搜索本书"
>
<Search size={14} className="shrink-0" aria-hidden />
<span className="truncate text-left">搜索本书</span>
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="搜索本书"
description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`}
maxWidthClass="max-w-lg"
panelClassName="p-5 sm:p-6"
>
<div className="relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={query}
maxLength={MAX_QUERY}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索章节标题与正文"
aria-label="搜索章节标题与正文"
className="j13-search-field w-full rounded-xl pl-10 pr-9 py-2.5 text-[13.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除关键词"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
<div className="mt-3 max-h-[56vh] overflow-y-auto j13-rail-scroll -mx-1 px-1">
{!trimmed ? (
<p className="meta text-[12.5px] py-8 text-center">
输入关键词,按章查看命中片段,点击即可跳转
</p>
) : totalHits === 0 ? (
<p className="meta text-[12.5px] py-8 text-center">全书没有找到「{trimmed}」</p>
) : (
<div className="flex flex-col gap-2">
<p className="meta text-[11.5px] px-1">
{results.length} 章命中 · 共 {totalHits} 处
</p>
{results.map((hit) => {
const isCurrent = hit.no === currentNo;
return (
<section
key={hit.no}
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--line)" }}
>
<button
type="button"
onClick={() => focusHit(hit.no, 0)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<span
className="text-[12.5px] font-bold truncate min-w-0"
style={{ color: "var(--ink)" }}
>
第 {hit.no} 章 · {hit.title}
</span>
{isCurrent ? (
<span
className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
本章
</span>
) : null}
<span className="meta text-[11px] ml-auto shrink-0 inline-flex items-center gap-0.5">
{hit.count} 处
<ChevronRight size={12} aria-hidden />
</span>
</button>
{hit.snippets.map((snip) => (
<button
key={snip.ordinal}
type="button"
onClick={() => focusHit(hit.no, snip.ordinal)}
className="block w-full text-left px-3 py-1.5 text-[12.5px] border-t border-[var(--line)] hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
<SnippetText snip={snip} />
</button>
))}
</section>
);
})}
</div>
)}
</div>
</Modal>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
export default function BrandLockup({
@@ -45,11 +46,11 @@ export default function BrandLockup({
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
</>
) : (
<Logo src={src} fit={fit} alt={label} decorative={showText} />
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
)
) : (
<Bubble h={box.h} label={showText ? undefined : label} />
@@ -81,6 +82,7 @@ function Logo({
alt,
decorative = false,
extra = "",
cssPx,
}: {
src: string;
fit: BrandLogoFit;
@@ -88,6 +90,8 @@ function Logo({
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
decorative?: boolean;
extra?: string;
/** 图槽最大边 CSS px,用于密度变体 */
cssPx: number;
}) {
if (fit === "repeat") {
return (
@@ -100,9 +104,17 @@ function Logo({
/>
);
}
// 本站光栅图走 1x/2x 变体(SVG/ICO 后端透传);外链图保持原地址
const own = isOwnImage(src);
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
<img
src={own ? variantURL(src, pickVariantWidth(cssPx)) : src}
srcSet={own ? densitySrcSet(src, cssPx) : undefined}
alt={alt}
aria-hidden={decorative || undefined}
className={`j13-brand-logo ${extra}`}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More