feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -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)

View 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})
}

View File

@@ -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 决定新帖/新评的初始状态:

391
backend/handler/library.go Normal file
View File

@@ -0,0 +1,391 @@
package handler
import (
"errors"
"net/http"
"net/url"
"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
}
d, err := h.LibrarySvc.Create(&in)
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})
}
// ---------- 管理端:章节 ----------
// 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)})
}

View File

@@ -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 {

View File

@@ -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})
}

View File

@@ -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)
}

View File

@@ -3,8 +3,11 @@ 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/service" "github.com/freefire/jiang13-bbs/service"
@@ -311,6 +314,75 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url}) c.JSON(http.StatusOK, gin.H{"url": url})
} }
// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目
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
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID)
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()

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -522,6 +522,52 @@ 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)
Published bool `gorm:"not null;index" json:"published"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// 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 +588,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"`

View File

@@ -101,6 +101,7 @@ 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)
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 +119,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 +159,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) {
@@ -180,6 +186,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 上传文件静态服务(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,6 +203,7 @@ 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)
@@ -211,6 +221,10 @@ 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)
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)
@@ -234,6 +248,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 +299,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)
@@ -367,6 +387,27 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
announceAPI.POST("/pages", h.AdminCreateSitePage) announceAPI.POST("/pages", h.AdminCreateSitePage)
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage) announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage) announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
announceAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
// 书库管理(管理员及以上,同公告/单页权限)
libraryAPI := staffAPI.Group("/library", authMW.RequirePerm(service.PermAnnouncements))
{
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
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)
}
// 广告位管理(管理员及以上) // 广告位管理(管理员及以上)
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
@@ -400,6 +441,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))

View File

@@ -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

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"

View 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
}

View 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))
}

View 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])
}
}

View File

@@ -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
} }

View 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)
}
}

504
backend/service/library.go Normal file
View File

@@ -0,0 +1,504 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"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", "Published", "SortOrder", "EntriesAuto",
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
type LibraryDocDetail struct {
model.LibraryDoc
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
}
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
type LibraryDocListItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
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.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.Description = in.Description
d.CoverURL = in.CoverURL
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)
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
}
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(&sections).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)
}
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})
}
return out
}
func (s *LibraryService) Create(in *LibraryInput) (*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}
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)
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"description": d.Description,
"cover_url": d.CoverURL,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}).Error; err != nil {
return nil, err
}
return &d, nil
}
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, CoverURL: d.CoverURL,
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
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
}
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"))
}

View 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)
}

View File

@@ -0,0 +1,506 @@
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.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) {}, ""},
}
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 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("草稿条目不应出现在公开详情")
}
}

View File

@@ -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)

View File

@@ -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
}

View 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)
}
}

View 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
}

View File

@@ -585,6 +585,35 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
return s.SaveBackground(f) return s.SaveBackground(f)
} }
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*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)
}
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)
}
// 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)

View File

@@ -100,13 +100,7 @@ export default async function AboutIndexPage() {
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"

View File

@@ -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>

View File

@@ -0,0 +1,493 @@
"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";
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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
"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";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
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]);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
onClose={onClose}
maxWidthClass="max-w-lg"
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="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,505 @@
"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="章节正文"
/>
</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>
);
}

View 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 { isAdminOrAbove } 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 || !isAdminOrAbove(me.user.role)) {
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>
);
}

View 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;
}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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,
@@ -75,6 +76,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 +205,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);
@@ -224,6 +241,18 @@ export default function BrandSeoPanel({
</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} /> 上传

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );

View File

@@ -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="站点公告"
@@ -144,7 +134,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>

View File

@@ -145,13 +145,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"

View File

