Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e98052c16 | |||
| ff2ab286fb | |||
| 7fbd6ba699 | |||
| 4f3f3a265c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ out/
|
|||||||
.vercel/
|
.vercel/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-debug.log*
|
next-debug.log*
|
||||||
|
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
|
||||||
|
frontend/public/fonts/
|
||||||
|
|
||||||
# ===== Go 构建产物 =====
|
# ===== Go 构建产物 =====
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseSiteURLFromIni(t *testing.T) {
|
func TestParseSiteURLFromIni(t *testing.T) {
|
||||||
work := t.TempDir()
|
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 {
|
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bo
|
|||||||
return "", true // 字段不存在,视为未上传
|
return "", true // 字段不存在,视为未上传
|
||||||
}
|
}
|
||||||
if fh.Size > service.LegacyZipMaxBytes {
|
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
|
return "", false
|
||||||
}
|
}
|
||||||
path, err := saveMultipartToTemp(fh, pattern)
|
path, err := saveMultipartToTemp(fh, pattern)
|
||||||
|
|||||||
@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
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 封禁 / 解封用户
|
// AdminSetUserBan 封禁 / 解封用户
|
||||||
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
||||||
id, ok := parseAdminUserID(c)
|
id, ok := parseAdminUserID(c)
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
|
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
|
||||||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
|
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
|
||||||
)
|
)
|
||||||
|
|
||||||
// setAuthCookies 设置认证 cookie:
|
// setAuthCookies 设置认证 cookie:
|
||||||
// - access token(HttpOnly,15min)
|
// - access token(HttpOnly,7天)
|
||||||
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
|
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
|
||||||
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
||||||
// - CSRF token(JS 可读,7天,双提交校验)
|
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
|
||||||
//
|
//
|
||||||
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
||||||
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
||||||
@@ -273,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
|||||||
|
|
||||||
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
||||||
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
||||||
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
|
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
|
||||||
|
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
|
||||||
func (h *Handlers) Logout(c *gin.Context) {
|
func (h *Handlers) Logout(c *gin.Context) {
|
||||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||||||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||||||
@@ -381,6 +382,9 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
|
|||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
"can_manage_messages": user.CanManageMessages,
|
"can_manage_messages": user.CanManageMessages,
|
||||||
|
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
|
||||||
|
// 最终权限以后端 RequirePerm 校验为准
|
||||||
|
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
|
||||||
"level": user.Level,
|
"level": user.Level,
|
||||||
"total_points": user.TotalPoints,
|
"total_points": user.TotalPoints,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -42,6 +43,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
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 {
|
func (h *Handlers) chatOversee(userID uint) bool {
|
||||||
actor, err := h.Auth.LoadActor(userID)
|
actor, err := h.Auth.LoadActor(userID)
|
||||||
if err != nil || actor == nil {
|
if err != nil || actor == nil {
|
||||||
@@ -322,7 +343,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChatMemberMute 禁言/解禁成员
|
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
|
||||||
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
roomID, ok := chatRoomID(c)
|
roomID, ok := chatRoomID(c)
|
||||||
@@ -336,16 +357,58 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
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()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
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 站长任命/撤销群管理员
|
// SetChatMemberRole 站长任命/撤销群管理员
|
||||||
|
|||||||
@@ -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 评论请求
|
// CreateCommentRequest 评论请求
|
||||||
type CreateCommentRequest struct {
|
type CreateCommentRequest struct {
|
||||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||||
|
|||||||
208
backend/handler/friendlink.go
Normal file
208
backend/handler/friendlink.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentUserID 从 gin 上下文取登录用户 ID;RequireAuth 已保证存在
|
||||||
|
func currentUserID(c *gin.Context) (uint, bool) {
|
||||||
|
v, ok := c.Get("user")
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
claims, ok := v.(*service.UserClaims)
|
||||||
|
if !ok || claims.ID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return claims.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/friend-links 公开列表(仅 approved)
|
||||||
|
func (h *Handlers) GetFriendLinks(c *gin.Context) {
|
||||||
|
links, err := h.FriendLink.ListApproved()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/friend-links/apply 登录用户提交申请
|
||||||
|
func (h *Handlers) ApplyFriendLink(c *gin.Context) {
|
||||||
|
uid, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.FriendLinkInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fl, err := h.FriendLink.Apply(in, uid)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "提交申请失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"link": fl})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/friend-links/mine 当前登录用户自己的申请记录(含待审/拒绝与拒绝理由)
|
||||||
|
func (h *Handlers) MyFriendLinks(c *gin.Context) {
|
||||||
|
uid, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
links, err := h.FriendLink.ListMine(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取申请记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/friend-links/:id 申请人修改自己的申请;已通过/已驳回的修改后回退待审
|
||||||
|
func (h *Handlers) OwnerUpdateFriendLink(c *gin.Context) {
|
||||||
|
uid, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.FriendLinkInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.FriendLink.OwnerUpdate(uint(id), uid, in); err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存修改失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/friend-links 后台全量列表
|
||||||
|
func (h *Handlers) AdminListFriendLinks(c *gin.Context) {
|
||||||
|
links, err := h.FriendLink.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/friend-links 站长直接新建(status=approved)
|
||||||
|
func (h *Handlers) AdminCreateFriendLink(c *gin.Context) {
|
||||||
|
var in service.FriendLinkInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fl, err := h.FriendLink.AdminCreate(in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建友情链接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"link": fl})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/friend-links/:id 全量更新内容(不改 status)
|
||||||
|
func (h *Handlers) AdminUpdateFriendLink(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.FriendLinkInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.FriendLink.AdminUpdate(uint(id), in); err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新友情链接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/friend-links/:id/status 审批:body {status, reject_reason?}
|
||||||
|
func (h *Handlers) AdminSetFriendLinkStatus(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
RejectReason string `json:"reject_reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.FriendLink.AdminSetStatus(uint(id), body.Status, body.RejectReason); err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "审批参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新状态失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/friend-links/:id 软删除
|
||||||
|
func (h *Handlers) AdminDeleteFriendLink(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.FriendLink.AdminDelete(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除友情链接失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -38,8 +38,10 @@ type Handlers struct {
|
|||||||
HidePwd *service.HidePasswordCookie
|
HidePwd *service.HidePasswordCookie
|
||||||
Ads *service.AdService
|
Ads *service.AdService
|
||||||
Sidebar *service.SidebarService
|
Sidebar *service.SidebarService
|
||||||
|
FriendLink *service.FriendLinkService
|
||||||
Badge *service.BadgeService
|
Badge *service.BadgeService
|
||||||
LeaderboardSvc *service.LeaderboardService
|
LeaderboardSvc *service.LeaderboardService
|
||||||
|
LibrarySvc *service.LibraryService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
499
backend/handler/library.go
Normal file
499
backend/handler/library.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 公开端 ----------
|
||||||
|
|
||||||
|
// LibraryList 已发布条目目录
|
||||||
|
func (h *Handlers) LibraryList(c *gin.Context) {
|
||||||
|
list, err := h.LibrarySvc.ListPublished()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryDetail 公开条目详情(按 slug,仅已发布)
|
||||||
|
func (h *Handlers) LibraryDetail(c *gin.Context) {
|
||||||
|
d, err := h.LibrarySvc.GetPublishedBySlug(c.Param("slug"))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"doc": d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryFileDownload 强制下载(attachment;成功后计数)
|
||||||
|
func (h *Handlers) LibraryFileDownload(c *gin.Context) {
|
||||||
|
f, ok := h.libraryPublicFile(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.LibrarySvc.IncrDownload(f.ID)
|
||||||
|
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name))
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Content-Security-Policy", "sandbox")
|
||||||
|
ct := f.MIME
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", ct)
|
||||||
|
c.File(h.LibrarySvc.FilePath(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryPublicFile 公开文件公共校验(存在 + 所属条目已发布)
|
||||||
|
func (h *Handlers) libraryPublicFile(c *gin.Context) (*model.LibraryFile, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
f, err := h.LibrarySvc.GetPublicFile(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端 ----------
|
||||||
|
|
||||||
|
// AdminListLibraryDocs 后台条目列表(含未发布)
|
||||||
|
func (h *Handlers) AdminListLibraryDocs(c *gin.Context) {
|
||||||
|
list, err := h.LibrarySvc.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateLibraryDoc 新建条目
|
||||||
|
func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
|
||||||
|
var in service.LibraryInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"doc": d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateLibraryDoc 编辑条目
|
||||||
|
func (h *Handlers) AdminUpdateLibraryDoc(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.LibraryInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := h.LibrarySvc.Update(uint(id), &in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"doc": d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteLibraryDoc 删除条目(软删)
|
||||||
|
func (h *Handlers) AdminDeleteLibraryDoc(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.LibrarySvc.Delete(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPurgeLibraryDoc 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件)
|
||||||
|
func (h *Handlers) AdminPurgeLibraryDoc(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.LibrarySvc.Purge(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrLibraryNotDeleted) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUploadLibraryFile 上传文件到条目
|
||||||
|
func (h *Handlers) AdminUploadLibraryFile(c *gin.Context) {
|
||||||
|
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxBytes, err := h.Setting.AttachmentMaxBytes()
|
||||||
|
if err != nil || maxBytes < 1 {
|
||||||
|
maxBytes = service.FileMaxBytes
|
||||||
|
}
|
||||||
|
overhead := int64(64 << 10) // multipart 边界开销
|
||||||
|
limit := maxBytes + 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)
|
||||||
|
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.Size > maxBytes {
|
||||||
|
mb := int(maxBytes >> 20)
|
||||||
|
if mb < 1 {
|
||||||
|
mb = 1
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
f, err := h.LibrarySvc.AddFile(uint(docID), claims.ID, file.Filename, src)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryExtDenied) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"file": f})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteLibraryFile 删除条目文件
|
||||||
|
func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.LibrarySvc.DeleteFile(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 空=章,非空=节)
|
||||||
|
func (h *Handlers) AdminCreateLibrarySection(c *gin.Context) {
|
||||||
|
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.SectionInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec, err := h.LibrarySvc.CreateSection(uint(docID), &in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"section": sec})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateLibrarySection 编辑章节标题与正文
|
||||||
|
func (h *Handlers) AdminUpdateLibrarySection(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.SectionInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec, err := h.LibrarySvc.UpdateSection(uint(id), &in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrSectionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"section": sec})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteLibrarySection 删除章节(章级联删除其下小节)
|
||||||
|
func (h *Handlers) AdminDeleteLibrarySection(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.LibrarySvc.DeleteSection(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrSectionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMoveLibrarySection 章节上移/下移(同父兄弟间交换顺序)
|
||||||
|
func (h *Handlers) AdminMoveLibrarySection(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.LibrarySvc.MoveSection(uint(id), in.Direction); err != nil {
|
||||||
|
if errors.Is(err, service.ErrSectionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminImportLibraryMarkdown 导入 md 并按标题拆章
|
||||||
|
func (h *Handlers) AdminImportLibraryMarkdown(c *gin.Context) {
|
||||||
|
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SplitLevel string `json:"split_level"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sections, err := h.LibrarySvc.ImportMarkdown(uint(docID), in.Filename, in.Content, in.SplitLevel)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminImportLibraryMarkdownBatch 批量导入 md:多个文件按传入顺序依次拆章
|
||||||
|
func (h *Handlers) AdminImportLibraryMarkdownBatch(c *gin.Context) {
|
||||||
|
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Files []service.ImportFileInput `json:"files"`
|
||||||
|
SplitLevel string `json:"split_level"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sections, err := h.LibrarySvc.ImportMarkdownBatch(uint(docID), in.Files, in.SplitLevel)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
|
||||||
|
}
|
||||||
@@ -5,9 +5,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/gin-gonic/gin"
|
"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 获取当前用户的通知列表(分页)
|
// Notifications 获取当前用户的通知列表(分页)
|
||||||
func (h *Handlers) Notifications(c *gin.Context) {
|
func (h *Handlers) Notifications(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"notifications": nonNilSlice(list),
|
"notifications": nonNilSlice(views),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"size": size,
|
"size": size,
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
|
|||||||
defer r.Close()
|
defer r.Close()
|
||||||
c.Header("Content-Type", m)
|
c.Header("Content-Type", m)
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
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)
|
c.Status(200)
|
||||||
_, _ = io.Copy(c.Writer, r)
|
_, _ = io.Copy(c.Writer, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ type updateSettingsRequest struct {
|
|||||||
LevelsFx *bool `json:"levels_fx"`
|
LevelsFx *bool `json:"levels_fx"`
|
||||||
|
|
||||||
UrlStyle *string `json:"url_style"`
|
UrlStyle *string `json:"url_style"`
|
||||||
|
|
||||||
|
MobileTabs *[]string `json:"mobile_tabs"`
|
||||||
|
|
||||||
|
HeaderNav *[]service.HeaderNavItem `json:"header_nav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -136,6 +140,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"levels_fx": saved.LevelsFx,
|
"levels_fx": saved.LevelsFx,
|
||||||
|
|
||||||
"url_style": saved.UrlStyle,
|
"url_style": saved.UrlStyle,
|
||||||
|
|
||||||
|
"mobile_tabs": saved.MobileTabs,
|
||||||
|
|
||||||
|
"header_nav": saved.HeaderNav,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +167,7 @@ func (req *updateSettingsRequest) hasAny() bool {
|
|||||||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
|
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
|
||||||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
|
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
|
||||||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
|
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
|
||||||
req.UrlStyle != nil
|
req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||||
@@ -552,6 +560,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.MobileTabs != nil {
|
||||||
|
if err := h.Setting.SetMobileTabs(*req.MobileTabs); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "移动端底栏配置无效(至少保留一项,且取值须在允许列表内)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.HeaderNav != nil {
|
||||||
|
if err := h.Setting.SetHeaderNav(*req.HeaderNav); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "顶部导航配置无效(最多 8 项,内置项须在允许列表内,自定义项名称 1–16 字、地址须为站内路径或 http(s))"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saved, err := h.Setting.Public()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminPurgeSitePage 彻底删除单页(仅限已软删)
|
||||||
|
func (h *Handlers) AdminPurgeSitePage(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.SitePage.Purge(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrSitePageNotDeleted) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,20 @@ func (h *Handlers) TimelineFromGit(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimelineFromReleases 从 Git releases 页 URL 导入时间线条目(登录 + CSRF + 限流)
|
||||||
|
func (h *Handlers) TimelineFromReleases(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Setting.ImportTimelineFromReleases(req.URLs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -99,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
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 {
|
if err != nil {
|
||||||
var maxErr *http.MaxBytesError
|
var maxErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxErr) {
|
if errors.As(err, &maxErr) {
|
||||||
@@ -311,6 +315,84 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
|
||||||
|
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
|
||||||
|
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
|
||||||
|
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
AttachmentID uint `json:"attachment_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
|
||||||
|
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
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||||
|
}
|
||||||
|
|
||||||
|
// brandAliasSlots 固定地址路径段 → 品牌槽位
|
||||||
|
var brandAliasSlots = map[string]string{
|
||||||
|
"logo-light": service.BrandSlotLight,
|
||||||
|
"logo-dark": service.BrandSlotDark,
|
||||||
|
"favicon": service.BrandSlotFavicon,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon):
|
||||||
|
// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。
|
||||||
|
func (h *Handlers) BrandImage(c *gin.Context) {
|
||||||
|
slot, ok := brandAliasSlots[c.Param("slot")]
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var url string
|
||||||
|
var err error
|
||||||
|
switch slot {
|
||||||
|
case service.BrandSlotLight:
|
||||||
|
url, err = h.Setting.LogoLightURL()
|
||||||
|
case service.BrandSlotDark:
|
||||||
|
url, err = h.Setting.LogoDarkURL()
|
||||||
|
default:
|
||||||
|
url, err = h.Setting.FaviconURL()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := h.Upload.OpenBrandFile(url)
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
// 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效
|
||||||
|
if ct := mime.TypeByExtension(path.Ext(url)); ct != "" {
|
||||||
|
c.Header("Content-Type", ct)
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=3600")
|
||||||
|
http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f)
|
||||||
|
}
|
||||||
|
|
||||||
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
||||||
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
||||||
items, counts, err := h.Upload.AdminMediaLibrary()
|
items, counts, err := h.Upload.AdminMediaLibrary()
|
||||||
@@ -332,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "image/webp", data)
|
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 {
|
func brandTooLarge(slot string) string {
|
||||||
if slot == service.BrandSlotFavicon {
|
if slot == service.BrandSlotFavicon {
|
||||||
return "Favicon 不能超过 512KB"
|
return "Favicon 不能超过 512KB"
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 访客与该用户的关注关系(关注按钮初始态)
|
// 访客与该用户的关注关系(关注按钮初始态)
|
||||||
userPayload["is_following"] = isFollowing
|
userPayload["is_following"] = isFollowing
|
||||||
// 邮箱仅本人可见,打码展示(j***@163.com)
|
// 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填)
|
||||||
if viewerID == user.ID && user.Email != "" {
|
if viewerID == user.ID && user.Email != "" {
|
||||||
userPayload["email"] = maskEmail(user.Email)
|
userPayload["email"] = user.Email
|
||||||
}
|
}
|
||||||
// 徽章墙(失败不阻塞资料返回)
|
// 徽章墙(失败不阻塞资料返回)
|
||||||
if h.Badge != nil {
|
if h.Badge != nil {
|
||||||
@@ -158,21 +158,6 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
|
|
||||||
func maskEmail(email string) string {
|
|
||||||
at := -1
|
|
||||||
for i, r := range email {
|
|
||||||
if r == '@' {
|
|
||||||
at = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if at <= 0 {
|
|
||||||
return "***"
|
|
||||||
}
|
|
||||||
return email[:1] + "***" + email[at:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
||||||
func (h *Handlers) UserComments(c *gin.Context) {
|
func (h *Handlers) UserComments(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -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 {
|
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
actor := CurrentActor(c)
|
actor := CurrentActor(c)
|
||||||
|
|||||||
19
backend/middleware/upload_cache.go
Normal file
19
backend/middleware/upload_cache.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,8 @@ func InitDB(dsn string) error {
|
|||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
&Badge{}, &UserBadge{},
|
&Badge{}, &UserBadge{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
|
||||||
&ImportRecord{},
|
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,9 @@ func InitDB(dsn string) error {
|
|||||||
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
||||||
backfillUserTotalPoints(db)
|
backfillUserTotalPoints(db)
|
||||||
|
|
||||||
|
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
|
||||||
|
backfillLibraryCreator(db)
|
||||||
|
|
||||||
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
||||||
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
||||||
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
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 }{
|
pairs := []struct{ table, constraint string }{
|
||||||
{"chat_rooms", "fk_chat_rooms_last_message"},
|
{"chat_rooms", "fk_chat_rooms_last_message"},
|
||||||
{"notifications", "fk_notifications_room"},
|
{"notifications", "fk_notifications_room"},
|
||||||
|
{"chat_messages", "fk_chat_messages_sender"},
|
||||||
}
|
}
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
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 为升级前已存在的板块补齐新字段默认值。
|
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type User struct {
|
|||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||||
|
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
|
||||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||||
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||||
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
||||||
@@ -522,6 +523,58 @@ type SitePage struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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"`
|
||||||
|
// 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,不进公开静态目录)
|
||||||
|
type LibraryFile struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"` // 上传管理员
|
||||||
|
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||||
|
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
||||||
|
Ext string `gorm:"size:16;not null;default:''" json:"ext"`
|
||||||
|
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||||
|
Size int `gorm:"not null;default:0" json:"size"`
|
||||||
|
DownloadCount int `gorm:"not null;default:0" json:"download_count"`
|
||||||
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibrarySection 书库章节(两级树:ParentID 空=章,非空=节;正文 markdown)
|
||||||
|
type LibrarySection struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id"` // nil=章;非空=节,父必须是同条目的章
|
||||||
|
Title string `gorm:"size:200;not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text" json:"content"`
|
||||||
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||||
type SiteSetting struct {
|
type SiteSetting struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
@@ -542,6 +595,24 @@ type SidebarWidget struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||||
type SiteDailyStats struct {
|
type SiteDailyStats struct {
|
||||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||||
@@ -588,11 +659,19 @@ const (
|
|||||||
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
|
||||||
|
const (
|
||||||
|
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
|
||||||
|
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
|
||||||
|
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||||
|
)
|
||||||
|
|
||||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_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"`
|
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"`
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||||
@@ -672,6 +751,7 @@ type ChatRoomMember struct {
|
|||||||
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
||||||
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
||||||
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
|
||||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -683,17 +763,18 @@ type ChatRoomMember struct {
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_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"`
|
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
||||||
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
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"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
||||||
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
||||||
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||||
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
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"`
|
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
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 仅用于已撤回消息展示撤回者昵称
|
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
||||||
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
||||||
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
||||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||||
|
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
|
||||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
postFileSvc.WithOperations(ops)
|
postFileSvc.WithOperations(ops)
|
||||||
pointsSvc := service.NewPointsService(model.DB)
|
pointsSvc := service.NewPointsService(model.DB)
|
||||||
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
||||||
|
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
||||||
|
WithSetting(settingSvc).
|
||||||
|
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
@@ -118,6 +121,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
if err := postFileSvc.EnsureDir(); err != nil {
|
if err := postFileSvc.EnsureDir(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := librarySvc.EnsureDir(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
|
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
|
||||||
@@ -155,8 +161,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||||
Ads: adSvc,
|
Ads: adSvc,
|
||||||
Sidebar: service.NewSidebarService(model.DB),
|
Sidebar: service.NewSidebarService(model.DB),
|
||||||
|
FriendLink: service.NewFriendLinkService(model.DB),
|
||||||
Badge: badgeSvc,
|
Badge: badgeSvc,
|
||||||
LeaderboardSvc: leaderboardSvc,
|
LeaderboardSvc: leaderboardSvc,
|
||||||
|
LibrarySvc: librarySvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
notifSvc.OnNotifyNew = func(userID uint) {
|
||||||
@@ -177,9 +185,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||||
r.Use(middleware.VisitCapture(visitSvc))
|
r.Use(middleware.VisitCapture(visitSvc))
|
||||||
|
|
||||||
|
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
|
||||||
|
r.Use(middleware.UploadStaticCache())
|
||||||
|
|
||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
|
||||||
|
// 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链
|
||||||
|
r.GET("/api/brand/:slot", h.BrandImage)
|
||||||
|
|
||||||
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
||||||
r.GET("/health", h.Health)
|
r.GET("/health", h.Health)
|
||||||
|
|
||||||
@@ -194,12 +208,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||||
pubAPI.GET("/overview", h.Overview)
|
pubAPI.GET("/overview", h.Overview)
|
||||||
pubAPI.GET("/sidebar", h.GetSidebar)
|
pubAPI.GET("/sidebar", h.GetSidebar)
|
||||||
|
pubAPI.GET("/friend-links", h.GetFriendLinks)
|
||||||
pubAPI.GET("/posts", h.Posts)
|
pubAPI.GET("/posts", h.Posts)
|
||||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
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/attachments/:aid/download", h.DownloadPostAttachment)
|
||||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
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", h.UserProfile)
|
||||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||||
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
||||||
@@ -211,7 +225,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/settings", h.PublicSettings)
|
pubAPI.GET("/settings", h.PublicSettings)
|
||||||
pubAPI.GET("/leaderboard", h.Leaderboard)
|
pubAPI.GET("/leaderboard", h.Leaderboard)
|
||||||
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
|
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
|
||||||
|
// 书库(公开浏览/下载;文件经专用端点消费,不进 /uploads 静态目录)
|
||||||
|
pubAPI.GET("/library", h.LibraryList)
|
||||||
|
pubAPI.GET("/library/:slug", h.LibraryDetail)
|
||||||
|
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
|
||||||
pubAPI.GET("/site-state", h.SiteState)
|
pubAPI.GET("/site-state", h.SiteState)
|
||||||
|
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
|
||||||
|
pubAPI.GET("/img", h.ImageVariant)
|
||||||
pubAPI.GET("/media/:object", h.PublicObject)
|
pubAPI.GET("/media/:object", h.PublicObject)
|
||||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||||
@@ -234,6 +254,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.GET("/me/login-devices", h.MyLoginDevices)
|
api.GET("/me/login-devices", h.MyLoginDevices)
|
||||||
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
||||||
api.PUT("/profile", h.UpdateProfile)
|
api.PUT("/profile", h.UpdateProfile)
|
||||||
|
// 友情链接申请(登录用户;提交后进 pending 待审批)
|
||||||
|
api.POST("/friend-links/apply", h.ApplyFriendLink)
|
||||||
|
api.GET("/friend-links/mine", h.MyFriendLinks)
|
||||||
|
api.PUT("/friend-links/:id", h.OwnerUpdateFriendLink)
|
||||||
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
|
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
|
||||||
api.PUT("/posts/:id", h.UpdatePost)
|
api.PUT("/posts/:id", h.UpdatePost)
|
||||||
api.GET("/posts/:id/history", h.PostEditHistory)
|
api.GET("/posts/:id/history", h.PostEditHistory)
|
||||||
@@ -281,6 +305,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
|
|
||||||
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
||||||
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
|
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
|
||||||
|
// 时间线:从 Git releases 页导入更新日志
|
||||||
|
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
|
||||||
|
|
||||||
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
||||||
@@ -356,20 +382,52 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||||
// (见下方 msgAPI)
|
// (见下方 msgAPI)
|
||||||
|
|
||||||
// 站点公告文章管理(管理员及以上)
|
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
|
||||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
|
||||||
|
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
|
||||||
|
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
|
||||||
|
|
||||||
|
// 站点公告文章管理(PermAnnouncements)
|
||||||
|
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||||
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||||
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||||
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
||||||
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
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)
|
|
||||||
|
|
||||||
// 广告位管理(管理员及以上)
|
// 站点单页管理(PermPages)
|
||||||
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
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)
|
||||||
|
libraryAPI.DELETE("/docs/:id/purge", h.AdminPurgeLibraryDoc)
|
||||||
|
libraryAPI.POST("/docs/:id/files", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadLibraryFile)
|
||||||
|
libraryAPI.DELETE("/files/:fid", h.AdminDeleteLibraryFile)
|
||||||
|
// 章节(两级树:章 → 节)
|
||||||
|
libraryAPI.POST("/docs/:id/sections", h.AdminCreateLibrarySection)
|
||||||
|
libraryAPI.PUT("/sections/:sid", h.AdminUpdateLibrarySection)
|
||||||
|
libraryAPI.DELETE("/sections/:sid", h.AdminDeleteLibrarySection)
|
||||||
|
libraryAPI.POST("/sections/:sid/move", h.AdminMoveLibrarySection)
|
||||||
|
libraryAPI.POST("/docs/:id/import-md", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdown)
|
||||||
|
libraryAPI.POST("/docs/:id/import-md-batch", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdownBatch)
|
||||||
|
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广告位管理(PermAds)
|
||||||
|
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
|
||||||
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
||||||
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
||||||
adsAPI.GET("/ads", h.AdminListAds)
|
adsAPI.GET("/ads", h.AdminListAds)
|
||||||
@@ -400,6 +458,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
||||||
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
||||||
|
|
||||||
|
// 友情链接管理(超级管理员/站长):CRUD + 审批
|
||||||
|
friendLinkAPI := staffAPI.Group("/friend-links", authMW.RequirePerm(service.PermSettings))
|
||||||
|
{
|
||||||
|
friendLinkAPI.GET("", h.AdminListFriendLinks)
|
||||||
|
friendLinkAPI.POST("", h.AdminCreateFriendLink)
|
||||||
|
friendLinkAPI.PUT("/:id", h.AdminUpdateFriendLink)
|
||||||
|
friendLinkAPI.POST("/:id/status", h.AdminSetFriendLinkStatus)
|
||||||
|
friendLinkAPI.DELETE("/:id", h.AdminDeleteFriendLink)
|
||||||
|
}
|
||||||
|
|
||||||
// 站点设置(超级管理员/站长)
|
// 站点设置(超级管理员/站长)
|
||||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||||
@@ -426,6 +494,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
usersAPI.GET("/users", h.AdminListUsers)
|
usersAPI.GET("/users", h.AdminListUsers)
|
||||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
usersAPI.PUT("/users/:id/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.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||||
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -10,13 +12,110 @@ import (
|
|||||||
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
||||||
const (
|
const (
|
||||||
PermUsers = "users" // 用户与权限管理(超管/站长)
|
PermUsers = "users" // 用户与权限管理(超管/站长)
|
||||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
|
||||||
|
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
|
||||||
|
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
|
||||||
|
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||||
PermBoards = "boards" // 板块管理(仅站长)
|
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 现取,
|
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||||
type Actor struct {
|
type Actor struct {
|
||||||
@@ -25,6 +124,7 @@ type Actor struct {
|
|||||||
Role model.Role
|
Role model.Role
|
||||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||||
|
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStaff 是否管理团队成员
|
// IsStaff 是否管理团队成员
|
||||||
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
|
|||||||
return a != nil && model.IsStaff(a.Role)
|
return a != nil && model.IsStaff(a.Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPerm 是否拥有某后台功能点
|
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
|
||||||
func (a *Actor) HasPerm(p string) bool {
|
func (a *Actor) HasPerm(p string) bool {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch p {
|
if roleDefaultPerm(a.Role, p) {
|
||||||
case PermBoards:
|
return true
|
||||||
return a.Role == model.RoleOwner
|
}
|
||||||
case PermUsers, PermSettings:
|
if p == PermMessages {
|
||||||
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:
|
|
||||||
return a.HasSiteMessagePerm()
|
return a.HasSiteMessagePerm()
|
||||||
}
|
}
|
||||||
|
for _, e := range a.ExtraPerms {
|
||||||
|
if e == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
|||||||
return a.HasPerm(PermUsers)
|
return a.HasPerm(PermUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadActor 读取用户实时角色与板块授权
|
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
|
||||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||||
var u model.User
|
var u model.User
|
||||||
if err := s.db.Select("id", "username", "role", "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
|
return nil, err
|
||||||
}
|
}
|
||||||
actor := &Actor{
|
actor := &Actor{
|
||||||
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
ExtraPerms: ParsePermOverrides(u.PermOverrides),
|
||||||
}
|
}
|
||||||
if u.Role == model.RoleBoardAdmin {
|
if u.Role == model.RoleBoardAdmin {
|
||||||
var ids []uint
|
var ids []uint
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ type AdminContentComment struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
|
||||||
Board model.Board `json:"board"`
|
Board model.Board `json:"board"`
|
||||||
User model.User `json:"user"`
|
User model.User `json:"user"`
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
items := make([]AdminContentComment, 0, len(rows))
|
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 {
|
for _, r := range rows {
|
||||||
it := AdminContentComment{
|
it := AdminContentComment{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
Board: boards[r.BoardID],
|
Board: boards[r.BoardID],
|
||||||
User: users[r.UserID],
|
User: users[r.UserID],
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[r.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if r.DeletedAt.Valid {
|
if r.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := r.DeletedAt.Time
|
t := r.DeletedAt.Time
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type AdminUserItem struct {
|
|||||||
BoardIDs []uint `json:"board_ids"`
|
BoardIDs []uint `json:"board_ids"`
|
||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
CanManageMessages bool `json:"can_manage_messages"`
|
CanManageMessages bool `json:"can_manage_messages"`
|
||||||
|
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
|
||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
BoardIDs: []uint{},
|
BoardIDs: []uint{},
|
||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
PermOverrides: ParsePermOverrides(u.PermOverrides),
|
||||||
Points: u.Points,
|
Points: u.Points,
|
||||||
TotalPoints: u.TotalPoints,
|
TotalPoints: u.TotalPoints,
|
||||||
Level: u.Level,
|
Level: u.Level,
|
||||||
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return err
|
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
|
return nil
|
||||||
})
|
})
|
||||||
if err != 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)
|
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 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||||
if operator == nil || operator.Role != model.RoleOwner {
|
if operator == nil || operator.Role != model.RoleOwner {
|
||||||
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
Floor uint `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminAuditMessageItem 审计聊天消息行
|
// 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 {
|
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||||
return nil, 0, page, err
|
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))
|
items := make([]AdminAuditCommentItem, 0, len(comments))
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
it := AdminAuditCommentItem{
|
it := AdminAuditCommentItem{
|
||||||
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
|||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[c.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if c.DeletedAt.Valid {
|
if c.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := c.DeletedAt.Time
|
t := c.DeletedAt.Time
|
||||||
|
|||||||
@@ -24,10 +24,15 @@ const (
|
|||||||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||||||
CSRFHeaderName = "X-CSRF-Token"
|
CSRFHeaderName = "X-CSRF-Token"
|
||||||
|
|
||||||
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
|
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
|
||||||
AccessTokenTTL = 15 * time.Minute
|
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
|
||||||
// RefreshTokenTTL refresh token 有效期(长期)
|
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
|
||||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
|
||||||
|
// 是手机端弹"登录已失效"的主因)
|
||||||
|
AccessTokenTTL = 7 * 24 * time.Hour
|
||||||
|
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
|
||||||
|
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
|
||||||
|
RefreshTokenTTL = 30 * 24 * time.Hour
|
||||||
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
|
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
|
||||||
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
|
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
|
||||||
RefreshRotationGrace = 60 * time.Second
|
RefreshRotationGrace = 60 * time.Second
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ const (
|
|||||||
BrandSlotDark = "logo_dark"
|
BrandSlotDark = "logo_dark"
|
||||||
BrandSlotFavicon = "favicon"
|
BrandSlotFavicon = "favicon"
|
||||||
|
|
||||||
|
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
|
||||||
|
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
|
||||||
|
BrandAliasPathLight = "/api/brand/logo-light"
|
||||||
|
BrandAliasPathDark = "/api/brand/logo-dark"
|
||||||
|
BrandAliasPathFavicon = "/api/brand/favicon"
|
||||||
|
|
||||||
BrandMarkImageText = "image_text"
|
BrandMarkImageText = "image_text"
|
||||||
BrandMarkImage = "image"
|
BrandMarkImage = "image"
|
||||||
BrandMarkText = "text"
|
BrandMarkText = "text"
|
||||||
|
|||||||
@@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool {
|
|||||||
return err == nil && !info.IsDir()
|
return err == nil && !info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist
|
||||||
|
func (s *UploadService) OpenBrandFile(url string) (*os.File, error) {
|
||||||
|
if BrandRelPath(url) == "" {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
abs, ok := s.safeUploadPath(url)
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
|
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
|
||||||
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
||||||
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenBrandFile(t *testing.T) {
|
||||||
|
s, dir := newMediaLibraryService(t)
|
||||||
|
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
||||||
|
if _, err := s.OpenBrandFile(png); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("missing file want ErrNotExist, got %v", err)
|
||||||
|
}
|
||||||
|
writeMediaFile(t, dir, "brand/0123456789abcdef0123456789abcdef.png", []byte("png"))
|
||||||
|
f, err := s.OpenBrandFile(png)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"/uploads/images/0123456789abcdef0123456789abcdef.png",
|
||||||
|
"/uploads/brand/../avatars/x.png",
|
||||||
|
"http://evil.test/x.png",
|
||||||
|
} {
|
||||||
|
if _, err := s.OpenBrandFile(bad); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("should reject %q, got %v", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeBrandURL(t *testing.T) {
|
func TestNormalizeBrandURL(t *testing.T) {
|
||||||
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,44 +767,85 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
|||||||
return s.removeMember(roomID, targetID)
|
return s.removeMember(roomID, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true),
|
||||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。
|
||||||
room, err := s.getRoom(roomID)
|
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||||
if err != nil {
|
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 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
|
||||||
|
}
|
||||||
if room.RoomType == model.ChatRoomTypeDirect {
|
if room.RoomType == model.ChatRoomTypeDirect {
|
||||||
return ErrChatOwnerOnly
|
return "", ErrChatOwnerOnly
|
||||||
}
|
}
|
||||||
target, err := s.membership(s.db, roomID, targetID)
|
target, err := s.membership(s.db, roomID, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleOwner {
|
if target.Role == model.ChatRoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
var targetUser model.User
|
var targetUser model.User
|
||||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
|
||||||
return ErrChatUserGone
|
return "", ErrChatUserGone
|
||||||
}
|
}
|
||||||
if targetUser.Role == model.RoleOwner {
|
if targetUser.Role == model.RoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
if !oversee {
|
if !oversee {
|
||||||
op, err := s.membership(s.db, roomID, operatorID)
|
op, err := s.membership(s.db, roomID, operatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||||
return ErrChatMuteDenied
|
return "", ErrChatMuteDenied
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
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).
|
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);不可改群主
|
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||||
|
|||||||
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
|
|||||||
return FloorNumber(s.db, postID, commentID)
|
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 且(作者 / 该板可审);
|
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||||
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
||||||
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
||||||
@@ -442,6 +516,8 @@ type UserCommentItem struct {
|
|||||||
PostID uint `json:"post_id"`
|
PostID uint `json:"post_id"`
|
||||||
PostTitle string `json:"post_title"`
|
PostTitle string `json:"post_title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
|||||||
if items == nil {
|
if items == nil {
|
||||||
items = []UserCommentItem{}
|
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
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
305
backend/service/friendlink.go
Normal file
305
backend/service/friendlink.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 友情链接状态
|
||||||
|
const (
|
||||||
|
FriendLinkStatusPending = "pending"
|
||||||
|
FriendLinkStatusApproved = "approved"
|
||||||
|
FriendLinkStatusRejected = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 字段长度上限
|
||||||
|
const (
|
||||||
|
friendLinkMaxName = 64
|
||||||
|
friendLinkMaxURL = 512
|
||||||
|
friendLinkMaxDescription = 255
|
||||||
|
friendLinkMaxLogoURL = 512
|
||||||
|
friendLinkMaxCategory = 32
|
||||||
|
friendLinkMaxReciprocal = 512
|
||||||
|
friendLinkMaxReject = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrFriendLinkInvalid 参数校验失败
|
||||||
|
ErrFriendLinkInvalid = errors.New("友情链接参数无效")
|
||||||
|
// ErrFriendLinkNotFound 记录不存在
|
||||||
|
ErrFriendLinkNotFound = errors.New("友情链接不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定)
|
||||||
|
type FriendLinkInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ReciprocalURL string `json:"reciprocal_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FriendLinkService 友情链接申请 + 后台 CRUD/审批
|
||||||
|
type FriendLinkService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFriendLinkService(db *gorm.DB) *FriendLinkService {
|
||||||
|
return &FriendLinkService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateInput 校验并归一化入参;返回归一化后的值
|
||||||
|
func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
rawURL := strings.TrimSpace(in.URL)
|
||||||
|
if rawURL == "" || len(rawURL) > friendLinkMaxURL {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
desc := strings.TrimSpace(in.Description)
|
||||||
|
if utf8.RuneCountInString(desc) > friendLinkMaxDescription {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
logo := strings.TrimSpace(in.LogoURL)
|
||||||
|
if len(logo) > friendLinkMaxLogoURL {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if logo != "" {
|
||||||
|
if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
category := strings.TrimSpace(in.Category)
|
||||||
|
if utf8.RuneCountInString(category) > friendLinkMaxCategory {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
reciprocal := strings.TrimSpace(in.ReciprocalURL)
|
||||||
|
if len(reciprocal) > friendLinkMaxReciprocal {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if reciprocal != "" {
|
||||||
|
if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FriendLinkInput{
|
||||||
|
Name: name, URL: rawURL, Description: desc, LogoURL: logo,
|
||||||
|
Category: category, ReciprocalURL: reciprocal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC
|
||||||
|
func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Where("status = ?", FriendLinkStatusApproved).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id
|
||||||
|
func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序
|
||||||
|
func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Where("applicant_user_id = ?", userID).
|
||||||
|
Order("created_at DESC, id DESC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审
|
||||||
|
func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var fl model.FriendLink
|
||||||
|
if err := tx.First(&fl, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性
|
||||||
|
if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"name": norm.Name,
|
||||||
|
"url": norm.URL,
|
||||||
|
"logo_url": norm.LogoURL,
|
||||||
|
"reciprocal_url": norm.ReciprocalURL,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if fl.Status != FriendLinkStatusPending {
|
||||||
|
updates["status"] = FriendLinkStatusPending
|
||||||
|
updates["reject_reason"] = ""
|
||||||
|
}
|
||||||
|
return tx.Model(&fl).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用)
|
||||||
|
func (s *FriendLinkService) nextSortOrder() (int, error) {
|
||||||
|
var maxOrder int
|
||||||
|
err := s.db.Model(&model.FriendLink{}).
|
||||||
|
Where("status = ?", FriendLinkStatusApproved).
|
||||||
|
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return maxOrder + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 用户申请:status=pending
|
||||||
|
func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uid := applicantUserID
|
||||||
|
now := time.Now()
|
||||||
|
fl := &model.FriendLink{
|
||||||
|
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||||
|
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||||
|
Status: FriendLinkStatusPending, SortOrder: 0,
|
||||||
|
ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(fl).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreate 站长直接新建:status=approved,自动分配 sort_order
|
||||||
|
func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
order, err := s.nextSortOrder()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fl := &model.FriendLink{
|
||||||
|
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||||
|
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||||
|
Status: FriendLinkStatusApproved, SortOrder: order,
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(fl).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate 全量更新内容(不改 status/sort_order/applicant)
|
||||||
|
func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := s.db.Model(&model.FriendLink{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"name": norm.Name,
|
||||||
|
"url": norm.URL,
|
||||||
|
"description": norm.Description,
|
||||||
|
"logo_url": norm.LogoURL,
|
||||||
|
"category": norm.Category,
|
||||||
|
"reciprocal_url": norm.ReciprocalURL,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason
|
||||||
|
func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error {
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected &&
|
||||||
|
status != FriendLinkStatusPending {
|
||||||
|
return ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject {
|
||||||
|
return ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var fl model.FriendLink
|
||||||
|
if err := tx.First(&fl, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
"reject_reason": "",
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case FriendLinkStatusApproved:
|
||||||
|
if fl.SortOrder == 0 {
|
||||||
|
var maxOrder int
|
||||||
|
if err := tx.Model(&model.FriendLink{}).
|
||||||
|
Where("status = ? AND id <> ?", FriendLinkStatusApproved, id).
|
||||||
|
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["sort_order"] = maxOrder + 1
|
||||||
|
}
|
||||||
|
case FriendLinkStatusRejected:
|
||||||
|
updates["reject_reason"] = rejectReason
|
||||||
|
case FriendLinkStatusPending:
|
||||||
|
// 切回 pending 视为下线,不动 sort_order
|
||||||
|
}
|
||||||
|
return tx.Model(&fl).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDelete 软删除
|
||||||
|
func (s *FriendLinkService) AdminDelete(id uint) error {
|
||||||
|
res := s.db.Delete(&model.FriendLink{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
191
backend/service/header_nav.go
Normal file
191
backend/service/header_nav.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 桌面端 Header 主导航(品牌名右侧的菜单)。
|
||||||
|
// 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生,
|
||||||
|
// 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。
|
||||||
|
const (
|
||||||
|
// SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏
|
||||||
|
SettingKeyHeaderNav = "header_nav"
|
||||||
|
|
||||||
|
HeaderNavItemBuiltin = "builtin"
|
||||||
|
HeaderNavItemCustom = "custom"
|
||||||
|
|
||||||
|
// MaxHeaderNavItems 菜单项总数上限(内置 + 自定义)
|
||||||
|
MaxHeaderNavItems = 8
|
||||||
|
// MaxHeaderNavLabelRunes 自定义菜单文案长度上限
|
||||||
|
MaxHeaderNavLabelRunes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
|
||||||
|
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
|
||||||
|
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"}
|
||||||
|
|
||||||
|
// HeaderNavItem 顶部导航菜单项:
|
||||||
|
//
|
||||||
|
// builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生;
|
||||||
|
// custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。
|
||||||
|
type HeaderNavItem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
NewTab bool `json:"new_tab,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHeaderNav 内置默认三项(缺行时回落)
|
||||||
|
func DefaultHeaderNav() []HeaderNavItem {
|
||||||
|
out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys))
|
||||||
|
for _, k := range HeaderNavBuiltinKeys {
|
||||||
|
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerNavBuiltinAllowed(k string) bool {
|
||||||
|
for _, valid := range HeaderNavBuiltinKeys {
|
||||||
|
if k == valid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false
|
||||||
|
type headerNavItemIn struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
NewTab *bool `json:"new_tab"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错;
|
||||||
|
// 内置项重复去重(首次出现为准,与 mobile_tabs 一致)
|
||||||
|
func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) {
|
||||||
|
if len(raw) > MaxHeaderNavItems {
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
out := make([]HeaderNavItem, 0, len(raw))
|
||||||
|
seenBuiltin := make(map[string]struct{}, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
switch strings.TrimSpace(item.Type) {
|
||||||
|
case HeaderNavItemBuiltin:
|
||||||
|
k := strings.TrimSpace(strings.ToLower(item.Key))
|
||||||
|
if !headerNavBuiltinAllowed(k) {
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
if _, dup := seenBuiltin[k]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenBuiltin[k] = struct{}{}
|
||||||
|
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||||
|
case HeaderNavItemCustom:
|
||||||
|
label := strings.TrimSpace(item.Label)
|
||||||
|
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
u, ok := NormalizeFooterURL(item.URL)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
out = append(out, HeaderNavItem{
|
||||||
|
Type: HeaderNavItemCustom,
|
||||||
|
Label: label,
|
||||||
|
URL: u,
|
||||||
|
NewTab: item.NewTab,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过;
|
||||||
|
// 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分
|
||||||
|
func parseHeaderNavJSON(raw string) []HeaderNavItem {
|
||||||
|
var in []headerNavItemIn
|
||||||
|
if err := json.Unmarshal([]byte(raw), &in); err != nil {
|
||||||
|
return DefaultHeaderNav()
|
||||||
|
}
|
||||||
|
out := make([]HeaderNavItem, 0, len(in))
|
||||||
|
seenBuiltin := make(map[string]struct{}, len(in))
|
||||||
|
for _, item := range in {
|
||||||
|
switch strings.TrimSpace(item.Type) {
|
||||||
|
case HeaderNavItemBuiltin:
|
||||||
|
k := strings.TrimSpace(strings.ToLower(item.Key))
|
||||||
|
if !headerNavBuiltinAllowed(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seenBuiltin[k]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenBuiltin[k] = struct{}{}
|
||||||
|
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||||
|
case HeaderNavItemCustom:
|
||||||
|
label := strings.TrimSpace(item.Label)
|
||||||
|
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, ok := NormalizeFooterURL(item.URL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newTab := false
|
||||||
|
if item.NewTab != nil {
|
||||||
|
newTab = *item.NewTab
|
||||||
|
} else if isExternalFooterURL(u) {
|
||||||
|
newTab = true
|
||||||
|
}
|
||||||
|
out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab})
|
||||||
|
}
|
||||||
|
if len(out) >= MaxHeaderNavItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerNavEqual(a, b []HeaderNavItem) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片
|
||||||
|
func (s *SettingService) HeaderNav() []HeaderNavItem {
|
||||||
|
v, found, err := s.getValue(SettingKeyHeaderNav)
|
||||||
|
if err != nil || !found || strings.TrimSpace(v) == "" {
|
||||||
|
return DefaultHeaderNav()
|
||||||
|
}
|
||||||
|
return parseHeaderNavJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义,
|
||||||
|
// 空数组原样写入(表示全部隐藏)
|
||||||
|
func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error {
|
||||||
|
out, err := NormalizeHeaderNav(items)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if headerNavEqual(out, DefaultHeaderNav()) {
|
||||||
|
return s.deleteKey(SettingKeyHeaderNav)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.putValue(SettingKeyHeaderNav, string(b))
|
||||||
|
}
|
||||||
164
backend/service/header_nav_test.go
Normal file
164
backend/service/header_nav_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHeaderNavTestService(t *testing.T) *SettingService {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return NewSettingService(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
|
||||||
|
s := newHeaderNavTestService(t)
|
||||||
|
got := s.HeaderNav()
|
||||||
|
if len(got) != 3 ||
|
||||||
|
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
|
||||||
|
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
|
||||||
|
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) {
|
||||||
|
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
|
||||||
|
}
|
||||||
|
pub, err := s.Public()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pub.HeaderNav) != 3 {
|
||||||
|
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaderNavRoundTrip(t *testing.T) {
|
||||||
|
s := newHeaderNavTestService(t)
|
||||||
|
want := []HeaderNavItem{
|
||||||
|
{Type: HeaderNavItemBuiltin, Key: "library"},
|
||||||
|
{Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false},
|
||||||
|
{Type: HeaderNavItemBuiltin, Key: "home"},
|
||||||
|
{Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true},
|
||||||
|
}
|
||||||
|
if err := s.SetHeaderNav(want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := s.HeaderNav()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaderNavEmptyHidesAll(t *testing.T) {
|
||||||
|
s := newHeaderNavTestService(t)
|
||||||
|
if err := s.SetHeaderNav(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s.HeaderNav(); len(got) != 0 {
|
||||||
|
t.Fatalf("explicit empty list should hide all menus, got %+v", got)
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
|
||||||
|
if rows != 1 {
|
||||||
|
t.Fatalf("empty list should be persisted, rows=%d", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHeaderNavDefaultDeletesKey(t *testing.T) {
|
||||||
|
s := newHeaderNavTestService(t)
|
||||||
|
if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
|
||||||
|
if rows != 0 {
|
||||||
|
t.Fatalf("default config should delete the row, rows=%d", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) {
|
||||||
|
longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1)
|
||||||
|
cases := [][]HeaderNavItem{
|
||||||
|
{{Type: HeaderNavItemBuiltin, Key: "unknown"}},
|
||||||
|
{{Type: "weird", Key: "home"}},
|
||||||
|
{{Type: HeaderNavItemCustom, Label: "", URL: "/x"}},
|
||||||
|
{{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}},
|
||||||
|
{{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}},
|
||||||
|
{{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}},
|
||||||
|
{{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}},
|
||||||
|
}
|
||||||
|
for i, items := range cases {
|
||||||
|
if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) {
|
||||||
|
t.Fatalf("case %d should fail, got %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1)
|
||||||
|
for i := range tooMany {
|
||||||
|
tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}
|
||||||
|
}
|
||||||
|
if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) {
|
||||||
|
t.Fatalf("over-cap list should fail, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
|
||||||
|
got, err := NormalizeHeaderNav([]HeaderNavItem{
|
||||||
|
{Type: HeaderNavItemBuiltin, Key: "home"},
|
||||||
|
{Type: HeaderNavItemBuiltin, Key: " HOME "},
|
||||||
|
{Type: HeaderNavItemBuiltin, Key: "library"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" {
|
||||||
|
t.Fatalf("builtin duplicates should be deduped, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHeaderNavJSONTolerant(t *testing.T) {
|
||||||
|
// 坏 JSON 回落默认
|
||||||
|
if got := parseHeaderNavJSON("{bad"); len(got) != 3 {
|
||||||
|
t.Fatalf("bad json should fall back to defaults, got %+v", got)
|
||||||
|
}
|
||||||
|
// 空数组 = 全部隐藏
|
||||||
|
if got := parseHeaderNavJSON("[]"); len(got) != 0 {
|
||||||
|
t.Fatalf("empty array should be respected, got %+v", got)
|
||||||
|
}
|
||||||
|
// 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true
|
||||||
|
got := parseHeaderNavJSON(`[
|
||||||
|
{"type":"builtin","key":"home"},
|
||||||
|
{"type":"builtin","key":"nope"},
|
||||||
|
{"type":"builtin","key":"home"},
|
||||||
|
{"type":"custom","label":"","url":"/x"},
|
||||||
|
{"type":"custom","label":"外链","url":"https://example.com"},
|
||||||
|
{"type":"custom","label":"站内","url":"/p/about","new_tab":true}
|
||||||
|
]`)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("want 3 valid items, got %+v", got)
|
||||||
|
}
|
||||||
|
if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) {
|
||||||
|
t.Fatalf("item0 = %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab {
|
||||||
|
t.Fatalf("external custom item should default new_tab=true, got %+v", got[1])
|
||||||
|
}
|
||||||
|
if got[2].Label != "站内" || !got[2].NewTab {
|
||||||
|
t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
276
backend/service/image_variants.go
Normal file
276
backend/service/image_variants.go
Normal 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
|
||||||
|
}
|
||||||
282
backend/service/image_variants_test.go
Normal file
282
backend/service/image_variants_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const (
|
|||||||
PeriodMonth = "month"
|
PeriodMonth = "month"
|
||||||
PeriodAll = "all"
|
PeriodAll = "all"
|
||||||
|
|
||||||
MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分
|
MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分
|
||||||
MetricPost = "post" // 发帖最多
|
MetricPost = "post" // 发帖最多
|
||||||
MetricComment = "comment" // 回复最多
|
MetricComment = "comment" // 回复最多
|
||||||
MetricLike = "like" // 获赞最多
|
MetricLike = "like" // 获赞最多
|
||||||
@@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund,
|
||||||
|
// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。
|
||||||
|
// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值
|
||||||
|
func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) {
|
||||||
|
clause := ""
|
||||||
|
var timeArgs []interface{}
|
||||||
|
if from != nil {
|
||||||
|
clause += " AND l.created_at >= ?"
|
||||||
|
timeArgs = append(timeArgs, *from)
|
||||||
|
}
|
||||||
|
if to != nil {
|
||||||
|
clause += " AND l.created_at < ?"
|
||||||
|
timeArgs = append(timeArgs, *to)
|
||||||
|
}
|
||||||
|
sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" +
|
||||||
|
" JOIN users AS u ON u.id = l.user_id" +
|
||||||
|
" WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause +
|
||||||
|
" GROUP BY l.user_id"
|
||||||
|
args := []interface{}{model.PointReasonBountyRefund, false}
|
||||||
|
args = append(args, timeArgs...)
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
UserID uint
|
||||||
|
Pts int64
|
||||||
|
}
|
||||||
|
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uint]int64, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.UserID] = r.Pts
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
|
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
|
||||||
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
|
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
|
||||||
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
|
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
|
||||||
@@ -167,7 +202,7 @@ func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, e
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween)
|
// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints)
|
||||||
func scoreOf(metric string, st statsRow) int64 {
|
func scoreOf(metric string, st statsRow) int64 {
|
||||||
switch metric {
|
switch metric {
|
||||||
case MetricPost:
|
case MetricPost:
|
||||||
@@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 {
|
|||||||
return st.LikeCount
|
return st.LikeCount
|
||||||
case MetricAnswer:
|
case MetricAnswer:
|
||||||
return st.AnswerCount
|
return st.AnswerCount
|
||||||
default:
|
|
||||||
return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount
|
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// entryFromUser 由用户行构造榜单条目(不含统计)
|
// entryFromUser 由用户行构造榜单条目(不含统计)
|
||||||
@@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
|
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
|
||||||
// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。
|
// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints),
|
||||||
|
// 细分维度走统计聚合口径。
|
||||||
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
|
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
|
||||||
if metric == MetricAll && from == nil {
|
if metric == MetricAll && from == nil {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
@@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entries := make([]LeaderboardEntry, 0, len(stats))
|
// 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户
|
||||||
|
usePts := metric == MetricAll && from != nil
|
||||||
|
pts := map[uint]int64{}
|
||||||
|
if usePts {
|
||||||
|
if pts, err = s.earnedPoints(from, to); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cand := make(map[uint]statsRow, len(stats)+len(pts))
|
||||||
for id, r := range stats {
|
for id, r := range stats {
|
||||||
|
cand[id] = r
|
||||||
|
}
|
||||||
|
for id := range pts {
|
||||||
|
if _, ok := cand[id]; !ok {
|
||||||
|
cand[id] = statsRow{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries := make([]LeaderboardEntry, 0, len(cand))
|
||||||
|
for id, r := range cand {
|
||||||
score := scoreOf(metric, r)
|
score := scoreOf(metric, r)
|
||||||
|
if usePts {
|
||||||
|
score = pts[id]
|
||||||
|
}
|
||||||
if score <= 0 {
|
if score <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
67
backend/service/leaderboard_test.go
Normal file
67
backend/service/leaderboard_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund),
|
||||||
|
// 支出、悬赏退回、封禁用户、窗口外入账均不计
|
||||||
|
func TestEarnedPointsWindowAndExclusions(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.PointLedger{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
weekAgo := now.AddDate(0, 0, -7)
|
||||||
|
users := []*model.User{
|
||||||
|
{Username: "earner", Password: "x"},
|
||||||
|
{Username: "refund_only", Password: "x"},
|
||||||
|
{Username: "banned", Password: "x", Banned: true},
|
||||||
|
{Username: "old_earner", Password: "x"},
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if err := db.Create(u).Error; err != nil {
|
||||||
|
t.Fatalf("seed user %s: %v", u.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a, d := users[0].ID, users[3].ID
|
||||||
|
ledgers := []model.PointLedger{
|
||||||
|
{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: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
|
||||||
|
}
|
||||||
|
if err := db.Create(&ledgers).Error; err != nil {
|
||||||
|
t.Fatalf("seed ledgers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewLeaderboardService(db)
|
||||||
|
from := weekAgo
|
||||||
|
pts, err := s.earnedPoints(&from, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("earnedPoints: %v", err)
|
||||||
|
}
|
||||||
|
if len(pts) != 1 || pts[a] != 10 {
|
||||||
|
t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := s.earnedPoints(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("earnedPoints(all): %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 || all[a] != 10 || all[d] != 5 {
|
||||||
|
t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
652
backend/service/library.go
Normal file
652
backend/service/library.go
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发,
|
||||||
|
// 书籍正文走章节阅读,不做在线预览
|
||||||
|
var libraryAllowedExts = map[string]struct{}{
|
||||||
|
"html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {},
|
||||||
|
"zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {},
|
||||||
|
"doc": {}, "docx": {}, "ppt": {}, "pptx": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxLibraryFilesPerDoc = 20
|
||||||
|
libraryDescriptionMax = 20000
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLibraryNotFound = errors.New("书库条目不存在")
|
||||||
|
ErrLibraryExtDenied = errors.New("不支持该文档格式")
|
||||||
|
ErrLibraryTooMany = errors.New("文件数量超过上限")
|
||||||
|
ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryExtAllowed 上传扩展名是否在书库白名单内
|
||||||
|
func LibraryExtAllowed(ext string) bool {
|
||||||
|
_, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
|
||||||
|
var libraryDocWriteFields = []string{
|
||||||
|
"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 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
||||||
|
type LibraryDocDetail struct {
|
||||||
|
model.LibraryDoc
|
||||||
|
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 纯文本截断,含文件概要)
|
||||||
|
type LibraryDocListItem struct {
|
||||||
|
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=不修改)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *LibraryInput) normalize() error {
|
||||||
|
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
|
||||||
|
if in.Slug == "" {
|
||||||
|
return errors.New("slug 不能为空")
|
||||||
|
}
|
||||||
|
if len(in.Slug) > 64 {
|
||||||
|
return errors.New("slug 不能超过 64 字符")
|
||||||
|
}
|
||||||
|
if !sitePageSlugRe.MatchString(in.Slug) {
|
||||||
|
return errors.New("slug 仅允许小写字母、数字与连字符")
|
||||||
|
}
|
||||||
|
|
||||||
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
|
if in.Title == "" {
|
||||||
|
return errors.New("标题不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(in.Title) > 200 {
|
||||||
|
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 字")
|
||||||
|
}
|
||||||
|
|
||||||
|
in.CoverURL = strings.TrimSpace(in.CoverURL)
|
||||||
|
if utf8.RuneCountInString(in.CoverURL) > 512 {
|
||||||
|
return errors.New("封面地址过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if in.SortOrder != nil {
|
||||||
|
d.SortOrder = *in.SortOrder
|
||||||
|
}
|
||||||
|
if in.EntriesAuto != nil {
|
||||||
|
d.EntriesAuto = *in.EntriesAuto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
|
||||||
|
type LibraryService struct {
|
||||||
|
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 {
|
||||||
|
return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSetting 注入站点设置(文件大小上限复用附件设置)
|
||||||
|
func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
|
||||||
|
s.setting = setting
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) FilePath(f *model.LibraryFile) string {
|
||||||
|
return filepath.Join(s.dir, f.StoredName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||||||
|
// active=在用条目占用;deleted=已删除条目占用
|
||||||
|
func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
|
||||||
|
q := s.db.Unscoped().Model(&model.LibraryDoc{}).Where("slug = ?", slug).Select("deleted_at")
|
||||||
|
if excludeID > 0 {
|
||||||
|
q = q.Where("id <> ?", excludeID)
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
DeletedAt gorm.DeletedAt
|
||||||
|
}
|
||||||
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.DeletedAt.Valid {
|
||||||
|
deleted = true
|
||||||
|
} else {
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active, deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 条目:管理端 ----------
|
||||||
|
|
||||||
|
// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除)
|
||||||
|
func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) {
|
||||||
|
var docs []model.LibraryDoc
|
||||||
|
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.attachFiles(docs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 管理后台按 ID 取条目
|
||||||
|
func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) {
|
||||||
|
var d model.LibraryDoc
|
||||||
|
if err := s.db.First(&d, id).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
out := s.attachFiles([]model.LibraryDoc{d})
|
||||||
|
return &out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail {
|
||||||
|
out := make([]LibraryDocDetail, 0, len(docs))
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
ids = append(ids, d.ID)
|
||||||
|
}
|
||||||
|
var files []model.LibraryFile
|
||||||
|
if err := s.db.Where("doc_id IN ?", ids).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
||||||
|
files = nil
|
||||||
|
}
|
||||||
|
byDoc := make(map[uint][]model.LibraryFile, len(docs))
|
||||||
|
for _, f := range files {
|
||||||
|
byDoc[f.DocID] = append(byDoc[f.DocID], f)
|
||||||
|
}
|
||||||
|
var sections []model.LibrarySection
|
||||||
|
if err := s.db.Where("doc_id IN ?", ids).
|
||||||
|
Order("sort_order ASC, id ASC").Find(§ions).Error; err != nil {
|
||||||
|
sections = nil
|
||||||
|
}
|
||||||
|
secByDoc := make(map[uint][]model.LibrarySection, len(docs))
|
||||||
|
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 {
|
||||||
|
fs = []model.LibraryFile{}
|
||||||
|
}
|
||||||
|
ss := secByDoc[d.ID]
|
||||||
|
if ss == nil {
|
||||||
|
ss = []model.LibrarySection{}
|
||||||
|
}
|
||||||
|
out = append(out, LibraryDocDetail{
|
||||||
|
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
|
||||||
|
Creator: creatorByID[d.CreatorID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
return nil, errors.New("slug 已被占用")
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) {
|
||||||
|
var d model.LibraryDoc
|
||||||
|
if err := s.db.First(&d, id).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if err := in.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
active, deleted, err := s.slugTaken(in.Slug, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
return nil, errors.New("slug 已被占用")
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||||
|
}
|
||||||
|
in.applyTo(&d)
|
||||||
|
// 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,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用)
|
||||||
|
func (s *LibraryService) Purge(id uint) error {
|
||||||
|
var d model.LibraryDoc
|
||||||
|
if err := s.db.Unscoped().First(&d, id).Error; err != nil {
|
||||||
|
return ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if !d.DeletedAt.Valid {
|
||||||
|
return ErrLibraryNotDeleted
|
||||||
|
}
|
||||||
|
// 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘
|
||||||
|
var files []model.LibraryFile
|
||||||
|
if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回)
|
||||||
|
for i := range files {
|
||||||
|
_ = os.Remove(s.FilePath(&files[i]))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 条目:公开端 ----------
|
||||||
|
|
||||||
|
// ListPublished 已发布条目目录(含文件概要,不含 description 全文)
|
||||||
|
func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
|
||||||
|
details, err := s.publishedDetails()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]LibraryDocListItem, 0, len(details))
|
||||||
|
for _, d := range details {
|
||||||
|
out = append(out, LibraryDocListItem{
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublishedBySlug 公开详情(仅已发布)
|
||||||
|
func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) {
|
||||||
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||||
|
var d model.LibraryDoc
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) {
|
||||||
|
var docs []model.LibraryDoc
|
||||||
|
if err := s.db.Where("published = ?", true).
|
||||||
|
Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.attachFiles(docs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 文件 ----------
|
||||||
|
|
||||||
|
func (s *LibraryService) maxBytes() int64 {
|
||||||
|
if s.setting == nil {
|
||||||
|
return FileMaxBytes
|
||||||
|
}
|
||||||
|
n, err := s.setting.AttachmentMaxBytes()
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return FileMaxBytes
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile 上传文件到条目(落盘 data/library + 建行)
|
||||||
|
func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, errors.New("文件为空")
|
||||||
|
}
|
||||||
|
var doc model.LibraryDoc
|
||||||
|
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
name := sanitizeFilename(originalName)
|
||||||
|
if name == "" {
|
||||||
|
name = "file"
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(ExtOfFilename(name), ".")
|
||||||
|
if !LibraryExtAllowed(ext) {
|
||||||
|
return nil, ErrLibraryExtDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count >= MaxLibraryFilesPerDoc {
|
||||||
|
return nil, ErrLibraryTooMany
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stored := hex.EncodeToString(raw) + "." + ext
|
||||||
|
full := filepath.Join(s.dir, stored)
|
||||||
|
|
||||||
|
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maxB := s.maxBytes()
|
||||||
|
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
||||||
|
_ = f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
return nil, copyErr
|
||||||
|
}
|
||||||
|
if written == 0 {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
return nil, errors.New("文件为空")
|
||||||
|
}
|
||||||
|
if written > maxB {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
mb := int(maxB >> 20)
|
||||||
|
if mb < 1 {
|
||||||
|
mb = 1
|
||||||
|
}
|
||||||
|
return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
file := &model.LibraryFile{
|
||||||
|
DocID: docID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
StoredName: stored,
|
||||||
|
Ext: ext,
|
||||||
|
MIME: MimeForAttachmentExt("." + ext),
|
||||||
|
Size: int(written),
|
||||||
|
}
|
||||||
|
if err := s.db.Create(file).Error; err != nil {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile 删除文件(软删行 + 磁盘尽力删除)
|
||||||
|
func (s *LibraryService) DeleteFile(id uint) error {
|
||||||
|
var f model.LibraryFile
|
||||||
|
if err := s.db.First(&f, id).Error; err != nil {
|
||||||
|
return ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if err := s.db.Delete(&f).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.FilePath(&f))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达)
|
||||||
|
func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) {
|
||||||
|
var f model.LibraryFile
|
||||||
|
if err := s.db.First(&f, id).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if f.DocID == 0 {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := s.db.Model(&model.LibraryDoc{}).
|
||||||
|
Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrDownload 下载计数原子递增
|
||||||
|
func (s *LibraryService) IncrDownload(id uint) {
|
||||||
|
s.db.Model(&model.LibraryFile{}).Where("id = ?", id).
|
||||||
|
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||||
|
}
|
||||||
369
backend/service/library_export.go
Normal file
369
backend/service/library_export.go
Normal 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
|
||||||
|
}
|
||||||
199
backend/service/library_export_test.go
Normal file
199
backend/service/library_export_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
631
backend/service/library_import.go
Normal file
631
backend/service/library_import.go
Normal 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 := §ions[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 := §ions[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)
|
||||||
|
}
|
||||||
354
backend/service/library_import_test.go
Normal file
354
backend/service/library_import_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
422
backend/service/library_section.go
Normal file
422
backend/service/library_section.go
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 章节约束:两级树(章 → 节),正文为 markdown
|
||||||
|
const (
|
||||||
|
MaxSectionsPerDoc = 200 // 单条目章+节总数上限
|
||||||
|
MaxSectionContent = 100_000 // 单章节正文字符上限
|
||||||
|
MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB)
|
||||||
|
MaxImportFiles = 50 // 单次批量导入文件数上限
|
||||||
|
MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB)
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSectionNotFound = errors.New("章节不存在")
|
||||||
|
|
||||||
|
// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整)
|
||||||
|
type SectionInput struct {
|
||||||
|
ParentID *uint `json:"parent_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *SectionInput) normalize() error {
|
||||||
|
if in.ParentID != nil && *in.ParentID == 0 {
|
||||||
|
in.ParentID = nil
|
||||||
|
}
|
||||||
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
|
if in.Title == "" {
|
||||||
|
return errors.New("章节标题不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(in.Title) > 200 {
|
||||||
|
return errors.New("章节标题不能超过 200 字")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(in.Content) > MaxSectionContent {
|
||||||
|
return errors.New("章节正文不能超过 100000 字")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) section(id uint) (*model.LibrarySection, error) {
|
||||||
|
var sec model.LibrarySection
|
||||||
|
if err := s.db.First(&sec, id).Error; err != nil {
|
||||||
|
return nil, ErrSectionNotFound
|
||||||
|
}
|
||||||
|
return &sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) sectionCount(docID uint) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1)
|
||||||
|
func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) {
|
||||||
|
q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID)
|
||||||
|
if parentID == nil {
|
||||||
|
q = q.Where("parent_id IS NULL")
|
||||||
|
} else {
|
||||||
|
q = q.Where("parent_id = ?", *parentID)
|
||||||
|
}
|
||||||
|
var maxSort int
|
||||||
|
if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return maxSort, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSections 条目下全部章节(平铺,章在前节按组内序)
|
||||||
|
func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) {
|
||||||
|
var secs []model.LibrarySection
|
||||||
|
if err := s.db.Where("doc_id = ?", docID).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return secs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateParent 两级约束:父必须是同条目下的章(自身不能是节)
|
||||||
|
func (s *LibraryService) validateParent(docID uint, parentID *uint) error {
|
||||||
|
if parentID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parent, err := s.section(*parentID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("父章节不存在")
|
||||||
|
}
|
||||||
|
if parent.DocID != docID {
|
||||||
|
return errors.New("父章节不属于该条目")
|
||||||
|
}
|
||||||
|
if parent.ParentID != nil {
|
||||||
|
return errors.New("目录最多两级:章下不能再建小节")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||||
|
if err := in.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var doc model.LibraryDoc
|
||||||
|
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if err := s.validateParent(docID, in.ParentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count, err := s.sectionCount(docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count >= MaxSectionsPerDoc {
|
||||||
|
return nil, errors.New("章节数量超过上限")
|
||||||
|
}
|
||||||
|
maxSort, err := s.sectionMaxSort(docID, in.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{
|
||||||
|
DocID: docID,
|
||||||
|
ParentID: in.ParentID,
|
||||||
|
Title: in.Title,
|
||||||
|
Content: in.Content,
|
||||||
|
SortOrder: maxSort + 1,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(sec).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSection 更新标题与正文(父章节与顺序不变)
|
||||||
|
func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||||
|
in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更
|
||||||
|
if err := in.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sec, err := s.section(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Model(sec).Updates(map[string]interface{}{
|
||||||
|
"title": in.Title,
|
||||||
|
"content": in.Content,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.section(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSection 删除章节;若为章则级联软删其下小节
|
||||||
|
func (s *LibraryService) DeleteSection(id uint) error {
|
||||||
|
sec, err := s.section(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if sec.ParentID == nil {
|
||||||
|
if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Delete(sec).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op)
|
||||||
|
func (s *LibraryService) MoveSection(id uint, direction string) error {
|
||||||
|
if direction != "up" && direction != "down" {
|
||||||
|
return errors.New("无效的移动方向")
|
||||||
|
}
|
||||||
|
sec, err := s.section(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := s.db.Where("doc_id = ?", sec.DocID)
|
||||||
|
if sec.ParentID == nil {
|
||||||
|
q = q.Where("parent_id IS NULL")
|
||||||
|
} else {
|
||||||
|
q = q.Where("parent_id = ?", *sec.ParentID)
|
||||||
|
}
|
||||||
|
var sibs []model.LibrarySection
|
||||||
|
if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i := range sibs {
|
||||||
|
if sibs[i].ID == sec.ID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return ErrSectionNotFound
|
||||||
|
}
|
||||||
|
var other *model.LibrarySection
|
||||||
|
if direction == "up" && idx > 0 {
|
||||||
|
other = &sibs[idx-1]
|
||||||
|
}
|
||||||
|
if direction == "down" && idx < len(sibs)-1 {
|
||||||
|
other = &sibs[idx+1]
|
||||||
|
}
|
||||||
|
if other == nil {
|
||||||
|
return nil // 已在边界,无需移动
|
||||||
|
}
|
||||||
|
a, b := sec.SortOrder, other.SortOrder
|
||||||
|
if a == b {
|
||||||
|
b = a + 1 // 相同排序值时保证交换后顺序确实变化
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID).
|
||||||
|
UpdateColumn("sort_order", b).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID).
|
||||||
|
UpdateColumn("sort_order", a).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- md 拆章导入 ----------
|
||||||
|
|
||||||
|
type mdChapterChunk struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块;
|
||||||
|
// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。
|
||||||
|
func splitMarkdownChapters(content, level string) []mdChapterChunk {
|
||||||
|
prefix := "# "
|
||||||
|
if level == "h2" {
|
||||||
|
prefix = "## "
|
||||||
|
}
|
||||||
|
var chunks []mdChapterChunk
|
||||||
|
var pre strings.Builder
|
||||||
|
bodies := []strings.Builder{}
|
||||||
|
cur := -1
|
||||||
|
inCode := false
|
||||||
|
text := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
text = strings.ReplaceAll(text, "\r", "\n")
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "```") {
|
||||||
|
inCode = !inCode
|
||||||
|
}
|
||||||
|
if !inCode && strings.HasPrefix(t, prefix) {
|
||||||
|
title := strings.TrimSpace(strings.TrimPrefix(t, prefix))
|
||||||
|
title = strings.TrimSpace(strings.TrimRight(title, "#"))
|
||||||
|
chunks = append(chunks, mdChapterChunk{Title: title})
|
||||||
|
bodies = append(bodies, strings.Builder{})
|
||||||
|
cur = len(chunks) - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur < 0 {
|
||||||
|
pre.WriteString(line)
|
||||||
|
pre.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
bodies[cur].WriteString(line)
|
||||||
|
bodies[cur].WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]mdChapterChunk, len(chunks))
|
||||||
|
for i := range chunks {
|
||||||
|
body := bodies[i].String()
|
||||||
|
if i == 0 && pre.Len() > 0 {
|
||||||
|
body = pre.String() + body
|
||||||
|
}
|
||||||
|
out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes 按 rune 截断
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
if utf8.RuneCountInString(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string([]rune(s)[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFileInput 批量导入的单个文件
|
||||||
|
type ImportFileInput struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名;
|
||||||
|
// splitLevel 为 none 或未命中标题时整篇一章。
|
||||||
|
func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) {
|
||||||
|
if len(content) > MaxImportBytes {
|
||||||
|
return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB")
|
||||||
|
}
|
||||||
|
base := sanitizeFilename(filename)
|
||||||
|
if i := strings.LastIndex(base, "."); i > 0 {
|
||||||
|
base = base[:i]
|
||||||
|
}
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
base = "未命名章节"
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(base) > 200 {
|
||||||
|
base = truncateRunes(base, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []mdChapterChunk
|
||||||
|
if splitLevel == "none" {
|
||||||
|
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||||
|
} else {
|
||||||
|
chunks = splitMarkdownChapters(content, splitLevel)
|
||||||
|
}
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||||
|
}
|
||||||
|
return base, chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。
|
||||||
|
// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。
|
||||||
|
// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。
|
||||||
|
func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) {
|
||||||
|
var doc model.LibraryDoc
|
||||||
|
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||||
|
return nil, ErrLibraryNotFound
|
||||||
|
}
|
||||||
|
if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" {
|
||||||
|
return nil, errors.New("无效的拆分方式")
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, errors.New("没有可导入的文件")
|
||||||
|
}
|
||||||
|
if len(files) > MaxImportFiles {
|
||||||
|
return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件")
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, f := range files {
|
||||||
|
total += len(f.Content)
|
||||||
|
}
|
||||||
|
if total > MaxImportTotalBytes {
|
||||||
|
return nil, errors.New("导入内容总量过大:不能超过 20MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐文件拆块(保留文件边界,便于错误提示定位到文件)
|
||||||
|
type mdFilePlan struct {
|
||||||
|
Base string
|
||||||
|
Chunks []mdChapterChunk
|
||||||
|
}
|
||||||
|
plan := make([]mdFilePlan, 0, len(files))
|
||||||
|
totalChunks := 0
|
||||||
|
for _, f := range files {
|
||||||
|
base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plan = append(plan, mdFilePlan{Base: base, Chunks: chunks})
|
||||||
|
totalChunks += len(chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.sectionCount(docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count+int64(totalChunks) > MaxSectionsPerDoc {
|
||||||
|
return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)")
|
||||||
|
}
|
||||||
|
maxSort, err := s.sectionMaxSort(docID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
created := make([]model.LibrarySection, 0, totalChunks)
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
offset := 0
|
||||||
|
for _, fp := range plan {
|
||||||
|
for i, ch := range fp.Chunks {
|
||||||
|
title := strings.TrimSpace(ch.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = fp.Base + "(续)"
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) > 200 {
|
||||||
|
title = truncateRunes(title, 200)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(ch.Body) > MaxSectionContent {
|
||||||
|
return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入")
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{
|
||||||
|
DocID: docID,
|
||||||
|
Title: title,
|
||||||
|
Content: ch.Body,
|
||||||
|
SortOrder: maxSort + 1 + offset + i,
|
||||||
|
}
|
||||||
|
if err := tx.Create(sec).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created = append(created, *sec)
|
||||||
|
}
|
||||||
|
offset += len(fp.Chunks)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容)
|
||||||
|
func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) {
|
||||||
|
return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel)
|
||||||
|
}
|
||||||
559
backend/service/library_test.go
Normal file
559
backend/service/library_test.go
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLibraryTestService(t *testing.T) (*LibraryService, string) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewLibraryService(db, dir)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
return s, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func libPub(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
func libInt(v int) *int { return &v }
|
||||||
|
|
||||||
|
func TestLibraryNormalize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*LibraryInput)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"},
|
||||||
|
{"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"},
|
||||||
|
{"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"},
|
||||||
|
{"标题为空", 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 {
|
||||||
|
in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}
|
||||||
|
tc.mutate(in)
|
||||||
|
err := in.normalize()
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: 不应报错,got %v", tc.name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil {
|
||||||
|
t.Fatal("重复 slug 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(doc.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
|
||||||
|
_, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "已删除") {
|
||||||
|
t.Fatalf("软删后重建应提示已删除条目占用,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 彻底删除后可重建
|
||||||
|
if err := s.Purge(doc.ID); err != nil {
|
||||||
|
t.Fatalf("purge: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil {
|
||||||
|
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryPurgeRequiresDeleted(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"})
|
||||||
|
if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) {
|
||||||
|
t.Fatalf("未删条目 Purge 应拒绝,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) {
|
||||||
|
s, dir := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"})
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add file: %v", err)
|
||||||
|
}
|
||||||
|
full := filepath.Join(dir, "library", f.StoredName)
|
||||||
|
if _, err := os.Stat(full); err != nil {
|
||||||
|
t.Fatalf("文件未落盘: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil {
|
||||||
|
t.Fatalf("create section: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Delete(doc.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Purge(doc.ID); err != nil {
|
||||||
|
t.Fatalf("purge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("条目行应已清除,got %d", n)
|
||||||
|
}
|
||||||
|
s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("文件行应已清除,got %d", n)
|
||||||
|
}
|
||||||
|
s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("章节行应已清除,got %d", n)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(full); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("磁盘文件应已删除,err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExtWhitelists(t *testing.T) {
|
||||||
|
if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") {
|
||||||
|
t.Fatal("白名单内扩展名应允许")
|
||||||
|
}
|
||||||
|
if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") {
|
||||||
|
t.Fatal("白名单外扩展名应拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryAddFile(t *testing.T) {
|
||||||
|
s, dir := newLibraryTestService(t)
|
||||||
|
doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单内:落盘 + 建行
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add pdf: %v", err)
|
||||||
|
}
|
||||||
|
if f.Ext != "pdf" || f.Size != 8 {
|
||||||
|
t.Fatalf("文件元数据错误: %+v", f)
|
||||||
|
}
|
||||||
|
if f.MIME != "application/pdf" {
|
||||||
|
t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil {
|
||||||
|
t.Fatalf("文件未落盘: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// html 存储层 MIME 强制 octet-stream(下载安全)
|
||||||
|
h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("<h1>hi</h1>"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add html: %v", err)
|
||||||
|
}
|
||||||
|
if h.MIME != "application/octet-stream" {
|
||||||
|
t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单外拒绝
|
||||||
|
if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil {
|
||||||
|
t.Fatal("exe 应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil {
|
||||||
|
t.Fatal("无扩展名应被拒绝")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过每条目上限
|
||||||
|
for i := 0; i < MaxLibraryFilesPerDoc-2; i++ {
|
||||||
|
if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil {
|
||||||
|
t.Fatalf("add #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil {
|
||||||
|
t.Fatal("超出文件数上限应报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未发布条目的文件公开不可见
|
||||||
|
if _, err := s.GetPublicFile(f.ID); err == nil {
|
||||||
|
t.Fatal("未发布条目的文件不应公开可见")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryPublishedFilterAndOrder(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil {
|
||||||
|
t.Fatalf("create draft: %v", err)
|
||||||
|
}
|
||||||
|
published, _ := s.Create(&LibraryInput{
|
||||||
|
Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil {
|
||||||
|
t.Fatalf("add b: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil {
|
||||||
|
t.Fatalf("add a: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未发布不出现在公开目录
|
||||||
|
list, err := s.ListPublished()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPublished: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Slug != "live-doc" {
|
||||||
|
t.Fatalf("公开目录应只含 live-doc,got %+v", list)
|
||||||
|
}
|
||||||
|
if list[0].FileCount != 2 {
|
||||||
|
t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount)
|
||||||
|
}
|
||||||
|
if _, err := s.GetPublishedBySlug("draft-doc"); err == nil {
|
||||||
|
t.Fatal("未发布条目按 slug 不应可见")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删后公开彻底不可见
|
||||||
|
if err := s.Delete(published.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetPublishedBySlug("live-doc"); err == nil {
|
||||||
|
t.Fatal("软删条目不应可见")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出)
|
||||||
|
all, err := s.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all)
|
||||||
|
}
|
||||||
|
bySlug := map[string]LibraryDocDetail{}
|
||||||
|
for _, d := range all {
|
||||||
|
bySlug[d.Slug] = d
|
||||||
|
}
|
||||||
|
if d := bySlug["draft-doc"]; d.Deleted {
|
||||||
|
t.Fatalf("draft-doc 不应标记已删除: %+v", d)
|
||||||
|
}
|
||||||
|
if d := bySlug["live-doc"]; !d.Deleted {
|
||||||
|
t.Fatalf("live-doc 应标记已删除: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryFileOrdering(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)})
|
||||||
|
// b 先传(id 小),a 设 sort_order 靠前
|
||||||
|
b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b"))
|
||||||
|
a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a"))
|
||||||
|
s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1)
|
||||||
|
_ = b
|
||||||
|
|
||||||
|
d, err := s.GetPublishedBySlug("order")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
if len(d.Files) != 2 || d.Files[0].Name != "a.txt" {
|
||||||
|
t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryIncrDownload(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)})
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
s.IncrDownload(f.ID)
|
||||||
|
s.IncrDownload(f.ID)
|
||||||
|
got, err := s.GetPublicFile(f.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.DownloadCount != 2 {
|
||||||
|
t.Fatalf("下载计数应为 2,got %d", got.DownloadCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryDeleteFile(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"})
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteFile(f.ID); err != nil {
|
||||||
|
t.Fatalf("delete file: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteFile(f.ID); err == nil {
|
||||||
|
t.Fatal("重复删除应报不存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 章节(两级树) ----------
|
||||||
|
|
||||||
|
func TestLibrarySectionTwoLevelConstraint(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"})
|
||||||
|
|
||||||
|
ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create chapter: %v", err)
|
||||||
|
}
|
||||||
|
sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create section: %v", err)
|
||||||
|
}
|
||||||
|
if sec.ParentID == nil || *sec.ParentID != ch.ID {
|
||||||
|
t.Fatalf("节的父应为章 %d", ch.ID)
|
||||||
|
}
|
||||||
|
// 节下不能再挂节
|
||||||
|
if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil {
|
||||||
|
t.Fatal("三级嵌套应被拒绝")
|
||||||
|
}
|
||||||
|
// 跨条目父章节
|
||||||
|
doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"})
|
||||||
|
if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil {
|
||||||
|
t.Fatal("跨条目父章节应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibrarySectionMoveSwap(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"})
|
||||||
|
a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"})
|
||||||
|
b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"})
|
||||||
|
c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"})
|
||||||
|
|
||||||
|
if err := s.MoveSection(b.ID, "up"); err != nil {
|
||||||
|
t.Fatalf("move up: %v", err)
|
||||||
|
}
|
||||||
|
secs, _ := s.ListSections(doc.ID)
|
||||||
|
if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID {
|
||||||
|
t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs))
|
||||||
|
}
|
||||||
|
// 边界 no-op
|
||||||
|
if err := s.MoveSection(b.ID, "up"); err != nil {
|
||||||
|
t.Fatalf("边界上移: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.MoveSection(b.ID, "bad"); err == nil {
|
||||||
|
t.Fatal("非法方向应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func titles(secs []model.LibrarySection) []string {
|
||||||
|
out := make([]string, len(secs))
|
||||||
|
for i, sec := range secs {
|
||||||
|
out[i] = sec.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryImportMarkdown(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"})
|
||||||
|
|
||||||
|
// h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章
|
||||||
|
md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。"
|
||||||
|
secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 2 {
|
||||||
|
t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs))
|
||||||
|
}
|
||||||
|
if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") {
|
||||||
|
t.Fatalf("第一章错误: %+v", secs[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(secs[0].Content, "不是标题") {
|
||||||
|
t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分")
|
||||||
|
}
|
||||||
|
if secs[1].Title != "第二章" {
|
||||||
|
t.Fatalf("第二章标题错误: %+v", secs[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无命中标题 → 回退单章,标题取文件名
|
||||||
|
secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import fallback: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs2) != 1 || secs2[0].Title != "README" {
|
||||||
|
t.Fatalf("应回退单章 README: %+v", titles(secs2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非法 splitLevel
|
||||||
|
if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil {
|
||||||
|
t.Fatal("非法拆分方式应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryImportMarkdownBatch(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"})
|
||||||
|
|
||||||
|
files := []ImportFileInput{
|
||||||
|
{Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"},
|
||||||
|
{Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"},
|
||||||
|
{Filename: "03.md", Content: "# 丙\n\n内容丙。"},
|
||||||
|
}
|
||||||
|
secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch import: %v", err)
|
||||||
|
}
|
||||||
|
// 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名)
|
||||||
|
if len(secs) != 3 {
|
||||||
|
t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs))
|
||||||
|
}
|
||||||
|
if secs[0].Title != "甲" {
|
||||||
|
t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs))
|
||||||
|
}
|
||||||
|
if secs[1].Title != "02-不要慢性自杀" {
|
||||||
|
t.Fatalf("文件2 标题应取文件名: %+v", titles(secs))
|
||||||
|
}
|
||||||
|
if secs[2].Title != "03" {
|
||||||
|
t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs))
|
||||||
|
}
|
||||||
|
for i, sec := range secs {
|
||||||
|
if sec.SortOrder != i {
|
||||||
|
t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件数超限
|
||||||
|
many := make([]ImportFileInput, MaxImportFiles+1)
|
||||||
|
for i := range many {
|
||||||
|
many[i] = ImportFileInput{Filename: "f.md", Content: "x"}
|
||||||
|
}
|
||||||
|
if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil {
|
||||||
|
t.Fatal("文件数超过上限应报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空文件列表
|
||||||
|
if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil {
|
||||||
|
t.Fatal("空文件列表应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryDeleteSectionCascade(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"})
|
||||||
|
ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"})
|
||||||
|
sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"})
|
||||||
|
|
||||||
|
if err := s.DeleteSection(ch.ID); err != nil {
|
||||||
|
t.Fatalf("delete chapter: %v", err)
|
||||||
|
}
|
||||||
|
// 章与节都被软删
|
||||||
|
if _, err := s.section(ch.ID); err == nil {
|
||||||
|
t.Fatal("章应已删除")
|
||||||
|
}
|
||||||
|
if _, err := s.section(sec.ID); err == nil {
|
||||||
|
t.Fatal("章删除时其下小节应级联删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryDetailCarriesSections(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)})
|
||||||
|
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
d, err := s.GetPublishedBySlug("carry")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" {
|
||||||
|
t.Fatalf("公开详情应携带章节: %+v", d.Sections)
|
||||||
|
}
|
||||||
|
// 草稿条目不可见
|
||||||
|
doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"})
|
||||||
|
if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetPublishedBySlug("draft"); err == nil {
|
||||||
|
t.Fatal("草稿条目不应出现在公开详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
|
|||||||
{"brand", "brand", "品牌资源"},
|
{"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 媒体库条目
|
// MediaLibraryItem 媒体库条目
|
||||||
type MediaLibraryItem struct {
|
type MediaLibraryItem struct {
|
||||||
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
||||||
@@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
||||||
type attMeta struct {
|
type attMeta struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
Source string
|
||||||
URL string
|
URL string
|
||||||
MIME string
|
MIME string
|
||||||
Size int
|
Size int
|
||||||
@@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
}
|
}
|
||||||
var rows []attMeta
|
var rows []attMeta
|
||||||
if err := s.db.Table("attachments").
|
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").
|
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
metaByURL := make(map[string]attMeta, len(rows))
|
metaByURL := make(map[string]attMeta, len(rows))
|
||||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
||||||
items := make([]MediaLibraryItem, 0, len(rows))
|
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 {
|
for _, r := range rows {
|
||||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
if !strings.HasPrefix(r.URL, "/api/media/") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||||
|
continue // 未知 kind 不进媒体库
|
||||||
|
}
|
||||||
uploader := r.Nickname
|
uploader := r.Nickname
|
||||||
if uploader == "" {
|
if uploader == "" {
|
||||||
uploader = r.Username
|
uploader = r.Username
|
||||||
}
|
}
|
||||||
cat := r.Kind
|
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||||
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
|
|
||||||
continue // 未知 kind 不进媒体库
|
|
||||||
}
|
|
||||||
ca := r.CreatedAt
|
ca := r.CreatedAt
|
||||||
items = append(items, MediaLibraryItem{
|
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,
|
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
||||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||||
Uploader: uploader, UploadedAt: &ca,
|
Uploader: uploader, UploadedAt: &ca,
|
||||||
@@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
if m.Width > 0 {
|
if m.Width > 0 {
|
||||||
item.Width, item.Height = m.Width, m.Height
|
item.Width, item.Height = m.Width, m.Height
|
||||||
}
|
}
|
||||||
|
// 书库封面 / 正文插图改归“书籍”分类
|
||||||
|
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
|
||||||
|
item.Category = mediaCategoryBook
|
||||||
|
item.CategoryName = mediaCategoryBookName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
||||||
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
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{}
|
postByURL := map[string]postRef{}
|
||||||
type commentRef struct {
|
type commentRef struct {
|
||||||
id, postID uint
|
id, postID uint
|
||||||
|
floor uint
|
||||||
|
isRoot bool
|
||||||
}
|
}
|
||||||
commentByURL := map[string]commentRef{}
|
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 {
|
postTitle := func(id uint) string {
|
||||||
var p model.Post
|
var p model.Post
|
||||||
@@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if cm, ok := commentByURL[u]; ok {
|
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]
|
title := commentPostTitle[cm.postID]
|
||||||
if title != "" {
|
if title != "" {
|
||||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||||
|
|||||||
@@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("comment image missing")
|
t.Fatal("comment image missing")
|
||||||
}
|
}
|
||||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
|
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
|
||||||
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
|
|
||||||
t.Fatalf("comment source url wrong: %+v", cimg)
|
t.Fatalf("comment source url wrong: %+v", cimg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
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) {
|
func TestMediaThumb(t *testing.T) {
|
||||||
s, dir := newMediaLibraryService(t)
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
webpenc "github.com/gen2brain/webp"
|
|
||||||
"golang.org/x/image/draw"
|
|
||||||
xwebp "golang.org/x/image/webp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||||
|
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||||||
|
|
||||||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||||||
const MediaThumbMaxSide = 480
|
const MediaThumbMaxSide = 480
|
||||||
@@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var img image.Image
|
img, err := decodeImageBytes(data, ext == ".webp")
|
||||||
if ext == ".webp" {
|
|
||||||
img, err = xwebp.Decode(bytes.NewReader(data))
|
|
||||||
} else {
|
|
||||||
img, _, err = image.Decode(bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err // 如动图 WebP,调用方回退原图
|
return nil, err // 如动图 WebP,调用方回退原图
|
||||||
}
|
}
|
||||||
@@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
nh = MediaThumbMaxSide
|
nh = MediaThumbMaxSide
|
||||||
nw = max(1, w*MediaThumbMaxSide/h)
|
nw = max(1, w*MediaThumbMaxSide/h)
|
||||||
}
|
}
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
dst := scaleImage(img, nw, nh)
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
_ = atomicWriteFile(thumbPath, out)
|
||||||
tmp := thumbPath + ".partial"
|
return out, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
|||||||
})
|
})
|
||||||
return 0, e
|
return 0, e
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codeVerifyMaxAttempts = 5
|
codeVerifyMaxAttempts = 5
|
||||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
|||||||
type AcceptedAnswer struct {
|
type AcceptedAnswer struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
|||||||
ans := &AcceptedAnswer{
|
ans := &AcceptedAnswer{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Floor: floor,
|
Floor: floor,
|
||||||
|
IsRoot: c.ParentID == nil,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ const (
|
|||||||
|
|
||||||
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
|
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
|
||||||
SettingKeyUrlStyle = "url_style"
|
SettingKeyUrlStyle = "url_style"
|
||||||
|
|
||||||
|
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
|
||||||
|
// 缺行/非法/为空视为全量默认
|
||||||
|
SettingKeyMobileTabs = "mobile_tabs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -118,8 +122,19 @@ const (
|
|||||||
|
|
||||||
// DefaultUrlStyle 标准动态路由风格(/post/123)
|
// DefaultUrlStyle 标准动态路由风格(/post/123)
|
||||||
DefaultUrlStyle = "default"
|
DefaultUrlStyle = "default"
|
||||||
|
|
||||||
|
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
|
||||||
|
MaxMobileTabs = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
|
||||||
|
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
|
||||||
|
|
||||||
|
// DefaultMobileTabs 底栏默认全量配置
|
||||||
|
func DefaultMobileTabs() []string {
|
||||||
|
return append([]string(nil), MobileTabKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||||
var DefaultAttachmentExts = []string{
|
var DefaultAttachmentExts = []string{
|
||||||
"pdf", "txt", "md", "csv", "json",
|
"pdf", "txt", "md", "csv", "json",
|
||||||
@@ -206,6 +221,12 @@ type PublicSiteSettings struct {
|
|||||||
|
|
||||||
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
|
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
|
||||||
UrlStyle string `json:"url_style"`
|
UrlStyle string `json:"url_style"`
|
||||||
|
|
||||||
|
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
|
||||||
|
MobileTabs []string `json:"mobile_tabs"`
|
||||||
|
|
||||||
|
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
|
||||||
|
HeaderNav []HeaderNavItem `json:"header_nav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -310,6 +331,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
BrandLogoFit: BrandLogoFitContain,
|
BrandLogoFit: BrandLogoFitContain,
|
||||||
FooterLinks: []FooterLink{},
|
FooterLinks: []FooterLink{},
|
||||||
UrlStyle: DefaultUrlStyle,
|
UrlStyle: DefaultUrlStyle,
|
||||||
|
HeaderNav: DefaultHeaderNav(),
|
||||||
}
|
}
|
||||||
accent, err := s.AccentColor()
|
accent, err := s.AccentColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -513,6 +535,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
}
|
}
|
||||||
out.UrlStyle = urlStyle
|
out.UrlStyle = urlStyle
|
||||||
|
|
||||||
|
out.MobileTabs = s.MobileTabs()
|
||||||
|
out.HeaderNav = s.HeaderNav()
|
||||||
|
|
||||||
if err := s.fillBrand(&out); err != nil {
|
if err := s.fillBrand(&out); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -551,6 +576,84 @@ func (s *SettingService) SetUrlStyle(style string) error {
|
|||||||
return ErrInvalidSiteSetting
|
return ErrInvalidSiteSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素
|
||||||
|
func normalizeMobileTabs(tabs []string) ([]string, bool) {
|
||||||
|
allowed := make(map[string]struct{}, len(MobileTabKeys))
|
||||||
|
for _, k := range MobileTabKeys {
|
||||||
|
allowed[k] = struct{}{}
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(tabs))
|
||||||
|
out := make([]string, 0, len(tabs))
|
||||||
|
for _, t := range tabs {
|
||||||
|
k := strings.TrimSpace(strings.ToLower(t))
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowed[k]; !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if _, dup := seen[k]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
out = append(out, k)
|
||||||
|
if len(out) > MaxMobileTabs {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序)
|
||||||
|
func mobileTabsEqual(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(b))
|
||||||
|
for _, k := range b {
|
||||||
|
set[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, k := range a {
|
||||||
|
if _, ok := set[k]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量
|
||||||
|
func (s *SettingService) MobileTabs() []string {
|
||||||
|
v, found, err := s.getValue(SettingKeyMobileTabs)
|
||||||
|
if err != nil || !found || strings.TrimSpace(v) == "" {
|
||||||
|
return DefaultMobileTabs()
|
||||||
|
}
|
||||||
|
var tabs []string
|
||||||
|
if err := json.Unmarshal([]byte(v), &tabs); err != nil {
|
||||||
|
return DefaultMobileTabs()
|
||||||
|
}
|
||||||
|
out, ok := normalizeMobileTabs(tabs)
|
||||||
|
if !ok || len(out) == 0 {
|
||||||
|
return DefaultMobileTabs()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义
|
||||||
|
func (s *SettingService) SetMobileTabs(tabs []string) error {
|
||||||
|
out, ok := normalizeMobileTabs(tabs)
|
||||||
|
if !ok || len(out) == 0 {
|
||||||
|
return ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
if mobileTabsEqual(out, MobileTabKeys) {
|
||||||
|
return s.deleteKey(SettingKeyMobileTabs)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.putValue(SettingKeyMobileTabs, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
|
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
|
||||||
func (s *SettingService) AccentColor() (string, error) {
|
func (s *SettingService) AccentColor() (string, error) {
|
||||||
v, found, err := s.getValue(SettingKeyAccent)
|
v, found, err := s.getValue(SettingKeyAccent)
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
var (
|
||||||
|
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
|
||||||
|
)
|
||||||
|
|
||||||
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
||||||
var sitePageWriteFields = []string{
|
var sitePageWriteFields = []string{
|
||||||
@@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
|
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||||||
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
|
// active=在用单页占用;deleted=已删除单页占用
|
||||||
|
func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
|
||||||
|
q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at")
|
||||||
if excludeID > 0 {
|
if excludeID > 0 {
|
||||||
q = q.Where("id <> ?", excludeID)
|
q = q.Where("id <> ?", excludeID)
|
||||||
}
|
}
|
||||||
var n int64
|
var rows []struct {
|
||||||
if err := q.Count(&n).Error; err != nil {
|
DeletedAt gorm.DeletedAt
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
return n > 0, nil
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.DeletedAt.Valid {
|
||||||
|
deleted = true
|
||||||
|
} else {
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active, deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
||||||
@@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll 管理后台全部单页
|
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
|
||||||
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
|
type SitePageAdminItem struct {
|
||||||
var list []model.SitePage
|
model.SitePage
|
||||||
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
|
Deleted bool `json:"deleted"`
|
||||||
return list, err
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除)
|
||||||
|
func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) {
|
||||||
|
var pages []model.SitePage
|
||||||
|
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]SitePageAdminItem, 0, len(pages))
|
||||||
|
for _, p := range pages {
|
||||||
|
out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get 管理后台按 ID 取单页
|
// Get 管理后台按 ID 取单页
|
||||||
@@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
|
|||||||
if err := in.normalize(nil); err != nil {
|
if err := in.normalize(nil); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
taken, err := s.slugTaken(in.Slug, 0)
|
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if taken {
|
if active {
|
||||||
return nil, errors.New("slug 已被占用")
|
return nil, errors.New("slug 已被占用")
|
||||||
}
|
}
|
||||||
|
if deleted {
|
||||||
|
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||||
|
}
|
||||||
p := &model.SitePage{
|
p := &model.SitePage{
|
||||||
Published: false,
|
Published: false,
|
||||||
ShowInSidebar: false,
|
ShowInSidebar: false,
|
||||||
@@ -229,13 +258,16 @@ func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, e
|
|||||||
if err := in.normalize(&p); err != nil {
|
if err := in.normalize(&p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
taken, err := s.slugTaken(in.Slug, id)
|
active, deleted, err := s.slugTaken(in.Slug, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if taken {
|
if active {
|
||||||
return nil, errors.New("slug 已被占用")
|
return nil, errors.New("slug 已被占用")
|
||||||
}
|
}
|
||||||
|
if deleted {
|
||||||
|
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||||
|
}
|
||||||
in.applyTo(&p)
|
in.applyTo(&p)
|
||||||
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
||||||
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||||
@@ -264,3 +296,15 @@ func (s *SitePageService) Delete(id uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge 彻底删除已软删的单页(slug 释放可复用)
|
||||||
|
func (s *SitePageService) Purge(id uint) error {
|
||||||
|
var p model.SitePage
|
||||||
|
if err := s.db.Unscoped().First(&p, id).Error; err != nil {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
if !p.DeletedAt.Valid {
|
||||||
|
return ErrSitePageNotDeleted
|
||||||
|
}
|
||||||
|
return s.db.Unscoped().Delete(&p).Error
|
||||||
|
}
|
||||||
|
|||||||
69
backend/service/site_page_test.go
Normal file
69
backend/service/site_page_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSitePageTestService(t *testing.T) *SitePageService {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.SitePage{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return NewSitePageService(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sitePageInput(slug, title string) *SitePageInput {
|
||||||
|
return &SitePageInput{Slug: slug, Title: title, Content: "正文内容"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitePageSlugReuseAfterSoftDelete(t *testing.T) {
|
||||||
|
s := newSitePageTestService(t)
|
||||||
|
p, err := s.Create(sitePageInput("test", "测试"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(p.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
|
||||||
|
_, err = s.Create(sitePageInput("test", "重建"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "已删除") {
|
||||||
|
t.Fatalf("软删后重建应提示已删除单页占用,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端列表含软删行且带 deleted 标记
|
||||||
|
all, err := s.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 1 || !all[0].Deleted || all[0].Slug != "test" {
|
||||||
|
t.Fatalf("ListAll 应含软删单页且 Deleted=true,got %+v", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 彻底删除后可重建
|
||||||
|
if err := s.Purge(p.ID); err != nil {
|
||||||
|
t.Fatalf("purge: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(sitePageInput("test", "重建")); err != nil {
|
||||||
|
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitePagePurgeRequiresDeleted(t *testing.T) {
|
||||||
|
s := newSitePageTestService(t)
|
||||||
|
p, _ := s.Create(sitePageInput("alive", "在用"))
|
||||||
|
if err := s.Purge(p.ID); err == nil || !errors.Is(err, ErrSitePageNotDeleted) {
|
||||||
|
t.Fatalf("未删单页 Purge 应拒绝,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
371
backend/service/timeline_release.go
Normal file
371
backend/service/timeline_release.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
timelineReleaseMaxPages = 10
|
||||||
|
timelineReleaseMaxItems = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// timelineReleaseSource 内置 Release 适配(Gitea + GitHub)
|
||||||
|
type timelineReleaseSource struct {
|
||||||
|
id string
|
||||||
|
host string // 精确主机或 "*"(通配)
|
||||||
|
listPath string // releases 页正则
|
||||||
|
apiURL string // API 模板
|
||||||
|
query map[string]string
|
||||||
|
headers map[string]string
|
||||||
|
pagination string // link_header | query_page
|
||||||
|
dateField string // 优先取的字段:published_at / created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
var timelineReleaseSources = []timelineReleaseSource{
|
||||||
|
{
|
||||||
|
id: "github_release",
|
||||||
|
host: "github.com",
|
||||||
|
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
|
||||||
|
apiURL: "https://api.github.com/repos/{owner}/{repo}/releases",
|
||||||
|
query: map[string]string{"per_page": "100", "page": "{page}"},
|
||||||
|
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json"},
|
||||||
|
pagination: "link_header",
|
||||||
|
dateField: "published_at",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gitea_release",
|
||||||
|
host: "*",
|
||||||
|
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
|
||||||
|
apiURL: "https://{host}/api/v1/repos/{owner}/{repo}/releases",
|
||||||
|
query: map[string]string{"limit": "50", "page": "{page}"},
|
||||||
|
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/json"},
|
||||||
|
pagination: "query_page",
|
||||||
|
dateField: "created_at",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchReleaseSource 按 host + path 匹配内置 release 适配
|
||||||
|
func matchReleaseSource(host, path string) (*timelineReleaseSource, map[string]string, error) {
|
||||||
|
for i := range timelineReleaseSources {
|
||||||
|
src := &timelineReleaseSources[i]
|
||||||
|
if src.host != "*" && !strings.EqualFold(src.host, host) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if src.host == "*" && strings.EqualFold(host, "github.com") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(src.listPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := re.FindStringSubmatch(path); m != nil {
|
||||||
|
return src, subexpMap(re, m), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, errors.New("地址不符或主机未配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportTimelineFromReleases 按 releases 页 URL 拉取并解析发布记录
|
||||||
|
func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGitImportResult, error) {
|
||||||
|
cleanURLs := make([]string, 0, len(urls))
|
||||||
|
for _, u := range urls {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u != "" {
|
||||||
|
cleanURLs = append(cleanURLs, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleanURLs) == 0 {
|
||||||
|
return nil, errors.New("请提供至少一条 URL")
|
||||||
|
}
|
||||||
|
if len(cleanURLs) > 20 {
|
||||||
|
return nil, errors.New("一次最多 20 条 URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: timelineGitHTTPTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: publicOnlyDial,
|
||||||
|
TLSHandshakeTimeout: timelineGitHTTPTimeout,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
},
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 3 {
|
||||||
|
return errors.New("重定向过多")
|
||||||
|
}
|
||||||
|
if err := assertSafeHTTPSURL(req.URL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
seenURL := map[string]bool{}
|
||||||
|
var items []TimelineGitItem
|
||||||
|
var failMsgs []string
|
||||||
|
truncated := false
|
||||||
|
|
||||||
|
for _, rawURL := range cleanURLs {
|
||||||
|
part, partTrunc, err := s.importOneReleaseURL(client, rawURL, seenURL, timelineReleaseMaxItems-len(items))
|
||||||
|
if err != nil {
|
||||||
|
failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, part...)
|
||||||
|
if partTrunc {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
if len(items) >= timelineReleaseMaxItems {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &TimelineGitImportResult{Items: items}
|
||||||
|
if truncated {
|
||||||
|
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", timelineReleaseMaxItems)
|
||||||
|
}
|
||||||
|
if len(failMsgs) > 0 {
|
||||||
|
out.Error = strings.Join(failMsgs, ";")
|
||||||
|
}
|
||||||
|
if len(items) == 0 && out.Error == "" {
|
||||||
|
out.Error = "未能解析出 Release"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) importOneReleaseURL(
|
||||||
|
client *http.Client,
|
||||||
|
rawURL string,
|
||||||
|
seenURL map[string]bool,
|
||||||
|
remain int,
|
||||||
|
) ([]TimelineGitItem, bool, error) {
|
||||||
|
if remain <= 0 {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return nil, false, errors.New("URL 无效")
|
||||||
|
}
|
||||||
|
if err := assertSafeHTTPSURL(u); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
path := u.EscapedPath()
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
src, caps, err := matchReleaseSource(host, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
|
||||||
|
return nil, false, errors.New("仓库名非法")
|
||||||
|
}
|
||||||
|
|
||||||
|
startPage := 1
|
||||||
|
if p := u.Query().Get("page"); p != "" {
|
||||||
|
if n, e := parseIntPage(p); e == nil && n >= 1 {
|
||||||
|
startPage = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []TimelineGitItem
|
||||||
|
truncated := false
|
||||||
|
page := startPage
|
||||||
|
maxPages := timelineReleaseMaxPages
|
||||||
|
pagesDone := 0
|
||||||
|
nextURL := ""
|
||||||
|
|
||||||
|
for pagesDone < maxPages && len(out) < remain {
|
||||||
|
var apiURL string
|
||||||
|
if nextURL != "" {
|
||||||
|
apiURL = nextURL
|
||||||
|
nextURL = ""
|
||||||
|
} else {
|
||||||
|
apiURL = expandTemplate(src.apiURL, host, caps, page)
|
||||||
|
q := url.Values{}
|
||||||
|
for k, v := range src.query {
|
||||||
|
q.Set(k, expandTemplate(v, host, caps, page))
|
||||||
|
}
|
||||||
|
parsed, e := url.Parse(apiURL)
|
||||||
|
if e != nil {
|
||||||
|
return out, truncated, errors.New("API URL 无效")
|
||||||
|
}
|
||||||
|
if len(q) > 0 {
|
||||||
|
existing := parsed.Query()
|
||||||
|
for k, vs := range q {
|
||||||
|
existing.Set(k, vs[0])
|
||||||
|
}
|
||||||
|
parsed.RawQuery = existing.Encode()
|
||||||
|
}
|
||||||
|
apiURL = parsed.String()
|
||||||
|
}
|
||||||
|
parsedAPI, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return out, truncated, errors.New("API URL 无效")
|
||||||
|
}
|
||||||
|
if err := assertSafeHTTPSURL(parsedAPI); err != nil {
|
||||||
|
return out, truncated, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := http.Header{}
|
||||||
|
for k, v := range src.headers {
|
||||||
|
if allowedAdapterHeaders[strings.ToLower(k)] {
|
||||||
|
headers.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if headers.Get("User-Agent") == "" {
|
||||||
|
headers.Set("User-Agent", "jiang13-bbs")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers)
|
||||||
|
if err != nil {
|
||||||
|
return out, truncated, err
|
||||||
|
}
|
||||||
|
if status == 404 || status == 401 || status == 403 {
|
||||||
|
return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)")
|
||||||
|
}
|
||||||
|
if status == 429 {
|
||||||
|
return out, truncated, errors.New("远端限流,请稍后再试")
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return out, truncated, fmt.Errorf("远端返回 %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems, err := parseReleaseListJSON(body, src, host)
|
||||||
|
if err != nil {
|
||||||
|
return out, truncated, err
|
||||||
|
}
|
||||||
|
if len(pageItems) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, it := range pageItems {
|
||||||
|
if it.SourceURL != "" && seenURL[it.SourceURL] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.SourceURL != "" {
|
||||||
|
seenURL[it.SourceURL] = true
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
if len(out) >= remain {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pagesDone++
|
||||||
|
if src.pagination == "link_header" && linkNext != "" {
|
||||||
|
nu, e := url.Parse(linkNext)
|
||||||
|
if e != nil || assertSafeHTTPSURL(nu) != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextURL = nu.String()
|
||||||
|
} else if src.pagination == "query_page" {
|
||||||
|
page++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pagesDone >= maxPages {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
return out, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReleaseListJSON(body []byte, src *timelineReleaseSource, host string) ([]TimelineGitItem, error) {
|
||||||
|
var root any
|
||||||
|
if err := json.Unmarshal(body, &root); err != nil {
|
||||||
|
return nil, errors.New("响应非 JSON")
|
||||||
|
}
|
||||||
|
arr, ok := root.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("响应不是 Release 列表")
|
||||||
|
}
|
||||||
|
var out []TimelineGitItem
|
||||||
|
for _, el := range arr {
|
||||||
|
item, ok := mapReleaseObject(el, src, host)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapReleaseObject(el any, src *timelineReleaseSource, host string) (TimelineGitItem, bool) {
|
||||||
|
m, ok := el.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return TimelineGitItem{}, false
|
||||||
|
}
|
||||||
|
// 跳过草稿
|
||||||
|
if draft, ok := m["draft"].(bool); ok && draft {
|
||||||
|
return TimelineGitItem{}, false
|
||||||
|
}
|
||||||
|
name := jsonStringField(m, "name")
|
||||||
|
tag := jsonStringField(m, "tag_name")
|
||||||
|
title := strings.TrimSpace(name)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return TimelineGitItem{}, false
|
||||||
|
}
|
||||||
|
body := jsonStringField(m, "body")
|
||||||
|
dateRaw := jsonStringField(m, src.dateField)
|
||||||
|
if dateRaw == "" {
|
||||||
|
// 兜底用 created_at
|
||||||
|
dateRaw = jsonStringField(m, "created_at")
|
||||||
|
}
|
||||||
|
srcURL := jsonStringField(m, "html_url")
|
||||||
|
|
||||||
|
title = sanitizeTimelinePlain(title, timelineTitleMax)
|
||||||
|
body = sanitizeTimelinePlain(body, timelineBodyMax)
|
||||||
|
title = neutralizeDirectivePlain(title)
|
||||||
|
body = neutralizeDirectivePlain(body)
|
||||||
|
date := parseCommitDate(dateRaw)
|
||||||
|
srcURL = sanitizeSourceURL(srcURL, host)
|
||||||
|
return TimelineGitItem{
|
||||||
|
Date: date,
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
SourceURL: srcURL,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStringField 从 map[string]any 取字符串字段,兼容 string/number
|
||||||
|
func jsonStringField(m map[string]any, key string) string {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%v", x)
|
||||||
|
case json.Number:
|
||||||
|
return x.String()
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntPage(s string) (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, errors.New("page 非数字")
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
if n > 1_000_000 {
|
||||||
|
return 0, errors.New("page 过大")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -238,7 +238,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
// 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 {
|
if src == nil {
|
||||||
return nil, errors.New("文件为空")
|
return nil, errors.New("文件为空")
|
||||||
}
|
}
|
||||||
@@ -354,6 +354,7 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
|||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindImage,
|
Kind: model.AttachmentKindImage,
|
||||||
|
Source: normalizeImageSource(source),
|
||||||
URL: "/uploads/images/" + storeName,
|
URL: "/uploads/images/" + storeName,
|
||||||
MIME: storeMime,
|
MIME: storeMime,
|
||||||
Size: int(storeSize),
|
Size: int(storeSize),
|
||||||
@@ -585,6 +586,46 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
|||||||
return s.SaveBackground(f)
|
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 并记一条本人附件)
|
||||||
|
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||||||
|
// 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("图片不存在或不属于你")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return s.SaveImage(userID, r, source)
|
||||||
|
}
|
||||||
|
abs, ok := s.safeUploadPath(att.URL)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("无效的图片地址")
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("读取图片失败")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return s.SaveImage(userID, f, source)
|
||||||
|
}
|
||||||
|
|
||||||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||||||
func (s *UploadService) BackgroundFileExists(url string) bool {
|
func (s *UploadService) BackgroundFileExists(url string) bool {
|
||||||
rel := BgRelPath(url)
|
rel := BgRelPath(url)
|
||||||
@@ -638,6 +679,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
|||||||
return list, err
|
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 图片正被帖子内容引用,不可物理删除
|
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||||||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
|
|||||||
t.Fatalf("encode jpeg: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
|
|||||||
t.Fatalf("encode png: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode gif: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode webp: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { pageGlyph } from "@/lib/pageGlyph";
|
import { pageGlyph } from "@/lib/pageGlyph";
|
||||||
import {
|
import {
|
||||||
@@ -94,19 +94,13 @@ export default async function AboutIndexPage() {
|
|||||||
const announcements = overview?.announcements ?? [];
|
const announcements = overview?.announcements ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
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 left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
||||||
const right = <SiteDocAnnoNav items={announcements} />;
|
const right = <SiteDocAnnoNav items={announcements} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="px-5 sm:px-6 pt-5 pb-4"
|
className="px-5 sm:px-6 pt-5 pb-4"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
|
|||||||
import { AdText } from "@/components/AdText";
|
import { AdText } from "@/components/AdText";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
||||||
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
||||||
@@ -882,8 +883,14 @@ function AdCard({
|
|||||||
{/* 预览缩略 */}
|
{/* 预览缩略 */}
|
||||||
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
||||||
{ad.kind === "image" && ad.image_url ? (
|
{ad.kind === "image" && ad.image_url ? (
|
||||||
|
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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 ? (
|
) : ad.kind === "text" && ad.title ? (
|
||||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
<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} />
|
<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="点击上传/更换收款码">
|
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
||||||
{payment.qr_url ? (
|
{payment.qr_url ? (
|
||||||
|
// 96×96 收款码位:本站图走 96/160 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
@@ -1084,8 +1097,14 @@ function SponsorEditor({
|
|||||||
{/* Logo:点击即上传 */}
|
{/* Logo:点击即上传 */}
|
||||||
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
||||||
{item.logo_url ? (
|
{item.logo_url ? (
|
||||||
|
// 120×56 Logo 位:本站图走 160/256 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={15} />
|
<Upload size={15} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SponsorsConfig,
|
type SponsorsConfig,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAdsPage() {
|
export default async function AdminAdsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
|
||||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,34 @@ function kindBadge(kind: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 日期筛选:复用搜索框壳层样式,与工具条同高同视觉 */
|
||||||
|
function DateField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="admin-field-shell admin-search-shell w-full sm:w-44">
|
||||||
|
<span className="shrink-0 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="admin-field-input"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailsInner() {
|
function DetailsInner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
||||||
@@ -86,6 +114,17 @@ function DetailsInner() {
|
|||||||
|
|
||||||
const resetPage = () => setPage(1);
|
const resetPage = () => setPage(1);
|
||||||
|
|
||||||
|
const hasFilter = Boolean(kind || path || ip || bot || start || end);
|
||||||
|
const resetAll = () => {
|
||||||
|
setKind("");
|
||||||
|
setPath("");
|
||||||
|
setIp("");
|
||||||
|
setBot("");
|
||||||
|
setStart("");
|
||||||
|
setEnd("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="meta text-[12.5px]">
|
<p className="meta text-[12.5px]">
|
||||||
@@ -106,48 +145,38 @@ function DetailsInner() {
|
|||||||
ariaLabel="按路径筛选"
|
ariaLabel="按路径筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-44">
|
<div className="w-full sm:w-48">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={ip}
|
value={ip}
|
||||||
onChange={setIp}
|
onChange={setIp}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setIp(""); resetPage(); }}
|
onClear={() => { setIp(""); resetPage(); }}
|
||||||
placeholder="按 IP 筛选,回车应用"
|
placeholder="按 IP 精确筛选"
|
||||||
ariaLabel="按 IP 筛选"
|
ariaLabel="按 IP 筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-40">
|
<div className="w-full sm:w-44">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={bot}
|
value={bot}
|
||||||
onChange={setBot}
|
onChange={setBot}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setBot(""); resetPage(); }}
|
onClear={() => { setBot(""); resetPage(); }}
|
||||||
placeholder="按爬虫名筛选"
|
placeholder="按爬虫名精确筛选"
|
||||||
ariaLabel="按爬虫名筛选"
|
ariaLabel="按爬虫名筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
<DateField label="从" value={start} onChange={(v) => { setStart(v); resetPage(); }} ariaLabel="开始日期" />
|
||||||
从
|
<DateField label="至" value={end} onChange={(v) => { setEnd(v); resetPage(); }} ariaLabel="结束日期" />
|
||||||
<input
|
{hasFilter ? (
|
||||||
type="date"
|
<button
|
||||||
value={start}
|
type="button"
|
||||||
onChange={(e) => { setStart(e.target.value); resetPage(); }}
|
onClick={resetAll}
|
||||||
className="admin-field-input"
|
className="admin-field-shell admin-search-shell w-full sm:w-auto justify-center text-[13px] cursor-pointer transition-colors hover:bg-[var(--panel-2)]"
|
||||||
style={{ width: 150 }}
|
style={{ color: "var(--ink-2)" }}
|
||||||
aria-label="开始日期"
|
>
|
||||||
/>
|
重置筛选
|
||||||
</label>
|
</button>
|
||||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
) : null}
|
||||||
至
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={end}
|
|
||||||
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
|
|
||||||
className="admin-field-input"
|
|
||||||
style={{ width: 150 }}
|
|
||||||
aria-label="结束日期"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAnnouncementNewPage() {
|
export default async function AdminAnnouncementNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "站点公告管理",
|
title: "站点公告管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
|
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
|
||||||
export default async function AdminAnnouncementsPage() {
|
export default async function AdminAnnouncementsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SURFACE_LABEL: Record<BgSurface, string> = {
|
const SURFACE_LABEL: Record<BgSurface, string> = {
|
||||||
site: "前台背景",
|
site: "前台背景",
|
||||||
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<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]}候选图`}
|
alt={`${SURFACE_LABEL[surface]}候选图`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateAuthor } from "@/lib/roles";
|
import { canModerateAuthor } from "@/lib/roles";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { postPath } from "@/lib/urlStyle";
|
import { postPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
@@ -692,7 +692,7 @@ export default function ContentAdmin({
|
|||||||
c.post_title
|
c.post_title
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<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"
|
className="hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
496
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal file
496
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminCreateFriendLink,
|
||||||
|
apiAdminDeleteFriendLink,
|
||||||
|
apiAdminListFriendLinks,
|
||||||
|
apiAdminSetFriendLinkStatus,
|
||||||
|
apiAdminUpdateFriendLink,
|
||||||
|
type FriendLink,
|
||||||
|
type FriendLinkAdminInput,
|
||||||
|
type FriendLinkStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialLinks: FriendLink[];
|
||||||
|
initError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_CLASS = "w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors";
|
||||||
|
const FIELD_STYLE = {
|
||||||
|
background: "var(--panel-2)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { key: FriendLinkStatus; label: string }[] = [
|
||||||
|
{ key: "pending", label: "待审批" },
|
||||||
|
{ key: "approved", label: "已通过" },
|
||||||
|
{ key: "rejected", label: "已拒绝" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
logo_url: string;
|
||||||
|
category: string;
|
||||||
|
reciprocal_url: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
description: "",
|
||||||
|
logo_url: "",
|
||||||
|
category: "",
|
||||||
|
reciprocal_url: "",
|
||||||
|
sort_order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
|
||||||
|
const [links, setLinks] = useState<FriendLink[]>(initialLinks);
|
||||||
|
const [tab, setTab] = useState<FriendLinkStatus>("pending");
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState(initError);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { pending: 0, approved: 0, rejected: 0 } as Record<FriendLinkStatus, number>;
|
||||||
|
for (const l of links) c[l.status] = (c[l.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => links.filter((l) => l.status === tab).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id),
|
||||||
|
[links, tab]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListFriendLinks();
|
||||||
|
setLinks(res.links ?? []);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "加载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (l: FriendLink) => {
|
||||||
|
setEditingId(l.id);
|
||||||
|
setForm({
|
||||||
|
name: l.name,
|
||||||
|
url: l.url,
|
||||||
|
description: l.description || "",
|
||||||
|
logo_url: l.logo_url || "",
|
||||||
|
category: l.category || "",
|
||||||
|
reciprocal_url: l.reciprocal_url || "",
|
||||||
|
sort_order: l.sort_order,
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (k: keyof FormState, v: string | number) =>
|
||||||
|
setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body: FriendLinkAdminInput = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
logo_url: form.logo_url.trim() || undefined,
|
||||||
|
category: form.category.trim() || undefined,
|
||||||
|
reciprocal_url: form.reciprocal_url.trim() || undefined,
|
||||||
|
sort_order: form.sort_order,
|
||||||
|
};
|
||||||
|
if (editingId == null) {
|
||||||
|
await apiAdminCreateFriendLink(body);
|
||||||
|
} else {
|
||||||
|
await apiAdminUpdateFriendLink(editingId, body);
|
||||||
|
}
|
||||||
|
closeEditor();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = async (id: number, status: FriendLinkStatus) => {
|
||||||
|
let rejectReason = "";
|
||||||
|
if (status === "rejected") {
|
||||||
|
const r = window.prompt("请输入拒绝理由(可选)", "");
|
||||||
|
if (r === null) return;
|
||||||
|
rejectReason = r;
|
||||||
|
}
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminSetFriendLinkStatus(id, status, rejectReason || undefined);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!window.confirm("确定删除该友情链接?此操作不可撤销。")) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteFriendLink(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsPage>
|
||||||
|
<AdminPageHeader
|
||||||
|
icon={Link2}
|
||||||
|
title="友情链接管理"
|
||||||
|
description="审批用户申请的友情链接,或直接新建/编辑已收录条目"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||||
|
<Plus size={14} /> 新建
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="alert-error mb-4">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 px-4 pt-3 border-b" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const active = tab === t.key;
|
||||||
|
const n = counts[t.key] || 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className="px-3 py-2 text-[13.5px] font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||||
|
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
|
marginBottom: "-1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span className="ml-1.5 text-[11.5px] opacity-70">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<AdminEmpty
|
||||||
|
icon={<Link2 size={20} />}
|
||||||
|
title={tab === "pending" ? "暂无待审批申请" : tab === "approved" ? "暂无已收录友链" : "暂无被拒绝的申请"}
|
||||||
|
description={tab === "pending" ? "用户提交的申请会出现在这里" : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{filtered.map((l) => (
|
||||||
|
<li
|
||||||
|
key={l.id}
|
||||||
|
className="px-5 py-4 flex items-start gap-3"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{l.logo_url ? (
|
||||||
|
// 36px 位:本站图走 48/96 变体;外链保持原样
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
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}
|
||||||
|
className="rounded-lg object-contain shrink-0"
|
||||||
|
style={{ width: 36, height: 36, background: "var(--panel-2)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Link2 size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<a
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-semibold text-[14.5px] hover:text-[var(--accent)]"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</a>
|
||||||
|
{l.category ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
{l.category}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="meta text-[11.5px]">#{l.id} · 顺序 {l.sort_order}</span>
|
||||||
|
</div>
|
||||||
|
{l.description ? (
|
||||||
|
<p className="meta mt-1 text-[12.5px] line-clamp-2">{l.description}</p>
|
||||||
|
) : null}
|
||||||
|
{l.reciprocal_url ? (
|
||||||
|
<p className="meta mt-1 text-[11.5px]">
|
||||||
|
回链:
|
||||||
|
<a href={l.reciprocal_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-[var(--accent)]">
|
||||||
|
{l.reciprocal_url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{l.status === "rejected" && l.reject_reason ? (
|
||||||
|
<p className="mt-1 text-[12px]" style={{ color: "var(--danger)" }}>拒绝理由:{l.reject_reason}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{busyId === l.id ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" style={{ color: "var(--ink-3)" }} />
|
||||||
|
) : null}
|
||||||
|
{l.status === "pending" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "approved")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="通过"
|
||||||
|
>
|
||||||
|
<Check size={13} /> 通过
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "rejected")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="拒绝"
|
||||||
|
>
|
||||||
|
<X size={13} /> 拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{l.status === "rejected" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "approved")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="重新通过"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> 恢复
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{l.status === "approved" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => openEdit(l)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil size={13} /> 编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "pending")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="下线"
|
||||||
|
>
|
||||||
|
下线
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{l.status === "pending" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => openEdit(l)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => remove(l.id)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="删除"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||||
|
onClick={closeEditor}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="panel w-full max-w-lg p-5 sm:p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-[17px] font-bold mb-4" style={{ color: "var(--ink)" }}>
|
||||||
|
{editingId == null ? "新建友情链接" : "编辑友情链接"}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">名称 *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">地址 *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => set("url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">简介</span>
|
||||||
|
<input
|
||||||
|
maxLength={255}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set("description", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">Logo URL</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.logo_url}
|
||||||
|
onChange={(e) => set("logo_url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">分类</span>
|
||||||
|
<input
|
||||||
|
maxLength={32}
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => set("category", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">回链地址</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.reciprocal_url}
|
||||||
|
onChange={(e) => set("reciprocal_url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">顺序</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => set("sort_order", Number(e.target.value))}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={closeEditor} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
|
||||||
|
{saving ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||||
|
{saving ? "保存中…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AdminSettingsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/app/admin/friend-links/page.tsx
Normal file
36
frontend/app/admin/friend-links/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import FriendLinksAdmin from "./FriendLinksAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { fetchAdminFriendLinks, type FriendLink } from "@/lib/api";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "友情链接管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminFriendLinksPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
|
return <Forbidden text="友情链接管理仅超级管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let links: FriendLink[] = [];
|
||||||
|
let initError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetchAdminFriendLinks(cookie || undefined);
|
||||||
|
links = res.links ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
initError = e instanceof Error ? e.message : "加载失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||||
|
<FriendLinksAdmin initialLinks={links} initError={initError} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
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 AdminShell from "@/components/admin/AdminShell";
|
||||||
import AdminSessionRecover from "./AdminSessionRecover";
|
import AdminSessionRecover from "./AdminSessionRecover";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = authCookieHeader(cookieStore);
|
const cookie = authCookieHeader(cookieStore);
|
||||||
@@ -24,7 +24,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
const allowed =
|
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) {
|
if (!allowed || !me.user) {
|
||||||
// 已登录但无后台权限:保留提示卡片
|
// 已登录但无后台权限:保留提示卡片
|
||||||
|
|||||||
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { FileText, Loader2, Upload, X } from "lucide-react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import type { LibraryImportMdInput, LibrarySectionItem } from "@/lib/api";
|
||||||
|
import { parseEpub } from "./parseEpub";
|
||||||
|
|
||||||
|
const MAX_IMPORT_BYTES = 5 * 1024 * 1024; // 与后端 MaxImportBytes 一致(单文件)
|
||||||
|
const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 与后端 MaxImportTotalBytes 一致
|
||||||
|
const MAX_FILES = 50; // 与后端 MaxImportFiles 一致
|
||||||
|
const MAX_EPUB_BYTES = 50 * 1024 * 1024; // epub 原始文件上限(二进制,图片占大头,转出文本通常很小)
|
||||||
|
const MAX_CHAPTER_CHARS = 100_000; // 与后端 MaxSectionContent 一致(epub 每目录项即一章)
|
||||||
|
|
||||||
|
type SplitLevel = "h1" | "h2" | "none";
|
||||||
|
|
||||||
|
const SPLIT_OPTIONS: { value: SplitLevel; label: string; hint: string }[] = [
|
||||||
|
{ value: "h1", label: "按一级标题(#)", hint: "每个 # 标题拆为一章" },
|
||||||
|
{ value: "h2", label: "按二级标题(##)", hint: "每个 ## 标题拆为一章" },
|
||||||
|
{ value: "none", label: "不拆分", hint: "每个文件整篇一章" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PendingFile {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 splitMarkdownChapters 逻辑一致:跳过代码围栏,按所选级别标题取章标题列表 */
|
||||||
|
function splitPreview(content: string, level: SplitLevel): string[] {
|
||||||
|
if (level === "none") return [];
|
||||||
|
const prefix = level === "h2" ? "## " : "# ";
|
||||||
|
const titles: string[] = [];
|
||||||
|
let inCode = false;
|
||||||
|
let count = 0;
|
||||||
|
for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.startsWith("```")) inCode = !inCode;
|
||||||
|
if (!inCode && t.startsWith(prefix) && t.length > prefix.length) {
|
||||||
|
const title = t.slice(prefix.length).replace(/#+\s*$/, "").trim();
|
||||||
|
titles.push(title || `第 ${++count} 章`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个文件预计拆出的章数(未命中标题回退 1 章,标题取文件名) */
|
||||||
|
function splitCount(text: string, level: SplitLevel): number {
|
||||||
|
if (level === "none") return 1;
|
||||||
|
const n = splitPreview(text, level).length;
|
||||||
|
return n > 0 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportMdDialog({
|
||||||
|
onImport,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
/** 执行导入,返回新建章节(父组件负责刷新与选中第一章);失败时抛错 */
|
||||||
|
onImport: (input: LibraryImportMdInput) => Promise<LibrarySectionItem[]>;
|
||||||
|
/** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */
|
||||||
|
onClose: (created?: LibrarySectionItem[]) => void;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [files, setFiles] = useState<PendingFile[]>([]);
|
||||||
|
const [pasted, setPasted] = useState("");
|
||||||
|
const [splitLevel, setSplitLevel] = useState<SplitLevel>("h2");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
// epub 模式:列表已被 epub 解析结果整体替换,按目录顺序每项一章(不参与拆分)
|
||||||
|
const [fromEpub, setFromEpub] = useState(false);
|
||||||
|
const [epubBusy, setEpubBusy] = useState(false);
|
||||||
|
|
||||||
|
// 已选文件优先;未选文件时用粘贴内容(视为一个伪文件)
|
||||||
|
const sourceFiles = useMemo<PendingFile[]>(
|
||||||
|
() =>
|
||||||
|
files.length > 0
|
||||||
|
? files
|
||||||
|
: pasted.trim()
|
||||||
|
? [{ name: "未命名导入.md", text: pasted }]
|
||||||
|
: [],
|
||||||
|
[files, pasted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const perFileCounts = useMemo(
|
||||||
|
() => sourceFiles.map((f) => ({ name: f.name, count: splitCount(f.text, splitLevel) })),
|
||||||
|
[sourceFiles, splitLevel]
|
||||||
|
);
|
||||||
|
const totalChapters = perFileCounts.reduce((n, f) => n + f.count, 0);
|
||||||
|
const single = sourceFiles.length === 1 ? sourceFiles[0] : null;
|
||||||
|
const singleTitles = useMemo(
|
||||||
|
() => (single && splitLevel !== "none" ? splitPreview(single.text, splitLevel) : []),
|
||||||
|
[single, splitLevel]
|
||||||
|
);
|
||||||
|
const willFallback =
|
||||||
|
single != null && splitLevel !== "none" && singleTitles.length === 0 && single.text.trim().length > 0;
|
||||||
|
|
||||||
|
const oversizeFile = sourceFiles.find((f) => f.text.length > MAX_IMPORT_BYTES);
|
||||||
|
const overFiles = sourceFiles.length > MAX_FILES;
|
||||||
|
const overTotal = sourceFiles.reduce((n, f) => n + f.text.length, 0) > MAX_TOTAL_BYTES;
|
||||||
|
// epub 每个目录项即一章,无法再拆;超限章直接阻止导入(与后端校验一致)
|
||||||
|
const oversizeChapters = fromEpub
|
||||||
|
? sourceFiles.filter((f) => f.text.length > MAX_CHAPTER_CHARS)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
async function pickFiles(list: FileList | null) {
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
setError("");
|
||||||
|
const msgs: string[] = [];
|
||||||
|
const fileList = Array.from(list);
|
||||||
|
const epubFiles = fileList.filter((f) => f.name.toLowerCase().endsWith(".epub"));
|
||||||
|
const mdFiles = fileList.filter((f) => !f.name.toLowerCase().endsWith(".epub"));
|
||||||
|
|
||||||
|
// epub:解析为章节伪文件并整体替换(同一批次不与 md 混选)
|
||||||
|
if (epubFiles.length > 0) {
|
||||||
|
setEpubBusy(true);
|
||||||
|
try {
|
||||||
|
const chapters: PendingFile[] = [];
|
||||||
|
for (const f of epubFiles) {
|
||||||
|
if (f.size > MAX_EPUB_BYTES) {
|
||||||
|
msgs.push(`超过 50MB 已跳过:${f.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = await parseEpub(f);
|
||||||
|
for (const c of parsed) {
|
||||||
|
chapters.push({
|
||||||
|
name: `${String(chapters.length + 1).padStart(3, "0")}-${c.title || "未命名"}.md`,
|
||||||
|
text: c.markdown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chapters.length > 0) {
|
||||||
|
if (chapters.length > MAX_FILES) {
|
||||||
|
msgs.push(`epub 解析出 ${chapters.length} 章,超过单次上限,已截取前 ${MAX_FILES} 章`);
|
||||||
|
}
|
||||||
|
setFiles(chapters.slice(0, MAX_FILES));
|
||||||
|
setSplitLevel("none");
|
||||||
|
setFromEpub(true);
|
||||||
|
} else if (!msgs.length) {
|
||||||
|
msgs.push("未能从 epub 中解析出章节");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgs.push(e instanceof Error ? e.message : "epub 解析失败");
|
||||||
|
} finally {
|
||||||
|
setEpubBusy(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// md/txt:沿用原合并逻辑;当前若是 epub 章节列表则先清空
|
||||||
|
const picked: PendingFile[] = [];
|
||||||
|
const tooBig: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const f of mdFiles) {
|
||||||
|
if (f.size > MAX_IMPORT_BYTES) {
|
||||||
|
tooBig.push(f.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
picked.push({ name: f.name, text: await f.text() });
|
||||||
|
} catch {
|
||||||
|
failed.push(f.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let truncated = false;
|
||||||
|
setFiles((prev) => {
|
||||||
|
const merged = [...(fromEpub ? [] : prev), ...picked];
|
||||||
|
// 文件名自然排序,保证 01、02…23 顺序
|
||||||
|
merged.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true }));
|
||||||
|
truncated = merged.length > MAX_FILES;
|
||||||
|
return merged.slice(0, MAX_FILES);
|
||||||
|
});
|
||||||
|
if (fromEpub && picked.length > 0) {
|
||||||
|
setFromEpub(false);
|
||||||
|
msgs.push("已清空 epub 章节,改用所选 Markdown 文件");
|
||||||
|
}
|
||||||
|
if (tooBig.length) msgs.push(`超过 5MB 已跳过:${tooBig.join("、")}`);
|
||||||
|
if (failed.length) msgs.push(`无法读取:${failed.join("、")}`);
|
||||||
|
if (truncated) msgs.push(`单次最多 ${MAX_FILES} 个文件,已截取前 ${MAX_FILES} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgs.length) setError(msgs.join(";"));
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(idx: number) {
|
||||||
|
setFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (sourceFiles.length === 0) {
|
||||||
|
setError("请先选择文件或粘贴 Markdown 内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oversizeFile) {
|
||||||
|
setError(`单个文件不能超过 5MB:${oversizeFile.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overFiles) {
|
||||||
|
setError(`单次最多导入 ${MAX_FILES} 个文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overTotal) {
|
||||||
|
setError("内容总量过大:不能超过 20MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oversizeChapters.length > 0) {
|
||||||
|
setError(
|
||||||
|
`以下章节正文超过 100000 字,请先拆分后再导入:${oversizeChapters.map((f) => f.name).join("、")}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await onImport({
|
||||||
|
files: sourceFiles.map((f) => ({ filename: f.name, content: f.text })),
|
||||||
|
split_level: splitLevel,
|
||||||
|
});
|
||||||
|
toast(
|
||||||
|
sourceFiles.length > 1
|
||||||
|
? `已导入 ${sourceFiles.length} 个文件共 ${created.length} 章`
|
||||||
|
: `已导入 ${created.length} 章`,
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
onClose(created);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "导入失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = Boolean(oversizeFile || overFiles || overTotal || oversizeChapters.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="导入章节"
|
||||||
|
description="Markdown 按所选方式拆章;epub 按目录顺序导入,每个目录项一章"
|
||||||
|
onClose={onClose}
|
||||||
|
busy={busy}
|
||||||
|
maxWidthClass="max-w-xl"
|
||||||
|
bodyClassName="max-h-[60vh] overflow-y-auto j13-rail-scroll"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-line" onClick={() => onClose()} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || epubBusy || sourceFiles.length === 0 || blocked}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
aria-busy={busy || epubBusy || undefined}
|
||||||
|
>
|
||||||
|
{busy || epubBusy ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Upload size={14} aria-hidden />
|
||||||
|
)}
|
||||||
|
{busy || epubBusy ? (epubBusy ? "解析中…" : "导入中…") : "导入"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="admin-doc-label">
|
||||||
|
来源
|
||||||
|
{sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy || epubBusy}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileText size={13} aria-hidden /> 选择 .md / .txt / .epub 文件(可多选)
|
||||||
|
</button>
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-doc-textbtn"
|
||||||
|
disabled={busy || epubBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setFiles([]);
|
||||||
|
setFromEpub(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".md,.markdown,.txt,.epub,text/markdown,text/plain,application/epub+zip"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => void pickFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
className="rounded-xl max-h-48 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={`${f.name}-${i}`}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5"
|
||||||
|
style={{ borderBottom: i < files.length - 1 ? "1px solid var(--line)" : undefined }}
|
||||||
|
>
|
||||||
|
<span className="text-[12.5px] truncate min-w-0 flex-1" style={{ color: "var(--ink)" }}>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11px] shrink-0 tabular-nums">{f.text.length} 字符</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-5 h-5 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-label={`移除 ${f.name}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => removeAt(i)}
|
||||||
|
>
|
||||||
|
<X size={12} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<label className="admin-doc-label" htmlFor="import-md-text">
|
||||||
|
或直接粘贴 Markdown
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="import-md-text"
|
||||||
|
className="admin-field-input"
|
||||||
|
rows={6}
|
||||||
|
value={pasted}
|
||||||
|
onChange={(e) => setPasted(e.target.value)}
|
||||||
|
placeholder="# 第一章 …"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="admin-doc-hint">{pasted.length} 字符</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fromEpub ? (
|
||||||
|
<p className="admin-doc-hint">epub 按目录顺序导入,每个目录项一章,不参与拆分。</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">拆分方式(对每个文件生效)</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{SPLIT_OPTIONS.map((opt) => (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="import-split"
|
||||||
|
checked={splitLevel === opt.value}
|
||||||
|
onChange={() => setSplitLevel(opt.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11.5px]">{opt.hint}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">拆分预览</p>
|
||||||
|
{sourceFiles.length === 0 ? (
|
||||||
|
<p className="meta text-[12.5px]">等待内容…</p>
|
||||||
|
) : sourceFiles.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<ul
|
||||||
|
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{perFileCounts.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={`${f.name}-${i}`}
|
||||||
|
className="flex items-center justify-between gap-2 text-[12.5px] py-0.5"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
<span className="meta text-[11.5px] shrink-0 tabular-nums">{f.count} 章</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="admin-doc-hint">
|
||||||
|
共 {sourceFiles.length} 个文件 · 预计 {totalChapters} 章
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : willFallback ? (
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
未检测到所选级别的标题,将整篇导入为一章(标题取文件名)。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{(splitLevel === "none" ? [single!.name] : singleTitles).map((t, i) => (
|
||||||
|
<li key={i} className="text-[12.5px] py-0.5 truncate" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{i + 1}. {t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overTotal ? <p className="meta text-[12.5px]">内容总量超过 20MB,无法导入。</p> : null}
|
||||||
|
{oversizeChapters.length > 0 ? (
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
以下章节正文超过 100000 字,无法导入:{oversizeChapters.map((f) => f.name).join("、")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="admin-settings-row-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
1241
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
1241
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
150
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal file
150
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (item: MediaAttachment) => void;
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
void apiMyMedia()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error);
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems((res.attachments || []).filter((item) => item.kind === "image"));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError("加载媒体库失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const groups = groupBySource(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="从图库选择封面"
|
||||||
|
description="已选作封面的图片会单独分组,再次选择直接复用。"
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidthClass="max-w-2xl"
|
||||||
|
dismissOnOverlay
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||||||
|
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="meta text-[13px] py-6 text-center">
|
||||||
|
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<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)" }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
506
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal file
506
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CornerDownRight,
|
||||||
|
ListTree,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminCreateLibrarySection,
|
||||||
|
apiAdminDeleteLibrarySection,
|
||||||
|
apiAdminImportLibraryMarkdownBatch,
|
||||||
|
apiAdminMoveLibrarySection,
|
||||||
|
apiAdminUpdateLibrarySection,
|
||||||
|
type LibraryDoc,
|
||||||
|
type LibraryImportMdInput,
|
||||||
|
type LibrarySectionItem,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import { AdminEmpty } from "@/components/admin";
|
||||||
|
import ImportMdDialog from "./ImportMdDialog";
|
||||||
|
|
||||||
|
const MAX_TITLE = 200;
|
||||||
|
const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。
|
||||||
|
* 数据从 doc.sections 派生(后端 detail 已带全量含 content);
|
||||||
|
* 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。
|
||||||
|
*/
|
||||||
|
export default function LibrarySectionsPanel({
|
||||||
|
doc,
|
||||||
|
onChanged,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
doc: LibraryDoc;
|
||||||
|
/** 数据变更后由父组件刷新(doc.sections 随之更新) */
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
/** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const sections = doc.sections ?? [];
|
||||||
|
|
||||||
|
// 平铺 → 章分组(顺序沿用后端 sort_order)
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
|
||||||
|
for (const s of sections) {
|
||||||
|
if (s.parent_id == null) gs.push({ chapter: s, subs: [] });
|
||||||
|
else gs[gs.length - 1]?.subs.push(s);
|
||||||
|
}
|
||||||
|
return gs;
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LibrarySectionItem | null>(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
// 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉
|
||||||
|
const [pendingSelectId, setPendingSelectId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => sections.find((s) => s.id === selectedId) ?? null,
|
||||||
|
[sections, selectedId]
|
||||||
|
);
|
||||||
|
const dirty = JSON.stringify({ title, content }) !== snapshot;
|
||||||
|
|
||||||
|
// dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange(dirty);
|
||||||
|
return () => onDirtyChange(false);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
// 选中项被删除后清空选中
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId != null && !sections.some((s) => s.id === selectedId)) {
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
}, [sections, selectedId]);
|
||||||
|
|
||||||
|
// 应用待选章节(新建 / 导入完成后)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingSelectId == null) return;
|
||||||
|
const s = sections.find((x) => x.id === pendingSelectId);
|
||||||
|
if (!s) return;
|
||||||
|
setPendingSelectId(null);
|
||||||
|
setSelectedId(s.id);
|
||||||
|
setTitle(s.title);
|
||||||
|
setContent(s.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||||||
|
setError("");
|
||||||
|
}, [sections, pendingSelectId]);
|
||||||
|
|
||||||
|
function loadSection(s: LibrarySectionItem) {
|
||||||
|
setSelectedId(s.id);
|
||||||
|
setTitle(s.title);
|
||||||
|
setContent(s.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */
|
||||||
|
function guardNav(action: () => void) {
|
||||||
|
if (dirty && !busy) setPendingNav(() => action);
|
||||||
|
else action();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSection(s: LibrarySectionItem) {
|
||||||
|
if (s.id === selectedId) return;
|
||||||
|
guardNav(() => loadSection(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSection(parent: LibrarySectionItem | null) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { section } = await apiAdminCreateLibrarySection(doc.id, {
|
||||||
|
parent_id: parent?.id,
|
||||||
|
title: parent ? "新节" : "新章",
|
||||||
|
});
|
||||||
|
await onChanged();
|
||||||
|
setPendingSelectId(section.id);
|
||||||
|
toast(parent ? "小节已添加" : "章已添加", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "添加章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (selectedId == null) return;
|
||||||
|
const t = title.trim();
|
||||||
|
if (!t) {
|
||||||
|
setError("章节标题不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { section } = await apiAdminUpdateLibrarySection(selectedId, {
|
||||||
|
title: t,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
await onChanged();
|
||||||
|
// 以服务端返回为准(后端会 trim 标题),保证 dirty 归零
|
||||||
|
setTitle(section.title);
|
||||||
|
setContent(section.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: section.title, content: section.content }));
|
||||||
|
toast("章节已保存", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "保存章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(s: LibrarySectionItem, direction: "up" | "down") {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminMoveLibrarySection(s.id, direction);
|
||||||
|
await onChanged(); // 选中 id 不变,原地刷新顺序
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "移动章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSection() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const target = deleteTarget;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteLibrarySection(target.id);
|
||||||
|
toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await onChanged();
|
||||||
|
if (target.id === selectedId) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setSnapshot(EMPTY_SNAPSHOT);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "删除章节失败");
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(input: LibraryImportMdInput) {
|
||||||
|
const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input);
|
||||||
|
await onChanged();
|
||||||
|
return res.sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImport(created?: LibrarySectionItem[]) {
|
||||||
|
setImportOpen(false);
|
||||||
|
if (created && created.length > 0) setPendingSelectId(created[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */
|
||||||
|
function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } {
|
||||||
|
if (s.parent_id == null) {
|
||||||
|
const i = groups.findIndex((g) => g.chapter.id === s.id);
|
||||||
|
return { first: i <= 0, last: i < 0 || i >= groups.length - 1 };
|
||||||
|
}
|
||||||
|
const g = groups.find((x) => x.subs.some((y) => y.id === s.id));
|
||||||
|
const subs = g?.subs ?? [];
|
||||||
|
const i = subs.findIndex((y) => y.id === s.id);
|
||||||
|
return { first: i <= 0, last: i < 0 || i >= subs.length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowAction({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) {
|
||||||
|
const active = s.id === selectedId;
|
||||||
|
const pos = siblingPos(s);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-0.5 pr-1.5"
|
||||||
|
style={{
|
||||||
|
paddingLeft: depth === 0 ? 4 : 22,
|
||||||
|
background: active ? "var(--accent-soft)" : undefined,
|
||||||
|
boxShadow: active ? "inset 2px 0 0 var(--accent)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectSection(s)}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
className="min-w-0 flex-1 text-left px-2 py-2 flex items-center gap-1 rounded-md transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
title={s.title}
|
||||||
|
>
|
||||||
|
{depth === 1 ? (
|
||||||
|
<CornerDownRight
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className="truncate text-[13px]"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--ink)",
|
||||||
|
fontWeight: depth === 0 ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.title || "未命名章节"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<RowAction label="上移" disabled={pos.first || busy} onClick={() => void move(s, "up")}>
|
||||||
|
<ChevronUp size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
<RowAction label="下移" disabled={pos.last || busy} onClick={() => void move(s, "down")}>
|
||||||
|
<ChevronDown size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
{depth === 0 ? (
|
||||||
|
<RowAction
|
||||||
|
label="添加小节"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void addSection(s)}
|
||||||
|
>
|
||||||
|
<CornerDownRight size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
) : null}
|
||||||
|
<RowAction label="删除" onClick={() => setDeleteTarget(s)}>
|
||||||
|
<Trash2 size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
{/* 左:章节树 */}
|
||||||
|
<aside
|
||||||
|
className="w-52 sm:w-60 shrink-0 flex flex-col min-h-0"
|
||||||
|
style={{ borderRight: "1px solid var(--line)" }}
|
||||||
|
aria-label="章节树"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-2.5 py-2.5 flex-wrap"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => guardNav(() => void addSection(null))}
|
||||||
|
>
|
||||||
|
<Plus size={13} aria-hidden /> 添加章
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => guardNav(() => setImportOpen(true))}
|
||||||
|
>
|
||||||
|
<Upload size={13} aria-hidden /> 导入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll py-1.5">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="meta text-[12.5px] px-3 py-3 leading-relaxed">
|
||||||
|
还没有章节。点上方「添加章」手动编写,或「导入」批量导入 .md / .epub(epub 按目录顺序拆章)。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g, gi) => (
|
||||||
|
<div key={g.chapter.id}>
|
||||||
|
{row(g.chapter, 0, gi === 0, gi === groups.length - 1)}
|
||||||
|
{g.subs.map((sub, si) => row(sub, 1, si === 0, si === g.subs.length - 1))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-3 py-2 meta text-[11.5px] tabular-nums"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{sections.length} 章节
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右:章节编辑器 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll px-4 sm:px-5 py-4 flex flex-col gap-4">
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<label className="admin-doc-label" htmlFor="lib-section-title">
|
||||||
|
章节标题
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lib-section-title"
|
||||||
|
className="admin-field-input"
|
||||||
|
maxLength={MAX_TITLE}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setError("");
|
||||||
|
setTitle(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="例如:第一章 …"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
|
||||||
|
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
|
||||||
|
<label className="admin-doc-label" htmlFor="lib-section-content">
|
||||||
|
正文(Markdown)
|
||||||
|
</label>
|
||||||
|
<MarkdownEditor
|
||||||
|
id="lib-section-content"
|
||||||
|
value={content}
|
||||||
|
onChange={(v) => {
|
||||||
|
setError("");
|
||||||
|
setContent(v.slice(0, MAX_CONTENT));
|
||||||
|
}}
|
||||||
|
maxChars={MAX_CONTENT}
|
||||||
|
allowHide={false}
|
||||||
|
entriesAuto={doc.entries_auto ?? false}
|
||||||
|
placeholder="本章正文,支持 Markdown…"
|
||||||
|
rows={16}
|
||||||
|
ariaLabel="章节正文"
|
||||||
|
uploadSource="library_content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作条:不随正文滚动 */}
|
||||||
|
<footer
|
||||||
|
className="shrink-0 flex items-center gap-2 px-3.5 sm:px-4 py-3"
|
||||||
|
style={{ borderTop: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
<div className="mr-auto flex items-center gap-3 min-w-0">
|
||||||
|
{error ? (
|
||||||
|
<p className="admin-settings-row-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : dirty ? (
|
||||||
|
<span className="meta text-[12px]">有未保存的修改</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy || deleteBusy}
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} aria-hidden /> 删除章节
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
title={dirty ? undefined : "没有需要保存的修改"}
|
||||||
|
onClick={() => void save()}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 size={13} className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Save size={13} aria-hidden />
|
||||||
|
)}
|
||||||
|
{busy ? "保存中…" : "保存章节"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||||
|
<AdminEmpty
|
||||||
|
icon={<ListTree size={22} />}
|
||||||
|
title="未选择章节"
|
||||||
|
description="从左侧选择章节编辑,或添加章 / 导入 Markdown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 未保存切换章节确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!pendingNav}
|
||||||
|
title="放弃未保存的修改?"
|
||||||
|
message="当前章节有未保存的修改,继续将丢失这些改动。"
|
||||||
|
danger
|
||||||
|
confirmLabel="放弃修改"
|
||||||
|
cancelLabel="继续编辑"
|
||||||
|
onCancel={() => setPendingNav(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const action = pendingNav;
|
||||||
|
setPendingNav(null);
|
||||||
|
action?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除章节确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title={deleteTarget?.parent_id == null ? "删除章" : "删除小节"}
|
||||||
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `确定删除「${deleteTarget.title}」吗?${
|
||||||
|
deleteTarget.parent_id == null ? "其下所有小节将一并删除," : ""
|
||||||
|
}且不可恢复。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
danger
|
||||||
|
busy={deleteBusy}
|
||||||
|
confirmLabel="删除"
|
||||||
|
onCancel={() => !deleteBusy && setDeleteTarget(null)}
|
||||||
|
onConfirm={() => void removeSection()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 导入 Markdown 拆章 */}
|
||||||
|
{importOpen ? <ImportMdDialog onImport={handleImport} onClose={closeImport} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/app/admin/library/page.tsx
Normal file
36
frontend/app/admin/library/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import LibraryAdmin from "./LibraryAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "书库管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminLibraryPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
|
||||||
|
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initial: LibraryDoc[] = [];
|
||||||
|
let initError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetchAdminLibraryDocs(cookie || undefined);
|
||||||
|
initial = res.docs ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
initError = e instanceof Error ? e.message : "加载书库失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载书库列表…</p>}>
|
||||||
|
<LibraryAdmin initial={initial} initError={initError} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/app/admin/library/parseEpub.ts
Normal file
91
frontend/app/admin/library/parseEpub.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* epub 导入解析(浏览器端):epub 本质是 zip + XHTML。
|
||||||
|
* 流程:JSZip 解压 → META-INF/container.xml 找 OPF → 按 spine 顺序读 XHTML
|
||||||
|
* → 清理(去 script/style、剔除内部相对路径图片)→ turndown 转 Markdown。
|
||||||
|
* 产物直接作为伪 .md 文件走现有批量导入接口,后端零改动。
|
||||||
|
* 两个依赖均在本模块内动态 import,避免打进主包。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EpubChapter {
|
||||||
|
title: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以 OPF 所在目录为基准解析 zip 内相对路径(处理 ./ ../ 与 URL 编码) */
|
||||||
|
function resolveZipPath(base: string, href: string): string {
|
||||||
|
const parts = `${base}${href}`.split("/");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!p || p === ".") continue;
|
||||||
|
if (p === "..") out.pop();
|
||||||
|
else out.push(p);
|
||||||
|
}
|
||||||
|
return out.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理标题里不适合做文件名的字符 */
|
||||||
|
function sanitizeTitle(t: string): string {
|
||||||
|
return t.replace(/[\\/:*?"<>|\n\r\t]+/g, " ").trim().slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseEpub(file: File): Promise<EpubChapter[]> {
|
||||||
|
const { default: JSZip } = await import("jszip");
|
||||||
|
const { default: TurndownService } = await import("turndown");
|
||||||
|
const zip = await JSZip.loadAsync(await file.arrayBuffer()).catch(() => {
|
||||||
|
throw new Error("无法读取 epub:不是有效的 zip 文件");
|
||||||
|
});
|
||||||
|
|
||||||
|
// container.xml → OPF 路径
|
||||||
|
const containerEntry = zip.file("META-INF/container.xml");
|
||||||
|
if (!containerEntry) throw new Error("无法读取 epub:缺少 META-INF/container.xml");
|
||||||
|
const containerDoc = new DOMParser().parseFromString(await containerEntry.async("text"), "application/xml");
|
||||||
|
const rootfilePath = containerDoc.querySelector("rootfile")?.getAttribute("full-path");
|
||||||
|
if (!rootfilePath) throw new Error("无法读取 epub:container.xml 未声明 OPF 文件");
|
||||||
|
|
||||||
|
// OPF → manifest(id → href/mediaType)+ spine(阅读顺序)
|
||||||
|
const opfEntry = zip.file(rootfilePath) ?? zip.file(decodeURIComponent(rootfilePath));
|
||||||
|
if (!opfEntry) throw new Error(`无法读取 epub:缺少 OPF 文件 ${rootfilePath}`);
|
||||||
|
const opfDoc = new DOMParser().parseFromString(await opfEntry.async("text"), "application/xml");
|
||||||
|
const opfDir = rootfilePath.includes("/") ? rootfilePath.slice(0, rootfilePath.lastIndexOf("/") + 1) : "";
|
||||||
|
|
||||||
|
const manifest = new Map<string, { href: string; mediaType: string }>();
|
||||||
|
opfDoc.querySelectorAll("manifest > item").forEach((item) => {
|
||||||
|
const id = item.getAttribute("id");
|
||||||
|
const href = item.getAttribute("href");
|
||||||
|
if (id && href) manifest.set(id, { href, mediaType: item.getAttribute("media-type") ?? "" });
|
||||||
|
});
|
||||||
|
const spineIds = Array.from(opfDoc.querySelectorAll("spine > itemref"))
|
||||||
|
.map((el) => el.getAttribute("idref"))
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
||||||
|
const chapters: EpubChapter[] = [];
|
||||||
|
|
||||||
|
for (const id of spineIds) {
|
||||||
|
const item = manifest.get(id);
|
||||||
|
// spine 里也会有图片/样式等资源项,只处理 HTML 文档
|
||||||
|
if (!item || !/xhtml|html/i.test(item.mediaType)) continue;
|
||||||
|
const path = resolveZipPath(opfDir, item.href);
|
||||||
|
const entry = zip.file(path) ?? zip.file(decodeURIComponent(path));
|
||||||
|
if (!entry) continue;
|
||||||
|
const doc = new DOMParser().parseFromString(await entry.async("text"), "text/html");
|
||||||
|
doc.querySelectorAll("script, style").forEach((el) => el.remove());
|
||||||
|
// 内部相对路径图片导入后无法解析,剔除;外链 http(s) 图片保留
|
||||||
|
doc.querySelectorAll("img").forEach((img) => {
|
||||||
|
if (!/^https?:\/\//i.test(img.getAttribute("src") ?? "")) img.remove();
|
||||||
|
});
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body) continue;
|
||||||
|
const markdown = td
|
||||||
|
.turndown(body.innerHTML)
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
if (!markdown) continue; // 封面页等纯图片页
|
||||||
|
const heading = Array.from(body.querySelectorAll("h1, h2, h3, h4, h5, h6")).find((h) => h.textContent?.trim());
|
||||||
|
const title = sanitizeTitle(heading?.textContent ?? doc.title ?? "") || `第 ${chapters.length + 1} 章`;
|
||||||
|
chapters.push({ title, markdown });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapters.length === 0) throw new Error("未能从 epub 中解析出任何章节");
|
||||||
|
return chapters;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||||
import type { MediaLibraryItem } from "@/lib/api";
|
import type { MediaLibraryItem } from "@/lib/api";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
function formatBytes(n: number): string {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
|
||||||
function thumbSrc(item: MediaLibraryItem): string {
|
function thumbSrc(item: MediaLibraryItem): string {
|
||||||
if (item.url.startsWith("/uploads/")) {
|
// 网格缩略走统一变体端点(256 档);非本站图原样返回
|
||||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
if (isOwnImage(item.url)) {
|
||||||
|
return variantURL(item.url, 256);
|
||||||
}
|
}
|
||||||
return item.url;
|
return item.url;
|
||||||
}
|
}
|
||||||
@@ -143,6 +145,8 @@ export default function MediaLibrary({
|
|||||||
<span className="block relative aspect-square overflow-hidden">
|
<span className="block relative aspect-square overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={thumbSrc(item)}
|
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}
|
alt={item.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
||||||
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
|
import { apiAdminDeleteSitePage, apiAdminPurgeSitePage, type SitePage } from "@/lib/api";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
@@ -61,8 +61,14 @@ export default function PagesAdmin({
|
|||||||
const [menuUp, setMenuUp] = useState(false);
|
const [menuUp, setMenuUp] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
const publishedCount = useMemo(
|
||||||
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
|
() => list.filter((p) => p.published && !p.deleted).length,
|
||||||
|
[list]
|
||||||
|
);
|
||||||
|
const draftCount = useMemo(
|
||||||
|
() => list.filter((p) => !p.published && !p.deleted).length,
|
||||||
|
[list]
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (statusFilter === "published") return list.filter((p) => p.published);
|
if (statusFilter === "published") return list.filter((p) => p.published);
|
||||||
@@ -93,9 +99,16 @@ export default function PagesAdmin({
|
|||||||
async function remove(p: SitePage) {
|
async function remove(p: SitePage) {
|
||||||
setDeletingId(p.id);
|
setDeletingId(p.id);
|
||||||
try {
|
try {
|
||||||
await apiAdminDeleteSitePage(p.id);
|
if (p.deleted) {
|
||||||
|
// 已软删的页面:彻底删除(不可恢复,释放 slug)
|
||||||
|
await apiAdminPurgeSitePage(p.id);
|
||||||
setList((l) => l.filter((x) => x.id !== p.id));
|
setList((l) => l.filter((x) => x.id !== p.id));
|
||||||
toast("页面已删除", "ok");
|
toast("页面已彻底删除,地址可重新使用", "ok");
|
||||||
|
} else {
|
||||||
|
await apiAdminDeleteSitePage(p.id);
|
||||||
|
setList((l) => l.map((x) => (x.id === p.id ? { ...x, deleted: true } : x)));
|
||||||
|
toast("页面已删除,可在列表中彻底删除以释放地址", "ok");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "删除失败");
|
toast(e instanceof Error ? e.message : "删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,20 +183,30 @@ export default function PagesAdmin({
|
|||||||
const updated = p.updated_at || "";
|
const updated = p.updated_at || "";
|
||||||
const path = sitePagePublicPath(p.slug, urlStyle);
|
const path = sitePagePublicPath(p.slug, urlStyle);
|
||||||
return (
|
return (
|
||||||
<tr key={p.id}>
|
<tr key={p.id} style={p.deleted ? { opacity: 0.65 } : undefined}>
|
||||||
<td data-label="页面">
|
<td data-label="页面">
|
||||||
|
{p.deleted ? (
|
||||||
|
<span className="admin-doc-title" title={p.title}>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
||||||
{p.title}
|
{p.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<p className="admin-doc-path">
|
<p className="admin-doc-path">
|
||||||
<code>{path}</code>
|
<code>{path}</code>
|
||||||
</p>
|
</p>
|
||||||
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="状态">
|
<td data-label="状态">
|
||||||
|
{p.deleted ? (
|
||||||
|
<AdminStatusChip tone="danger">已删除</AdminStatusChip>
|
||||||
|
) : (
|
||||||
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
||||||
{p.published ? "已发布" : "草稿"}
|
{p.published ? "已发布" : "草稿"}
|
||||||
</AdminStatusChip>
|
</AdminStatusChip>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="展示位置">{placeLabel(p)}</td>
|
<td data-label="展示位置">{placeLabel(p)}</td>
|
||||||
<td data-label="排序" className="tabular-nums">
|
<td data-label="排序" className="tabular-nums">
|
||||||
@@ -200,10 +223,12 @@ export default function PagesAdmin({
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="操作">
|
<td data-label="操作">
|
||||||
<div className="admin-doc-row-actions">
|
<div className="admin-doc-row-actions">
|
||||||
|
{!p.deleted ? (
|
||||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
||||||
编辑
|
编辑
|
||||||
</Link>
|
</Link>
|
||||||
{p.published ? (
|
) : null}
|
||||||
|
{p.published && !p.deleted ? (
|
||||||
<a
|
<a
|
||||||
href={path}
|
href={path}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -248,7 +273,7 @@ export default function PagesAdmin({
|
|||||||
setDeleteTarget(p);
|
setDeleteTarget(p);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
删除
|
{p.deleted ? "彻底删除" : "删除"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -277,15 +302,17 @@ export default function PagesAdmin({
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="删除单页"
|
title={deleteTarget?.deleted ? "彻底删除单页" : "删除单页"}
|
||||||
message={
|
message={
|
||||||
deleteTarget
|
deleteTarget
|
||||||
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
? deleteTarget.deleted
|
||||||
|
? `确定彻底删除「${deleteTarget.title}」吗?删除后不可恢复,地址将释放可重新使用。`
|
||||||
|
: `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,可在列表中彻底删除以释放地址。`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
danger
|
danger
|
||||||
busy={deletingId != null}
|
busy={deletingId != null}
|
||||||
confirmLabel="删除"
|
confirmLabel={deleteTarget?.deleted ? "彻底删除" : "删除"}
|
||||||
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteTarget) void remove(deleteTarget);
|
if (deleteTarget) void remove(deleteTarget);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import PageEditLoader from "./PageEditLoader";
|
import PageEditLoader from "./PageEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import PageComposeClient from "../PageComposeClient";
|
import PageComposeClient from "../PageComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPageNewPage() {
|
export default async function AdminPageNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPagesPage() {
|
export default async function AdminPagesPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
|||||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||||
import BrandSeoPanel from "./BrandSeoPanel";
|
import BrandSeoPanel from "./BrandSeoPanel";
|
||||||
import FooterLinksPanel from "./FooterLinksPanel";
|
import FooterLinksPanel from "./FooterLinksPanel";
|
||||||
|
import HeaderNavPanel from "./HeaderNavPanel";
|
||||||
|
import MobileTabBarPanel from "./MobileTabBarPanel";
|
||||||
import UrlStylePanel from "./UrlStylePanel";
|
import UrlStylePanel from "./UrlStylePanel";
|
||||||
import type { PublicSettings } from "@/lib/api";
|
import type { PublicSettings } from "@/lib/api";
|
||||||
|
|
||||||
@@ -15,19 +17,25 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
|
|||||||
const [brandDirty, setBrandDirty] = useState(false);
|
const [brandDirty, setBrandDirty] = useState(false);
|
||||||
const [seoDirty, setSeoDirty] = useState(false);
|
const [seoDirty, setSeoDirty] = useState(false);
|
||||||
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
|
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
|
||||||
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
|
const [mobileTabsDirty, setMobileTabsDirty] = useState(false);
|
||||||
|
const [headerNavDirty, setHeaderNavDirty] = useState(false);
|
||||||
|
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty || mobileTabsDirty || headerNavDirty;
|
||||||
const leave = useUnsavedGuard(dirty);
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||||
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
|
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
|
||||||
|
const onMobileTabsDirty = useCallback((d: boolean) => setMobileTabsDirty(d), []);
|
||||||
|
const onHeaderNavDirty = useCallback((d: boolean) => setHeaderNavDirty(d), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-settings-stack">
|
<div className="admin-settings-stack">
|
||||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||||
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
|
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
|
||||||
|
<HeaderNavPanel initial={live} onSaved={setLive} onDirtyChange={onHeaderNavDirty} />
|
||||||
|
<MobileTabBarPanel initial={live} onSaved={setLive} onDirtyChange={onMobileTabsDirty} />
|
||||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={leave.leaveOpen}
|
open={leave.leaveOpen}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||||
logo_light: "浅色 Logo",
|
logo_light: "浅色 Logo",
|
||||||
@@ -88,7 +89,14 @@ export default function BrandMediaPicker({
|
|||||||
data-tip="用作草稿预览,点保存后才生效"
|
data-tip="用作草稿预览,点保存后才生效"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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
|
<span
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
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" }}
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ImagePlus, Images, X } from "lucide-react";
|
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BRAND_LOGO_FITS,
|
BRAND_LOGO_FITS,
|
||||||
BRAND_LOGO_SIZES,
|
BRAND_LOGO_SIZES,
|
||||||
brandAccept,
|
brandAccept,
|
||||||
|
brandAliasPath,
|
||||||
brandFileOk,
|
brandFileOk,
|
||||||
type BrandLogoFit,
|
type BrandLogoFit,
|
||||||
type BrandLogoSize,
|
type BrandLogoSize,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type BrandSlot,
|
type BrandSlot,
|
||||||
} from "@/lib/brand";
|
} from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
import BrandMediaPicker from "./BrandMediaPicker";
|
import BrandMediaPicker from "./BrandMediaPicker";
|
||||||
|
|
||||||
type SlotDraft = {
|
type SlotDraft = {
|
||||||
@@ -75,6 +77,7 @@ export default function BrandSeoPanel({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||||
|
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||||
const blobs = useRef<string[]>([]);
|
const blobs = useRef<string[]>([]);
|
||||||
const saveGen = useRef(0);
|
const saveGen = useRef(0);
|
||||||
|
|
||||||
@@ -203,6 +206,21 @@ export default function BrandSeoPanel({
|
|||||||
const slotDraft = (id: BrandSlot) =>
|
const slotDraft = (id: BrandSlot) =>
|
||||||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||||
|
|
||||||
|
// 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
|
||||||
|
const savedOf = (slot: BrandSlot) =>
|
||||||
|
slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
|
||||||
|
|
||||||
|
const copyAlias = async (slot: BrandSlot) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
|
||||||
|
setCopiedSlot(slot);
|
||||||
|
toast("已复制固定地址", "ok");
|
||||||
|
window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
|
||||||
|
} catch {
|
||||||
|
toast("复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||||
const draft = slotDraft(slot);
|
const draft = slotDraft(slot);
|
||||||
const preview = previewOf(draft);
|
const preview = previewOf(draft);
|
||||||
@@ -216,14 +234,31 @@ export default function BrandSeoPanel({
|
|||||||
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
|
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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>
|
<span>未设置</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-brand-asset-meta">
|
<div className="admin-brand-asset-meta">
|
||||||
<span className="admin-brand-asset-label">{label}</span>
|
<span className="admin-brand-asset-label">{label}</span>
|
||||||
|
{savedOf(slot) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-brand-alias"
|
||||||
|
title="点击复制固定地址(换图不变链,友链引用请用此地址)"
|
||||||
|
onClick={() => void copyAlias(slot)}
|
||||||
|
>
|
||||||
|
<Link2 size={12} aria-hidden />
|
||||||
|
<span className="admin-brand-alias-url">{brandAliasPath(slot)}</span>
|
||||||
|
{copiedSlot === slot ? <Check size={12} aria-hidden /> : null}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<div className="admin-brand-asset-actions">
|
<div className="admin-brand-asset-actions">
|
||||||
<label className="btn btn-line btn-sm cursor-pointer">
|
<label className="btn btn-line btn-sm cursor-pointer">
|
||||||
<ImagePlus size={14} /> 上传
|
<ImagePlus size={14} /> 上传
|
||||||
|
|||||||
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
HEADER_NAV_BUILTIN_KEYS,
|
||||||
|
HEADER_NAV_BUILTIN_LABELS,
|
||||||
|
HEADER_NAV_LIMITS,
|
||||||
|
isHeaderNavExternal,
|
||||||
|
type HeaderNavBuiltinKey,
|
||||||
|
type HeaderNavItem,
|
||||||
|
} from "@/lib/headerNav";
|
||||||
|
import { footerURLAllowed } from "@/lib/brand";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||||||
|
home: <Home size={14} />,
|
||||||
|
leaderboard: <Trophy size={14} />,
|
||||||
|
library: <BookOpen size={14} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
||||||
|
const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
|
||||||
|
home: "站点首页 /",
|
||||||
|
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
||||||
|
library: "书库页(地址随伪静态风格变化)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
||||||
|
|
||||||
|
function clone(items: HeaderNavItem[]): HeaderNavItem[] {
|
||||||
|
return items.map((item) => ({ ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
|
||||||
|
const present = new Set(
|
||||||
|
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
home: present.has("home"),
|
||||||
|
leaderboard: present.has("leaderboard"),
|
||||||
|
library: present.has("library"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */
|
||||||
|
function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] {
|
||||||
|
const present = new Set(
|
||||||
|
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||||||
|
);
|
||||||
|
const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({
|
||||||
|
type: "builtin" as const,
|
||||||
|
key,
|
||||||
|
}));
|
||||||
|
return [...clone(items), ...hidden];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。
|
||||||
|
* 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。
|
||||||
|
*/
|
||||||
|
export default function HeaderNavPanel({
|
||||||
|
initial,
|
||||||
|
onSaved,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
initial: PublicSettings;
|
||||||
|
onSaved?: (next: PublicSettings) => void;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<HeaderNavItem[]>(() => rowsOf(initial.header_nav));
|
||||||
|
const [on, setOn] = useState<BuiltinSwitches>(() => switchesOf(initial.header_nav));
|
||||||
|
const [savedPayload, setSavedPayload] = useState<HeaderNavItem[]>(() => clone(initial.header_nav));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
// 新增自定义行挂载后自动聚焦名称输入框
|
||||||
|
const [focusIdx, setFocusIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序
|
||||||
|
const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]);
|
||||||
|
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||||||
|
const atCap = payload.length >= HEADER_NAV_LIMITS.items;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusIdx !== null) setFocusIdx(null);
|
||||||
|
}, [focusIdx]);
|
||||||
|
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= prev.length) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
const [row] = next.splice(i, 1);
|
||||||
|
next.splice(j, 0, row);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (i: number) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustom = () => {
|
||||||
|
if (atCap) return;
|
||||||
|
setError("");
|
||||||
|
setFocusIdx(rows.length);
|
||||||
|
setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustom = (i: number, patch: Partial<Extract<HeaderNavItem, { type: "custom" }>>) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setRows(rowsOf(savedPayload));
|
||||||
|
setOn(switchesOf(savedPayload));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
for (const item of payload) {
|
||||||
|
if (item.type !== "custom") continue;
|
||||||
|
if (!item.label.trim()) {
|
||||||
|
setError("自定义菜单名称不能为空");
|
||||||
|
toast("自定义菜单名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!footerURLAllowed(item.url)) {
|
||||||
|
setError(`地址无效:${item.url || "(空)"}`);
|
||||||
|
toast(`地址无效:${item.url || "(空)"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body: HeaderNavItem[] = payload.map((item) =>
|
||||||
|
item.type === "custom"
|
||||||
|
? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab }
|
||||||
|
: { type: "builtin", key: item.key },
|
||||||
|
);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ header_nav: body });
|
||||||
|
setSavedPayload(clone(res.header_nav));
|
||||||
|
setRows(rowsOf(res.header_nav));
|
||||||
|
setOn(switchesOf(res.header_nav));
|
||||||
|
onSaved?.(res);
|
||||||
|
toast("已保存,顶部导航已更新", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setError(msg);
|
||||||
|
toast(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsGroup
|
||||||
|
id="settings-header-nav"
|
||||||
|
title="顶部导航菜单"
|
||||||
|
description="桌面端页眉品牌名右侧的菜单,顺序即前台展示顺序;移动端导航由底部菜单承担。全部隐藏后前台不显示导航。"
|
||||||
|
footer={
|
||||||
|
<AdminSettingsActions
|
||||||
|
dirty={dirty}
|
||||||
|
saving={saving}
|
||||||
|
saveLabel="保存"
|
||||||
|
error={error}
|
||||||
|
onSave={() => void save()}
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((item, i) => {
|
||||||
|
const enabled = item.type === "custom" || on[item.key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.type === "builtin" ? `builtin-${item.key}` : `custom-${i}`}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-xl px-2 py-2"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
background: "var(--panel)",
|
||||||
|
opacity: enabled ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.type === "builtin" ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-full shrink-0"
|
||||||
|
style={{ color: "var(--ink-2)", background: "var(--panel-2)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{BUILTIN_ICONS[item.key]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13.5px] font-semibold shrink-0" style={{ color: "var(--ink)" }}>
|
||||||
|
{HEADER_NAV_BUILTIN_LABELS[item.key]}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[12px] truncate">{BUILTIN_HINTS[item.key]}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-field-shell w-32 shrink-0">
|
||||||
|
<input
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ padding: "7px 10px" }}
|
||||||
|
maxLength={HEADER_NAV_LIMITS.label}
|
||||||
|
value={item.label}
|
||||||
|
onChange={(e) => updateCustom(i, { label: e.target.value })}
|
||||||
|
placeholder="菜单名称"
|
||||||
|
aria-label={`第 ${i + 1} 个自定义菜单名称`}
|
||||||
|
autoFocus={focusIdx === i}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-shell min-w-[10rem] flex-1">
|
||||||
|
<input
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ padding: "7px 10px" }}
|
||||||
|
maxLength={HEADER_NAV_LIMITS.url}
|
||||||
|
value={item.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
const wasExt = isHeaderNavExternal(item.url);
|
||||||
|
const nowExt = isHeaderNavExternal(v);
|
||||||
|
updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||||
|
}}
|
||||||
|
placeholder="https://… 或 /p/about"
|
||||||
|
aria-label={`第 ${i + 1} 个自定义菜单地址`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 w-[8.75rem]">
|
||||||
|
<div className="admin-footer-open-toggle" role="group" aria-label={`第 ${i + 1} 个菜单打开方式`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={!item.new_tab}
|
||||||
|
onClick={() => updateCustom(i, { new_tab: false })}
|
||||||
|
>
|
||||||
|
本页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={item.new_tab}
|
||||||
|
onClick={() => updateCustom(i, { new_tab: true })}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} aria-hidden />
|
||||||
|
新标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="上移"
|
||||||
|
disabled={i === 0 || saving}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="下移"
|
||||||
|
disabled={i === rows.length - 1 || saving}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
{item.type === "builtin" ? (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={on[item.key]}
|
||||||
|
disabled={saving || (!on[item.key] && atCap)}
|
||||||
|
onChange={() => {
|
||||||
|
setError("");
|
||||||
|
setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] }));
|
||||||
|
}}
|
||||||
|
label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn is-danger"
|
||||||
|
aria-label="移除"
|
||||||
|
onClick={() => removeAt(i)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" disabled={atCap} onClick={addCustom}>
|
||||||
|
<Plus size={14} /> 新增自定义链接
|
||||||
|
<span className="meta text-[12px] ml-1">
|
||||||
|
{payload.length}/{HEADER_NAV_LIMITS.items}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||||
|
<li>内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。</li>
|
||||||
|
<li>自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import { MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
/** 底栏入口说明 */
|
||||||
|
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
||||||
|
home: { label: "首页", hint: "站点首页" },
|
||||||
|
library: { label: "书库", hint: "书库列表页入口" },
|
||||||
|
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
||||||
|
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
||||||
|
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabSwitches = Record<MobileTabKey, boolean>;
|
||||||
|
|
||||||
|
function switchesOf(tabs: MobileTabKey[]): TabSwitches {
|
||||||
|
return {
|
||||||
|
home: tabs.includes("home"),
|
||||||
|
library: tabs.includes("library"),
|
||||||
|
compose: tabs.includes("compose"),
|
||||||
|
messages: tabs.includes("messages"),
|
||||||
|
me: tabs.includes("me"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整顺序:已启用项按配置顺序在前,未启用项按默认顺序追加在后 */
|
||||||
|
function orderOf(tabs: MobileTabKey[]): MobileTabKey[] {
|
||||||
|
const enabled = normalizeMobileTabs(tabs);
|
||||||
|
const rest = MOBILE_TABS.filter((key) => !enabled.includes(key));
|
||||||
|
return [...enabled, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移动端底栏:开关 + 顺序调整,保存启用的 tab 子集(数组顺序即前台展示顺序) */
|
||||||
|
export default function MobileTabBarPanel({
|
||||||
|
initial,
|
||||||
|
onSaved,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
initial: PublicSettings;
|
||||||
|
onSaved?: (next: PublicSettings) => void;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [order, setOrder] = useState<MobileTabKey[]>(() => orderOf(initial.mobile_tabs));
|
||||||
|
const [on, setOn] = useState<TabSwitches>(() => switchesOf(normalizeMobileTabs(initial.mobile_tabs)));
|
||||||
|
const [savedPayload, setSavedPayload] = useState<MobileTabKey[]>(() => normalizeMobileTabs(initial.mobile_tabs));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const payload = order.filter((key) => on[key]);
|
||||||
|
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
setError("");
|
||||||
|
setOrder((prev) => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= prev.length) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
const [row] = next.splice(i, 1);
|
||||||
|
next.splice(j, 0, row);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setOrder(orderOf(savedPayload));
|
||||||
|
setOn(switchesOf(savedPayload));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
if (payload.length === 0) {
|
||||||
|
setError("至少保留一个入口");
|
||||||
|
toast("至少保留一个入口");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ mobile_tabs: [...payload] });
|
||||||
|
const nextSaved = normalizeMobileTabs(res.mobile_tabs);
|
||||||
|
setSavedPayload(nextSaved);
|
||||||
|
setOrder(orderOf(nextSaved));
|
||||||
|
setOn(switchesOf(nextSaved));
|
||||||
|
onSaved?.(res);
|
||||||
|
toast("已保存,移动端底栏已更新", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setError(msg);
|
||||||
|
toast(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsGroup
|
||||||
|
id="settings-mobile-tabbar"
|
||||||
|
title="移动端底栏"
|
||||||
|
description="控制移动端底部导航显示哪些入口及顺序;底栏只在各入口自己的首页显示。"
|
||||||
|
footer={
|
||||||
|
<AdminSettingsActions
|
||||||
|
dirty={dirty}
|
||||||
|
saving={saving}
|
||||||
|
saveLabel="保存"
|
||||||
|
error={error}
|
||||||
|
saveDisabled={payload.length === 0}
|
||||||
|
onSave={() => void save()}
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{order.map((key, i) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 rounded-xl px-2 py-2"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
{TAB_META[key].label}
|
||||||
|
</div>
|
||||||
|
<div className="meta text-[12px]">{TAB_META[key].hint}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label={`上移${TAB_META[key].label}`}
|
||||||
|
disabled={i === 0 || saving}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label={`下移${TAB_META[key].label}`}
|
||||||
|
disabled={i === order.length - 1 || saving}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={on[key]}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() => {
|
||||||
|
setError("");
|
||||||
|
setOn((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}}
|
||||||
|
label={`底栏显示${TAB_META[key].label}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||||
|
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
||||||
|
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
||||||
|
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { announcementPath, leaderboardPath, pagePath, postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
|
import {
|
||||||
|
announcementPath,
|
||||||
|
leaderboardPath,
|
||||||
|
libraryDocPath,
|
||||||
|
libraryPath,
|
||||||
|
pagePath,
|
||||||
|
postPath,
|
||||||
|
userPath,
|
||||||
|
type UrlStyle,
|
||||||
|
} from "@/lib/urlStyle";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
|
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
|
||||||
@@ -20,6 +29,8 @@ function samplePaths(style: UrlStyle) {
|
|||||||
{ kind: "公告", path: announcementPath(style, 1) },
|
{ kind: "公告", path: announcementPath(style, 1) },
|
||||||
{ kind: "自建页", path: pagePath(style, "about") },
|
{ kind: "自建页", path: pagePath(style, "about") },
|
||||||
{ kind: "排行榜", path: leaderboardPath(style) },
|
{ kind: "排行榜", path: leaderboardPath(style) },
|
||||||
|
{ kind: "书库", path: libraryPath(style) },
|
||||||
|
{ kind: "书籍", path: libraryDocPath(style, "slug") },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +128,7 @@ export default function UrlStylePanel({
|
|||||||
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
|
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
|
||||||
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
|
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
|
||||||
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
|
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
|
||||||
|
<li>书库列表与书籍详情参与伪静态(两种伪静态风格同模板);阅读页保持标准路径不参与。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
|
|||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||||
|
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
|
||||||
|
|
||||||
type Side = "left" | "right";
|
type Side = "left" | "right";
|
||||||
type SectionId = SidebarScope | "widgets";
|
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 SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
|
||||||
|
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
|
||||||
|
|
||||||
export default function SidebarAdmin({
|
export default function SidebarAdmin({
|
||||||
initialConfig,
|
initialConfig,
|
||||||
initialWidgets,
|
initialWidgets,
|
||||||
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
|
|||||||
const [draftName, setDraftName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
const [mixed, setMixed] = useState("");
|
const [mixed, setMixed] = useState("");
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
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 draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||||
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setDraftName("");
|
setDraftName("");
|
||||||
setMixed("");
|
setMixed("");
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(w.id);
|
setEditingId(w.id);
|
||||||
setDraftName(w.name);
|
setDraftName(w.name);
|
||||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitWidget = async () => {
|
const doSubmitWidget = async (code: WidgetDraftCode) => {
|
||||||
if (!draftOk) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
|
||||||
if (editingId == null) {
|
if (editingId == null) {
|
||||||
await apiAdminCreateSidebarWidget(code);
|
await apiAdminCreateSidebarWidget(code);
|
||||||
toast("工具已创建", "ok");
|
toast("工具已创建", "ok");
|
||||||
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
|
|||||||
toast("工具已保存", "ok");
|
toast("工具已保存", "ok");
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
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) => {
|
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
|
|||||||
<SectionHead
|
<SectionHead
|
||||||
icon={<Code2 size={16} />}
|
icon={<Code2 size={16} />}
|
||||||
title="自定义工具"
|
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>}
|
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
<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 ? "创建" : "保存"}
|
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
|
|||||||
<textarea
|
<textarea
|
||||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||||
value={mixed}
|
value={mixed}
|
||||||
onChange={(e) => setMixed(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
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>
|
</label>
|
||||||
|
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
<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)" }}>
|
<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.user</code>:当前登录用户(未登录为 null)</p>
|
||||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||||
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<p className="meta text-[11.5px]">
|
<p className="meta text-[11.5px]">
|
||||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 仅 warn 级问题时的二次确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={warnConfirmOpen}
|
||||||
|
title="代码存在风险提醒"
|
||||||
|
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||||||
|
confirmLabel="仍要保存"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={confirmWarnSubmit}
|
||||||
|
onCancel={cancelWarnSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 删除工具确认 */}
|
{/* 删除工具确认 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteId != null}
|
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({
|
function SectionHead({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
|
|||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
import { postPath, userPath } from "@/lib/urlStyle";
|
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
AdminPager,
|
AdminPager,
|
||||||
@@ -356,7 +356,7 @@ export default function UserAuditClient({
|
|||||||
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<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"
|
className="text-[11.5px] hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
AD_IMAGE_WIDTH,
|
AD_IMAGE_WIDTH,
|
||||||
AD_TEXT_COL_WIDTH,
|
AD_TEXT_COL_WIDTH,
|
||||||
} from "@/lib/ads";
|
} from "@/lib/ads";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type Kind = "image" | "text";
|
type Kind = "image" | "text";
|
||||||
|
|
||||||
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<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="收款码"
|
alt="收款码"
|
||||||
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
||||||
style={{ borderColor: "var(--line)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
@@ -381,7 +383,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<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}收款码`}
|
alt={`${selectedPay.name}收款码`}
|
||||||
className="h-36 w-36 object-contain"
|
className="h-36 w-36 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone, Pin } from "lucide-react";
|
||||||
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
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 { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import PostToc from "@/components/PostToc";
|
import PostToc from "@/components/PostToc";
|
||||||
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const announcements = overview?.announcements ?? [];
|
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 siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const toc = extractToc(a.content);
|
const toc = extractToc(a.content);
|
||||||
@@ -105,17 +105,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
<>
|
<>
|
||||||
<JsonLd data={jsonLd} />
|
<JsonLd data={jsonLd} />
|
||||||
|
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
<>
|
|
||||||
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
|
||||||
<SiteDocAnnoNav items={announcements} currentId={annId} />
|
|
||||||
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<article className="panel p-6 sm:p-8">
|
<article className="panel p-6 sm:p-8">
|
||||||
<SiteDocMasthead
|
<SiteDocMasthead
|
||||||
kicker="站点公告"
|
kicker="站点公告"
|
||||||
@@ -126,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
showTimeOfDay
|
showTimeOfDay
|
||||||
badges={
|
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>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -144,7 +138,8 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
{/* 手机端 #main 无底部内边距,补 mb 与 footer 拉开距离;桌面端由 lg:pb 提供,归零 */}
|
||||||
|
<div className="mt-5 mb-6 lg:mb-0 flex items-center gap-2 text-[12.5px] meta">
|
||||||
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
||||||
<span>由站点管理团队发布</span>
|
<span>由站点管理团队发布</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import Link from "next/link";
|
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 { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import {
|
import {
|
||||||
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
|
|||||||
>
|
>
|
||||||
置顶公告
|
置顶公告
|
||||||
</span>
|
</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>
|
<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}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(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}
|
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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>
|
<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}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(a.created_at)}
|
{formatDate(a.created_at)}
|
||||||
@@ -133,7 +141,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
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;
|
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
|
||||||
@@ -145,13 +153,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="px-5 sm:px-6 pt-5 pb-4"
|
className="px-5 sm:px-6 pt-5 pb-4"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
ChevronsDown,
|
ChevronsDown,
|
||||||
Smile,
|
Smile,
|
||||||
|
VolumeX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiListChatMessages,
|
apiListChatMessages,
|
||||||
@@ -41,11 +42,13 @@ import {
|
|||||||
RT_CHAT_MESSAGE,
|
RT_CHAT_MESSAGE,
|
||||||
RT_CHAT_MEMBERSHIP,
|
RT_CHAT_MEMBERSHIP,
|
||||||
RT_CHAT_RECALLED,
|
RT_CHAT_RECALLED,
|
||||||
|
RT_CHAT_MEMBER_MUTED,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import type {
|
import type {
|
||||||
RtChatMessageData,
|
RtChatMessageData,
|
||||||
RtChatMembershipData,
|
RtChatMembershipData,
|
||||||
RtChatRecalledData,
|
RtChatRecalledData,
|
||||||
|
RtChatMemberMutedData,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -55,6 +58,7 @@ import LevelBadge from "@/components/LevelBadge";
|
|||||||
import BadgeChip from "@/components/BadgeChip";
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import AutolinkText from "@/components/AutolinkText";
|
import AutolinkText from "@/components/AutolinkText";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||||||
import EmojiPicker from "@/components/EmojiPicker";
|
import EmojiPicker from "@/components/EmojiPicker";
|
||||||
@@ -591,8 +595,21 @@ export default function ChatRoomClient({
|
|||||||
y: number;
|
y: number;
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
/** 头像右键菜单:对成员的管理操作 */
|
||||||
|
const [userCtxMenu, setUserCtxMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
user: User;
|
||||||
|
member?: ChatRoomMember;
|
||||||
|
} | null>(null);
|
||||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
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 [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
const [mentionFilter, setMentionFilter] = useState("");
|
const [mentionFilter, setMentionFilter] = useState("");
|
||||||
@@ -684,7 +701,7 @@ export default function ChatRoomClient({
|
|||||||
const openUserCard = useCallback(
|
const openUserCard = useCallback(
|
||||||
(u: User) => {
|
(u: User) => {
|
||||||
const mem = members.find((m) => m.user_id === u.id);
|
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]
|
[members]
|
||||||
);
|
);
|
||||||
@@ -856,6 +873,40 @@ export default function ChatRoomClient({
|
|||||||
return off;
|
return off;
|
||||||
}, [roomId, router]);
|
}, [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 () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const handleRichInput = () => {
|
const handleRichInput = () => {
|
||||||
const el = inputRef.current;
|
const el = inputRef.current;
|
||||||
if (!el) return;
|
if (!el || mutedNotice) return;
|
||||||
const text = domToText(el);
|
const text = domToText(el);
|
||||||
if (text === lastRichText.current) return;
|
if (text === lastRichText.current) return;
|
||||||
if (text.length > 2000) {
|
if (text.length > 2000) {
|
||||||
@@ -1153,6 +1204,10 @@ export default function ChatRoomClient({
|
|||||||
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
// IME 组合中不拦截按键
|
// IME 组合中不拦截按键
|
||||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mentionOpen && mentionCandidates.length > 0) {
|
if (mentionOpen && mentionCandidates.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1196,6 +1251,10 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
|
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
|
||||||
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = e.clipboardData.getData("text/plain");
|
const text = e.clipboardData.getData("text/plain");
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -1309,23 +1368,55 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||||||
if (!canKickMembers) return;
|
if (!canKickMembers) return;
|
||||||
|
if (muted) {
|
||||||
|
// 打开时长选择弹窗(QQ 风格)
|
||||||
|
setMuteTarget({ uid, nickname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: muted ? "禁言成员" : "解除禁言",
|
title: "解除禁言",
|
||||||
message: muted
|
message: `确定解除 ${nickname} 的禁言?`,
|
||||||
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
|
confirmLabel: "解禁",
|
||||||
: `确定解除 ${nickname} 的禁言?`,
|
|
||||||
confirmLabel: muted ? "禁言" : "解禁",
|
|
||||||
danger: muted,
|
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await apiMuteChatMember(roomId, uid, muted);
|
await apiMuteChatMember(roomId, uid, false);
|
||||||
setMembers((prev) =>
|
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) => {
|
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
|
||||||
if (!siteOwner) return;
|
if (!siteOwner) return;
|
||||||
setConfirm({
|
setConfirm({
|
||||||
@@ -1454,6 +1545,62 @@ export default function ChatRoomClient({
|
|||||||
return items;
|
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 自带
|
// 点击空白关闭右键菜单 — ContextMenu 自带
|
||||||
|
|
||||||
const shellClass = embedded
|
const shellClass = embedded
|
||||||
@@ -1650,6 +1797,23 @@ export default function ChatRoomClient({
|
|||||||
5 * 60_000;
|
5 * 60_000;
|
||||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
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)) {
|
if (recalled && !(canModerateChat && m.content)) {
|
||||||
return (
|
return (
|
||||||
@@ -1704,10 +1868,26 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
<div className="w-8 shrink-0">
|
<div className="w-8 shrink-0">
|
||||||
{!grouped && (
|
{!grouped && (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
className="rounded-full transition-opacity hover:opacity-85"
|
tabIndex={0}
|
||||||
|
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||||||
onClick={() => openUserCard(m.sender)}
|
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} 的资料卡`}
|
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -1715,7 +1895,7 @@ export default function ChatRoomClient({
|
|||||||
src={m.sender.avatar || undefined}
|
src={m.sender.avatar || undefined}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1784,10 +1964,26 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
<div className="w-8 shrink-0">
|
<div className="w-8 shrink-0">
|
||||||
{!grouped && (
|
{!grouped && (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
className="rounded-full transition-opacity hover:opacity-85"
|
tabIndex={0}
|
||||||
|
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||||||
onClick={() => openUserCard(m.sender)}
|
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} 的资料卡`}
|
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -1795,7 +1991,7 @@ export default function ChatRoomClient({
|
|||||||
src={m.sender.avatar || undefined}
|
src={m.sender.avatar || undefined}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -2028,18 +2224,32 @@ export default function ChatRoomClient({
|
|||||||
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
|
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!!mutedNotice}
|
||||||
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
|
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)" }}
|
style={{
|
||||||
aria-label="表情"
|
color: mutedNotice
|
||||||
|
? "var(--ink-3)"
|
||||||
|
: emojiOpen
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
|
||||||
aria-expanded={emojiOpen}
|
aria-expanded={emojiOpen}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => setEmojiOpen((v) => !v)}
|
onClick={() => {
|
||||||
|
if (mutedNotice) return;
|
||||||
|
setEmojiOpen((v) => !v);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Smile size={19} />
|
<Smile size={19} />
|
||||||
</button>
|
</button>
|
||||||
{emojiOpen && (
|
{emojiOpen && !mutedNotice && (
|
||||||
<div className="j13-chat-emoji-panel">
|
<div className="j13-chat-emoji-panel">
|
||||||
<EmojiPicker onPick={insertEmoji} />
|
<EmojiPicker onPick={insertEmoji} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2047,17 +2257,28 @@ export default function ChatRoomClient({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
contentEditable
|
contentEditable={!mutedNotice}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label="输入消息"
|
aria-label={mutedNotice ? "禁言中" : "输入消息"}
|
||||||
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
|
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"
|
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}
|
onInput={handleRichInput}
|
||||||
onKeyDown={handleRichKeyDown}
|
onKeyDown={handleRichKeyDown}
|
||||||
onPaste={handleRichPaste}
|
onPaste={handleRichPaste}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
||||||
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
||||||
const composer = e.currentTarget.closest(".j13-chat-composer");
|
const composer = e.currentTarget.closest(".j13-chat-composer");
|
||||||
@@ -2067,7 +2288,7 @@ export default function ChatRoomClient({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={send}
|
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"
|
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
>
|
>
|
||||||
@@ -2156,6 +2377,7 @@ export default function ChatRoomClient({
|
|||||||
user: mem.user,
|
user: mem.user,
|
||||||
roomRole: mem.role,
|
roomRole: mem.role,
|
||||||
muted: mem.muted,
|
muted: mem.muted,
|
||||||
|
mutedUntil: mem.muted_until,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -2204,6 +2426,7 @@ export default function ChatRoomClient({
|
|||||||
user: mem.user,
|
user: mem.user,
|
||||||
roomRole: mem.role,
|
roomRole: mem.role,
|
||||||
muted: mem.muted,
|
muted: mem.muted,
|
||||||
|
mutedUntil: mem.muted_until,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
showTitle
|
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 && (
|
{userCard && (
|
||||||
<ChatUserCard
|
<ChatUserCard
|
||||||
target={userCard}
|
target={userCard}
|
||||||
@@ -2240,10 +2472,179 @@ export default function ChatRoomClient({
|
|||||||
if (!confirmBusy) setConfirm(null);
|
if (!confirmBusy) setConfirm(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MuteModal
|
||||||
|
open={!!muteTarget}
|
||||||
|
nickname={muteTarget?.nickname ?? ""}
|
||||||
|
busy={muteBusy}
|
||||||
|
onConfirm={submitMute}
|
||||||
|
onClose={() => {
|
||||||
|
if (!muteBusy) setMuteTarget(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</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({
|
function JoinButton({
|
||||||
roomId,
|
roomId,
|
||||||
onJoined,
|
onJoined,
|
||||||
@@ -2361,7 +2762,7 @@ function MembersPanel({
|
|||||||
{name}
|
{name}
|
||||||
{m.muted ? (
|
{m.muted ? (
|
||||||
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
· 禁言
|
· 禁言{formatMuteRemainShort(m.muted_until)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function ChatLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-chat-viewport
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -215,6 +215,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
/* 恒定保留滚动条槽位:短页面(如书库)无滚动条时视口宽度不再比长页面宽 10px,
|
||||||
|
避免 Header 居中容器与右侧按钮组在切页时横移 */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
scrollbar-color: var(--line-2) transparent;
|
scrollbar-color: var(--line-2) transparent;
|
||||||
background-color: var(--bg); /* 橡皮筋 overscroll 与画布同色 */
|
background-color: var(--bg); /* 橡皮筋 overscroll 与画布同色 */
|
||||||
--j13-bg-site-image: none;
|
--j13-bg-site-image: none;
|
||||||
@@ -356,6 +359,28 @@ html[data-lv-fx="off"] .lv-text {
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 无封面文本封面:颜色由书名派生的稳定色相 --cover-h 决定(lib/coverColor.ts),
|
||||||
|
同一本书任何页面/刷新同色;浅色=淡彩底深字,深色=暗底亮字 */
|
||||||
|
.j13-text-cover {
|
||||||
|
--cover-h: 210;
|
||||||
|
color: hsl(var(--cover-h) 45% 34%);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 140% at 20% -10%, hsl(var(--cover-h) 85% 55% / 0.2), transparent 60%),
|
||||||
|
hsl(var(--cover-h) 60% 95.5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .j13-text-cover {
|
||||||
|
color: hsl(var(--cover-h) 65% 78%);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 140% at 20% -10%, hsl(var(--cover-h) 70% 62% / 0.22), transparent 60%),
|
||||||
|
hsl(var(--cover-h) 28% 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 书库页标题:书香气衬线(宋体/楷体回退) */
|
||||||
|
.j13-library-title {
|
||||||
|
font-family: "Songti SC", "STSong", "SimSun", "Noto Serif CJK SC", serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
||||||
.j13-tip-bubble {
|
.j13-tip-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1967,6 +1992,10 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--ink-2);
|
color: var(--ink-2);
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
/* 作为 flex 子元素时保持固定宽度,避免被压缩或按内容计算,
|
||||||
|
否则窄屏下直接挂在 .j13-md-tool-groups 内的按钮会塌缩成图标宽度(如 15px),
|
||||||
|
而被 .j13-md-popover-anchor 包裹的按钮保持 34px,导致整行按钮宽度不一致 */
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.j13-md-tool:hover:not(:disabled) {
|
.j13-md-tool:hover:not(:disabled) {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
@@ -2397,6 +2426,47 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用)。
|
||||||
|
空间不足时允许「编写/预览」折到第二行,不能让固定宽按钮溢出后与模式切换重叠 */
|
||||||
|
.j13-md-editor.is-narrow .j13-md-toolbar {
|
||||||
|
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;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.j13-md-editor.is-narrow .j13-md-sep {
|
||||||
|
/* 窄屏下 1px 分隔符视觉几乎不可见却占 5px 横向空间,
|
||||||
|
反而打断按钮间均匀间距;改由 gap-0.5 统一控制间距 */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.j13-md-editor.is-narrow .j13-md-mode button {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* 「更多」内嵌的代码块/表情/隐藏内容弹层:相对更多按钮居中,避免贴边溢出屏幕 */
|
||||||
|
.j13-md-popover-center {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
/* 窄屏左锚点表单弹层(链接等)宽度收敛,保证完整落在屏内 */
|
||||||
|
.j13-md-editor.is-narrow .j13-md-popover-form {
|
||||||
|
min-width: 232px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 正文内嵌隐藏块 */
|
/* 正文内嵌隐藏块 */
|
||||||
.j13-hide-block {
|
.j13-hide-block {
|
||||||
@@ -2857,9 +2927,10 @@ body:has([data-compose-viewport]) > footer {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* <1024px(移动/平板,底部公共菜单显示区间):三区堆叠、统一左对齐连成整体,
|
/* <1024px(移动/平板,底部公共菜单显示区间):三区堆叠、统一左对齐连成整体,
|
||||||
避免「左中右各自断开」的残缺观感;底部避让公共菜单 */
|
避免「左中右各自断开」的残缺观感;底栏避让统一由 footer 承担(:has 判定),
|
||||||
|
#main 只留常规小 padding-bottom,无底栏页面(如全部通知)底部也有间隙 */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.site-footer {
|
body:has([data-j13-tabbar]) .site-footer {
|
||||||
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
.site-footer-inner {
|
.site-footer-inner {
|
||||||
@@ -2881,6 +2952,15 @@ body:has([data-compose-viewport]) > footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 通知下拉(移动端底部面板)避让底部公共菜单:面板在 sticky header 的层叠上下文内,
|
||||||
|
z-index 抬不过底栏,只能在底栏存在时整体抬高(与 footer 的避让值一致),
|
||||||
|
露出被遮住的「查看全部通知」入口 */
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
body:has([data-j13-tabbar]) .j13-notif-sheet {
|
||||||
|
bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body:has([data-compose-viewport]) #main {
|
body:has([data-compose-viewport]) #main {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -3103,7 +3183,8 @@ body:has([data-compose-viewport]) #main > [data-compose-viewport] {
|
|||||||
.j13-compose-typemodal-body {
|
.j13-compose-typemodal-body {
|
||||||
max-height: min(78vh, 42rem);
|
max-height: min(78vh, 42rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 2px;
|
/* 滚动容器会裁剪 box-shadow 聚焦圈(2px),四周各留 2px */
|
||||||
|
padding: 2px;
|
||||||
}
|
}
|
||||||
.j13-compose-typemodal-body .j13-compose-typepanel {
|
.j13-compose-typemodal-body .j13-compose-typepanel {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3197,6 +3278,13 @@ body:has([data-chat-viewport]) #main {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 消息列表页显示底栏时,锁视口布局底部避让(/chat/[id] 无底栏不受影响) */
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
body:has([data-j13-tabbar]) #main:has([data-chat-viewport]) {
|
||||||
|
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */
|
/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */
|
||||||
body:has([data-admin-viewport]) {
|
body:has([data-admin-viewport]) {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
@@ -3809,6 +3897,19 @@ a.j13-mention-token:hover {
|
|||||||
border-radius: var(--r);
|
border-radius: var(--r);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
/* 阅读型页面(公文壳 / 书库读页)手机端中栏去块化:
|
||||||
|
直接子级 panel 贴边铺满(-mx-4 抵消 #main 移动端 px-4),与帖子详情页移动端同款;
|
||||||
|
仅作用于直接子级,正文内的条目卡等嵌套 panel 不受影响 */
|
||||||
|
@media (max-width: 639.98px) {
|
||||||
|
.j13-doc-bleed > .panel {
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left-width: 0;
|
||||||
|
border-right-width: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover-lift {
|
.hover-lift {
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
@@ -4444,6 +4545,30 @@ a.j13-mention-token:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
/* 品牌图固定对外地址:点击复制,友链引用用 */
|
||||||
|
.admin-brand-alias {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.admin-brand-alias:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent, var(--line));
|
||||||
|
}
|
||||||
|
.admin-brand-alias-url {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-jetbrains), ui-monospace, monospace;
|
||||||
|
}
|
||||||
.admin-settings-row {
|
.admin-settings-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 10.75rem minmax(0, 1fr);
|
grid-template-columns: 10.75rem minmax(0, 1fr);
|
||||||
@@ -4601,13 +4726,17 @@ a.j13-mention-token:hover {
|
|||||||
#settings-identity .admin-settings-actions,
|
#settings-identity .admin-settings-actions,
|
||||||
#settings-brand .admin-settings-actions,
|
#settings-brand .admin-settings-actions,
|
||||||
#settings-url-style .admin-settings-actions,
|
#settings-url-style .admin-settings-actions,
|
||||||
#settings-footer .admin-settings-actions {
|
#settings-footer .admin-settings-actions,
|
||||||
|
#settings-header-nav .admin-settings-actions,
|
||||||
|
#settings-mobile-tabbar .admin-settings-actions {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
#settings-identity .admin-settings-actions-inner,
|
#settings-identity .admin-settings-actions-inner,
|
||||||
#settings-brand .admin-settings-actions-inner,
|
#settings-brand .admin-settings-actions-inner,
|
||||||
#settings-url-style .admin-settings-actions-inner,
|
#settings-url-style .admin-settings-actions-inner,
|
||||||
#settings-footer .admin-settings-actions-inner {
|
#settings-footer .admin-settings-actions-inner,
|
||||||
|
#settings-header-nav .admin-settings-actions-inner,
|
||||||
|
#settings-mobile-tabbar .admin-settings-actions-inner {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
.admin-settings-keywords {
|
.admin-settings-keywords {
|
||||||
@@ -5942,6 +6071,31 @@ a.j13-mention-token:hover {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 书库管理:左列表右编辑;桌面整页不滚(复用 admin-content-page 的 canvas 锁定),
|
||||||
|
两栏各自内部滚动;窄屏按 data-view 切换单栏 */
|
||||||
|
.admin-library-split {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 18rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.admin-library-split {
|
||||||
|
grid-template-columns: minmax(15rem, 21rem) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
.admin-library-split[data-view="editor"] .admin-library-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.admin-library-split[data-view="list"] .admin-library-editor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.admin-messages-panel {
|
.admin-messages-panel {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -6349,29 +6503,23 @@ a.j13-mention-token:hover {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.badge-pin {
|
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
|
||||||
display: inline-flex;
|
.badge-pin,
|
||||||
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;
|
|
||||||
}
|
|
||||||
.badge-rec {
|
.badge-rec {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
font-size: 12px;
|
width: 20px;
|
||||||
font-weight: 500;
|
height: 20px;
|
||||||
padding: 4px 11px;
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.badge-pin {
|
||||||
|
background: var(--clay-soft);
|
||||||
|
color: var(--clay);
|
||||||
|
}
|
||||||
|
.badge-rec {
|
||||||
background: var(--gold-soft);
|
background: var(--gold-soft);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.badge-pending {
|
.badge-pending {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -6679,7 +6827,7 @@ a.j13-mention-token:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin: 0 0 0.65rem -0.1rem;
|
margin: 0;
|
||||||
padding: 0.32rem 0.75rem 0.32rem 0.6rem;
|
padding: 0.32rem 0.75rem 0.32rem 0.6rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--ok-soft);
|
background: var(--ok-soft);
|
||||||
@@ -6691,6 +6839,7 @@ a.j13-mention-token:hover {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.j13-timeline-day-label time {
|
.j13-timeline-day-label time {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
@@ -6748,19 +6897,29 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
.j13-timeline-head {
|
.j13-timeline-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.j13-timeline-summary {
|
.j13-timeline-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13.5px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
/* 当天非首条:版本号紧贴左侧,与首条版本号对齐(不重复日期) */
|
||||||
|
.j13-timeline-item:not(.is-first-in-day) .j13-timeline-summary {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
/* 首条目卡片上方留一点呼吸,与日期胶囊拉开层次 */
|
||||||
|
.j13-timeline-item.is-first-in-day .j13-timeline-card {
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
.j13-timeline-chip {
|
.j13-timeline-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -7925,7 +8084,7 @@ a.j13-mention-token:hover {
|
|||||||
/* 移动端横向板块条(滚动条隐藏,保留触控惯性) */
|
/* 移动端横向板块条(滚动条隐藏,保留触控惯性) */
|
||||||
.j13-chips {
|
.j13-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -7943,7 +8102,7 @@ a.j13-mention-token:hover {
|
|||||||
/* 无框 chip:透明边框占位,选中/悬停切底色时尺寸不跳 */
|
/* 无框 chip:透明边框占位,选中/悬停切底色时尺寸不跳 */
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--ink-2);
|
color: var(--ink-2);
|
||||||
@@ -7979,7 +8138,7 @@ a.j13-mention-token:hover {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.j13-chip-fade-left {
|
.j13-chip-fade-left {
|
||||||
left: -1rem;
|
left: 0;
|
||||||
border-radius: 1rem 0 0 1rem;
|
border-radius: 1rem 0 0 1rem;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
@@ -7988,7 +8147,7 @@ a.j13-mention-token:hover {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
.j13-chip-fade-right {
|
.j13-chip-fade-right {
|
||||||
right: -1rem;
|
right: 0;
|
||||||
border-radius: 0 1rem 1rem 0;
|
border-radius: 0 1rem 1rem 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to left,
|
to left,
|
||||||
@@ -8055,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 正文
|
Markdown 正文
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -8068,10 +8236,11 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
.prose-content .j13-timeline-summary {
|
.prose-content .j13-timeline-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13.5px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.prose-content .j13-timeline-body,
|
.prose-content .j13-timeline-body,
|
||||||
.prose-content .j13-timeline-body p {
|
.prose-content .j13-timeline-body p {
|
||||||
@@ -8079,6 +8248,29 @@ a.j13-mention-token:hover {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
}
|
}
|
||||||
|
/* 条目卡嵌在 prose 内:压住字段值/摘要的段落与列表外边距(未分层规则才能胜过
|
||||||
|
.prose-content p 的 0.9rem 外边距),否则字段内容被推低、与左侧标签错位 */
|
||||||
|
.prose-content .j13-entry-card dd p,
|
||||||
|
.prose-content .j13-entry-card .j13-entry-summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.prose-content .j13-entry-card dd ul,
|
||||||
|
.prose-content .j13-entry-card dd ol {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.prose-content .j13-entry-card dd li {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
/* 条目卡标题字号:移动端收小(约 h3 水平),sm 起与 prose h2 同大;
|
||||||
|
卡内 h2 用 inline style 引用该变量,避免被上面的 .prose-content h2 压过 */
|
||||||
|
.j13-entry-card h2 {
|
||||||
|
--entry-title-size: 1.18rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.j13-entry-card h2 {
|
||||||
|
--entry-title-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||||
.prose-content h2 {
|
.prose-content h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -8588,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 顶部不留白,板块条与帖子框连成一张白纸) */
|
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
body:has(.j13-fluid-home) #main {
|
body:has(.j13-fluid-home) #main {
|
||||||
@@ -8619,8 +8819,8 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
前台 main 纸面 + 首页三栏「白纸一体」
|
前台 main 纸面 + 首页三栏「白纸一体」
|
||||||
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
||||||
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
|
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
|
||||||
背景图时让位(保持透出画布)。左栏与窄屏下沉面板无框化,
|
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
|
||||||
内容直接坐在纸面上;右栏保留卡片板块感(j13-module 原样)。
|
右栏保留卡片板块感(j13-module 原样)。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
html:not(.j13-has-site-bg) #main {
|
html:not(.j13-has-site-bg) #main {
|
||||||
background: var(--main-bg);
|
background: var(--main-bg);
|
||||||
@@ -8637,8 +8837,7 @@ html:not(.j13-has-site-bg) #main {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* 左栏小节分隔:任意块相邻时画发丝线(含站长自定义工具块) */
|
/* 左栏小节分隔:任意块相邻时画发丝线(含站长自定义工具块) */
|
||||||
.j13-rail-left > * + *,
|
.j13-rail-left > * + * {
|
||||||
.j13-fluid-home .j13-flat-rail > * + * {
|
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 1.25rem;
|
padding-top: 1.25rem;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -8654,8 +8853,7 @@ html:not(.j13-has-site-bg) #main {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
/* 栏内图标无底色:只留彩色字形本身(收成图标实际大小,避免隐形占位拉大间距) */
|
/* 栏内图标无底色:只留彩色字形本身(收成图标实际大小,避免隐形占位拉大间距) */
|
||||||
.j13-rail-left .j13-bcard-ico,
|
.j13-rail-left .j13-bcard-ico {
|
||||||
.j13-fluid-home .j13-flat-rail .j13-bcard-ico {
|
|
||||||
background: transparent !important; /* 覆盖「全部讨论/管理面板」图标的内联底色 */
|
background: transparent !important; /* 覆盖「全部讨论/管理面板」图标的内联底色 */
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -8696,25 +8894,16 @@ html:not(.j13-has-site-bg) #main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* 左栏分组标题:小号灰字,纸面风格的段落断点 */
|
/* 左栏分组标题:小号灰字,纸面风格的段落断点 */
|
||||||
.j13-rail-left .j13-rail-group,
|
.j13-rail-left .j13-rail-group {
|
||||||
.j13-fluid-home .j13-flat-rail .j13-rail-group {
|
|
||||||
padding: 10px 8px 4px;
|
padding: 10px 8px 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.j13-rail-left .j13-rail-group:first-child,
|
.j13-rail-left .j13-rail-group:first-child {
|
||||||
.j13-fluid-home .j13-flat-rail .j13-rail-group:first-child {
|
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
/* 手机端下沉面板:推广占位放开到整栏宽(248 定宽是桌面右栏尺寸,
|
|
||||||
窄屏居中两侧大片空白显得稀疏);/ads/buy 购买页预览保持真实尺寸不受影响 */
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.j13-fluid-home .j13-flat-rail .j13-ad-promo {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 左栏板块导航:当前项 accent 竖条 + 浅色底 */
|
/* 左栏板块导航:当前项 accent 竖条 + 浅色底 */
|
||||||
.j13-rail-left .j13-module .j13-bcard {
|
.j13-rail-left .j13-module .j13-bcard {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -9120,12 +9309,12 @@ button.cmt-edited {
|
|||||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评论内联回复表单:出现在目标评论正下方 */
|
/* 评论内联回复表单:与楼层块同级全宽显示(DOM 见 CommentSection 列表 map)。
|
||||||
|
前侧间距由 .cmt-item 的 padding-bottom 提供 */
|
||||||
.cmt-reply-form {
|
.cmt-reply-form {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评论区编辑器:矮输入区,底栏含提交 */
|
/* 评论区编辑器:矮输入区,底栏含提交 */
|
||||||
@@ -9148,12 +9337,32 @@ button.cmt-edited {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@container (max-width: 420px) {
|
@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 {
|
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
|
||||||
width: 36px;
|
width: 30px;
|
||||||
height: 36px;
|
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 {
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9779,15 +9988,12 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 侧栏管理:站长自定义工具容器 ──
|
/* ── 侧栏管理:站长自定义工具容器(Shadow DOM 宿主) ──
|
||||||
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
|
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
|
||||||
仅保证在侧栏列内不横向溢出。 */
|
仅保证在侧栏列内不横向溢出,工具内容隔离在 shadow 内(见 widgetRuntime)。 */
|
||||||
.j13-widget-body {
|
.j13-widget-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.j13-widget-body > .j13-widget-html {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
|
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
|
||||||
.j13-emoji-picker {
|
.j13-emoji-picker {
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export function generateMetadata(): Metadata {
|
|||||||
},
|
},
|
||||||
description: desc,
|
description: desc,
|
||||||
keywords,
|
keywords,
|
||||||
...(settings.favicon_url ? { icons: { icon: settings.favicon_url } } : {}),
|
// 固定地址:换图后浏览器/书签侧的 icon 链接不变
|
||||||
|
...(settings.favicon_url ? { icons: { icon: "/api/brand/favicon" } } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
@@ -139,6 +140,18 @@ export default async function RootLayout({
|
|||||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||||
>
|
>
|
||||||
<body className="min-h-screen flex flex-col">
|
<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 ? (
|
{accentCSS ? (
|
||||||
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
|
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
|
||||||
<style
|
<style
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { requestCanonical } from "@/lib/canonicalMeta";
|
|||||||
import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
|
import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import LevelBadge from "@/components/LevelBadge";
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ period?: string; metric?: string }>;
|
searchParams: Promise<{ period?: string; metric?: string }>;
|
||||||
@@ -411,7 +412,7 @@ function RulesPanel({ s }: { s: PublicSettings }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="meta text-[11.5px] mt-3 leading-relaxed">
|
<p className="meta text-[11.5px] mt-3 leading-relaxed">
|
||||||
累计积分即贡献值,决定等级与总榜排名;消费仅扣余额,不减少累计值。
|
贡献值 = 周期内累计获得的积分,按上方规则逐笔入账;消费仅扣余额,不减少累计值。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -442,6 +443,20 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
|
|||||||
const totalScore = entries.reduce((acc, e) => acc + e.score, 0);
|
const totalScore = entries.reduce((acc, e) => acc + e.score, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* 移动端:右栏(我的排名 + 贡献值规则)收进右抽屉,lg 起隐藏 */}
|
||||||
|
<MobileRailDrawers
|
||||||
|
right={{
|
||||||
|
label: "排名与规则",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
|
||||||
|
<RulesPanel s={settings} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
|
<div className="grid gap-5 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
|
||||||
{/* 左栏:贡献排行榜 */}
|
{/* 左栏:贡献排行榜 */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -592,11 +607,12 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右栏:我的排名 + 贡献值规则 */}
|
{/* 右栏:我的排名 + 贡献值规则(移动端收进右抽屉) */}
|
||||||
<aside className="space-y-4 lg:sticky lg:top-[76px]">
|
<aside className="hidden lg:block space-y-4 lg:sticky lg:top-[76px]">
|
||||||
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
|
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
|
||||||
<RulesPanel s={settings} />
|
<RulesPanel s={settings} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
323
frontend/app/library/[slug]/page.tsx
Normal file
323
frontend/app/library/[slug]/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, Download, Paperclip } from "lucide-react";
|
||||||
|
import { fetchLibraryDocBySlug, type LibraryFileItem } from "@/lib/api";
|
||||||
|
import { coverHue } from "@/lib/coverColor";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
|
import { SiteDocMasthead } from "@/components/site-doc";
|
||||||
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
|
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 }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无封面时:完整书名 + 书名派生稳定色相渐变块(有图封面走 AdaptiveCoverSlot 按横竖版自适应摆放) */
|
||||||
|
function CoverFallback({ title }: { title: string }) {
|
||||||
|
const name = title.trim() || "书";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="j13-text-cover w-full aspect-[3/4] rounded-2xl flex items-center justify-center px-6 select-none"
|
||||||
|
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span className="text-[26px] font-extrabold leading-relaxed text-center line-clamp-5 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 shrink-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
background: "var(--accent-soft)",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ext}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileRow({ f }: { f: LibraryFileItem }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl px-3 py-2.5"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
||||||
|
>
|
||||||
|
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<ExtChip ext={f.ext} />
|
||||||
|
<span
|
||||||
|
className="text-[13px] font-medium min-w-0 truncate"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
title={f.name}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</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 inline-flex items-center gap-1.5 shrink-0"
|
||||||
|
>
|
||||||
|
<Download size={13} /> 下载
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
|
||||||
|
if (!data) return { title: "条目不存在" };
|
||||||
|
const d = data.doc;
|
||||||
|
return {
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ? d.description.slice(0, 150) : d.title,
|
||||||
|
openGraph: {
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ? d.description.slice(0, 150) : d.title,
|
||||||
|
type: "article",
|
||||||
|
publishedTime: d.created_at,
|
||||||
|
},
|
||||||
|
...(await requestCanonical(libraryDocPath((await getPublicSettingsCached()).url_style, d.slug))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LibraryDocPage({ params }: PageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const [data, settings] = await Promise.all([
|
||||||
|
fetchLibraryDocBySlug(slug).catch(() => null),
|
||||||
|
getPublicSettingsCached(),
|
||||||
|
]);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const d = data.doc;
|
||||||
|
const urlStyle = settings.url_style;
|
||||||
|
const chapterCount = groupSections(d.sections ?? []).length;
|
||||||
|
|
||||||
|
const readingMinutes = estimateReadingMinutes(
|
||||||
|
[d.description, ...(d.sections ?? []).map((s) => s.content)].filter(Boolean).join("\n\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonLd = webPageJsonLd({
|
||||||
|
name: d.title,
|
||||||
|
description: d.description ? d.description.slice(0, 150) : undefined,
|
||||||
|
path: libraryDocPath(urlStyle, d.slug),
|
||||||
|
datePublished: d.created_at,
|
||||||
|
dateModified: d.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<JsonLd data={jsonLd} />
|
||||||
|
<div className="max-w-[1200px] 2xl:max-w-[1320px] mx-auto min-w-0">
|
||||||
|
{/* 手机端水平内边距收窄(px-3),介绍内容近全宽;sm 起恢复常规面板内距 */}
|
||||||
|
<article className="panel px-3 py-5 sm:p-8">
|
||||||
|
<SiteDocMasthead
|
||||||
|
kicker="书库"
|
||||||
|
title={d.title}
|
||||||
|
createdAt={d.created_at}
|
||||||
|
updatedAt={d.updated_at}
|
||||||
|
showTimeOfDay
|
||||||
|
readingMinutes={readingMinutes}
|
||||||
|
badges={
|
||||||
|
chapterCount > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold"
|
||||||
|
style={{ background: "var(--panel-2)", color: "var(--ink-2)", border: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{chapterCount} 章
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
|
||||||
|
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
|
||||||
|
{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"
|
||||||
|
variantWidths={[768, 1080, 1920]}
|
||||||
|
sizes="(max-width:1280px) 92vw, 1080px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdaptiveCoverSlot>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 lg:grid lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)] lg:gap-8">
|
||||||
|
{/* 左栏:封面 + 元信息 + 开始阅读 */}
|
||||||
|
<aside className="lg:sticky lg:top-[84px] self-start">
|
||||||
|
{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"
|
||||||
|
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)" }}>
|
||||||
|
{chapterCount}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="meta">附件数</dt>
|
||||||
|
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
{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
|
||||||
|
href={libraryDocReadPath(urlStyle, d.slug)}
|
||||||
|
className="btn btn-primary mt-4 w-full justify-center inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="text-[15px] font-bold inline-flex items-center gap-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Paperclip size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||||
|
介绍
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3">
|
||||||
|
{d.description ? (
|
||||||
|
<MarkdownBody content={d.description} />
|
||||||
|
) : (
|
||||||
|
<p className="meta text-[13px]">这本书还没有介绍。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
||||||
|
<BookOpen size={14} style={{ color: "var(--accent)" }} aria-hidden />
|
||||||
|
<span>来自站点书库</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
382
frontend/app/library/[slug]/read/page.tsx
Normal file
382
frontend/app/library/[slug]/read/page.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
import type { Components } from "react-markdown";
|
||||||
|
import {
|
||||||
|
fetchLibraryDocBySlug,
|
||||||
|
type LibraryDoc,
|
||||||
|
type LibrarySectionItem,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
|
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
|
||||||
|
import {
|
||||||
|
chapterNav,
|
||||||
|
groupSections,
|
||||||
|
type ChapterNav,
|
||||||
|
} from "@/lib/librarySections";
|
||||||
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
||||||
|
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 }>;
|
||||||
|
searchParams: Promise<{ c?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionGroups = ReturnType<typeof groupSections>;
|
||||||
|
|
||||||
|
interface ReadData {
|
||||||
|
doc: LibraryDoc;
|
||||||
|
groups: SectionGroups;
|
||||||
|
current: LibrarySectionItem | null;
|
||||||
|
nav: ChapterNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读页共享取数(generateMetadata 与 page 共用):
|
||||||
|
* fetch 失败/404 → null;?c 为书内章序号(1 起,可写入文章目录地址),
|
||||||
|
* 越界/缺省回落第一章;旧链接传章节全局 id 时兜底按 id 匹配(不报错)
|
||||||
|
*/
|
||||||
|
async function loadRead(slug: string, cParam?: string): Promise<ReadData | null> {
|
||||||
|
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
|
||||||
|
if (!data) return null;
|
||||||
|
const groups = groupSections(data.doc.sections ?? []);
|
||||||
|
const c = Number.parseInt(cParam ?? "", 10);
|
||||||
|
const byNo =
|
||||||
|
Number.isFinite(c) && c >= 1 && c <= groups.length ? groups[c - 1].chapter : undefined;
|
||||||
|
const byId = Number.isFinite(c) ? groups.find((g) => g.chapter.id === c)?.chapter : undefined;
|
||||||
|
const current = byNo ?? byId ?? groups[0]?.chapter ?? null;
|
||||||
|
return { doc: data.doc, groups, current, nav: chapterNav(groups, current?.id ?? null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为内容块的 markdown 标题挂章内锚点 id(ids 按标题出现顺序对应,与本章目录共享同一份) */
|
||||||
|
function headingComponentsFor(content: string, ids: string[]): Components | undefined {
|
||||||
|
const toc = markdownHeadingToc(extractToc(content || ""));
|
||||||
|
if (toc.length === 0) return undefined;
|
||||||
|
let idx = 0;
|
||||||
|
const render = (Tag: "h2" | "h3") =>
|
||||||
|
function MdHeading({ children }: { children?: React.ReactNode }) {
|
||||||
|
const item = toc[idx++];
|
||||||
|
return (
|
||||||
|
<Tag id={item ? ids[idx - 1] : undefined} style={{ scrollMarginTop: 90 }}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return { h2: render("h2"), h3: render("h3") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { c } = await searchParams;
|
||||||
|
const data = await loadRead(slug, c);
|
||||||
|
if (!data) return { title: "条目不存在" };
|
||||||
|
const title = data.current ? `${data.current.title} · ${data.doc.title}` : data.doc.title;
|
||||||
|
const description = (data.current?.content || data.doc.title).slice(0, 150);
|
||||||
|
// 全部章节共用一个 URL,避免重复收录;详情页是唯一索引入口
|
||||||
|
return { title, description, robots: { index: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LibraryReadPage({ params, searchParams }: PageProps) {
|
||||||
|
const [{ slug }, { c }] = await Promise.all([params, searchParams]);
|
||||||
|
const [data, settings] = await Promise.all([loadRead(slug, c), getPublicSettingsCached()]);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const urlStyle = settings.url_style;
|
||||||
|
const d = data.doc;
|
||||||
|
// 无章节条目:阅读页无内容可渲染,回详情页(介绍 + 附件)
|
||||||
|
if (!data.current) redirect(libraryDocPath(urlStyle, d.slug));
|
||||||
|
const current = data.current;
|
||||||
|
const { groups, nav } = data;
|
||||||
|
const subs = groups[nav.index]?.subs ?? [];
|
||||||
|
|
||||||
|
// 章内锚点统一 e-{sec}-{n}:sec 为书内章序号(与地址 ?sec= 一致),n 为章内 1 基顺序号,
|
||||||
|
// 按渲染顺序(章首 markdown 标题/条目卡标题 → 各小节及其标题/条目)由同一计数器分配,
|
||||||
|
// 目录、正文 markdown 标题、卡内条目标题共享同一份 id,可手写目录链接如 ?sec=1#e-1-1
|
||||||
|
// 条目卡模式先做与渲染一致的规范化(标题包壳后再提取),避免目录锚点错位
|
||||||
|
const normOf = (content: string) =>
|
||||||
|
d.entries_auto ? wrapImplicitEntries(content || "") : content || "";
|
||||||
|
const segsOf = (content: string) =>
|
||||||
|
splitContentSegments(normOf(content), { entriesAuto: d.entries_auto });
|
||||||
|
const secNo = nav.index + 1;
|
||||||
|
let anchorSeq = 0;
|
||||||
|
const nextAnchor = () => `e-${secNo}-${++anchorSeq}`;
|
||||||
|
const chToc = buildTocFromSegments(segsOf(current.content), nextAnchor);
|
||||||
|
// 小节:titleId 仍由页面 h3 直接挂载,其内容目录来自分段构建
|
||||||
|
const subAnchors = subs.map((sub) => ({
|
||||||
|
titleId: nextAnchor(),
|
||||||
|
toc: buildTocFromSegments(segsOf(sub.content), nextAnchor),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 右栏目录不重复章名(正文标题、左栏章节列表均已可见),章首条目/小节/小节内标题直接铺开,
|
||||||
|
// 省掉章名占位行,让有限宽度的每行能显示更长的标题
|
||||||
|
const chapterToc: TocItem[] = [...chToc.items];
|
||||||
|
subAnchors.forEach((a, si) => {
|
||||||
|
chapterToc.push({ level: 2, text: subs[si].title, id: a.titleId });
|
||||||
|
chapterToc.push(...a.toc.items);
|
||||||
|
});
|
||||||
|
const hasChapterToc = chapterToc.length > 0;
|
||||||
|
|
||||||
|
const detail = libraryDocPath(urlStyle, d.slug);
|
||||||
|
const chapterMinutes = estimateReadingMinutes(
|
||||||
|
[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">
|
||||||
|
<Link
|
||||||
|
href={detail}
|
||||||
|
className="inline-flex items-center gap-1.5 meta text-[12px] shrink-0 hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} aria-hidden /> 书籍详情
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={detail}
|
||||||
|
className="block rounded-xl p-1 -m-1 hover:bg-[var(--panel-2)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className="text-[13.5px] font-bold leading-snug line-clamp-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{d.title}
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[11.5px] mt-1">
|
||||||
|
{nav.total} 章 · 更新于 {formatRelative(d.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav aria-label="章节目录">
|
||||||
|
<p
|
||||||
|
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2 px-1"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
目录
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||||
|
{groups.map((g, i) => {
|
||||||
|
const active = g.chapter.id === current.id;
|
||||||
|
return (
|
||||||
|
<li key={g.chapter.id}>
|
||||||
|
<Link
|
||||||
|
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
|
||||||
|
className={`j13-toc-link${active ? " is-active" : ""}`}
|
||||||
|
aria-current={active ? "location" : undefined}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{g.chapter.title}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{/* 当前章的节:页内锚点(e-{sec}-{n});其他章的节:跳到该章 */}
|
||||||
|
{g.subs.map((sub, si) =>
|
||||||
|
active ? (
|
||||||
|
<a key={sub.id} href={`#${subAnchors[si].titleId}`} className="j13-toc-link is-h3">
|
||||||
|
{sub.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={sub.id}
|
||||||
|
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
|
||||||
|
className="j13-toc-link is-h3"
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{/* 移动端:吸顶工具栏 + 左右抽屉(目录 / 本章),lg 起隐藏;返回详情入口在左抽屉内 */}
|
||||||
|
<MobileRailDrawers
|
||||||
|
center={
|
||||||
|
<>
|
||||||
|
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
|
||||||
|
{current.title}
|
||||||
|
</p>
|
||||||
|
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
||||||
|
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
left={{ label: "目录", content: bookRail }}
|
||||||
|
right={
|
||||||
|
hasChapterToc
|
||||||
|
? {
|
||||||
|
label: "本章",
|
||||||
|
// 抽屉容器是无高度约束的滚动区,需包一层 h-full 限高 flex,
|
||||||
|
// PostToc 内列表才能成为滚动容器:否则列表不滚动(overscroll-behavior
|
||||||
|
// 阻断滚动链到抽屉容器)且跟随阅读位置的定位滚动落空
|
||||||
|
content: (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<PostToc items={chapterToc} title="本章目录" bare />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
hasChapterToc
|
||||||
|
? "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[280px_minmax(0,1fr)_280px] xl:gap-6"
|
||||||
|
: "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)] lg:gap-8"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 左侧书栏:详情入口 + 章节目录树(与移动端左抽屉共用 bookRail 内容) */}
|
||||||
|
<aside className="hidden lg:block min-w-0">
|
||||||
|
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] overflow-y-auto j13-rail-scroll panel p-4">
|
||||||
|
{bookRail}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
|
||||||
|
<div className="j13-doc-bleed min-w-0">
|
||||||
|
<article id="j13-book-article" className="panel p-6 sm:p-8">
|
||||||
|
<header>
|
||||||
|
<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"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{current.title}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
{current.content ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
<MarkdownBody
|
||||||
|
content={current.content}
|
||||||
|
components={headingComponentsFor(normOf(current.content), chToc.mdHeadIds)}
|
||||||
|
entriesAuto={d.entries_auto}
|
||||||
|
entriesTitleIds={chToc.entriesTitleIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{subs.map((sub, si) => (
|
||||||
|
<section key={sub.id} className="mt-8">
|
||||||
|
<h3
|
||||||
|
id={subAnchors[si].titleId}
|
||||||
|
className="scroll-mt-24 text-[17px] sm:text-[18px] font-bold"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</h3>
|
||||||
|
{sub.content ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<MarkdownBody
|
||||||
|
content={sub.content}
|
||||||
|
components={headingComponentsFor(normOf(sub.content), subAnchors[si].toc.mdHeadIds)}
|
||||||
|
entriesAuto={d.entries_auto}
|
||||||
|
entriesTitleIds={subAnchors[si].toc.entriesTitleIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<nav className="mt-5 grid gap-3 sm:grid-cols-2" aria-label="章节导航">
|
||||||
|
{nav.prev ? (
|
||||||
|
<Link
|
||||||
|
href={libraryDocReadPath(urlStyle, d.slug, nav.index)}
|
||||||
|
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||||
|
<ArrowLeft size={12} aria-hidden /> 上一章
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mt-1 block text-[13.5px] font-semibold truncate"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{nav.prev.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="panel p-4 opacity-50 select-none">
|
||||||
|
<span className="meta text-[11.5px]">上一章</span>
|
||||||
|
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||||
|
已是第一章
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nav.next ? (
|
||||||
|
<Link
|
||||||
|
href={libraryDocReadPath(urlStyle, d.slug, nav.index + 2)}
|
||||||
|
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors sm:text-right"
|
||||||
|
>
|
||||||
|
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||||
|
下一章 <ArrowRight size={12} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mt-1 block text-[13.5px] font-semibold truncate"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{nav.next.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="panel p-4 opacity-50 select-none sm:text-right">
|
||||||
|
<span className="meta text-[11.5px]">下一章</span>
|
||||||
|
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||||
|
已是最后一章
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右栏:章节内目录树(区域足够时宽屏常驻,滚动时可随时跳转)。
|
||||||
|
外层仅做 sticky 定位(flex-col + max-h 限高),滚动由 PostToc 内列表承担:
|
||||||
|
避免外层 overflow-y:auto 与内层列表的 overflow-y:auto 形成双层滚动容器,
|
||||||
|
滚轮路由到不可滚的内层导致滚轮失效(只能拖滚动条) */}
|
||||||
|
{hasChapterToc ? (
|
||||||
|
<aside className="hidden xl:block min-w-0">
|
||||||
|
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] flex flex-col">
|
||||||
|
<PostToc items={chapterToc} title="本章目录" bare />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/app/library/page.tsx
Normal file
79
frontend/app/library/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
|
import { libraryPath } from "@/lib/urlStyle";
|
||||||
|
import LibrarySearchGrid from "@/components/LibrarySearchGrid";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
return {
|
||||||
|
title: "书库",
|
||||||
|
description: settings.site_description,
|
||||||
|
...(await requestCanonical(libraryPath(settings.url_style))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LibraryPage() {
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
|
||||||
|
// 接口失败降级为空态,不让页面 500
|
||||||
|
let docs: LibraryDocListItem[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetchLibraryList();
|
||||||
|
docs = res.docs ?? [];
|
||||||
|
} catch {
|
||||||
|
/* 降级空态 */
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{/* Hero:书香气——serif 大标题、两侧晕染、底部水纹线 */}
|
||||||
|
<section
|
||||||
|
className="relative overflow-hidden px-6 py-12 sm:px-12 sm:py-16 text-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"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 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) 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="j13-library-title text-[32px] sm:text-[44px] leading-tight font-bold tracking-wide"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
书库
|
||||||
|
</h1>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<LibrarySearchGrid docs={docs} urlStyle={settings.url_style} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user