@@ -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,23 @@ 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%);
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */ /* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble { .j13-tip-bubble {
position: fixed; position: fixed;
@@ -1967,6 +1987,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 +2421,38 @@ 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: nowrap;
gap: 2px;
padding: 2px 0 8px;
}
.j13-md-editor.is-narrow .j13-md-tool-groups {
flex-wrap: nowrap;
}
.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 +2913,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 +2938,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 +3169,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 +3264,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 +3883,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 +4531,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 +4712,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 +6057,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;
@@ -6679,7 +6819,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 +6831,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 +6889,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 +8076,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 +8094,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 +8130,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 +8139,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,
@@ -8068,10 +8219,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 +8231,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;
@@ -8619,8 +8794,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 +8812,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 +8828,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 +8869,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;

View File

@@ -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,

View File

@@ -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>
</>
); );
} }

View File

@@ -0,0 +1,261 @@
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 } 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";
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-4 py-3"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
style={{ color: "var(--ink)" }}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
>
<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}>
<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" />
</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}>
<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" />
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
<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>
</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}
</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>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>
<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>
</>
);
}

View File

@@ -0,0 +1,360 @@
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 { formatRelative } from "@/lib/format";
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")
);
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
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 className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<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>
);
}

View File

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

View File

@@ -0,0 +1,506 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Info, Link2, Loader2, X } from "lucide-react";
import {
apiMyFriendLinks,
apiUpdateMyFriendLink,
type FriendLink,
type FriendLinkStatus,
} from "@/lib/api";
import LinksClient from "./LinksClient";
interface Props {
links: FriendLink[];
isLoggedIn: boolean;
canManage: boolean;
/** 本站信息(申请弹窗展示,供对方添加回链) */
siteName: string;
siteUrl: string;
siteLogo: string;
}
/** 申请须知(站点政策,可按需修改) */
const APPLY_NOTICE = "纯导链、无回链、带有营销类产品的网站申请将不予通过";
/** 弹窗内单条申请的可编辑字段 */
interface MineEdit {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
function toEdit(l: FriendLink): MineEdit {
return {
name: l.name,
url: l.url,
reciprocal_url: l.reciprocal_url || "",
logo_url: l.logo_url || "",
};
}
/** 取 URL 域名用于搜索匹配(解析失败返回原文) */
function domainOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url.toLowerCase();
}
}
const STATUS_META: Record<FriendLinkStatus, { label: string; color: string }> = {
pending: { label: "待审核", color: "var(--accent)" },
approved: { label: "已通过", color: "#16a34a" },
rejected: { label: "已拒绝", color: "var(--danger)" },
};
export default function LinksBoard({
links,
isLoggedIn,
canManage,
siteName,
siteUrl,
siteLogo,
}: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [mineOpen, setMineOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [mine, setMine] = useState<FriendLink[] | null>(null);
const [mineLoading, setMineLoading] = useState(false);
const [edits, setEdits] = useState<Record<number, MineEdit>>({});
const [savingId, setSavingId] = useState<number | null>(null);
const [editMsg, setEditMsg] = useState<{ id: number; ok: boolean; text: string } | null>(null);
// 从其他入口带 #apply 锚点跳转过来时(如首页侧栏「申请」),自动打开申请弹窗
useEffect(() => {
if (window.location.hash === "#apply") setApplyOpen(true);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return links;
return links.filter(
(l) => l.name.toLowerCase().includes(q) || domainOf(l.url).includes(q)
);
}, [links, query]);
const openMine = async () => {
setMineOpen(true);
if (mine == null) {
setMineLoading(true);
try {
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const l of list) if (!next[l.id]) next[l.id] = toEdit(l);
return next;
});
} catch {
setMine([]);
} finally {
setMineLoading(false);
}
}
};
const setEdit = (id: number, k: keyof MineEdit, v: string) =>
setEdits((e) => ({ ...e, [id]: { ...e[id], [k]: v } }));
const saveMine = async (l: FriendLink) => {
const edit = edits[l.id];
if (!edit || savingId != null) return;
setSavingId(l.id);
setEditMsg(null);
try {
await apiUpdateMyFriendLink(l.id, {
name: edit.name.trim(),
url: edit.url.trim(),
reciprocal_url: edit.reciprocal_url.trim() || undefined,
logo_url: edit.logo_url.trim() || undefined,
});
// 重拉本人申请列表;已通过/已驳回的修改会回退待审,同时刷新 SSR 卡片墙
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const nl of list) next[nl.id] = toEdit(nl);
return next;
});
const updated = list.find((x) => x.id === l.id);
setEditMsg({
id: l.id,
ok: true,
text:
updated && updated.status === "pending" && l.status !== "pending"
? "已保存,链接重新进入审核队列"
: "保存成功",
});
router.refresh();
} catch (err) {
setEditMsg({
id: l.id,
ok: false,
text: err instanceof Error ? err.message : "保存失败,请重试",
});
} finally {
setSavingId(null);
}
};
return (
<>
{/* 搜索 */}
<div className="mx-auto max-w-md">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索友链(域名或名称)"
className="w-full rounded-full px-4 py-2 text-[14px] outline-none transition-colors"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</div>
{/* 操作按钮 */}
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setApplyOpen(true)}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--accent)", color: "#fff" }}
>
申请友链
</button>
<button
type="button"
onClick={openMine}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--ink)", color: "var(--panel)" }}
>
我的申请
</button>
</div>
{/* 我的申请弹窗:列出本人提交的申请,可直接修改并保存 */}
{mineOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setMineOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
我的申请
</h3>
<button
type="button"
onClick={() => setMineOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{!isLoggedIn ? (
<p className="meta py-6 text-center text-[13px]">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后查看申请记录
</p>
) : mineLoading ? (
<p className="meta flex items-center justify-center gap-2 py-6 text-[13px]">
<Loader2 size={14} className="animate-spin" /> 加载中…
</p>
) : mine == null || mine.length === 0 ? (
<p className="meta py-6 text-center text-[13px]">暂无申请记录</p>
) : (
<ul className="space-y-4">
{mine.map((l) => {
const edit = edits[l.id] ?? toEdit(l);
return (
<li
key={l.id}
className="rounded-xl p-4"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{/* 头部:LOGO + 名称 + 状态 */}
<div className="flex items-center gap-3">
{edit.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={edit.logo_url}
alt=""
width={40}
height={40}
className="rounded-lg object-contain shrink-0"
style={{ width: 40, height: 40, background: "var(--panel)" }}
/>
) : (
<span
className="flex shrink-0 items-center justify-center rounded-lg"
style={{ width: 40, height: 40, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={16} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{l.name}
</span>
<span
className="shrink-0 rounded-full px-2.5 py-1 text-[11.5px] font-medium"
style={{
color: STATUS_META[l.status]?.color ?? "var(--ink-3)",
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{STATUS_META[l.status]?.label ?? l.status}
</span>
</div>
{l.status === "rejected" && l.reject_reason ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
拒绝理由:{l.reject_reason}
</p>
) : null}
{/* 可编辑字段 */}
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block text-[12px]">名称</span>
<input
value={edit.name}
maxLength={64}
onChange={(e) => setEdit(l.id, "name", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
<label className="block">
<span className="meta block text-[12px]">链接</span>
<input
type="url"
value={edit.url}
maxLength={512}
onChange={(e) => setEdit(l.id, "url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
</div>
<label className="mt-3 block">
<span className="meta block text-[12px]">友链地址</span>
<input
type="url"
value={edit.reciprocal_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "reciprocal_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="添加本站链接的页面"
/>
</label>
<label className="mt-3 block">
<span className="meta block text-[12px]">图片</span>
<input
type="url"
value={edit.logo_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "logo_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="LOGO图片地址"
/>
</label>
{/* 底部:反馈 + 保存 */}
<div className="mt-3 flex items-center justify-end gap-3">
{editMsg?.id === l.id ? (
<span
className="mr-auto text-[12px]"
style={{ color: editMsg.ok ? "var(--accent)" : "var(--danger)" }}
>
{editMsg.text}
</span>
) : l.status !== "pending" ? (
<span className="meta mr-auto text-[11.5px]">修改后将重新进入审核</span>
) : null}
<button
type="button"
onClick={() => saveMine(l)}
disabled={savingId === l.id}
className="btn btn-primary btn-sm"
>
{savingId === l.id ? <Loader2 size={13} className="animate-spin" /> : null}
保存修改
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
) : null}
{/* 申请友链弹窗:本站信息 + 申请表单 */}
{applyOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setApplyOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
申请友链
</h3>
<button
type="button"
onClick={() => setApplyOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{/* 本站信息:供对方先添加我方链接 */}
<div
className="rounded-xl p-4"
style={{ background: "var(--accent-soft)", border: "1px solid var(--line)" }}
>
<h4 className="text-[14px] font-bold" style={{ color: "var(--ink)" }}>
本站信息(请添加本站链接后再申请)
</h4>
<dl className="mt-2.5 space-y-1.5 text-[13px]" style={{ color: "var(--ink)" }}>
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">名称:</dt>
<dd className="min-w-0 break-all">{siteName}</dd>
</div>
{siteUrl ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">地址:</dt>
<dd className="min-w-0 break-all">
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--accent)]"
>
{siteUrl}
</a>
</dd>
</div>
) : null}
{siteLogo ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">LOGO:</dt>
<dd className="min-w-0 break-all">
{siteLogo}
<span className="ml-1 text-[12px]" style={{ color: "var(--ink-2)" }}>
(固定地址,换图不失效)
</span>
</dd>
</div>
) : null}
</dl>
</div>
{/* 申请须知 */}
<p
className="mt-3 flex items-start gap-2 rounded-lg px-3.5 py-2.5 text-[12.5px] leading-relaxed"
style={{
background: "var(--panel-2)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink-2)",
}}
>
<Info size={15} style={{ color: "var(--accent)", flexShrink: 0, marginTop: 2 }} aria-hidden />
<span>{APPLY_NOTICE}</span>
</p>
<LinksClient
isLoggedIn={isLoggedIn}
canManage={canManage}
onClose={() => setApplyOpen(false)}
/>
</div>
</div>
</div>
) : null}
{/* 卡片墙 */}
{filtered.length === 0 ? (
<p className="meta mt-8 text-center text-[13.5px]">
{links.length === 0 ? "暂无友链,欢迎申请收录" : "没有匹配的友链"}
</p>
) : (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{filtered.map((l) => (
<a
key={l.id}
href={l.url}
target="_blank"
rel="noopener noreferrer"
title={l.description || l.name}
className="flex flex-col items-center justify-center gap-2.5 rounded-xl px-3 py-5 transition-all hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
minHeight: 116,
}}
>
{l.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
alt=""
loading="lazy"
width={48}
height={48}
className="rounded-lg object-contain"
style={{ width: 48, height: 48 }}
/>
) : (
<span
className="flex items-center justify-center rounded-lg"
style={{ width: 48, height: 48, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={22} />
</span>
)}
<span className="max-w-full truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{l.name}
</span>
</a>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
interface Props {
isLoggedIn: boolean;
canManage: boolean;
/** 关闭所在弹窗(取消按钮);弹窗外渲染时可不传 */
onClose?: () => void;
}
interface FormState {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
const EMPTY_FORM: FormState = {
name: "",
url: "",
reciprocal_url: "",
logo_url: "",
};
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)",
};
export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
if (!isLoggedIn) {
return (
<p className="meta mt-3">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后申请友情链接。
</p>
);
}
const set = (k: keyof FormState, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const onPickFile = async (file: File | undefined) => {
if (!file || uploading) return;
setUploading(true);
setMsg(null);
try {
const res = await apiUploadPostImage(file, file.name);
if (res.url) {
set("logo_url", res.url);
} else {
setMsg({ ok: false, text: res.error || "上传失败,请稍后重试" });
}
} catch {
setMsg({ ok: false, text: "上传失败,请稍后重试" });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
setMsg(null);
setSubmitting(true);
try {
await apiApplyFriendLink({
name: form.name.trim(),
url: form.url.trim(),
reciprocal_url: form.reciprocal_url.trim() || undefined,
logo_url: form.logo_url.trim() || undefined,
});
setMsg({ ok: true, text: "申请已提交,待管理员审核通过后展示。" });
setForm(EMPTY_FORM);
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "提交失败,请重试" });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={onSubmit} className="mt-5 space-y-4">
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站名称
</span>
<input
required
maxLength={64}
value={form.name}
onChange={(e) => set("name", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站名称"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站链接
</span>
<input
required
type="url"
maxLength={512}
value={form.url}
onChange={(e) => set("url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站地址(以http开头)"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
添加我方链接的页面地址
</span>
<input
type="url"
maxLength={512}
value={form.reciprocal_url}
onChange={(e) => set("reciprocal_url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="如:https://您的域名/link.htm(非首页时必填)"
/>
<span className="meta mt-1.5 block text-[12px]">
若友链不在首页,请填写此页面地址,方便管理员核对是否添加了我方链接
</span>
</label>
<div className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
填写或上传本站LOGO
</span>
<div className="mt-1.5 flex items-stretch gap-2">
{/* 预览 */}
<span
className="flex w-16 shrink-0 items-center justify-center rounded-lg text-[11px] meta overflow-hidden"
style={{ border: "1px dashed var(--line)", background: "var(--panel-2)" }}
aria-hidden
>
{form.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logo_url}
alt=""
className="h-full w-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
"预览"
)}
</span>
<input
type="url"
maxLength={512}
value={form.logo_url}
onChange={(e) => set("logo_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
placeholder="LOGO图片地址"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="btn btn-line shrink-0 whitespace-nowrap"
>
{uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
上传
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onPickFile(e.target.files?.[0])}
/>
</div>
</div>
{msg ? (
<p
className="text-[13px] rounded-lg px-3 py-2"
style={{
background: msg.ok ? "var(--accent-soft)" : "var(--panel-2)",
color: msg.ok ? "var(--accent)" : "var(--ink)",
border: "1px solid var(--line)",
}}
>
{msg.text}
</p>
) : null}
<div className="flex items-center gap-3 pt-1">
<p className="meta flex-1 text-[12px]">
提交后进入待审核状态,可在「我的申请」中查看进度或修改
</p>
<button
type="button"
className="btn btn-line"
onClick={() => {
if (onClose) {
onClose();
} else {
setForm(EMPTY_FORM);
setMsg(null);
}
}}
disabled={submitting}
>
取消
</button>
<button type="submit" disabled={submitting} className="btn btn-primary">
{submitting ? "提交中…" : "提交申请"}
</button>
</div>
{canManage ? (
<p className="meta text-[12px]">
管理员可前往{" "}
<Link href="/admin/friend-links" className="underline hover:text-[var(--accent)]">
友情链接管理
</Link>{" "}
审批
</p>
) : null}
</form>
);
}

View File

@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchFriendLinks, fetchSiteState, type FriendLink } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { brandAliasPath } from "@/lib/brand";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { requestCanonical } from "@/lib/canonicalMeta";
import { resolveSiteOrigin } from "@/lib/siteUrl";
import LinksBoard from "./LinksBoard";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "友情链接",
description: "本站友情链接收录与申请",
...(await requestCanonical("/links")),
};
}
export default async function FriendLinksPage() {
const cookie = authCookieHeader(await cookies());
let links: FriendLink[] = [];
let error = "";
try {
const res = await fetchFriendLinks(cookie || undefined);
links = res.links ?? [];
} catch (e) {
error = e instanceof Error ? e.message : "加载友情链接失败";
}
const [settings, siteState, me] = await Promise.all([
getPublicSettingsCached(),
fetchSiteState(),
getMeCached(cookie || undefined),
]);
const isLoggedIn = !!me.user;
const canManage = !!me.user && isAdminOrAbove(me.user.role);
// 站点 LOGO 用固定对外地址(换图不变链,对方收藏的地址永不失效),补全为绝对地址供申请人直接复制
const siteBase = siteState.site_url?.trim() || resolveSiteOrigin() || "";
const logoAlias = settings.logo_light_url
? brandAliasPath("logo_light")
: settings.favicon_url
? brandAliasPath("favicon")
: "";
const siteLogo = logoAlias && siteBase ? new URL(logoAlias, siteBase).toString() : "";
return (
<div className="mx-auto w-full max-w-[1200px] px-4 sm:px-6 py-8">
{/* 标题 + 申请须知 */}
<header className="text-center">
<h1
className="text-[26px] sm:text-[30px] font-extrabold tracking-tight inline-flex flex-col items-center"
style={{ color: "var(--ink)" }}
>
友情链接
<span
className="mt-1.5 h-[3px] w-10 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
</h1>
</header>
{/* 搜索 + 卡片墙 + 我的申请 + 申请弹窗 */}
<div className="mt-5">
{error ? (
<p
className="rounded-lg px-3 py-2 text-[13px] text-center"
style={{ background: "var(--panel-2)", color: "var(--ink)", border: "1px solid var(--line)" }}
>
{error}
</p>
) : (
<LinksBoard
links={links}
isLoggedIn={isLoggedIn}
canManage={canManage}
siteName={settings.site_name}
siteUrl={siteState.site_url}
siteLogo={siteLogo}
/>
)}
</div>
</div>
);
}

View File

@@ -106,24 +106,7 @@ export default async function SitePagePublic({ 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}
<SiteDocPageNav
pages={sidebarPages}
currentSlug={slug}
title="文档目录"
canManage={canManagePages}
/>
{toc.length === 0 && announcements.length > 0 ? (
<SiteDocAnnoNav items={announcements} />
) : null}
</>
}
>
<article className="panel p-6 sm:p-8"> <article className="panel p-6 sm:p-8">
<SiteDocMasthead <SiteDocMasthead
kicker="官方文件" kicker="官方文件"
@@ -145,7 +128,8 @@ export default async function SitePagePublic({ 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>

View File

@@ -7,6 +7,8 @@ import {
fetchOverview, fetchOverview,
fetchBoardSidebar, fetchBoardSidebar,
fetchSidebar, fetchSidebar,
fetchFriendLinks,
type FriendLink,
type OverviewResponse, type OverviewResponse,
type BoardSidebarResponse, type BoardSidebarResponse,
type SidebarPublicPayload, type SidebarPublicPayload,
@@ -109,13 +111,18 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch
? Promise.resolve(null) ? Promise.resolve(null)
: fetchSidebar(cookie).catch(() => null); : fetchSidebar(cookie).catch(() => null);
// 友情链接列表失败不阻塞页面
const friendLinksPromise: Promise<{ links: FriendLink[] } | null> = isSearch
? Promise.resolve(null)
: fetchFriendLinks(cookie).catch(() => null);
const [me, postsData, overview, boardSidebar, sidebarData] = await Promise.all([ const [me, postsData, overview, boardSidebar, sidebarData, friendLinksData] = await Promise.all([
mePromise, mePromise,
postsPromise, postsPromise,
overviewPromise, overviewPromise,
boardSidebarPromise, boardSidebarPromise,
sidebarPromise, sidebarPromise,
friendLinksPromise,
]); ]);
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] }; const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user; const user = me.user;
@@ -142,6 +149,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
user, user,
boards, boards,
checkin: (boardSidebar ?? overview)?.checkin ?? undefined, checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
// 友链列表独立于 overview:即使聚合接口失败也保留「申请」入口
friendLinks: friendLinksData?.links ?? [],
...(overview ...(overview
? { ? {
stats: overview.stats, stats: overview.stats,
@@ -171,11 +180,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
} }
: {}), : {}),
}; };
// 窄屏堆叠:右栏列表 + 左栏列表(板块目录已在顶部有横向板块条,跳过)
const mobileBlocks = [
...scopeLists.right,
...scopeLists.left.filter((r) => r.key !== "board_nav"),
];
const pageName = isSearch const pageName = isSearch
? `搜索“${keyword}”` ? `搜索“${keyword}”`
@@ -240,7 +244,31 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return ( return (
<div className="j13-fluid-home"> <div className="j13-fluid-home">
<JsonLd data={listingLd} /> <JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} /> {/* 移动端:顶部横向板块条 = 左右抽屉的触发行——
☰ 打开左抽屉(桌面左栏:板块目录 + 工作台),▦ 打开右抽屉(桌面右栏面板) */}
<WorkspaceNavMobile
boards={boards}
left={
scopeLists.left.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.left}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
right={
scopeLists.right.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
/>
<div className={`flex items-start gap-6 xl:gap-7 flex-1 ${activeBoard ? "" : "lg:mt-0"}`}> <div className={`flex items-start gap-6 xl:gap-7 flex-1 ${activeBoard ? "" : "lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */} {/* 左栏:按配置渲染(缺省 = 板块目录) */}
@@ -326,17 +354,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
</aside> </aside>
) : null} ) : null}
</div> </div>
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
{mobileBlocks.length > 0 && (
<div className="j13-flat-rail mt-3 lg:hidden">
<SidebarRenderer
blocks={mobileBlocks}
widgets={sidebar.widgets}
props={sidebarProps}
/>
</div>
)}
</div> </div>
); );
} }

View File

@@ -16,7 +16,6 @@ import {
fetchUserProfile, fetchUserProfile,
fetchSidebar, fetchSidebar,
postDeleteTypeLabel, postDeleteTypeLabel,
type UserProfile,
type PostListItem, type PostListItem,
type CommentsResponse, type CommentsResponse,
type SidebarPublicPayload, type SidebarPublicPayload,
@@ -39,6 +38,7 @@ import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments"; import PostAttachments from "@/components/PostAttachments";
import PostEditedMark from "@/components/PostEditedMark"; import PostEditedMark from "@/components/PostEditedMark";
import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels"; import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import AcceptedAnswerCard from "@/components/AcceptedAnswerCard"; import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles"; import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons"; import { IconComment, IconEye } from "@/components/Icons";
@@ -122,7 +122,7 @@ function RelatedPosts({
const titleId = `${uid}-title`; const titleId = `${uid}-title`;
return ( return (
<section <section
className="j13-module shrink-0 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none" className="j13-module shrink-0"
aria-labelledby={titleId} aria-labelledby={titleId}
> >
<p className="j13-module-title mb-1.5" id={titleId}> <p className="j13-module-title mb-1.5" id={titleId}>
@@ -163,87 +163,9 @@ function RelatedPosts({
); );
} }
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */ /* ============================================================
function AuthorCardStacked({ 右栏模块(lg 起展示;移动端收进右抽屉)
profile, ============================================================ */
allowComments,
boardNames,
boardColors,
urlStyle,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
urlStyle: UrlStyle;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
return (
<div className="panel p-4 sm:p-6 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<p
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
>
关于作者
</p>
<div className="flex items-start gap-4 flex-wrap">
<Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={userPath(urlStyle, u.id)}
className="font-semibold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<LevelBadge level={u.level} />
{(u.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{u.role !== "user" && (
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNames} boardColors={boardColors} />
)}
</div>
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<span>发帖 {post_count}</span>
{allowComments ? (
<>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
</>
) : null}
<span aria-hidden>·</span>
<span>积分 {points}</span>
{streak > 0 && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-0.5 font-medium" style={{ color: "var(--gold)" }}>
<Flame size={11} /> 连签 {streak} 天
</span>
</>
)}
</p>
{signature ? (
<p className="mt-2 text-[13px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{signature}
</p>
) : null}
</div>
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm shrink-0 self-center">
访问主页
</Link>
</div>
</div>
);
}
export default async function PostDetailPage({ params, searchParams }: PageProps) { export default async function PostDetailPage({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
@@ -374,6 +296,29 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]" ? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
: "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]"; : "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
// 移动端抽屉内容(lg 以下):左抽屉 = 左栏侧栏块,右抽屉 = 右栏侧栏块(含文章目录)+ 同板块热议;
// 桌面常驻 aside 与抽屉共用渲染函数,uid 区分同屏两份 DOM 的 aria-labelledby
const leftRailNode = hasLeftRail ? (
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
) : null;
const hasTocBlock = sidebarLists.right.some((r) => r.key === "post_toc");
const renderRightRail = (uid: string) => (
<>
{hasRightRail ? (
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
) : null}
{related.length > 0 ? (
<RelatedPosts
posts={related}
boardId={post.board_id}
uid={uid}
allowComments={allowComments}
urlStyle={urlStyle}
/>
) : null}
</>
);
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态 // 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published"; const isPublished = post.status === "published";
const postType = normalizePostType(post.post_type); const postType = normalizePostType(post.post_type);
@@ -487,6 +432,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return ( return (
<> <>
<JsonLd data={jsonLd} /> <JsonLd data={jsonLd} />
{/* 移动端:吸顶工具栏 + 左右抽屉(导航 / 目录),lg 起隐藏;两侧皆无内容时组件自身不渲染 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{post.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {readingMinutes} 分钟读完</p>
</>
}
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
right={
hasRightRail || related.length > 0
? {
label: hasTocBlock ? "目录" : "热议",
content: renderRightRail("related-drawer"),
}
: undefined
}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 {/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */} 侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div <div
@@ -500,7 +465,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden" className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航" aria-label="帖子导航"
> >
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} /> {leftRailNode}
</aside> </aside>
)} )}
@@ -697,18 +662,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div> </div>
</div> </div>
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */} {/* 窄屏作者卡已移除:作者信息随左栏配置收进移动端左抽屉 */}
{profile && (
<div className="lg:hidden mt-4">
<AuthorCardStacked
profile={profile}
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
urlStyle={urlStyle}
/>
</div>
)}
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */} {/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
{isPublished ? ( {isPublished ? (
@@ -756,17 +710,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</p> </p>
</section> </section>
) : null} ) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-4">
<RelatedPosts
posts={related}
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
urlStyle={urlStyle}
/>
</div>
</article> </article>
</div> </div>
@@ -776,8 +719,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden" className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助" aria-label="阅读辅助"
> >
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} /> {renderRightRail("related")}
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} urlStyle={urlStyle} />
</aside> </aside>
)} )}
</div> </div>

View File

@@ -4,9 +4,10 @@ import {
fetchPosts, fetchPosts,
fetchAnnouncementsPage, fetchAnnouncementsPage,
fetchPublishedSitePages, fetchPublishedSitePages,
fetchLibraryList,
} from "@/lib/api"; } from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl"; import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
import { announcementPath, postPath, pagePath, leaderboardPath } from "@/lib/urlStyle"; import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData"; import { getPublicSettingsCached } from "@/lib/serverData";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@@ -33,6 +34,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.5, priority: 0.5,
}, },
{
url: url(libraryPath(style)),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{ {
url: url("/about"), url: url("/about"),
lastModified: new Date(), lastModified: new Date(),
@@ -83,5 +90,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 */ /* 忽略 */
} }
try {
const { docs } = await fetchLibraryList();
for (const d of docs) {
entries.push({
url: url(libraryDocPath(style, d.slug)),
lastModified: new Date(d.updated_at || d.created_at),
changeFrequency: "weekly",
priority: 0.4,
});
}
} catch {
/* 忽略 */
}
return entries; return entries;
} }

View File

@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
const MIN_TOP_WIDTH = 600;
/**
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
*/
export default function AdaptiveCoverSlot({
src,
orientation,
children,
}: {
src: string;
orientation: "portrait" | "landscape";
children: ReactNode;
}) {
const ref = useRef<HTMLImageElement>(null);
const [match, setMatch] = useState(orientation === "portrait");
const measure = (img: HTMLImageElement) => {
if (!img.naturalWidth || !img.naturalHeight) return;
const landscape = img.naturalWidth > img.naturalHeight;
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
};
useEffect(() => {
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
if (ref.current?.complete) measure(ref.current);
}, []);
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={ref}
src={src}
alt=""
aria-hidden
className="hidden"
onLoad={(e) => measure(e.currentTarget)}
/>
{match ? children : null}
</>
);
}

View File

@@ -14,6 +14,7 @@ export default function ConfirmDialog({
cancelLabel = "取消", cancelLabel = "取消",
danger = false, danger = false,
busy = false, busy = false,
zIndexClass = "z-[1100]",
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
@@ -24,6 +25,8 @@ export default function ConfirmDialog({
cancelLabel?: string; cancelLabel?: string;
danger?: boolean; danger?: boolean;
busy?: boolean; busy?: boolean;
/** 叠层高度;默认 1100,被更高弹窗遮挡时上调(如编辑器主弹窗 1200 时传 1300) */
zIndexClass?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
@@ -33,7 +36,7 @@ export default function ConfirmDialog({
title={title} title={title}
onClose={onCancel} onClose={onCancel}
busy={busy} busy={busy}
zIndexClass="z-[1100]" zIndexClass={zIndexClass}
maxWidthClass="max-w-sm" maxWidthClass="max-w-sm"
panelClassName="p-5" panelClassName="p-5"
footer={ footer={

View File

@@ -13,11 +13,13 @@ import {
Bell, Bell,
Home, Home,
Trophy, Trophy,
BookOpen,
User as UserIcon, User as UserIcon,
ChevronDown, ChevronDown,
ShieldCheck, ShieldCheck,
MessageSquare, MessageSquare,
X, X,
ExternalLink,
} from "lucide-react"; } from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api"; import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies"; import { hasAuthCookieHint } from "@/lib/cookies";
@@ -34,8 +36,13 @@ import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip"; import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup"; import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider"; import { useAllowMessages } from "./MessagesPolicyProvider";
import { useUrlStyle } from "./SiteBrandProvider"; import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle"; import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
import {
HEADER_NAV_BUILTIN_LABELS,
isHeaderNavExternal,
type HeaderNavBuiltinKey,
} from "@/lib/headerNav";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand"; import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致); // 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
@@ -72,6 +79,33 @@ export default function Header({
const pathname = usePathname(); const pathname = usePathname();
const urlStyle = useUrlStyle(); const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle); const lbHref = leaderboardPath(urlStyle);
const libHref = libraryPath(urlStyle);
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
const headerNav = useSiteBrand().header_nav;
const builtinNavIcon: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={15} />,
leaderboard: <Trophy size={15} />,
library: <BookOpen size={15} />,
};
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
home: "/",
leaderboard: lbHref,
library: libHref,
};
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
if (!pathname) return false;
if (key === "home") return pathname === "/";
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
return pathname.startsWith("/library");
};
// 自定义站内菜单高亮:精确或子路径;"/" 仅精确,避免高亮全站
const isCustomNavActive = (url: string): boolean => {
if (!pathname || !url.startsWith("/") || url.startsWith("//")) return false;
if (url === "/") return pathname === "/";
return pathname === url || pathname.startsWith(`${url}/`);
};
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const hideComposeNav = const hideComposeNav =
pathname === "/compose" || pathname === "/compose" ||
@@ -244,18 +278,35 @@ export default function Header({
logoDark={logoDark} logoDark={logoDark}
/> />
</Link> </Link>
{headerNav.length > 0 && (
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}> <nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}> {headerNav.map((item, idx) =>
首页 item.type === "builtin" ? (
</NavPill>
<NavPill <NavPill
href={lbHref} key={`builtin-${item.key}`}
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))} href={builtinNavHref[item.key]}
icon={<Trophy size={15} />} active={isBuiltinNavActive(item.key)}
icon={builtinNavIcon[item.key]}
> >
排行榜 {HEADER_NAV_BUILTIN_LABELS[item.key]}
</NavPill> </NavPill>
) : (
<NavPill
key={`custom-${idx}`}
href={item.url}
external={isHeaderNavExternal(item.url)}
newTab={item.new_tab}
active={!isHeaderNavExternal(item.url) && isCustomNavActive(item.url)}
>
{item.label}
{isHeaderNavExternal(item.url) && item.new_tab ? (
<ExternalLink size={12} aria-hidden style={{ color: "var(--ink-3)" }} />
) : null}
</NavPill>
),
)}
</nav> </nav>
)}
</div> </div>
{/* 中部弹性留白,把搜索与操作区推到右侧 */} {/* 中部弹性留白,把搜索与操作区推到右侧 */}
@@ -415,23 +466,36 @@ function NavPill({
href, href,
active, active,
icon, icon,
external,
newTab,
children, children,
}: { }: {
href: string; href: string;
active?: boolean; active?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
external?: boolean;
newTab?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( const cls =
<Link "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors";
href={href} const style =
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
style={
active active
? { background: "transparent", color: "var(--accent)" } ? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" } : { color: "var(--ink)" };
const target = newTab ? "_blank" : undefined;
const rel = newTab ? "noopener noreferrer" : undefined;
// 自定义外链渲染原生 <a>;站内链接(含勾选新标签的)走 Next Link 保留软导航
if (external) {
return (
<a href={href} target={target} rel={rel} className={cls} style={style}>
{icon}
{children}
</a>
);
} }
> return (
<Link href={href} target={target} rel={rel} className={cls} style={style}>
{icon} {icon}
{children} {children}
</Link> </Link>

View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import ImageLightbox from "@/components/markdown/ImageLightbox";
/**
* 静态图片图箱入口(区别于 markdown 管线的 MdImage):
* 封面等非 markdown 图片点击进全屏图箱(单图,无多图切换)。
* 在 Link 内使用时 preventDefault + stopPropagation 阻断外层导航——
* 点图片本身是看图,点卡片其余区域仍走原链接。
*/
export default function LightboxImage({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className={`cursor-zoom-in${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
draggable={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
/>
{open && (
<ImageLightbox
items={[{ src, alt }]}
index={0}
onClose={() => setOpen(false)}
onIndexChange={() => {}}
/>
)}
</>
);
}

View File

@@ -6,6 +6,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks"; import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock"; import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline"; import MdTimeline from "@/components/markdown/MdTimeline";
import MdEntries from "@/components/markdown/MdEntries";
type MarkdownBodyProps = { type MarkdownBodyProps = {
content: string; content: string;
@@ -16,10 +17,14 @@ type MarkdownBodyProps = {
/** 登录跳转(公告等无 postId 时) */ /** 登录跳转(公告等无 postId 时) */
loginHref?: string; loginHref?: string;
loggedIn?: boolean; loggedIn?: boolean;
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
entriesAuto?: boolean;
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
entriesTitleIds?: (string[] | undefined)[];
}; };
/** /**
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline]。 * 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline] / [entries]。
*/ */
export default async function MarkdownBody({ export default async function MarkdownBody({
content, content,
@@ -28,8 +33,12 @@ export default async function MarkdownBody({
postId, postId,
loginHref, loginHref,
loggedIn = false, loggedIn = false,
entriesAuto = false,
entriesTitleIds,
}: MarkdownBodyProps) { }: MarkdownBodyProps) {
const segs = splitContentSegments(expandDyEmojiShortcodes(content || "")); const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
entriesAuto,
});
const nodes = []; const nodes = [];
for (let i = 0; i < segs.length; i++) { for (let i = 0; i < segs.length; i++) {
const seg = segs[i]; const seg = segs[i];
@@ -60,6 +69,12 @@ export default async function MarkdownBody({
); );
continue; continue;
} }
if (seg.type === "entries") {
nodes.push(
<MdEntries key={`entries-${i}`} items={seg.items} titleIds={entriesTitleIds?.[i]} />
);
continue;
}
const kind = seg.kind as HideKind; const kind = seg.kind as HideKind;
if (seg.locked) { if (seg.locked) {
nodes.push( nodes.push(

View File

@@ -8,6 +8,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks"; import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock"; import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline"; import MdTimeline from "@/components/markdown/MdTimeline";
import { MdEntriesView } from "@/components/markdown/MdEntries";
type MarkdownBodyClientProps = { type MarkdownBodyClientProps = {
content: string; content: string;
@@ -16,10 +17,14 @@ type MarkdownBodyClientProps = {
postId?: number; postId?: number;
loginHref?: string; loginHref?: string;
loggedIn?: boolean; loggedIn?: boolean;
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
entriesAuto?: boolean;
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
entriesTitleIds?: (string[] | undefined)[];
}; };
/** /**
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线预览。 * 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线 / 条目卡预览。
*/ */
export default function MarkdownBodyClient({ export default function MarkdownBodyClient({
content, content,
@@ -28,8 +33,12 @@ export default function MarkdownBodyClient({
postId, postId,
loginHref, loginHref,
loggedIn = false, loggedIn = false,
entriesAuto = false,
entriesTitleIds,
}: MarkdownBodyClientProps) { }: MarkdownBodyClientProps) {
const segs = splitContentSegments(expandDyEmojiShortcodes(content || "")); const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
entriesAuto,
});
const mdComponents = mergeMarkdownComponents(components); const mdComponents = mergeMarkdownComponents(components);
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位 // 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
const nestedMd = mergeMarkdownComponents(); const nestedMd = mergeMarkdownComponents();
@@ -60,6 +69,20 @@ export default function MarkdownBodyClient({
/> />
); );
} }
if (seg.type === "entries") {
return (
<MdEntriesView
key={`entries-${i}`}
items={seg.items}
titleIds={entriesTitleIds?.[i]}
renderMd={(t) => (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{expandDyEmojiShortcodes(t)}
</ReactMarkdown>
)}
/>
);
}
const kind = seg.kind as HideKind; const kind = seg.kind as HideKind;
if (seg.locked) { if (seg.locked) {
return ( return (

View File

@@ -33,12 +33,14 @@ import {
History, History,
Ellipsis, Ellipsis,
Smile, Smile,
LayoutList,
} from "lucide-react"; } from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api"; import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal"; import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor"; import TimelineEditor from "@/components/TimelineEditor";
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
import EmojiPicker from "@/components/EmojiPicker"; import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
@@ -79,6 +81,8 @@ type MarkdownEditorProps = {
/** 提交失败等原因,显示在编辑器内 */ /** 提交失败等原因,显示在编辑器内 */
error?: string; error?: string;
onUploadingChange?: (uploading: boolean) => void; onUploadingChange?: (uploading: boolean) => void;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
}; };
type EditorMode = "write" | "preview"; type EditorMode = "write" | "preview";
@@ -159,6 +163,7 @@ export default function MarkdownEditor({
maxChars, maxChars,
error, error,
onUploadingChange, onUploadingChange,
entriesAuto = false,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle(); const urlStyle = useUrlStyle();
@@ -212,10 +217,9 @@ export default function MarkdownEditor({
}, [uploading]); }, [uploading]);
useEffect(() => { useEffect(() => {
if (!isComment) return;
const el = toolbarRef.current; const el = toolbarRef.current;
if (!el) return; if (!el) return;
const apply = () => setToolNarrow(el.clientWidth < 520); const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640));
apply(); apply();
const ro = new ResizeObserver(apply); const ro = new ResizeObserver(apply);
ro.observe(el); ro.observe(el);
@@ -742,337 +746,14 @@ export default function MarkdownEditor({
l.toLowerCase().includes(codeQuery.trim().toLowerCase()) l.toLowerCase().includes(codeQuery.trim().toLowerCase())
); );
const ToolBtn = ({ // 代码块语言弹层:常规时挂在行内按钮下,窄屏收进「更多」后挂在其锚点下(居中防溢出)
label, const codePopover =
icon, popover === "code" ? (
onClick, <div
active, className={`j13-md-popover j13-md-popover-wide${toolNarrow ? " j13-md-popover-center" : ""}`}
disabled: dis, role="dialog"
}: { aria-label="选择语言"
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
disabled?: boolean;
}) => (
<button
type="button"
className={`j13-md-tool${active ? " is-active" : ""}`}
data-tip={label}
aria-label={label}
disabled={disabled || dis}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={onClick}
> >
{icon}
</button>
);
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
<div className="j13-md-toolbar" ref={toolbarRef}>
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn
label="粗体 Ctrl+B"
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
{!isComment && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)}
{!isComment && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="标题"
icon={
<span className="inline-flex items-center gap-0.5">
<Heading2 size={15} />
<ChevronDown size={11} />
</span>
}
active={popover === "heading"}
onClick={() => openPopover("heading")}
/>
{popover === "heading" && (
<div className="j13-md-popover" role="menu">
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "## ", after: "", placeholder: "二级标题" });
setPopover(null);
}}
>
<Heading2 size={14} /> 二级标题
</button>
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "### ", after: "", placeholder: "三级标题" });
setPopover(null);
}}
>
<Heading3 size={14} /> 三级标题
</button>
</div>
)}
</div>
)}
{(!isComment || !toolNarrow) && <Sep />}
{(!isComment || !toolNarrow) && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" })
}
/>
)}
{(!isComment || !toolNarrow) && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" })
}
/>
)}
{!isComment && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
onClick={() => insertAtCursor("---")}
/>
)}
<Sep />
<ToolBtn
label="行内代码"
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{(!isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
icon={<Code2 size={15} />}
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button
key={lang}
type="button"
onClick={() => insertCodeBlock(lang)}
>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
)}
</div>
)}
<Sep />
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="链接 Ctrl+K"
icon={<LinkIcon size={15} />}
active={popover === "link"}
onClick={() => openPopover("link")}
/>
{popover === "link" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入链接">
<label className="j13-md-field">
<span>文字</span>
<input
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
className="j13-md-popover-input"
/>
</label>
<label className="j13-md-field">
<span>URL</span>
<input
autoFocus
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="j13-md-popover-input"
placeholder="https://… 或 /post/123"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
</label>
<button
type="button"
className="j13-md-adv-toggle"
aria-expanded={linkAdvanced}
onClick={() => setLinkAdvanced((v) => !v)}
>
高级选项
<ChevronDown
size={12}
className={`transition-transform${linkAdvanced ? " rotate-180" : ""}`}
/>
</button>
{linkAdvanced && (
<div className="j13-md-link-search">
<input
className="j13-md-popover-input"
placeholder="搜索帖子…"
value={postQuery}
onChange={(e) => setPostQuery(e.target.value)}
/>
<div className="j13-md-popover-list">
{postLoading ? (
<p className="meta px-2 py-3 text-center">搜索中…</p>
) : postResults.length === 0 ? (
<p className="meta px-2 py-3 text-center">
{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}
</p>
) : (
postResults.map((p) => (
<button
key={p.id}
type="button"
className="j13-md-post-row"
data-tip={p.title}
onClick={() => pickPostLink(p)}
>
<span className="truncate flex-1">{p.title}</span>
<span className="j13-md-post-tag">帖子</span>
</button>
))
)}
</div>
</div>
)}
<div className="j13-md-popover-footer">
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={applyLink}>
插入
</button>
</div>
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
icon={<Smile size={15} />}
active={popover === "emoji"}
onClick={() => openPopover("emoji")}
/>
{popover === "emoji" && (
<div className="j13-md-popover j13-md-emoji-popover" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
icon={<ImageIcon size={15} />}
active={imageModalOpen}
disabled={uploading}
onClick={openImageModal}
/>
</div>
{isComment && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && popover === "code") ? " is-active" : ""}`}
data-tip="更多工具"
aria-label="更多工具"
aria-haspopup="menu"
aria-expanded={popover === "more"}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => openPopover("more")}
>
<Ellipsis size={15} />
</button>
{popover === "more" && (
<div className="j13-md-popover" role="menu" aria-label="更多工具">
{toolNarrow && (
<>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }); setPopover(null); }}>引用</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }); setPopover(null); }}>无序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("code")}>代码块</button>
</>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input <input
autoFocus autoFocus
className="j13-md-popover-input" className="j13-md-popover-input"
@@ -1097,27 +778,16 @@ export default function MarkdownEditor({
)} )}
</div> </div>
</div> </div>
)} ) : null;
</div>
)} // 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下
{!isComment && <Sep />} const hidePopover =
{!isComment && ( allowHide && popover === "hide" ? (
<ToolBtn <div
label="时间线" className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
icon={<History size={15} />} role="dialog"
onClick={openTimelineEditor} aria-label="插入隐藏内容"
/> >
)}
{allowHide ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? ( {hideStep === "menu" ? (
<> <>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}> <button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
@@ -1248,7 +918,353 @@ export default function MarkdownEditor({
</> </>
)} )}
</div> </div>
) : null;
const ToolBtn = ({
label,
icon,
onClick,
active,
disabled: dis,
}: {
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
disabled?: boolean;
}) => (
<button
type="button"
className={`j13-md-tool${active ? " is-active" : ""}`}
data-tip={label}
aria-label={label}
disabled={disabled || dis}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={onClick}
>
{icon}
</button>
);
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div
className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${
isComment ? " is-comment" : ""
}${toolNarrow ? " is-narrow" : ""}`}
>
<div className="j13-md-toolbar" ref={toolbarRef}>
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn
label="粗体 Ctrl+B"
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
{!isComment && !toolNarrow && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)} )}
{!isComment && !toolNarrow && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && !toolNarrow && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="标题"
icon={
<span className="inline-flex items-center gap-0.5">
<Heading2 size={15} />
<ChevronDown size={11} />
</span>
}
active={popover === "heading"}
onClick={() => openPopover("heading")}
/>
{popover === "heading" && (
<div className="j13-md-popover" role="menu">
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "## ", after: "", placeholder: "二级标题" });
setPopover(null);
}}
>
<Heading2 size={14} /> 二级标题
</button>
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "### ", after: "", placeholder: "三级标题" });
setPopover(null);
}}
>
<Heading3 size={14} /> 三级标题
</button>
</div>
)}
</div>
)}
{!toolNarrow && <Sep />}
{!toolNarrow && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" })
}
/>
)}
{!toolNarrow && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" })
}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && !toolNarrow && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && !toolNarrow && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
onClick={() => insertAtCursor("---")}
/>
)}
<Sep />
<ToolBtn
label="行内代码"
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
icon={<Code2 size={15} />}
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{codePopover}
</div>
)}
<Sep />
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="链接 Ctrl+K"
icon={<LinkIcon size={15} />}
active={popover === "link"}
onClick={() => openPopover("link")}
/>
{popover === "link" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入链接">
<label className="j13-md-field">
<span>文字</span>
<input
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
className="j13-md-popover-input"
/>
</label>
<label className="j13-md-field">
<span>URL</span>
<input
autoFocus
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="j13-md-popover-input"
placeholder="https://… 或 /post/123"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
</label>
<button
type="button"
className="j13-md-adv-toggle"
aria-expanded={linkAdvanced}
onClick={() => setLinkAdvanced((v) => !v)}
>
高级选项
<ChevronDown
size={12}
className={`transition-transform${linkAdvanced ? " rotate-180" : ""}`}
/>
</button>
{linkAdvanced && (
<div className="j13-md-link-search">
<input
className="j13-md-popover-input"
placeholder="搜索帖子…"
value={postQuery}
onChange={(e) => setPostQuery(e.target.value)}
/>
<div className="j13-md-popover-list">
{postLoading ? (
<p className="meta px-2 py-3 text-center">搜索中…</p>
) : postResults.length === 0 ? (
<p className="meta px-2 py-3 text-center">
{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}
</p>
) : (
postResults.map((p) => (
<button
key={p.id}
type="button"
className="j13-md-post-row"
data-tip={p.title}
onClick={() => pickPostLink(p)}
>
<span className="truncate flex-1">{p.title}</span>
<span className="j13-md-post-tag">帖子</span>
</button>
))
)}
</div>
</div>
)}
<div className="j13-md-popover-footer">
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={applyLink}>
插入
</button>
</div>
</div>
)}
</div>
{(isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
icon={<Smile size={15} />}
active={popover === "emoji"}
onClick={() => openPopover("emoji")}
/>
{popover === "emoji" && (
<div className="j13-md-popover j13-md-emoji-popover" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
</div>
)}
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
icon={<ImageIcon size={15} />}
active={imageModalOpen}
disabled={uploading}
onClick={openImageModal}
/>
</div>
{(isComment || toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && (popover === "code" || popover === "emoji" || popover === "hide")) ? " is-active" : ""}`}
data-tip="更多工具"
aria-label="更多工具"
aria-haspopup="menu"
aria-expanded={popover === "more"}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => openPopover("more")}
>
<Ellipsis size={15} />
</button>
{popover === "more" && (
<div className="j13-md-popover" role="menu" aria-label="更多工具">
{toolNarrow && (
<>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }); setPopover(null); }}>引用</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }); setPopover(null); }}>无序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("code")}>代码块</button>
</>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
{!isComment && allowHide && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("hide")}>隐藏内容</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && codePopover}
{toolNarrow && popover === "emoji" && !isComment && (
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
{toolNarrow && hidePopover}
</div>
)}
{!isComment && !toolNarrow && <Sep />}
{!isComment && !toolNarrow && (
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="条目卡"
icon={<LayoutList size={15} />}
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
/>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{hidePopover}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1281,7 +1297,7 @@ export default function MarkdownEditor({
<Eye size={12} /> 预览 <Eye size={12} /> 预览
</button> </button>
</div> </div>
{(!isComment || !toolNarrow) && ( {!toolNarrow && (
<button <button
type="button" type="button"
className="j13-md-tool" className="j13-md-tool"
@@ -1354,7 +1370,7 @@ export default function MarkdownEditor({
预览 预览
</div> </div>
{deferredValue.trim() ? ( {deferredValue.trim() ? (
<MarkdownBodyClient content={deferredValue} /> <MarkdownBodyClient content={deferredValue} entriesAuto={entriesAuto} />
) : ( ) : (
<p className="meta text-center py-16">暂无内容可预览</p> <p className="meta text-center py-16">暂无内容可预览</p>
)} )}

View File

@@ -0,0 +1,206 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Hash, ListTree, X } from "lucide-react";
type DrawerSide = "left" | "right";
export interface RailDrawerSide {
/** 触发按钮与抽屉标题 */
label: string;
/** 抽屉内容(服务端渲染节点) */
content: ReactNode;
/** 按钮图标(缺省:左 ListTree / 右 Hash) */
icon?: ReactNode;
}
/**
* 抽屉开合副作用:ESC 关闭 + 锁定背景滚动。
* onClose 经 ref 引用,避免回调身份变化导致反复装卸监听与滚动锁泄漏。
*/
export function useDrawerChrome(open: boolean, onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open]);
}
/**
* 单侧抽屉面板(lg 以下生效):遮罩 + 侧滑面板。
* - 关闭时保留 DOM 以获得滑出过渡,invisible 延迟切换保证动画完整;
* - 抽屉内点击链接(锚点 / 跳转)后自动收起。
*/
export function RailDrawerPanel({
side,
title,
open,
onClose,
children,
}: {
side: DrawerSide;
title: string;
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
return (
<div
className="lg:hidden"
style={{ visibility: open ? "visible" : "hidden", transition: "visibility .2s" }}
aria-hidden={!open}
>
<div
className={`fixed inset-0 z-[1090] bg-black/40 transition-opacity duration-200 ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={onClose}
/>
<aside
role="dialog"
aria-modal="true"
aria-label={title}
className={`fixed bottom-0 z-[1100] flex w-[min(82vw,320px)] flex-col ${
side === "left" ? "left-0" : "right-0"
} transition-transform duration-200 ease-out ${
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full"
}`}
style={{
top: 0,
background: "var(--panel)",
boxShadow: "0 0 40px rgba(0,0,0,0.18)",
borderLeft: side === "right" ? "1px solid var(--line)" : undefined,
borderRight: side === "left" ? "1px solid var(--line)" : undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) onClose();
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="text-[13px] font-bold" style={{ color: "var(--ink)" }}>
{title}
</span>
<button
type="button"
className="ml-auto rounded-lg p-1.5 transition-colors hover:bg-[var(--panel-2)]"
onClick={onClose}
aria-label="关闭"
>
<X size={16} style={{ color: "var(--ink-2)" }} aria-hidden />
</button>
</div>
<div className="j13-rail-scroll flex flex-1 flex-col gap-3 overflow-y-auto p-3">
{children}
</div>
</aside>
</div>
);
}
/**
* 通用移动端侧栏抽屉(lg 以下生效,桌面仍用常驻侧栏):
* - 吸顶工具栏贴在全局 Header 下方,左右各一个抽屉触发按钮,中间可放页面上文;
* - 左右两侧内容均可选,两侧皆空时整体不渲染;
* - 渐进增强:ESC 关闭、背景滚动锁定、点链接自动收起。
*/
export default function MobileRailDrawers({
left,
right,
center,
}: {
/** 左抽屉(对应桌面左栏) */
left?: RailDrawerSide;
/** 右抽屉(对应桌面右栏) */
right?: RailDrawerSide;
/** 工具栏中间内容(如当前文章标题),可省略 */
center?: ReactNode;
}) {
const [open, setOpen] = useState<DrawerSide | null>(null);
useDrawerChrome(open !== null, () => setOpen(null));
if (!left && !right) return null;
const btn =
"relative inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium shrink-0 transition-colors hover:bg-[var(--panel-2)] active:scale-[0.97] " +
// 触控热区上下外扩 8px(视觉不变),移动端更易点中
"before:absolute before:inset-x-0 before:-inset-y-2 before:content-['']";
const sides: { side: DrawerSide; cfg?: RailDrawerSide; fallbackIcon: ReactNode }[] = [
{ side: "left", cfg: left, fallbackIcon: <ListTree size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
{ side: "right", cfg: right, fallbackIcon: <Hash size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
];
return (
<>
{/* 吸顶工具栏:与书籍阅读页一致的三段布局——左钮贴左、中间文字居中、右钮贴右;
有中间文字而缺某侧按钮时以等宽占位保持文字不偏心 */}
<header
className="lg:hidden sticky z-30 mb-3 flex items-center gap-1 rounded-xl px-1 py-1"
style={{
top: "var(--header-h)",
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
border: "1px solid var(--line)",
}}
>
{left ? (
<button
type="button"
className={btn}
onClick={() => setOpen("left")}
aria-haspopup="dialog"
>
{left.icon ?? sides[0].fallbackIcon}
{left.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
{center ? <div className="min-w-0 flex-1 px-1 text-center">{center}</div> : null}
{right ? (
<button
type="button"
className={`${btn} ml-auto`}
onClick={() => setOpen("right")}
aria-haspopup="dialog"
>
{right.icon ?? sides[1].fallbackIcon}
{right.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
</header>
{sides.map(({ side, cfg }) =>
cfg ? (
<RailDrawerPanel
key={side}
side={side}
title={cfg.label}
open={open === side}
onClose={() => setOpen(null)}
>
{cfg.content}
</RailDrawerPanel>
) : null
)}
</>
);
}

View File

@@ -2,26 +2,28 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { import {
Home, Home,
Trophy, BookOpen,
PenSquare, PenSquare,
MessageSquare, MessageSquare,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api"; import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider"; import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider"; import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle"; import { userPath, libraryPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime"; import type { RtChatUnreadData } from "@/lib/realtime";
import type { MobileTabKey } from "@/lib/mobileTabs";
/** /**
* 移动端全局底部公共菜单(lg 以下显示): * 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。 * 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。 * SiteChrome 只在启用 tab 的根路径渲染本组件,
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
*/ */
export default function MobileTabBar({ export default function MobileTabBar({
user, user,
@@ -32,8 +34,8 @@ export default function MobileTabBar({
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle(); const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const lbHref = leaderboardPath(urlStyle); const libHref = libraryPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread); const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标 // 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -80,8 +82,55 @@ export default function MobileTabBar({
const isActive = (match: (p: string) => boolean) => const isActive = (match: (p: string) => boolean) =>
!!pathname && match(pathname); !!pathname && match(pathname);
// 配置已由 SiteBrandProvider normalize(白名单内子集,空回落全量);数组顺序即展示顺序
const tabNodes: Record<MobileTabKey, React.ReactNode> = {
home: (
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
),
library: (
<TabLink
href={libHref}
icon={<BookOpen size={20} />}
label="书库"
active={isActive((p) => p === libHref)}
/>
),
compose: (
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
),
messages: showMessages ? (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p === "/chat")}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
) : null,
me: (
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={!!user && isActive((p) => p === userPath(urlStyle, user.id))}
/>
),
};
return ( return (
<nav <nav
data-j13-tabbar
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t" className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
aria-label="底部导航" aria-label="底部导航"
style={{ style={{
@@ -93,49 +142,9 @@ export default function MobileTabBar({
}} }}
> >
<div className="flex items-stretch"> <div className="flex items-stretch">
<TabLink {mobileTabs.map((key) =>
href="/" tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
<TabLink
href={lbHref}
icon={<Trophy size={20} />}
label="排行榜"
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
/>
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
{showMessages && (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p.startsWith("/chat"))}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
)} )}
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user &&
isActive((p) => {
const parsed = parseEntityPath(p);
return (
(parsed?.kind === "user" && parsed.id === user.id) ||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
);
})
}
/>
</div> </div>
</nav> </nav>
); );

View File

@@ -213,7 +213,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
aria-hidden="true" aria-hidden="true"
/> />
<div <div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col className="j13-notif-sheet absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!" max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
style={{ style={{
background: "var(--panel)", background: "var(--panel)",

View File

@@ -50,7 +50,7 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
"pager-btn min-w-[38px] h-9 px-1 flex items-center justify-center font-semibold text-[13px] rounded-full border transition-colors duration-150"; "pager-btn min-w-[38px] h-9 px-1 flex items-center justify-center font-semibold text-[13px] rounded-full border transition-colors duration-150";
return ( return (
<nav className="flex items-center justify-center gap-1.5 mt-10 flex-wrap" aria-label="分页"> <nav className="flex items-center justify-center gap-1.5 mt-6 flex-wrap" aria-label="分页">
{page > 1 && ( {page > 1 && (
<Link <Link
href={buildHref(page - 1)} href={buildHref(page - 1)}

View File

@@ -16,7 +16,15 @@ export interface TocItem {
* - IntersectionObserver 高亮当前小节; * - IntersectionObserver 高亮当前小节;
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。 * - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
*/ */
export default function PostToc({ items }: { items: TocItem[] }) { export default function PostToc({
items,
title = "目录",
bare = false,
}: {
items: TocItem[];
title?: string;
bare?: boolean;
}) {
const [active, setActive] = useState<string>(items[0]?.id ?? ""); const [active, setActive] = useState<string>(items[0]?.id ?? "");
const navRef = useRef<HTMLElement>(null); const navRef = useRef<HTMLElement>(null);
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动 // 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
@@ -38,15 +46,25 @@ export default function PostToc({ items }: { items: TocItem[] }) {
return () => obs.disconnect(); return () => obs.disconnect();
}, [items]); }, [items]);
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动) // active 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。
// 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚),
// scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录,
// 表现为"进页自动下滚 / 无法向上滑动"。
useEffect(() => { useEffect(() => {
if (skipFollow.current) { if (skipFollow.current) {
skipFollow.current = false; skipFollow.current = false;
return; return;
} }
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`); const list = navRef.current?.querySelector<HTMLElement>(".j13-rail-scroll");
if (!el) return; const el = list?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() }); if (!list || !el) return;
const listRect = list.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (elRect.top < listRect.top) {
list.scrollBy({ top: elRect.top - listRect.top, behavior: scrollBehavior() });
} else if (elRect.bottom > listRect.bottom) {
list.scrollBy({ top: elRect.bottom - listRect.bottom, behavior: scrollBehavior() });
}
}, [active]); }, [active]);
const jump = (id: string) => (e: React.MouseEvent) => { const jump = (id: string) => (e: React.MouseEvent) => {
@@ -59,16 +77,29 @@ export default function PostToc({ items }: { items: TocItem[] }) {
}; };
return ( return (
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col"> <nav
ref={navRef}
aria-label="文章目录"
className={`${bare ? "py-4" : "panel p-4"} flex min-h-0 flex-col`}
style={
bare
? {
background: "var(--panel)",
borderTop: "1px solid var(--line)",
borderBottom: "1px solid var(--line)",
}
: undefined
}
>
<p <p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0" className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }} style={{ color: "var(--ink-3)" }}
> >
目录 {title}
</p> </p>
<ul <ul
className="j13-rail-scroll space-y-0.5 pr-0.5" className="j13-rail-scroll min-h-0 flex-1 space-y-0.5 pr-0.5"
style={{ borderLeft: "1px solid var(--line)" }} style={bare ? undefined : { borderLeft: "1px solid var(--line)" }}
> >
{items.map((it) => { {items.map((it) => {
const isActive = it.id === active; const isActive = it.id === active;

View File

@@ -4,6 +4,8 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type R
import { apiGetSettings, type PublicSettings } from "@/lib/api"; import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand"; import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle"; import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile"; import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
@@ -19,6 +21,8 @@ export type SiteBrand = {
brand_logo_fit: PublicSettings["brand_logo_fit"]; brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"]; footer_links: PublicSettings["footer_links"];
url_style: UrlStyle; url_style: UrlStyle;
mobile_tabs: MobileTabKey[];
header_nav: HeaderNavItem[];
}; };
const SiteBrandContext = createContext<SiteBrand | null>(null); const SiteBrandContext = createContext<SiteBrand | null>(null);
@@ -36,6 +40,8 @@ function fromSettings(s: PublicSettings): SiteBrand {
brand_logo_fit: s.brand_logo_fit, brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links, footer_links: s.footer_links,
url_style: s.url_style, url_style: s.url_style,
mobile_tabs: s.mobile_tabs,
header_nav: s.header_nav,
}; };
} }
@@ -51,7 +57,9 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
a.brand_logo_size === b.brand_logo_size && a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit && a.brand_logo_fit === b.brand_logo_fit &&
a.url_style === b.url_style && a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
JSON.stringify(a.header_nav) === JSON.stringify(b.header_nav)
); );
} }
@@ -102,6 +110,8 @@ export function useSiteBrand(): SiteBrand {
brand_logo_fit: "contain", brand_logo_fit: "contain",
footer_links: [], footer_links: [],
url_style: "default", url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
}; };
} }
return ctx; return ctx;
@@ -153,6 +163,8 @@ export default function SiteBrandProvider({
? normalizeFooterLinks(raw.footer_links) ? normalizeFooterLinks(raw.footer_links)
: prev.footer_links, : prev.footer_links,
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style, url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
mobile_tabs: raw.mobile_tabs !== undefined ? normalizeMobileTabs(raw.mobile_tabs) : prev.mobile_tabs,
header_nav: raw.header_nav !== undefined ? normalizeHeaderNav(raw.header_nav) : prev.header_nav,
}; };
if (sameBrand(prev, next)) return; if (sameBrand(prev, next)) return;
retitle(prev, next); retitle(prev, next);

View File

@@ -7,6 +7,8 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop"; import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar"; import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api"; import type { User } from "@/lib/api";
import { isWideShellPath } from "@/lib/siteUrl";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider"; import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) { function pathIsAdmin(pathname: string | null) {
@@ -24,11 +26,6 @@ function pathIsComposeStudio(pathname: string | null) {
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname); return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
} }
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
function pathHasNoTabBar(pathname: string | null) {
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
}
/** /**
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。 * 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致; * 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
@@ -85,7 +82,19 @@ export default function SiteChrome({
// 聊天 / 发帖工作台锁视口,窗口回顶无意义 // 聊天 / 发帖工作台锁视口,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname); const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
const showTabBar = showBackToTop; // 底栏只在已启用 tab 的根路径显示(个人页子 tab 走 ?tab= 查询参数,不影响 pathname);
// 发帖无「首页」概念不产生可见路径,发帖工作台 / 帖子编辑页继续排除
const enabledTabs = new Set(brand.mobile_tabs);
const meHref = user ? userPath(brand.url_style, user.id) : "";
const tabBarRoots: string[] = [];
if (enabledTabs.has("home")) tabBarRoots.push("/");
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
const wideShell = isWideShellPath(pathname);
return ( return (
<> <>
@@ -105,7 +114,7 @@ export default function SiteChrome({
<main <main
id="main" id="main"
tabIndex={-1} tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none" className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
> >
{children} {children}
</main> </main>

View File

@@ -7,6 +7,7 @@ import {
GitBranch, GitBranch,
Loader2, Loader2,
Plus, Plus,
Tag,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
@@ -19,7 +20,7 @@ import {
serializeTimelineBlock, serializeTimelineBlock,
type TimelineItem, type TimelineItem,
} from "@/lib/timelineBlocks"; } from "@/lib/timelineBlocks";
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api"; import { apiTimelineFromGit, apiTimelineFromReleases, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append"; type ImportMode = "replace" | "append";
@@ -108,24 +109,16 @@ export default function TimelineEditor({
); );
}; };
const runImport = async () => { const collectURLs = () =>
const urls = urlsText urlsText
.split(/\n/) .split(/\n/)
.map((l) => l.trim()) .map((l) => l.trim())
.filter(Boolean); .filter(Boolean);
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL"); const applyImported = (mapped: TimelineItem[], errorHint: string) => {
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (mapped.length === 0) { if (mapped.length === 0) {
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填"); setImportError(errorHint);
if (res.warning) toast(res.warning); return false;
return;
} }
setItems((prev) => { setItems((prev) => {
if (importMode === "append") { if (importMode === "append") {
@@ -139,6 +132,24 @@ export default function TimelineEditor({
} }
return mapped; return mapped;
}); });
return true;
};
const runImportCommits = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok"); if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok"); else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error); if (res.error) setImportError(res.error);
@@ -149,6 +160,31 @@ export default function TimelineEditor({
} }
}; };
const runImportReleases = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条 Release 页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromReleases({ urls });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok");
else toast(`已从 Release 导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => { const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false); const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) { if (kept.length === 0) {
@@ -222,7 +258,7 @@ export default function TimelineEditor({
</p> </p>
</div> </div>
<p className="meta text-[12px] mb-2 leading-relaxed"> <p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。 粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。
</p> </p>
<textarea <textarea
value={urlsText} value={urlsText}
@@ -231,7 +267,7 @@ export default function TimelineEditor({
setImportError(null); setImportError(null);
}} }}
rows={3} rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"} placeholder={"https://github.com/org/repo/commits/main\nhttps://git.iioio.com/freefire/jiang13-bbs/releases"}
className="j13-tl-urls" className="j13-tl-urls"
disabled={importing} disabled={importing}
/> />
@@ -260,20 +296,40 @@ export default function TimelineEditor({
<option value="append">追加</option> <option value="append">追加</option>
</select> </select>
</div> </div>
<div className="ml-auto flex items-center gap-1.5">
<button <button
type="button" type="button"
className="btn btn-line btn-sm ml-auto" className="btn btn-line btn-sm"
disabled={importing} disabled={importing}
onClick={() => void runImport()} onClick={() => void runImportCommits()}
> >
{importing ? ( {importing ? (
<> <>
<Loader2 size={14} className="animate-spin" /> 导入中… <Loader2 size={14} className="animate-spin" /> 导入中…
</> </>
) : ( ) : (
"导入" <>
<GitBranch size={14} /> 导入提交
</>
)} )}
</button> </button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportReleases()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<Tag size={14} /> 导入 Release
</>
)}
</button>
</div>
</div> </div>
{showReplaceWarning ? ( {showReplaceWarning ? (
<p className="j13-tl-banner is-warn"> <p className="j13-tl-banner is-warn">
@@ -300,7 +356,7 @@ export default function TimelineEditor({
<button <button
type="button" type="button"
className="btn btn-line btn-sm" className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [...prev, emptyItem()])} onClick={() => setItems((prev) => [emptyItem(), ...prev])}
> >
<Plus size={14} /> 添加 <Plus size={14} /> 添加
</button> </button>
@@ -408,6 +464,7 @@ export default function TimelineEditor({
title="删除条目" title="删除条目"
message="确定删除这条时间线项目?" message="确定删除这条时间线项目?"
danger danger
zIndexClass="z-[1300]"
confirmLabel="删除" confirmLabel="删除"
onCancel={() => setDeleteIdx(null)} onCancel={() => setDeleteIdx(null)}
onConfirm={() => { onConfirm={() => {

View File

@@ -22,6 +22,7 @@ import {
Settings, Settings,
ChevronDown, ChevronDown,
FileText, FileText,
BookOpen,
CircleDollarSign, CircleDollarSign,
Columns3, Columns3,
PanelLeft, PanelLeft,
@@ -29,6 +30,7 @@ import {
Award, Award,
Layers, Layers,
Images, Images,
Link2,
} from "lucide-react"; } from "lucide-react";
import type { User } from "@/lib/api"; import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api"; import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -108,6 +110,18 @@ const NAV_GROUPS: NavGroup[] = [
icon: FileText, icon: FileText,
visible: (u) => isAdminOrAbove(u.role), visible: (u) => isAdminOrAbove(u.role),
}, },
{
href: "/admin/library",
label: "书库管理",
icon: BookOpen,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/friend-links",
label: "友情链接",
icon: Link2,
visible: (u) => isSuperOrOwner(u.role),
},
], ],
}, },
{ {

View File

@@ -66,10 +66,14 @@ export function AdminChartCard({
</div> </div>
{actions} {actions}
</div> </div>
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */} {/* 捕获阶段拦截 SVG 内 mousedown:冒泡阶段太晚,浏览器已开始选区黑框;非 SVG(输入框/表格)不拦截,否则无法聚焦输入 */}
{/* overflow-hidden 会沿自身 padding box 裁剪:px/p- 相同值负 margin 外扩裁剪边界 3px(内容宽度不变),
避免贴边子元素(如明细工具条首个搜索框)的 2px 聚焦光环被裁掉 */}
<div <div
className="w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden" className="overflow-hidden px-[3px] -mx-[3px] [&_svg]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
onMouseDownCapture={(e) => e.preventDefault()} onMouseDownCapture={(e) => {
if ((e.target as Element).closest("svg")) e.preventDefault();
}}
> >
{children} {children}
</div> </div>

View File

@@ -26,13 +26,13 @@ import type {
SidebarPageItem, SidebarPageItem,
PublicAdItem, PublicAdItem,
PublicSponsorItem, PublicSponsorItem,
FriendLink,
} from "@/lib/api"; } from "@/lib/api";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText"; import { AdText } from "@/components/AdText";
import { getPublicSettingsCached } from "@/lib/serverData"; import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle"; import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
import { PulseCheckin } from "./CheckinCard"; import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format"; import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph"; import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc"; import { AboutSiteHeader } from "@/components/site-doc";
@@ -396,38 +396,53 @@ export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[
/* ---------- 友情链接 ---------- */ /* ---------- 友情链接 ---------- */
export function FriendLinks() { export function FriendLinks({ links }: { links: FriendLink[] }) {
if (FRIEND_LINKS.length === 0) return null; // 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
return ( return (
<section className="j13-module" aria-label="友情链接"> <section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3"> <p className="j13-module-title mb-3">
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接 <Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
<span className="ml-auto inline-flex items-center gap-3 text-[12px] font-normal">
<Link href="/links" className="meta transition-colors hover:text-[var(--accent)]">
全部
</Link>
<Link <Link
href="/compose" href="/links#apply"
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]" className="meta transition-colors hover:text-[var(--accent)]"
> >
申请 申请
</Link> </Link>
</span>
</p> </p>
{links.length === 0 ? (
<p className="meta text-[12.5px]">暂无友链,欢迎申请</p>
) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{FRIEND_LINKS.map((l) => {links.map((l) => (
l.href.startsWith("http") ? (
<a <a
key={l.name} key={l.id}
href={l.href} href={l.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="j13-chip" className="j13-chip inline-flex items-center gap-1.5"
title={l.description || l.name}
> >
{l.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
alt=""
width={14}
height={14}
className="object-contain"
style={{ width: 14, height: 14 }}
/>
) : null}
{l.name} {l.name}
</a> </a>
) : ( ))}
<span key={l.name} className="j13-chip" data-tip="友链待配置">
{l.name}
</span>
)
)}
</div> </div>
)}
</section> </section>
); );
} }
@@ -452,6 +467,7 @@ type PanelsProps = {
sponsors?: PublicSponsorItem[]; sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string; sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean; sponsorsEnabled?: boolean;
friendLinks?: FriendLink[];
}; };
export function AdsPanel({ export function AdsPanel({
@@ -615,6 +631,7 @@ function CommunityPanelsBody({
sponsors = [], sponsors = [],
sponsorsPanelTitle = "赞助商", sponsorsPanelTitle = "赞助商",
sponsorsEnabled = true, sponsorsEnabled = true,
friendLinks = [],
}: PanelsProps) { }: PanelsProps) {
return ( return (
<> <>
@@ -625,7 +642,7 @@ function CommunityPanelsBody({
<HotRanking posts={hot} allowComments={allowComments} /> <HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} /> <NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} /> <ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks /> <FriendLinks links={friendLinks} />
<AboutSite <AboutSite
pages={sidebarPages} pages={sidebarPages}
siteDescription={siteDescription} siteDescription={siteDescription}

View File

@@ -2,13 +2,14 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, ShieldCheck } from "lucide-react"; import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api"; import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles"; import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon"; import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider"; import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle"; import { userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 // 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) { function boardHref(id: number) {
@@ -116,13 +117,27 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
); );
} }
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载 // 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) { // 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
export function WorkspaceNavMobile({
boards,
left,
right,
}: {
boards: BoardWithCount[];
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
left?: ReactNode;
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
right?: ReactNode;
}) {
const params = useSearchParams(); const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0; const activeBoard = Number(params.get("board")) || 0;
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false);
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
useDrawerChrome(drawer !== null, () => setDrawer(null));
const updateScrollState = useCallback(() => { const updateScrollState = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -151,9 +166,28 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
}); });
}, [activeBoard, boards]); }, [activeBoard, boards]);
const triggerBtn =
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
return ( return (
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5"> <div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
<div className="relative"> <div className="relative flex items-center gap-1.5">
{left ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("left")}
aria-haspopup="dialog"
aria-label="打开导航抽屉"
>
<Menu size={15} aria-hidden />
</button>
) : null}
<div className="relative min-w-0 flex-1">
<div <div
ref={scrollRef} ref={scrollRef}
className="j13-chips" className="j13-chips"
@@ -188,6 +222,32 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
{right ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("right")}
aria-haspopup="dialog"
aria-label="打开面板抽屉"
>
<LayoutGrid size={15} aria-hidden />
</button>
) : null}
</div>
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
{left ? (
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
{left}
</RailDrawerPanel>
) : null}
{right ? (
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
{right}
</RailDrawerPanel>
) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,197 @@
import { ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
import { ENTRY_TITLE_NUM_RE, type EntryItem } from "@/lib/entryBlocks";
/** 标签 chip:"主:次" 或 "主=次" 形式渲染主次两段,否则单段;不做语义配色,保持字段通用 */
function EntryTag({ tag }: { tag: string }) {
const idx = tag.search(/[::=]/);
const main = idx >= 0 ? tag.slice(0, idx) : tag;
const sub = idx >= 0 ? tag.slice(idx + 1) : "";
return (
<span
className="inline-flex items-baseline gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] leading-5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<span className="font-semibold" style={{ color: "var(--ink-2)" }}>
{main}
</span>
{sub ? (
<span style={{ color: "var(--ink-3)" }}>{sub}</span>
) : null}
</span>
);
}
/**
* 条目卡纯展示视图:编号标题 + 标签 chips + 高亮摘要 + 通用字段行 + 折叠来源。
* 字段名不限定(成本/收益/备注只是书写约定),任何书籍可定义自己的字段;
* markdown 片段由调用方注入(服务端 renderMarkdownToReact / 客户端 ReactMarkdown 预览)。
*/
export function MdEntriesView({
items,
renderMd,
titleIds,
}: {
items: EntryItem[];
renderMd: (text: string) => ReactNode;
/** 与目录共享的标题锚点 id(按条目顺序;缺省不挂 id) */
titleIds?: (string | undefined)[];
}) {
return (
<div className="my-4 -mx-2 flex flex-col gap-4 sm:-mx-3">
{items.map((it, i) => (
<article
key={i}
className="j13-entry-card rounded-2xl px-3 py-4 sm:px-4 sm:py-5"
style={{
border: "1px solid var(--line)",
// 极浅灰卡底:panel-2 半量混入 panel,深浅色模式各自成立
background: "color-mix(in srgb, var(--panel-2) 50%, var(--panel))",
}}
>
{!it.plain ? (
<h2
id={titleIds?.[i]}
className="flex items-baseline gap-2.5"
style={{
color: "var(--ink)",
scrollMarginTop: 90,
// 二级标题字号(移动端经 --entry-title-size 收小,见 globals.css);
// 覆盖 .prose-content h2 的外边距与底边线(卡内不需要)
fontSize: "var(--entry-title-size, 1.4rem)",
fontWeight: 700,
lineHeight: 1.35,
margin: 0,
padding: 0,
border: "none",
}}
>
{ENTRY_TITLE_NUM_RE.test(it.title) ? null : (
<span
className="tabular-nums text-[12.5px] font-semibold shrink-0"
style={{ color: "var(--ink-3)" }}
>
{i + 1}
</span>
)}
<span className="min-w-0">{it.title}</span>
</h2>
) : null}
{it.tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{it.tags.map((tag, ti) => (
<EntryTag key={ti} tag={tag} />
))}
</div>
) : null}
{it.summary?.trim() ? (
<div
className="j13-entry-summary mt-3 rounded-xl px-3.5 py-3 text-[14px] leading-relaxed [&>p]:my-0"
style={{
background: "var(--accent-soft)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink)",
}}
>
{renderMd(it.summary)}
</div>
) : null}
{it.fields.length > 0 ? (
<dl className="mt-3 flex flex-col gap-2.5">
{it.fields.map((f, fi) => (
<div
key={fi}
className="grid grid-cols-1 gap-y-0.5 sm:grid-cols-[76px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
>
<dt className="text-[12.5px] leading-[22px]" style={{ color: "var(--ink-3)" }}>
{f.label}
</dt>
<dd
className="min-w-0 text-[14px] leading-relaxed [&>p]:my-0 [&>ul]:my-1 [&>ol]:my-1"
style={{
// 正文用正常墨色;备注/来源类说明性字段保持浅灰
color:
f.label === "备注" || f.label === "来源"
? "var(--ink-2)"
: "var(--ink)",
}}
>
{f.value.trim() ? renderMd(f.value) : null}
</dd>
</div>
))}
</dl>
) : null}
{it.sources.length > 0 ? (
<details className="group mt-3">
<summary
className="inline-flex cursor-pointer select-none list-none items-center gap-1 text-[12.5px] font-medium [&::-webkit-details-marker]:hidden"
style={{ color: "var(--ink-2)" }}
>
<ChevronRight
size={13}
className="transition-transform group-open:rotate-90"
aria-hidden
/>
来源 · {it.sources.length} 条
</summary>
<div className="mt-2 flex flex-col gap-2">
{it.sources.map((s, si) => (
<div
key={si}
className="flex gap-2 text-[12.5px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
{si + 1}.
</span>
<div className="min-w-0">
<span>{s.text}</span>
{s.url ? (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="block break-all hover:text-[var(--accent)]"
style={{ color: "var(--ink-3)" }}
>
{s.url}
</a>
) : null}
</div>
</div>
))}
</div>
</details>
) : null}
</article>
))}
</div>
);
}
/** 服务端条目卡:markdown 片段走站点统一渲染管线(Shiki 等) */
export default async function MdEntries({
items,
titleIds,
}: {
items: EntryItem[];
titleIds?: (string | undefined)[];
}) {
const cache = new Map<string, ReactNode>();
await Promise.all(
items.flatMap((it) => {
const put = async (text?: string) => {
if (!text?.trim() || cache.has(text)) return;
cache.set(text, await renderMarkdownToReact(text));
};
return [put(it.summary), ...it.fields.map((f) => put(f.value))];
})
);
return <MdEntriesView items={items} titleIds={titleIds} renderMd={(t) => cache.get(t) ?? null} />;
}

View File

@@ -48,34 +48,44 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
style={{ scrollMarginTop: 90 }} style={{ scrollMarginTop: 90 }}
aria-labelledby={`${timelineDayId(g.date)}-label`} aria-labelledby={`${timelineDayId(g.date)}-label`}
> >
<div className="j13-timeline-day-label" id={`${timelineDayId(g.date)}-label`}> <ul className="j13-timeline-items">
{g.items.map((it, idx) => {
const flatIdx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
? renderBody(it, flatIdx)
: it.body.trim()
? <span className="whitespace-pre-wrap">{it.body}</span>
: null;
const isFirstInDay = idx === 0;
return (
<li
key={`${g.date}-${flatIdx}-${it.title}`}
className={`j13-timeline-item${isFirstInDay ? " is-first-in-day" : ""}`}
role="listitem"
>
<span className="j13-timeline-dot" aria-hidden />
<div className="j13-timeline-card">
<div className="j13-timeline-head">
{isFirstInDay ? (
<div
className="j13-timeline-day-label"
id={`${timelineDayId(g.date)}-label`}
>
<time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}> <time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}>
{g.date} {g.date}
</time> </time>
<span className="j13-timeline-day-count">{g.items.length}</span> <span className="j13-timeline-day-count">{g.items.length}</span>
</div> </div>
<ul className="j13-timeline-items"> ) : null}
{g.items.map((it) => { <p className="j13-timeline-summary">{rest}</p>
const idx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
? renderBody(it, idx)
: it.body.trim()
? <span className="whitespace-pre-wrap">{it.body}</span>
: null;
return (
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
<span className="j13-timeline-dot" aria-hidden />
<div className="j13-timeline-card">
<div className="j13-timeline-head">
{kind ? ( {kind ? (
<span className={`j13-timeline-chip is-${kind}`}> <span className={`j13-timeline-chip is-${kind}`}>
{kind} {kind}
{scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null} {scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null}
</span> </span>
) : null} ) : null}
<p className="j13-timeline-summary">{rest}</p>
{it.sourceUrl ? ( {it.sourceUrl ? (
<a <a
href={it.sourceUrl} href={it.sourceUrl}

View File

@@ -6,6 +6,7 @@ import type {
BoardSidebarStats, BoardSidebarStats,
BoardWithCount, BoardWithCount,
CheckinStatus, CheckinStatus,
FriendLink,
NewUser, NewUser,
OverviewStats, OverviewStats,
PostListItem, PostListItem,
@@ -62,6 +63,7 @@ export interface SidebarBlockProps {
hot?: PostListItem[]; hot?: PostListItem[];
newUsers?: NewUser[]; newUsers?: NewUser[];
activeUsers?: ActiveUser[]; activeUsers?: ActiveUser[];
friendLinks?: FriendLink[];
sidebarPages?: SidebarPageItem[]; sidebarPages?: SidebarPageItem[];
siteDescription?: string; siteDescription?: string;
canManagePages?: boolean; canManagePages?: boolean;
@@ -117,7 +119,8 @@ export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} /> <ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
) : null; ) : null;
case "friend_links": case "friend_links":
return <FriendLinks />; // 空列表也渲染(保留申请入口);显隐由侧栏布局配置控制
return <FriendLinks links={p.friendLinks ?? []} />;
case "about_site": case "about_site":
return p.sidebarPages || p.canManagePages ? ( return p.sidebarPages || p.canManagePages ? (
<AboutSite <AboutSite

View File

@@ -1,23 +1,28 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import MobileRailDrawers from "@/components/MobileRailDrawers";
/** /**
* 公文阅读三栏壳:宽度与帖子详情对齐。 * 公文阅读三栏壳:宽度与帖子详情对齐。
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;窄屏仅中栏,侧栏下沉。 * lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;
* 窄屏仅中栏,左右两栏收进移动端抽屉(工具栏按钮:导航 / 目录)。
*/ */
export default function SiteDocShell({ export default function SiteDocShell({
left, left,
right, right,
children, children,
mobileExtras,
}: { }: {
left?: ReactNode; left?: ReactNode;
right?: ReactNode; right?: ReactNode;
children: ReactNode; children: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
}) { }) {
return ( return (
<> <>
{/* 移动端:左右抽屉(导航 / 目录),lg 起隐藏;两侧皆空时组件自身不渲染 */}
<MobileRailDrawers
left={left ? { label: "导航", content: left } : undefined}
right={right ? { label: "目录", content: right } : undefined}
/>
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7"> <div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
<aside <aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden" className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
@@ -26,7 +31,7 @@ export default function SiteDocShell({
{left} {left}
</aside> </aside>
<div className="min-w-0"> <div className="j13-doc-bleed min-w-0">
{children} {children}
</div> </div>
@@ -37,8 +42,6 @@ export default function SiteDocShell({
{right} {right}
</aside> </aside>
</div> </div>
{mobileExtras ? <div className="lg:hidden mt-6 space-y-4">{mobileExtras}</div> : null}
</> </>
); );
} }

View File

@@ -14,6 +14,8 @@ import {
} from "./brand"; } from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels"; import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle"; import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "./mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "./headerNav";
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies"; import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot"; import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents"; import { emitForceLogout } from "./userEvents";
@@ -243,6 +245,7 @@ export interface SitePage {
allow_comments: boolean; allow_comments: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted?: boolean; // 软删标记(仅管理端列表)
} }
export interface SitePageInput { export interface SitePageInput {
@@ -255,6 +258,72 @@ export interface SitePageInput {
sort_order?: number; sort_order?: number;
} }
/** 书库文件(epub/pdf/html 等;StoredName 不出后端) */
export interface LibraryFileItem {
id: number;
doc_id: number;
user_id: number;
name: string;
ext: string;
mime: string;
size: number;
download_count: number;
sort_order: number;
created_at: string;
}
/** 书库章节(两级树:parent_id 空=章,非空=节;正文 markdown) */
export interface LibrarySectionItem {
id: number;
doc_id: number;
parent_id: number | null;
title: string;
content: string;
sort_order: number;
created_at: string;
updated_at: string;
}
/** 书库条目(详情/管理端,含文件与章节) */
export interface LibraryDoc {
id: number;
slug: string;
title: string;
description: string;
cover_url: string;
published: boolean;
sort_order: number;
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
created_at: string;
updated_at: string;
deleted?: boolean; // 软删标记(仅管理端列表)
files: LibraryFileItem[];
sections: LibrarySectionItem[];
}
/** 书库公开目录条目(不含 description 全文) */
export interface LibraryDocListItem {
id: number;
slug: string;
title: string;
cover_url: string;
sort_order: number;
file_count: number;
files: LibraryFileItem[];
created_at: string;
updated_at: string;
}
export interface LibraryInput {
slug: string;
title: string;
description?: string;
cover_url?: string;
published: boolean;
sort_order?: number;
entries_auto?: boolean;
}
export interface NewUser { export interface NewUser {
id: number; id: number;
username: string; username: string;
@@ -592,7 +661,7 @@ export interface UserProfile {
badges?: UserBadgeView[]; badges?: UserBadgeView[];
/** 访客(登录态)与该用户的关注关系;本人访问恒 false */ /** 访客(登录态)与该用户的关注关系;本人访问恒 false */
is_following?: boolean; is_following?: boolean;
/** 仅本人访问时下发的打码邮箱(j***@163.com) */ /** 仅本人访问时下发的邮箱明文(账号信息展示与资料表单回填) */
email?: string; email?: string;
}; };
stats: { stats: {
@@ -1012,6 +1081,10 @@ export interface PublicSettings {
levels_fx: boolean; levels_fx: boolean;
/** 详情页伪静态风格;缺省 default(标准 /post/123) */ /** 详情页伪静态风格;缺省 default(标准 /post/123) */
url_style: UrlStyle; url_style: UrlStyle;
/** 移动端底栏显示的 tab(固定顺序子集);空/非法回落默认全量 */
mobile_tabs: MobileTabKey[];
/** 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 */
header_nav: HeaderNavItem[];
} }
/** 与后端 DefaultAttachmentExts 保持同步 */ /** 与后端 DefaultAttachmentExts 保持同步 */
@@ -1077,6 +1150,8 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
levels: [...DEFAULT_LEVEL_DEFS], levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true, levels_fx: true,
url_style: "default", url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
}; };
function normalizeAttachmentExts(raw: unknown): string[] { function normalizeAttachmentExts(raw: unknown): string[] {
@@ -1167,6 +1242,9 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
url_style: normalizeUrlStyle(data?.url_style), url_style: normalizeUrlStyle(data?.url_style),
levels: normalizeLevels(data?.levels), levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false, levels_fx: data?.levels_fx !== false,
mobile_tabs: normalizeMobileTabs(data?.mobile_tabs),
// undefined(字段缺失)回落默认;[] 为空配置(全部隐藏),语义在 normalize 内区分
header_nav: normalizeHeaderNav(data?.header_nav),
}; };
} }
@@ -2686,6 +2764,19 @@ export async function apiTimelineFromGit(input: {
return data as { items: TimelineGitItem[]; warning?: string; error?: string }; return data as { items: TimelineGitItem[]; warning?: string; error?: string };
} }
export async function apiTimelineFromReleases(input: {
urls: string[];
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
const res = await fetchWithRefresh("/api/timeline/from-releases", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
}
/** SSR / 公开:已发布单页目录 */ /** SSR / 公开:已发布单页目录 */
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> { export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
const res = await fetch(`${API_BASE}/api/pages`, ssrInit()); const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
@@ -2753,6 +2844,215 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
} }
} }
/** 彻底删除单页(仅限已软删;删除后 slug 释放可复用) */
export async function apiAdminPurgeSitePage(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/pages/${id}/purge`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "彻底删除失败");
}
}
// ===== 书库(公开 + 管理) =====
/** SSR / 公开:已发布书库目录 */
export async function fetchLibraryList(): Promise<{ docs: LibraryDocListItem[] }> {
const res = await fetch(`${API_BASE}/api/library`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return { docs: data.docs ?? [] };
}
/** SSR / 公开:书库条目详情(404 返 null) */
export async function fetchLibraryDocBySlug(slug: string): Promise<{ doc: LibraryDoc } | null> {
const res = await fetch(`${API_BASE}/api/library/${encodeURIComponent(slug)}`, ssrInit());
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取条目失败");
return res.json();
}
/** SSR:管理端书库目录(含未发布) */
export async function fetchAdminLibraryDocs(
cookieHeader?: string
): Promise<{ docs: LibraryDoc[] }> {
const res = await fetch(`${API_BASE}/api/admin/library/docs`, ssrInit(cookieHeaders(cookieHeader)));
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return data as { docs: LibraryDoc[] };
}
export async function apiAdminListLibraryDocs(): Promise<{ docs: LibraryDoc[] }> {
const res = await fetchWithRefresh("/api/admin/library/docs", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return data as { docs: LibraryDoc[] };
}
async function saveLibraryDoc(
method: "POST" | "PUT",
url: string,
input: LibraryInput
): Promise<{ doc: LibraryDoc }> {
const res = await fetchWithRefresh(url, {
method,
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存条目失败");
return data as { doc: LibraryDoc };
}
export function apiAdminCreateLibraryDoc(input: LibraryInput) {
return saveLibraryDoc("POST", "/api/admin/library/docs", input);
}
export function apiAdminUpdateLibraryDoc(id: number, input: LibraryInput) {
return saveLibraryDoc("PUT", `/api/admin/library/docs/${id}`, input);
}
export async function apiAdminDeleteLibraryDoc(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除条目失败");
}
}
/** 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件,slug 释放可复用) */
export async function apiAdminPurgeLibraryDoc(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}/purge`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "彻底删除失败");
}
}
/** 上传文件到书库条目(multipart;扩展名/大小由后端白名单校验) */
export async function apiAdminUploadLibraryFile(
docId: number,
file: File
): Promise<{ file: LibraryFileItem; error?: string }> {
const form = new FormData();
form.append("file", file, file.name);
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/files`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return { file: null as unknown as LibraryFileItem, error: data.error || "上传失败" };
return data as { file: LibraryFileItem };
}
export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/files/${fid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除文件失败");
}
}
// ===== 书库章节(两级树)=====
export interface LibrarySectionInput {
parent_id?: number | null;
title: string;
content?: string;
}
/** 批量导入的单个 md 文件 */
export interface LibraryImportFileInput {
filename: string;
content: string;
}
/** 导入 md 拆章入参:多文件按传入顺序依次拆章;split_level h1/h2/none 对每个文件生效 */
export interface LibraryImportMdInput {
files: LibraryImportFileInput[];
split_level: "h1" | "h2" | "none";
}
export async function apiAdminCreateLibrarySection(
docId: number,
input: LibrarySectionInput
): Promise<{ section: LibrarySectionItem }> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/sections`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建章节失败");
return data as { section: LibrarySectionItem };
}
export async function apiAdminUpdateLibrarySection(
sid: number,
input: Pick<LibrarySectionInput, "title" | "content">
): Promise<{ section: LibrarySectionItem }> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
method: "PUT",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存章节失败");
return data as { section: LibrarySectionItem };
}
export async function apiAdminDeleteLibrarySection(sid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除章节失败");
}
}
export async function apiAdminMoveLibrarySection(
sid: number,
direction: "up" | "down"
): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}/move`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify({ direction }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "移动章节失败");
}
}
export async function apiAdminImportLibraryMarkdownBatch(
docId: number,
input: LibraryImportMdInput
): Promise<{ created: number; sections: LibrarySectionItem[] }> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/import-md-batch`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return data as { created: number; sections: LibrarySectionItem[] };
}
// ===== 用户管理(管理员) ===== // ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回 // 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
@@ -3367,6 +3667,10 @@ export type UpdateSiteSettingsBody = {
allow_register?: boolean; allow_register?: boolean;
allow_comments?: boolean; allow_comments?: boolean;
url_style?: UrlStyle; url_style?: UrlStyle;
/** 移动端底栏显示的 tab(固定顺序子集,整体替换保存;空数组会被后端拒绝) */
mobile_tabs?: MobileTabKey[];
/** 桌面端 Header 主导航(整体替换保存;空数组表示全部隐藏) */
header_nav?: HeaderNavItem[];
comments_require_login?: boolean; comments_require_login?: boolean;
allow_messages?: boolean; allow_messages?: boolean;
post_cooldown_hours?: number; post_cooldown_hours?: number;
@@ -3484,6 +3788,20 @@ export async function apiBackgroundFromMedia(
return url; return url;
} }
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ attachment_id: attachmentId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
}
// ===== 板块(客户端,供角色授权弹窗勾选) ===== // ===== 板块(客户端,供角色授权弹窗勾选) =====
export async function apiListBoards(): Promise<{ boards: Board[] }> { export async function apiListBoards(): Promise<{ boards: Board[] }> {
@@ -4443,3 +4761,101 @@ export async function apiAdminUpdateSidebarWidget(
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> { export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE"); return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
} }
/* ---------- 友情链接(申请审批制) ---------- */
export type FriendLinkStatus = "pending" | "approved" | "rejected";
export interface FriendLink {
id: number;
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
sort_order: number;
status: FriendLinkStatus;
applicant_user_id?: number | null;
reject_reason?: string;
created_at: string;
updated_at: string;
}
export interface FriendLinkApplyInput {
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
}
export interface FriendLinkAdminInput {
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
sort_order?: number;
}
/** SSR 公开友情链接列表(仅 approved) */
export async function fetchFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
const res = await fetch(`${API_BASE}/api/friend-links`, ssrInit(cookieHeaders(cookie)));
if (!res.ok) throw new Error("获取友情链接失败");
return res.json();
}
/** 用户提交友情链接申请(登录) */
export async function apiApplyFriendLink(body: FriendLinkApplyInput): Promise<{ link: FriendLink }> {
return apiOperations("/api/friend-links/apply", "POST", body);
}
/** 我的申请记录(含待审/拒绝与拒绝理由) */
export async function apiMyFriendLinks(): Promise<{ links: FriendLink[] }> {
return apiOperations("/api/friend-links/mine");
}
/** 申请人修改自己的申请;已通过/已驳回的修改后回退待审 */
export async function apiUpdateMyFriendLink(
id: number,
body: FriendLinkApplyInput
): Promise<{ ok: boolean }> {
return apiOperations(`/api/friend-links/${id}`, "PUT", body);
}
/** SSR 管理端全量列表(含 pending/rejected) */
export async function fetchAdminFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
const res = await fetch(`${API_BASE}/api/admin/friend-links`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载友情链接失败");
return res.json();
}
export async function apiAdminListFriendLinks(): Promise<{ links: FriendLink[] }> {
return apiOperations("/api/admin/friend-links");
}
export async function apiAdminCreateFriendLink(body: FriendLinkAdminInput): Promise<{ link: FriendLink }> {
return apiOperations("/api/admin/friend-links", "POST", body);
}
export async function apiAdminUpdateFriendLink(id: number, body: FriendLinkAdminInput): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "PUT", body);
}
export async function apiAdminSetFriendLinkStatus(
id: number,
status: FriendLinkStatus,
reject_reason?: string
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}/status`, "POST", reject_reason ? { status, reject_reason } : { status });
}
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
}

View File

@@ -107,6 +107,14 @@ export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
return re.test(u) ? u : ""; return re.test(u) ? u : "";
} }
/** 品牌图固定对外地址(与 Go service/brand.go 的 BrandAliasPath* 对齐):
* 友链等外部引用此地址,站点换图后地址不变 */
export function brandAliasPath(slot: BrandSlot): string {
if (slot === "logo_light") return "/api/brand/logo-light";
if (slot === "logo_dark") return "/api/brand/logo-dark";
return "/api/brand/favicon";
}
export function normalizeKeywords(raw: unknown): string[] { export function normalizeKeywords(raw: unknown): string[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
const seen = new Set<string>(); const seen = new Set<string>();

View File

@@ -0,0 +1,11 @@
/**
* 书名 → 稳定色相(0-359):djb2 哈希取模。
* 无封面文本封面的配色来源——同一本书在任何页面、任何刷新下颜色一致,
* 不使用 Math.random()(那会导致每次刷新变色)。
* 用法:元素挂 .j13-text-cover 类并内联 --cover-h,取色规则见 globals.css。
*/
export function coverHue(title: string): number {
let h = 5381;
for (let i = 0; i < title.length; i++) h = (h * 33) ^ title.charCodeAt(i);
return (h >>> 0) % 360;
}

View File

@@ -1,11 +1,19 @@
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */ /** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
import { import {
splitHideSegments, splitHideSegments,
type HideKind, type HideKind,
type HideSegment, type HideSegment,
} from "@/lib/hideBlocks"; } from "./hideBlocks.ts";
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks"; import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
import {
isEntriesClose,
isEntriesOffOpen,
isEntriesOpen,
parseEntriesBody,
wrapImplicitEntries,
type EntryItem,
} from "./entryBlocks.ts";
export type ContentSegment = export type ContentSegment =
| { type: "md"; text: string } | { type: "md"; text: string }
@@ -16,7 +24,8 @@ export type ContentSegment =
locked: boolean; locked: boolean;
body: string; body: string;
} }
| { type: "timeline"; items: TimelineItem[]; rawBody: string }; | { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string };
function isTimelineClose(line: string): boolean { function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]"; return line.trim().toLowerCase() === "[/timeline]";
@@ -29,11 +38,23 @@ function isTimelineOpen(trimmed: string): boolean {
/** /**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。 * 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。 * 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
* [entries:off] 块内始终保持普通 Markdown。
*/ */
export function splitContentSegments(content: string): ContentSegment[] { export function splitContentSegments(
content: string,
opts?: { entriesAuto?: boolean }
): ContentSegment[] {
if (!content) return [{ type: "md", text: "" }]; if (!content) return [{ type: "md", text: "" }];
if (opts?.entriesAuto) {
content = wrapImplicitEntries(content);
}
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = []; const raw: Array<
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
> = [];
let buf: string[] = []; let buf: string[] = [];
let inCode = false; let inCode = false;
let i = 0; let i = 0;
@@ -44,6 +65,54 @@ export function splitContentSegments(content: string): ContentSegment[] {
buf = []; buf = [];
}; };
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
const scanBlock = (
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean
): { body: string; end: number } | null => {
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isOpen(inner)) return null;
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
bodyLines.push(lines[j]);
j++;
}
return null;
};
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
const takeBlock = (
line: string,
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean,
type: "timeline" | "entries"
): boolean => {
const hit = scanBlock(isOpen, isClose);
if (!hit) {
buf.push(line);
i++;
return true;
}
flushMd();
raw.push({ type, body: hit.body });
i = hit.end + 1;
return true;
};
while (i < lines.length) { while (i < lines.length) {
const trimmed = lines[i].trim(); const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) { if (trimmed.startsWith("```")) {
@@ -59,45 +128,26 @@ export function splitContentSegments(content: string): ContentSegment[] {
} }
if (isTimelineOpen(trimmed)) { if (isTimelineOpen(trimmed)) {
flushMd(); takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
let found = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue; continue;
} }
if (innerCode) {
bodyLines.push(lines[j]); if (isEntriesOpen(trimmed)) {
j++; takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
continue; continue;
} }
if (isTimelineOpen(inner)) {
// 嵌套视为失败:整段当 md // [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
buf.push(lines[i]); // 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
i++; if (isEntriesOffOpen(trimmed)) {
found = false; const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
break; if (!hit) {
}
if (isTimelineClose(inner)) {
found = true;
break;
}
bodyLines.push(lines[j]);
j++;
}
if (!found) {
buf.push(lines[i]); buf.push(lines[i]);
i++; i++;
continue; continue;
} }
raw.push({ type: "timeline", body: bodyLines.join("\n") }); buf.push(hit.body);
i = j + 1; i = hit.end + 1;
continue; continue;
} }
@@ -116,6 +166,14 @@ export function splitContentSegments(content: string): ContentSegment[] {
}); });
continue; continue;
} }
if (part.type === "entries") {
out.push({
type: "entries",
items: parseEntriesBody(part.body),
rawBody: part.body,
});
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[]; const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) { for (const s of hideSegs) {
if (s.type === "md") { if (s.type === "md") {

View File

@@ -0,0 +1,351 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
parseEntriesBody,
parseEntrySource,
serializeEntriesBlock,
wrapImplicitEntries,
} from "./entryBlocks.ts";
import { splitContentSegments } from "./directiveBlocks.ts";
import { buildTocFromSegments, extractToc } from "./toc.ts";
describe("parseEntrySource", () => {
it("最后一个 | 后为 http(s) 时拆分文本与链接", () => {
assert.deepEqual(parseEntrySource("NHTSA (2024). Report | https://a.gov/x"), {
text: "NHTSA (2024). Report",
url: "https://a.gov/x",
});
});
it("无链接或非 http 后缀时整行作为文本", () => {
assert.deepEqual(parseEntrySource("WHO 口径"), { text: "WHO 口径" });
assert.deepEqual(parseEntrySource("标题 | 备注说明"), { text: "标题 | 备注说明" });
});
it("兼容全角竖线", () => {
assert.deepEqual(parseEntrySource("来源|https://a.gov/x"), {
text: "来源",
url: "https://a.gov/x",
});
});
});
describe("parseEntriesBody", () => {
it("完整条目:标题/标签/摘要/字段/来源", () => {
const items = parseEntriesBody(
[
"## 系安全带,前排后排都系",
"@tags 性价比:极高 | 证据:A级 | 不花钱",
"@summary 坐前排系上安全带,概率大约降一半。",
"成本 :: 不花钱。每次上车多花 2 秒。",
"收益 :: NHTSA 估计致命伤风险下降 45%。",
"@source NHTSA (2024). Report | https://a.gov/x",
"@source WHO (2025). Fact sheet",
].join("\n")
);
assert.equal(items.length, 1);
assert.deepEqual(items[0], {
title: "系安全带,前排后排都系",
tags: ["性价比:极高", "证据:A级", "不花钱"],
summary: "坐前排系上安全带,概率大约降一半。",
fields: [
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
],
sources: [
{ text: "NHTSA (2024). Report", url: "https://a.gov/x" },
{ text: "WHO (2025). Fact sheet" },
],
});
});
it("多条目按标题切分;标题后裸文本计入摘要", () => {
const items = parseEntriesBody(
[
"## 甲",
"第一段摘要。",
"第二行摘要。",
"成本 :: 10 元。",
"## 乙",
"@tags 快",
].join("\n")
);
assert.equal(items.length, 2);
assert.equal(items[0].title, "甲");
assert.equal(items[0].summary, "第一段摘要。\n第二行摘要。");
assert.deepEqual(items[0].fields, [{ label: "成本", value: "10 元。" }]);
assert.equal(items[1].title, "乙");
assert.deepEqual(items[1].tags, ["快"]);
});
it("字段值多行追加到最后一个字段;无条目的内容丢弃", () => {
const items = parseEntriesBody(
[
"块首无标题行应丢弃",
"## 甲",
"成本 :: 第一行",
"续行仍属成本",
"@tags x",
].join("\n")
);
assert.equal(items.length, 1);
assert.equal(items[0].fields[0].value, "第一行\n续行仍属成本");
});
it("空块返回空数组", () => {
assert.deepEqual(parseEntriesBody(""), []);
});
it("@plain 标记无标题头,序列化往返保留", () => {
const items = parseEntriesBody(["## 甲", "@plain", "@tags x", "成本 :: 1"].join("\n"));
assert.equal(items[0].plain, true);
const round = parseEntriesBody(
serializeEntriesBlock(items).slice("[entries]\n".length, -"\n[/entries]".length)
);
assert.equal(round[0].plain, true);
const normal = parseEntriesBody(["## 甲", "@tags x"].join("\n"));
assert.equal(normal[0].plain, undefined);
});
});
describe("serializeEntriesBlock", () => {
it("往返:序列化后可重新解析", () => {
const src = parseEntriesBody(
[
"## 系安全带",
"@tags 性价比:极高 | 不花钱",
"@summary 概率降一半。",
"成本 :: 不花钱。",
"@source NHTSA | https://a.gov/x",
].join("\n")
);
const round = parseEntriesBody(serializeEntriesBlock(src).slice("[entries]\n".length, -"\n[/entries]".length));
assert.deepEqual(round, src);
});
});
describe("splitContentSegments 集成 [entries]", () => {
it("条目块单独成段,前后 md 保留", () => {
const segs = splitContentSegments(
["前文", "", "[entries]", "## 甲", "@tags x", "[/entries]", "", "后文"].join("\n")
);
assert.deepEqual(
segs.map((s) => s.type),
["md", "entries", "md"]
);
if (segs[1].type === "entries") {
assert.equal(segs[1].items[0].title, "甲");
} else {
assert.fail("第二段应为 entries");
}
});
it("代码围栏内的块标记与未闭合块按普通 md 处理", () => {
const fenced = splitContentSegments(["```", "[entries]", "```"].join("\n"));
assert.deepEqual(fenced.map((s) => s.type), ["md"]);
const unclosed = splitContentSegments(["[entries]", "## 甲"].join("\n"));
assert.deepEqual(unclosed.map((s) => s.type), ["md"]);
});
});
describe("extractToc 跳过 entries 块", () => {
it("块内标题不进目录,块外锚点不错位", () => {
const toc = extractToc(
["## 外部一", "[entries]", "## 卡片标题", "[/entries]", "### 外部二"].join("\n")
);
assert.deepEqual(
toc.map((t) => `${t.level}:${t.text}:${t.id}`),
["2:外部一:h-0", "3:外部二:h-1"]
);
});
});
describe("wrapImplicitEntries(书级条目卡模式)", () => {
it("标题 + 指令行自动包壳;普通小节与正文不受影响", () => {
const src = [
"# 第一章",
"",
"## 说明小节",
"这是普通段落,不是条目。",
"",
"## 系安全带",
"@tags 性价比:极高",
"@summary 概率降一半。",
"成本 :: 不花钱。",
"",
"普通正文段落,出现在条目之后。",
].join("\n");
const wrapped = wrapImplicitEntries(src);
const segs = splitContentSegments(wrapped, { entriesAuto: false });
assert.deepEqual(
segs.map((s) => s.type),
["md", "entries", "md"]
);
if (segs[1].type === "entries") {
assert.equal(segs[1].items.length, 1);
assert.equal(segs[1].items[0].title, "系安全带");
assert.deepEqual(segs[1].items[0].tags, ["性价比:极高"]);
assert.deepEqual(segs[1].items[0].fields, [{ label: "成本", value: "不花钱。" }]);
} else {
assert.fail("隐式条目应解析为 entries 段");
}
// 普通小节标题与条目后正文都按普通 md 渲染
if (segs[0].type !== "md" || segs[2].type !== "md") assert.fail("前后应为 md 段");
assert.ok(segs[0].text.includes("## 说明小节"));
assert.ok(segs[2].text.includes("普通正文段落"));
});
it("空行后仍是指令行时条目继续;空行后普通文本则条目收尾", () => {
const src = [
"## 甲",
"@tags a",
"",
"备注 :: 续字段。",
"",
"收尾后的普通段落。",
].join("\n");
const items = parseEntriesBody(
wrapImplicitEntries(src)
.split("[entries]\n")[1]
?.split("\n[/entries]")[0] ?? ""
);
assert.equal(items.length, 1);
assert.deepEqual(items[0].tags, ["a"]);
assert.deepEqual(items[0].fields, [{ label: "备注", value: "续字段。" }]);
});
it("显式 [entries] 块与 [entries:off]、时间线块原样跳过", () => {
const src = [
"[entries]",
"## 显式条目",
"@tags x",
"[/entries]",
"",
"[entries:off]",
"## off 内标题",
"@tags 不应识别为条目",
"[/entries]",
"",
"## 隐式条目",
"@summary 摘要。",
].join("\n");
const segs = splitContentSegments(src, { entriesAuto: true });
assert.deepEqual(
segs.map((s) => s.type),
["entries", "md", "entries"]
);
if (segs[0].type === "entries") assert.equal(segs[0].items[0].title, "显式条目");
else assert.fail("显式块应保持 entries");
// off 段内容原样按普通 md 渲染(含 off 内字面标记行)
if (segs[1].type !== "md") assert.fail("off 段应为 md");
assert.ok(segs[1].text.includes("## off 内标题"));
assert.ok(segs[1].text.includes("@tags 不应识别为条目"));
if (segs[2].type === "entries") assert.equal(segs[2].items[0].title, "隐式条目");
else assert.fail("off 之后的条目应正常识别");
});
it("幂等:包壳结果重复执行不变", () => {
const src = [
"## 系安全带",
"@tags 钱=0",
"成本 :: 不花钱。",
"",
"## 隐式条目",
"@tags x",
].join("\n");
const once = wrapImplicitEntries(src);
assert.equal(wrapImplicitEntries(once), once);
});
it("条目标题进卡后目录不再收录,块外标题锚点不漂移", () => {
const src = [
"## 前章",
"",
"### 系安全带",
"@summary 降一半。",
"成本 :: 不花钱。",
"",
"### 后节",
].join("\n");
const toc = extractToc(wrapImplicitEntries(src));
assert.deepEqual(
toc.map((t) => `${t.level}:${t.text}`),
["2:前章", "3:后节"]
);
});
});
describe("buildTocFromSegments(条目卡模式目录)", () => {
it("md 标题与条目标题按渲染顺序编入目录并分配锚点", () => {
const src = [
"开头说明。",
"",
"## 系安全带",
"@tags 钱=0",
"成本 :: 不花钱。",
"",
"### 小节标题",
].join("\n");
const segs = splitContentSegments(wrapImplicitEntries(src));
let n = 0;
const { items, mdHeadIds, entriesTitleIds } = buildTocFromSegments(segs, () => `e-1-${++n}`);
assert.deepEqual(
items.map((t) => `${t.level}:${t.text}`),
["2:系安全带", "3:小节标题"]
);
assert.deepEqual(mdHeadIds, ["e-1-2"]);
assert.equal(entriesTitleIds[0], undefined);
assert.deepEqual(entriesTitleIds[1], ["e-1-1"]);
});
});
describe("注释标签写法不再识别(已收敛为一套 @ 指令写法)", () => {
const USER_SAMPLE = [
"<!-- 成本标签: 钱=0 时间=少 -->",
"- 成本:不花钱。",
"- 说人话:死亡风险降一半。",
].join("\n");
it("entriesAuto 开启时注释标签 + 字段列表仍保持普通 Markdown", () => {
const segs = splitContentSegments(USER_SAMPLE, { entriesAuto: true });
assert.deepEqual(segs.map((s) => s.type), ["md"]);
if (segs[0].type !== "md") assert.fail("应为 md 段");
assert.ok(segs[0].text.includes("- 成本:不花钱。"));
});
});
describe("端到端:@ 指令写法(安全带条目)", () => {
it("标题/@tags/@summary/字段/多来源完整成卡", () => {
const src = [
"### 系安全带,前排后排都系",
"@tags 钱=0 | 时间=少 | 毅力=否 | 收益=大 | 口径=死亡率 | 证据:A",
"@summary 坐前排系上安全带,出车祸时被撞死的概率大约降一半。",
"成本 :: 不花钱。每次上车多花 2 秒。",
"收益 :: NHTSA 估计致命伤风险下降 45%。",
"备注 :: 后排也要系。",
"@source NHTSA (2024). Occupant Protection (DOT HS 813 573) | https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
"@source WHO (2025). Road traffic injuries fact sheet | https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
].join("\n");
const segs = splitContentSegments(src, { entriesAuto: true });
assert.deepEqual(segs.map((s) => s.type), ["entries"]);
const it = segs[0].items[0];
assert.equal(it.title, "系安全带,前排后排都系");
assert.equal(it.summary, "坐前排系上安全带,出车祸时被撞死的概率大约降一半。");
assert.deepEqual(it.tags, [
"钱=0",
"时间=少",
"毅力=否",
"收益=大",
"口径=死亡率",
"证据:A",
]);
assert.deepEqual(it.fields, [
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
{ label: "备注", value: "后排也要系。" },
]);
assert.deepEqual(it.sources, [
{
text: "NHTSA (2024). Occupant Protection (DOT HS 813 573)",
url: "https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
},
{
text: "WHO (2025). Road traffic injuries fact sheet",
url: "https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
},
]);
});
});

298
frontend/lib/entryBlocks.ts Normal file
View File

@@ -0,0 +1,298 @@
/** 正文 [entries]…[/entries] 条目卡块解析:
* 通用「结构化小节」渲染约定——字段名不限定,任何书籍可定义自己的字段。
* 块内语法:
* ## 条目标题
* @tags 标签一 | 主:次 | 标签三 → 标签 chips(| 或 | 分隔,"主:次" 渲染主次两段)
* @summary 一句话结论 → 高亮摘要块(可多行;标题后的裸文本也计入摘要)
* 字段名 :: 内容 → 灰色标签行(label 任意,可多行)
* @source 来源文本 | https://… → 折叠来源列表(url 可省)
*/
export interface EntrySource {
text: string;
url?: string;
}
export interface EntryField {
label: string;
value: string;
}
export interface EntryItem {
title: string;
tags: string[];
summary?: string;
fields: EntryField[];
sources: EntrySource[];
/** 无标题头(注释标签卡等场景):不渲染编号与标题行 */
plain?: boolean;
}
const HEADING_RE = /^#{1,4}\s+(.+)$/;
/** 条目标题自带序号前缀("1."、"12、"、"(3)"、"(4)"):卡片不再叠加自动编号 */
export const ENTRY_TITLE_NUM_RE = /^(?:[((]\d+[))]|\d+\s*[.、))])/;
const TAGS_RE = /^@tags\s+(.+)$/i;
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
const SOURCE_RE = /^@source\s+(.+)$/i;
const PLAIN_RE = /^@plain$/i;
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
/** 来源行:"文本 | url",取最后一个 | 且后段为 http(s) 才拆分;文本/链接兼容全角竖线 */
export function parseEntrySource(raw: string): EntrySource {
const idx = Math.max(raw.lastIndexOf("|"), raw.lastIndexOf("|"));
if (idx > 0) {
const url = raw.slice(idx + 1).trim();
if (/^https?:\/\//i.test(url)) {
return { text: raw.slice(0, idx).trim(), url };
}
}
return { text: raw.trim() };
}
export function parseEntriesBody(body: string): EntryItem[] {
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const items: EntryItem[] = [];
let cur: EntryItem | null = null;
/** 普通文本行的归宿:摘要 / 最后一个字段(缺省时并入摘要) */
let target: { kind: "summary" } | { kind: "field"; index: number } | null = null;
const flush = () => {
if (cur) items.push(cur);
cur = null;
target = null;
};
const appendSummary = (text: string) => {
if (!cur) return;
cur.summary = cur.summary ? `${cur.summary}\n${text}` : text;
};
for (const line of lines) {
const t = line.trim();
if (!t) continue;
const hm = t.match(HEADING_RE);
if (hm) {
flush();
cur = { title: hm[1].trim(), tags: [], fields: [], sources: [] };
target = null;
continue;
}
if (!cur) continue;
const tm = t.match(TAGS_RE);
if (tm) {
cur.tags.push(...tm[1].split(/[||]/).map((s) => s.trim()).filter(Boolean));
target = null;
continue;
}
const sm = t.match(SUMMARY_RE);
if (sm) {
const rest = (sm[1] ?? "").trim();
if (rest) appendSummary(rest);
target = { kind: "summary" };
continue;
}
const scm = t.match(SOURCE_RE);
if (scm) {
cur.sources.push(parseEntrySource(scm[1]));
target = null;
continue;
}
if (PLAIN_RE.test(t)) {
cur.plain = true;
target = null;
continue;
}
const fm = t.match(FIELD_RE);
if (fm) {
cur.fields.push({ label: fm[1].trim(), value: fm[2] });
target = { kind: "field", index: cur.fields.length - 1 };
continue;
}
// 普通行:追加到开放容器;无容器时视为摘要(标题后直接跟正文段落)
if (target?.kind === "field") {
const f = cur.fields[target.index];
f.value = `${f.value}\n${t}`;
} else {
appendSummary(t);
}
}
flush();
return items;
}
/** 序列化回块文本(编辑器面板复用);selected=false 的条目丢弃,与时间线一致 */
export function serializeEntriesBlock(items: (EntryItem & { selected?: boolean })[]): string {
const parts = items
.filter((it) => it.selected !== false)
.map((it) => {
const lines = [`## ${it.title.trim() || "未命名"}`];
if (it.plain) lines.push("@plain");
if (it.tags.length > 0) lines.push(`@tags ${it.tags.join(" | ")}`);
if (it.summary?.trim()) lines.push(`@summary ${it.summary.trim()}`);
for (const f of it.fields) {
if (!f.label.trim() && !f.value.trim()) continue;
lines.push(`${f.label.trim() || "备注"} :: ${f.value.trim()}`);
}
for (const s of it.sources) {
if (!s.text.trim()) continue;
lines.push(`@source ${s.text.trim()}${s.url?.trim() ? ` | ${s.url.trim()}` : ""}`);
}
return lines.join("\n");
});
return `[entries]\n${parts.join("\n\n")}\n[/entries]`;
}
export const ENTRIES_OPEN = "[entries]";
export const ENTRIES_CLOSE = "[/entries]";
/** [entries:off] 例外块:书级条目卡模式开启时,块内恢复普通 Markdown 渲染 */
export const ENTRIES_OFF_OPEN = "[entries:off]";
export function isEntriesOpen(line: string): boolean {
return line.trim().toLowerCase() === ENTRIES_OPEN;
}
export function isEntriesClose(line: string): boolean {
return line.trim().toLowerCase() === ENTRIES_CLOSE;
}
export function isEntriesOffOpen(line: string): boolean {
return line.trim().toLowerCase() === ENTRIES_OFF_OPEN;
}
/** 条目指令行:@tags / @summary / @source / @plain / 字段名 :: 内容(隐式模式识别条目特征用) */
export function isEntrySignalLine(t: string): boolean {
return (
TAGS_RE.test(t) || SUMMARY_RE.test(t) || SOURCE_RE.test(t) || PLAIN_RE.test(t) || FIELD_RE.test(t)
);
}
/** 已知指令块标记行(隐式模式扫描时原样跳过,不当作条目内容) */
const DIRECTIVE_LINE_RE = /^\[(\/)?(entries(?::off)?|timeline|hide[^\]]*)\]$/i;
export function isDirectiveLine(t: string): boolean {
return DIRECTIVE_LINE_RE.test(t);
}
/**
* 隐式条目模式:把「## 标题 + 条目指令行」的区间自动包上 [entries] 壳。
* 规则:
* - 标题行(1-4 级)与其后到空行/结构标记前的段落里出现任一条目指令行 → 确认为条目;
* 否则视为普通小节标题,原样输出
* - 条目体持续收集:指令行后的普通文本行归入条目(与显式块语义一致);
* 空行后若仍是指令行则继续,否则条目在空行处收尾
* - 代码围栏与已知指令块([entries]/[entries:off]/[timeline]/[hide…])原样跳过
*/
export function wrapImplicitEntries(src: string): string {
const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const out: string[] = [];
let inCode = false;
let i = 0;
/** 从 i 行的开标记找匹配闭标记,返回闭标记行下标;找不到返回 -1 */
const findClose = (start: number, isClose: (t: string) => boolean): number => {
let innerCode = false;
for (let j = start; j < lines.length; j++) {
const t = lines[j].trim();
if (t.startsWith("```")) innerCode = !innerCode;
if (!innerCode && isClose(t)) return j;
}
return -1;
};
while (i < lines.length) {
const t = lines[i].trim();
if (t.startsWith("```")) {
inCode = !inCode;
out.push(lines[i]);
i++;
continue;
}
if (inCode) {
out.push(lines[i]);
i++;
continue;
}
// 显式指令块整段原样([entries] 本身无需再包;off/timeline/hide 内容不参与隐式识别)
if (isEntriesOpen(t) || isEntriesOffOpen(t) || isDirectiveLine(t)) {
const close = findClose(
i + 1,
isEntriesOffOpen(t)
? isEntriesClose
: isEntriesOpen(t)
? isEntriesClose
: t.toLowerCase() === "[timeline]"
? (x) => x.toLowerCase() === "[/timeline]"
: (x) => x.toLowerCase().startsWith("[/hide")
);
if (close >= 0) {
for (; i <= close; i++) out.push(lines[i]);
} else {
out.push(lines[i]);
i++;
}
continue;
}
if (HEADING_RE.test(t)) {
// 前导段:标题行后到空行/结构标记前的所有行
const seg: string[] = [];
let k = i + 1;
while (k < lines.length) {
const tk = lines[k].trim();
if (!tk || tk.startsWith("```") || isDirectiveLine(tk) || HEADING_RE.test(tk)) break;
seg.push(lines[k]);
k++;
}
if (!seg.some(isEntrySignalLine)) {
out.push(lines[i]);
i++;
continue;
}
// 条目确认:包壳输出标题 + 前导段,再继续吃条目体
out.push(ENTRIES_OPEN);
out.push(lines[i]);
out.push(...seg);
i = k;
while (i < lines.length) {
const t2 = lines[i].trim();
if (!t2) {
// 空行后仍是指令行 → 保留空行继续收集;否则条目到此收尾
let p = i;
while (p < lines.length && !lines[p].trim()) p++;
if (p < lines.length && isEntrySignalLine(lines[p].trim())) {
out.push(lines[i]);
i++;
continue;
}
break;
}
if (t2.startsWith("```") || isDirectiveLine(t2) || HEADING_RE.test(t2)) break;
out.push(lines[i]);
i++;
}
out.push(ENTRIES_CLOSE);
continue;
}
out.push(lines[i]);
i++;
}
return out.join("\n");
}
/** 编辑器「条目卡」插入模板:字段名(字段名::内容)按书籍内容自定义 */
export const ENTRIES_TEMPLATE = `[entries]
## 条目标题
@tags 标签 | 主:次
@summary 一句话结论。
字段名 :: 内容。
@source 来源标题 | https://example.com
[/entries]`;
/** 编辑器「条目卡」插入模板(隐式模式):书籍开启全章节条目卡后免写外壳 */
export const ENTRIES_TEMPLATE_AUTO = `## 条目标题
@tags 标签 | 主:次
@summary 一句话结论。
字段名 :: 内容。
@source 来源标题 | https://example.com`;

View File

@@ -1,7 +0,0 @@
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
export interface FriendLink {
name: string;
href: string;
}
export const FRIEND_LINKS: FriendLink[] = [];

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DEFAULT_HEADER_NAV,
HEADER_NAV_BUILTIN_KEYS,
isHeaderNavCustom,
normalizeHeaderNav,
type HeaderNavItem,
} from "./headerNav.ts";
describe("normalizeHeaderNav", () => {
it("缺行/非法输入回落内置默认三项", () => {
assert.deepEqual(normalizeHeaderNav(undefined), DEFAULT_HEADER_NAV);
assert.deepEqual(normalizeHeaderNav(null), DEFAULT_HEADER_NAV);
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
});
it("默认三项顺序与注册表一致", () => {
assert.equal(DEFAULT_HEADER_NAV.length, 3);
assert.deepEqual(
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
[...HEADER_NAV_BUILTIN_KEYS],
);
});
it("显式空数组表示全部隐藏,不回落默认", () => {
assert.deepEqual(normalizeHeaderNav([]), []);
});
it("自定义顺序与内容原样保留", () => {
const items: HeaderNavItem[] = [
{ type: "builtin", key: "library" },
{ type: "custom", label: "关于", url: "/p/about", new_tab: false },
{ type: "builtin", key: "home" },
{ type: "custom", label: "官网", url: "https://example.com", new_tab: true },
];
assert.deepEqual(normalizeHeaderNav(items), items);
});
it("内置 key 去空白/小写容错、重复去重", () => {
assert.deepEqual(
normalizeHeaderNav([
{ type: "builtin", key: " HOME " },
{ type: "builtin", key: "home" },
{ type: "builtin", key: "library" },
]),
[
{ type: "builtin", key: "home" },
{ type: "builtin", key: "library" },
],
);
});
it("未知内置 key 与非法自定义项被剔除", () => {
assert.deepEqual(
normalizeHeaderNav([
{ type: "builtin", key: "nope" },
{ type: "custom", label: "", url: "/x" },
{ type: "custom", label: "坏地址", url: "javascript:alert(1)" },
{ type: "custom", label: "相对路径", url: "relative" },
{ type: "weird", key: "home" },
{ type: "custom", label: "合法", url: "/links" },
]),
[{ type: "custom", label: "合法", url: "/links", new_tab: false }],
);
});
it("自定义外链 new_tab 缺省为 true,站内为 false", () => {
const got = normalizeHeaderNav([
{ type: "custom", label: "外链", url: "https://example.com" },
{ type: "custom", label: "站内", url: "/p/about" },
]);
assert.equal(got.length, 2);
assert.ok(got.every(isHeaderNavCustom));
assert.equal((got[0] as { new_tab: boolean }).new_tab, true);
assert.equal((got[1] as { new_tab: boolean }).new_tab, false);
});
});

88
frontend/lib/headerNav.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* 桌面端 Header 主导航配置的单一事实源:
* 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。
*
* 配置为有序数组,数组顺序即展示顺序:
* - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化);
* - custom 存文案 + 地址(站内路径或 http(s) 外链)。
* 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。
* 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。
*/
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
/** 内置菜单默认文案;后台面板用同一文案 */
export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
home: "首页",
leaderboard: "排行榜",
library: "书库",
};
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
export type HeaderNavCustomItem = {
type: "custom";
label: string;
url: string;
new_tab: boolean;
};
export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem;
export const HEADER_NAV_LIMITS = {
items: 8,
label: 16,
url: 200,
} as const;
/** 内置默认三项(缺行时回落) */
export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({
type: "builtin" as const,
key,
}));
export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem {
return item.type === "custom";
}
/**
* 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过;
* 入参非数组回落默认;空数组原样返回(全部隐藏)。
*/
export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] {
if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item }));
const out: HeaderNavItem[] = [];
const seenBuiltin = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as Record<string, unknown>;
const type = String(rec.type ?? "").trim();
if (type === "builtin") {
const key = String(rec.key ?? "")
.trim()
.toLowerCase();
if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) {
continue;
}
seenBuiltin.add(key);
out.push({ type: "builtin", key: key as HeaderNavBuiltinKey });
} else if (type === "custom") {
const label = String(rec.label ?? "").trim();
const url = String(rec.url ?? "").trim();
if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue;
if (!footerURLAllowed(url)) continue;
const omitted = rec.new_tab === undefined || rec.new_tab === null;
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
out.push({ type: "custom", label, url, new_tab: newTab });
}
if (out.length >= HEADER_NAV_LIMITS.items) break;
}
return out;
}
/** 自定义菜单是否为外链(外链渲染 target=_blank) */
export function isHeaderNavExternal(url: string): boolean {
return isExternalFooterURL(url.trim());
}

View File

@@ -0,0 +1,39 @@
import type { LibrarySectionItem } from "@/lib/api";
/** 章 → 其下小节(保持平铺顺序;顺序沿用后端 sort_order) */
export function groupSections(sections: LibrarySectionItem[]) {
const groups: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
for (const s of sections) {
if (s.parent_id == null) {
groups.push({ chapter: s, subs: [] });
} else {
groups[groups.length - 1]?.subs.push(s);
}
}
return groups;
}
export interface ChapterNav {
/** 当前章下标(0 起) */
index: number;
/** 章总数 */
total: number;
prev: LibrarySectionItem | null;
next: LibrarySectionItem | null;
}
/** 当前章在章节序列中的位置与相邻章(找不到回落第一章) */
export function chapterNav(
groups: { chapter: LibrarySectionItem }[],
currentId: number | null
): ChapterNav {
const total = groups.length;
let index = groups.findIndex((g) => g.chapter.id === currentId);
if (index < 0) index = 0;
return {
index,
total,
prev: index > 0 ? groups[index - 1].chapter : null,
next: index < total - 1 ? groups[index + 1].chapter : null,
};
}

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { DEFAULT_MOBILE_TABS, MOBILE_TABS, normalizeMobileTabs } from "./mobileTabs.ts";
describe("normalizeMobileTabs", () => {
it("合法子集原样保留", () => {
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
});
it("去重与去空白", () => {
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
});
it("大小写与首尾空格容错", () => {
assert.deepEqual(normalizeMobileTabs([" HOME ", "Me"]), ["home", "me"]);
});
it("非法元素被剔除", () => {
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
});
it("空数组/非法输入回落默认全量", () => {
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
});
it("默认全量与固定顺序一致", () => {
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
});
});

View File

@@ -0,0 +1,29 @@
/**
* 移动端底栏配置的单一事实源:
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
*/
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
export type MobileTabKey = (typeof MOBILE_TABS)[number];
/** 底栏默认全量配置 */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
const seen = new Set<string>();
const out: MobileTabKey[] = [];
for (const item of raw) {
const key = String(item ?? "")
.trim()
.toLowerCase();
if (!key || seen.has(key)) continue;
if (!(MOBILE_TABS as readonly string[]).includes(key)) continue;
seen.add(key);
out.push(key as MobileTabKey);
}
return out.length > 0 ? out : [...DEFAULT_MOBILE_TABS];
}

View File

@@ -11,6 +11,7 @@
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。 // - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
import { apiMe } from "./api"; import { apiMe } from "./api";
import type { HeaderNavItem } from "./headerNav";
export const RT_HELLO = "hello"; export const RT_HELLO = "hello";
export const RT_PONG = "pong"; export const RT_PONG = "pong";
@@ -62,6 +63,8 @@ export interface RtSettingsData {
allow_register?: boolean; allow_register?: boolean;
allow_comments?: boolean; allow_comments?: boolean;
url_style?: string; url_style?: string;
mobile_tabs?: string[];
header_nav?: HeaderNavItem[];
comments_require_login?: boolean; comments_require_login?: boolean;
allow_messages?: boolean; allow_messages?: boolean;
post_cooldown_hours?: number; post_cooldown_hours?: number;

View File

@@ -6,6 +6,7 @@ import {
canonicalPath, canonicalPath,
homeCanonicalQuery, homeCanonicalQuery,
isTrackingParam, isTrackingParam,
isWideShellPath,
parseSiteOrigin, parseSiteOrigin,
resolveSiteOrigin, resolveSiteOrigin,
} from "./siteUrl.ts"; } from "./siteUrl.ts";
@@ -115,3 +116,24 @@ describe("homeCanonicalQuery", () => {
); );
}); });
}); });
describe("isWideShellPath(书库页放宽档位)", () => {
it("覆盖三种伪静态风格的书库路径", () => {
assert.equal(isWideShellPath("/library"), true);
assert.equal(isWideShellPath("/library/abc"), true);
assert.equal(isWideShellPath("/library/abc/read"), true);
assert.equal(isWideShellPath("/library.html"), true);
assert.equal(isWideShellPath("/library-abc.html"), true);
assert.equal(isWideShellPath("/library-abc.html?sec=2"), true);
assert.equal(isWideShellPath("/library-abc-read-2.html"), true);
});
it("非书库路径不放宽", () => {
assert.equal(isWideShellPath("/"), false);
assert.equal(isWideShellPath("/post-1.html"), false);
assert.equal(isWideShellPath("/p-about.html"), false);
assert.equal(isWideShellPath("/u/5"), false);
assert.equal(isWideShellPath("/admin/library"), false);
assert.equal(isWideShellPath("/my-library.html"), false);
assert.equal(isWideShellPath(null), false);
});
});

View File

@@ -115,6 +115,21 @@ export function canonicalBoard(raw: string | undefined): number | undefined {
return n; return n;
} }
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
export function isWideShellPath(pathname: string | null | undefined): boolean {
if (!pathname) return false;
const p = pathname.split("?")[0];
return (
p === "/library" ||
p.startsWith("/library/") ||
p === "/library.html" ||
/^\/library-.*\.html$/.test(p)
);
}
export function defaultFeedSort(allowComments: boolean): FeedSort { export function defaultFeedSort(allowComments: boolean): FeedSort {
return allowComments ? "latest" : "new"; return allowComments ? "latest" : "new";
} }

View File

@@ -33,7 +33,9 @@ function isDirectiveLine(t: string): boolean {
return ( return (
lower === "[/hide]" || lower === "[/hide]" ||
lower === "[/timeline]" || lower === "[/timeline]" ||
lower === "[/entries]" ||
lower === "[timeline]" || lower === "[timeline]" ||
lower === "[entries]" ||
lower.startsWith("[hide") || lower.startsWith("[hide") ||
t.startsWith("<!-- git:") t.startsWith("<!-- git:")
); );

View File

@@ -1,4 +1,5 @@
import type { TocItem } from "@/components/PostToc"; import type { TocItem } from "@/components/PostToc";
import type { ContentSegment } from "@/lib/directiveBlocks";
const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/; const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/; const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/;
@@ -14,13 +15,14 @@ export function timelineDayId(date: string): string {
* 从正文提取目录: * 从正文提取目录:
* - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id) * - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id)
* - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点) * - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点)
* - 代码围栏 / [hide] 块内标题不计入 * - 代码围栏 / [hide] / [entries] 块内标题不计入
*/ */
export function extractToc(content: string): TocItem[] { export function extractToc(content: string): TocItem[] {
const items: TocItem[] = []; const items: TocItem[] = [];
let inCode = false; let inCode = false;
let inTimeline = false; let inTimeline = false;
let inHide = false; let inHide = false;
let inEntries = false;
let headingCount = 0; let headingCount = 0;
/** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */ /** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */
let timelineDates: string[] = []; let timelineDates: string[] = [];
@@ -65,6 +67,17 @@ export function extractToc(content: string): TocItem[] {
continue; continue;
} }
if (!inHide && !inTimeline && !inEntries && lower === "[entries]") {
inEntries = true;
continue;
}
if (inEntries && lower === "[/entries]") {
inEntries = false;
continue;
}
// 条目卡块内标题不进目录(渲染时也不挂 h-{n} 锚点,避免错位)
if (inEntries) continue;
if (!inHide && HIDE_OPEN_RE.test(line)) { if (!inHide && HIDE_OPEN_RE.test(line)) {
inHide = true; inHide = true;
continue; continue;
@@ -95,6 +108,60 @@ export function markdownHeadingToc(toc: TocItem[]): TocItem[] {
return toc.filter((t) => /^h-\d+$/.test(t.id)); return toc.filter((t) => /^h-\d+$/.test(t.id));
} }
/** buildTocFromSegments 的产物:目录项 + 供正文挂锚点的三组 id */
export interface SegmentsToc {
/** 按渲染顺序的目录项(md 标题 / 条目标题 / 时间线日),id 已由 nextAnchor 分配 */
items: TocItem[];
/** md 段标题 id 序列(供 headingComponentsFor 按序挂到 h2/h3) */
mdHeadIds: string[];
/** 按段索引组织的条目标题 id(传给 MdEntries 挂到卡内标题) */
entriesTitleIds: (string[] | undefined)[];
}
/**
* 基于「渲染分段」构建章内目录(条目卡模式下的目录来源):
* - md 段标题 → h2/h3 级目录项(顺序与 headingRenderer 一致)
* - entries 段条目标题 → 二级目录项(与卡内标题共享锚点,目录可跳转)
* - timeline 段 → 日分组锚点(与 extractToc 同为日倒序);hide 段内容不进目录
* nextAnchor 与阅读页章内锚点计数器共享,保证目录、正文、卡内标题同一份 id
*/
export function buildTocFromSegments(
segs: ContentSegment[],
nextAnchor: () => string
): SegmentsToc {
const items: TocItem[] = [];
const mdHeadIds: string[] = [];
const entriesTitleIds: (string[] | undefined)[] = [];
for (let si = 0; si < segs.length; si++) {
const seg = segs[si];
if (seg.type === "md") {
for (const raw of seg.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
const m = HEADING_RE.exec(raw.trim());
if (!m) continue;
const id = nextAnchor();
items.push({ level: m[1].length as 2 | 3, text: m[2], id });
mdHeadIds.push(id);
}
continue;
}
if (seg.type === "entries") {
const ids = seg.items.map(() => nextAnchor());
seg.items.forEach((it, i) => items.push({ level: 2, text: it.title, id: ids[i] }));
entriesTitleIds[si] = ids;
continue;
}
if (seg.type === "timeline") {
const dates = [...new Set(seg.items.map((t) => t.date))].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
for (const date of dates) {
items.push({ level: 2, text: date, id: timelineDayId(date) });
}
}
// hide 段内容(未解锁/未登录不可见)不进目录
}
return { items, mdHeadIds, entriesTitleIds };
}
/** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */ /** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */
export function estimateReadingMinutes(text: string, fallbackLen = 0): number { export function estimateReadingMinutes(text: string, fallbackLen = 0): number {
const len = text.trim().length > 0 ? text.trim().length : fallbackLen; const len = text.trim().length > 0 ? text.trim().length : fallbackLen;

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