feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
"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"
|
||||
@@ -12,17 +15,23 @@ import (
|
||||
|
||||
// ===== 公开接口 =====
|
||||
|
||||
// AnnouncementsList 已发布公告列表(首页右栏 / 公告页)
|
||||
// AnnouncementsList 已发布公告列表(分页:page/size,默认 size=20)
|
||||
func (h *Handlers) AnnouncementsList(c *gin.Context) {
|
||||
list, err := h.Announcement.ListPublished(10)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
list, total, err := h.Announcement.ListPublishedPage(page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"announcements": nonNilSlice(list),
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// AnnouncementDetail 已发布公告详情
|
||||
// AnnouncementDetail 已发布公告详情(按读者能力脱敏 [hide])
|
||||
func (h *Handlers) AnnouncementDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -38,7 +47,18 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||
caps := markdown.ViewerCaps{}
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
caps.LoggedIn = true
|
||||
if model.RoleLevel(model.Role(claims.Role)) >= model.RoleLevel(model.RoleAdmin) {
|
||||
caps.Bypass = true
|
||||
}
|
||||
}
|
||||
// 公告不可评论:回复块若有残留也永不解锁(保存期已拒收)
|
||||
sanitized, _ := markdown.SanitizeForViewer(a.Content, caps)
|
||||
out := *a
|
||||
out.Content = sanitized
|
||||
c.JSON(http.StatusOK, gin.H{"announcement": out})
|
||||
}
|
||||
|
||||
// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
|
||||
@@ -92,6 +112,25 @@ func (h *Handlers) AdminUpdateAnnouncement(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||
}
|
||||
|
||||
// AdminToggleAnnouncementPin 切换公告置顶
|
||||
func (h *Handlers) AdminToggleAnnouncementPin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
|
||||
return
|
||||
}
|
||||
a, err := h.Announcement.TogglePin(uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||
}
|
||||
|
||||
// AdminDeleteAnnouncement 删除公告
|
||||
func (h *Handlers) AdminDeleteAnnouncement(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -21,6 +21,7 @@ type Handlers struct {
|
||||
OverviewSvc *service.OverviewService
|
||||
Checkin *service.CheckinService
|
||||
Announcement *service.AnnouncementService
|
||||
SitePage *service.SitePageService
|
||||
Upload *service.UploadService
|
||||
PostFile *service.PostFileService
|
||||
Points *service.PointsService
|
||||
|
||||
@@ -34,6 +34,8 @@ func (h *Handlers) Overview(c *gin.Context) {
|
||||
"active_users": data.ActiveUsers,
|
||||
"boards": data.Boards,
|
||||
"announcements": data.Announcements,
|
||||
"announcements_total": data.AnnouncementsTotal,
|
||||
"sidebar_pages": data.SidebarPages,
|
||||
"new_users": data.NewUsers,
|
||||
"checkin": data.Checkin,
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ type updateSettingsRequest struct {
|
||||
AttachmentMaxMB *int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount *int `json:"attachment_max_count"`
|
||||
ImageMaxMB *int `json:"image_max_mb"`
|
||||
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -75,7 +76,24 @@ func (req *updateSettingsRequest) hasAny() bool {
|
||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
|
||||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
||||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil
|
||||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.TimelineGitImport != nil
|
||||
}
|
||||
|
||||
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
|
||||
func (h *Handlers) AdminGetSettings(c *gin.Context) {
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||
return
|
||||
}
|
||||
adapter, err := h.Setting.TimelineGitAdapterJSON()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提交导入配置失败"})
|
||||
return
|
||||
}
|
||||
out := settingsPayload(saved)
|
||||
out["timeline_git_import"] = adapter
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// PUT /api/admin/settings
|
||||
@@ -247,6 +265,16 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var savedAdapter string
|
||||
if req.TimelineGitImport != nil {
|
||||
normalized, err := h.Setting.SetTimelineGitAdapterJSON(*req.TimelineGitImport)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
savedAdapter = normalized
|
||||
}
|
||||
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||
@@ -260,5 +288,8 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
})
|
||||
out := settingsPayload(saved)
|
||||
out["ok"] = true
|
||||
if req.TimelineGitImport != nil {
|
||||
out["timeline_git_import"] = savedAdapter
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
93
backend/handler/site_page.go
Normal file
93
backend/handler/site_page.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SitePageDetail 公开单页详情(按 slug)
|
||||
func (h *Handlers) SitePageDetail(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
p, err := h.SitePage.GetPublishedBySlug(slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取页面失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"page": p})
|
||||
}
|
||||
|
||||
// AdminListSitePages 后台单页列表
|
||||
func (h *Handlers) AdminListSitePages(c *gin.Context) {
|
||||
list, err := h.SitePage.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取页面失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pages": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// AdminCreateSitePage 新建单页
|
||||
func (h *Handlers) AdminCreateSitePage(c *gin.Context) {
|
||||
var in service.SitePageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
p, err := h.SitePage.Create(&in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"page": p})
|
||||
}
|
||||
|
||||
// AdminUpdateSitePage 编辑单页
|
||||
func (h *Handlers) AdminUpdateSitePage(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.SitePageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
p, err := h.SitePage.Update(uint(id), &in)
|
||||
if err != nil {
|
||||
if 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{"page": p})
|
||||
}
|
||||
|
||||
// AdminDeleteSitePage 删除单页
|
||||
func (h *Handlers) AdminDeleteSitePage(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.Delete(uint(id)); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
29
backend/handler/timeline.go
Normal file
29
backend/handler/timeline.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TimelineFromGit 从 Git commits 页 URL 导入时间线条目(登录 + CSRF + 限流)
|
||||
func (h *Handlers) TimelineFromGit(c *gin.Context) {
|
||||
var req struct {
|
||||
URLs []string `json:"urls"`
|
||||
FollowPages *bool `json:"follow_pages"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
follow := true
|
||||
if req.FollowPages != nil {
|
||||
follow = *req.FollowPages
|
||||
}
|
||||
result, err := h.Setting.ImportTimelineFromGit(req.URLs, follow)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package markdown 提供帖子正文隐藏块(:::hide)的解析、校验与脱敏。
|
||||
// Package markdown 提供帖子正文隐藏块(行级 BBCode [hide]…[/hide])的解析、校验与脱敏。
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -58,7 +59,10 @@ var (
|
||||
ErrHidePasswordNeed = errors.New("密码可见块须设置 1–64 字符且不含空格的密码")
|
||||
)
|
||||
|
||||
// ParseHideBlocks 扫描正文中的 :::hide 块(忽略代码围栏内伪语法)。
|
||||
// 开标记:整行 [hide <kind> …];闭标记:整行 [/hide]
|
||||
var hideOpenRe = regexp.MustCompile(`(?i)^\[hide(?:\s+(.+))?\]$`)
|
||||
|
||||
// ParseHideBlocks 扫描正文中的 [hide]…[/hide] 块(忽略代码围栏内伪语法)。
|
||||
func ParseHideBlocks(content string) ([]HideBlock, error) {
|
||||
lines := splitLines(content)
|
||||
var blocks []HideBlock
|
||||
@@ -175,7 +179,7 @@ func DeriveAccessFromContent(content string) (DerivedAccess, error) {
|
||||
}
|
||||
|
||||
// SanitizeForViewer 按读者能力脱敏:未满足条件的块清空正文并加 locked。
|
||||
// 密码块即使已解锁,也不向读者回传明文密码(开标记写成 :::hide password)。
|
||||
// 密码块即使已解锁,也不向读者回传明文密码(开标记写成 [hide password] 或 [hide password locked])。
|
||||
func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fullyLocked bool) {
|
||||
blocks, err := ParseHideBlocks(content)
|
||||
if err != nil {
|
||||
@@ -209,10 +213,10 @@ func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fully
|
||||
anyVisible = true
|
||||
}
|
||||
}
|
||||
out = append(out, ":::")
|
||||
out = append(out, "[/hide]")
|
||||
} else {
|
||||
out = append(out, openMarkerLine(b.Kind, b.Points, "", true))
|
||||
out = append(out, ":::")
|
||||
out = append(out, "[/hide]")
|
||||
}
|
||||
cursor = b.End + 1
|
||||
}
|
||||
@@ -249,16 +253,16 @@ func WrapContentAsHide(kind string, points int, content string) string {
|
||||
body := strings.TrimRight(content, "\n")
|
||||
open := openMarkerLine(kind, points, "", false)
|
||||
if body == "" {
|
||||
return open + "\n:::\n"
|
||||
return open + "\n[/hide]\n"
|
||||
}
|
||||
return open + "\n" + body + "\n:::\n"
|
||||
return open + "\n" + body + "\n[/hide]\n"
|
||||
}
|
||||
|
||||
// HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等)
|
||||
func HasHideBlocks(content string) bool {
|
||||
blocks, err := ParseHideBlocks(content)
|
||||
if err != nil {
|
||||
return strings.Contains(content, ":::hide")
|
||||
return strings.Contains(strings.ToLower(content), "[hide")
|
||||
}
|
||||
return len(blocks) > 0
|
||||
}
|
||||
@@ -281,28 +285,30 @@ func blockUnlocked(b HideBlock, caps ViewerCaps) bool {
|
||||
// openMarkerLine 生成开标记。密码仅在原文存储时写入;对外脱敏输出传空 password。
|
||||
func openMarkerLine(kind string, points int, password string, locked bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(":::hide ")
|
||||
b.WriteString("[hide ")
|
||||
b.WriteString(kind)
|
||||
if kind == HideKindPoints && points > 0 {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString("=")
|
||||
b.WriteString(strconv.Itoa(points))
|
||||
}
|
||||
if kind == HideKindPassword && password != "" && !locked {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString("=")
|
||||
b.WriteString(password)
|
||||
}
|
||||
if locked {
|
||||
b.WriteString(" locked")
|
||||
}
|
||||
b.WriteByte(']')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseOpenMarker 解析 :::hide <kind> [points|password] [locked]
|
||||
// parseOpenMarker 解析 [hide <kind>[=arg] [locked]]
|
||||
func parseOpenMarker(trimmed string) (kind string, points int, password string, locked bool, ok bool) {
|
||||
if !strings.HasPrefix(trimmed, ":::hide") {
|
||||
m := hideOpenRe.FindStringSubmatch(trimmed)
|
||||
if m == nil {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[len(":::hide"):])
|
||||
rest := strings.TrimSpace(m[1])
|
||||
if rest == "" {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
@@ -310,32 +316,43 @@ func parseOpenMarker(trimmed string) (kind string, points int, password string,
|
||||
if len(parts) == 0 {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
kind = parts[0]
|
||||
|
||||
head := parts[0]
|
||||
kind = head
|
||||
arg := ""
|
||||
if i := strings.IndexByte(head, '='); i >= 0 {
|
||||
kind = head[:i]
|
||||
arg = head[i+1:]
|
||||
}
|
||||
kind = strings.ToLower(kind)
|
||||
switch kind {
|
||||
case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword:
|
||||
default:
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
|
||||
idx := 1
|
||||
if kind == HideKindPoints {
|
||||
if idx >= len(parts) {
|
||||
if arg == "" {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
n, err := strconv.Atoi(parts[idx])
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
points = n
|
||||
idx++
|
||||
} else if kind == HideKindPassword {
|
||||
// 允许::::hide password locked(脱敏)或 :::hide password <pwd> [locked]
|
||||
if idx < len(parts) && parts[idx] != "locked" {
|
||||
password = parts[idx]
|
||||
idx++
|
||||
// [hide password=secret] 或脱敏 [hide password locked]
|
||||
if arg != "" {
|
||||
password = arg
|
||||
}
|
||||
} else if arg != "" {
|
||||
// login/reply 不应带 =arg
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
|
||||
for ; idx < len(parts); idx++ {
|
||||
if parts[idx] == "locked" {
|
||||
if strings.EqualFold(parts[idx], "locked") {
|
||||
locked = true
|
||||
} else {
|
||||
return "", 0, "", false, false
|
||||
@@ -356,7 +373,7 @@ func validatePassword(pwd string) error {
|
||||
}
|
||||
|
||||
func isCloseMarker(trimmed string) bool {
|
||||
return trimmed == ":::"
|
||||
return strings.EqualFold(trimmed, "[/hide]")
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
@@ -378,7 +395,7 @@ func ValidateHideContent(content string) error {
|
||||
case errors.Is(err, ErrHideNested):
|
||||
return fmt.Errorf("隐藏块不可嵌套")
|
||||
case errors.Is(err, ErrHideUnclosed):
|
||||
return fmt.Errorf("隐藏块未正确闭合,请检查 ::: 标记")
|
||||
return fmt.Errorf("隐藏块未正确闭合,请检查 [/hide] 标记")
|
||||
case errors.Is(err, ErrHidePointsNeed):
|
||||
return fmt.Errorf("积分可见块须指定 1–100000 的积分")
|
||||
case errors.Is(err, ErrHidePasswordNeed):
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestParseIgnoreCodeFence(t *testing.T) {
|
||||
src := "公开\n\n```\n:::hide login\n假的\n:::\n```\n\n结尾\n"
|
||||
src := "公开\n\n```\n[hide login]\n假的\n[/hide]\n```\n\n结尾\n"
|
||||
blocks, err := ParseHideBlocks(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -17,7 +17,7 @@ func TestParseIgnoreCodeFence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseMixedAndDerive(t *testing.T) {
|
||||
src := "公开段\n\n:::hide login\n登录内容\n:::\n\n:::hide points 10\n积分A\n:::\n\n:::hide points 5\n积分B\n:::\n"
|
||||
src := "公开段\n\n[hide login]\n登录内容\n[/hide]\n\n[hide points=10]\n积分A\n[/hide]\n\n[hide points=5]\n积分B\n[/hide]\n"
|
||||
blocks, err := ParseHideBlocks(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -35,7 +35,7 @@ func TestParseMixedAndDerive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseNestedRejected(t *testing.T) {
|
||||
src := ":::hide login\n外\n:::hide reply\n内\n:::\n:::\n"
|
||||
src := "[hide login]\n外\n[hide reply]\n内\n[/hide]\n[/hide]\n"
|
||||
_, err := ParseHideBlocks(src)
|
||||
if err != ErrHideNested {
|
||||
t.Fatalf("want ErrHideNested, got %v", err)
|
||||
@@ -43,7 +43,7 @@ func TestParseNestedRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeLocksBody(t *testing.T) {
|
||||
src := "公开\n\n:::hide login\n秘密内容\n:::\n\n尾\n"
|
||||
src := "公开\n\n[hide login]\n秘密内容\n[/hide]\n\n尾\n"
|
||||
out, fully := SanitizeForViewer(src, ViewerCaps{})
|
||||
if fully {
|
||||
t.Fatal("should not be fully locked")
|
||||
@@ -51,7 +51,7 @@ func TestSanitizeLocksBody(t *testing.T) {
|
||||
if strings.Contains(out, "秘密内容") {
|
||||
t.Fatalf("leaked: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ":::hide login locked") {
|
||||
if !strings.Contains(out, "[hide login locked]") {
|
||||
t.Fatalf("missing locked marker: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "公开") || !strings.Contains(out, "尾") {
|
||||
@@ -60,7 +60,7 @@ func TestSanitizeLocksBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeUnlockLogin(t *testing.T) {
|
||||
src := ":::hide login\n秘密\n:::\n"
|
||||
src := "[hide login]\n秘密\n[/hide]\n"
|
||||
out, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
|
||||
if fully {
|
||||
t.Fatal("should see content")
|
||||
@@ -74,7 +74,7 @@ func TestSanitizeUnlockLogin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeBypass(t *testing.T) {
|
||||
src := ":::hide points 9\n付费\n:::\n"
|
||||
src := "[hide points=9]\n付费\n[/hide]\n"
|
||||
out, _ := SanitizeForViewer(src, ViewerCaps{Bypass: true})
|
||||
if !strings.Contains(out, "付费") {
|
||||
t.Fatalf("bypass failed: %q", out)
|
||||
@@ -82,7 +82,7 @@ func TestSanitizeBypass(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizePointsPaid(t *testing.T) {
|
||||
src := ":::hide points 3\n付费文\n:::\n"
|
||||
src := "[hide points=3]\n付费文\n[/hide]\n"
|
||||
out, _ := SanitizeForViewer(src, ViewerCaps{LoggedIn: true, PointsPaid: true})
|
||||
if !strings.Contains(out, "付费文") {
|
||||
t.Fatalf("paid unlock failed: %q", out)
|
||||
@@ -90,7 +90,7 @@ func TestSanitizePointsPaid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFullyLocked(t *testing.T) {
|
||||
src := ":::hide reply\n仅回复\n:::\n"
|
||||
src := "[hide reply]\n仅回复\n[/hide]\n"
|
||||
_, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
|
||||
if !fully {
|
||||
t.Fatal("expected fully locked")
|
||||
@@ -98,7 +98,7 @@ func TestFullyLocked(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeInsideHide(t *testing.T) {
|
||||
src := ":::hide login\n```\n:::hide reply\n伪\n:::\n```\n真\n:::\n"
|
||||
src := "[hide login]\n```\n[hide reply]\n伪\n[/hide]\n```\n真\n[/hide]\n"
|
||||
blocks, err := ParseHideBlocks(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -112,7 +112,7 @@ func TestCodeInsideHide(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParsePasswordAndSanitize(t *testing.T) {
|
||||
src := "公开\n\n:::hide password secret1\n密码内容\n:::\n"
|
||||
src := "公开\n\n[hide password=secret1]\n密码内容\n[/hide]\n"
|
||||
blocks, err := ParseHideBlocks(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -131,7 +131,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
|
||||
if strings.Contains(out, "secret1") || strings.Contains(out, "密码内容") {
|
||||
t.Fatalf("leaked: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ":::hide password locked") {
|
||||
if !strings.Contains(out, "[hide password locked]") {
|
||||
t.Fatalf("%q", out)
|
||||
}
|
||||
out2, _ := SanitizeForViewer(src, ViewerCaps{PasswordUnlocked: map[int]bool{0: true}})
|
||||
@@ -144,7 +144,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMatchPasswordBlocks(t *testing.T) {
|
||||
src := ":::hide password aaa\nA\n:::\n\n:::hide password bbb\nB\n:::\n"
|
||||
src := "[hide password=aaa]\nA\n[/hide]\n\n[hide password=bbb]\nB\n[/hide]\n"
|
||||
hit, err := MatchPasswordBlocks(src, "bbb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -155,7 +155,7 @@ func TestMatchPasswordBlocks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPasswordNeed(t *testing.T) {
|
||||
_, err := ParseHideBlocks(":::hide password\n无密码\n:::\n")
|
||||
_, err := ParseHideBlocks("[hide password]\n无密码\n[/hide]\n")
|
||||
if err != ErrHidePasswordNeed {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
@@ -163,10 +163,34 @@ func TestPasswordNeed(t *testing.T) {
|
||||
|
||||
func TestWrapContentAsHide(t *testing.T) {
|
||||
got := WrapContentAsHide("points", 20, "旧正文")
|
||||
if !strings.HasPrefix(got, ":::hide points 20\n") {
|
||||
if !strings.HasPrefix(got, "[hide points=20]\n") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "旧正文") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimelineDoesNotBreakHideValidate(t *testing.T) {
|
||||
src := "[timeline]\n## 2026-09-16 feat\n说明\n[/timeline]\n"
|
||||
if err := ValidateHideContent(src); err != nil {
|
||||
t.Fatalf("timeline-only should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteLegacyDirectives(t *testing.T) {
|
||||
src := "公开\n\n:::hide login\n秘\n:::\n\n:::timeline\n## 2026-01-01 A\n:::\n"
|
||||
got := RewriteLegacyDirectives(src)
|
||||
if strings.Contains(got, ":::") {
|
||||
t.Fatalf("legacy remains: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[hide login]") || !strings.Contains(got, "[/hide]") {
|
||||
t.Fatalf("hide missing: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[timeline]") || !strings.Contains(got, "[/timeline]") {
|
||||
t.Fatalf("timeline missing: %q", got)
|
||||
}
|
||||
if err := ValidateHideContent(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
158
backend/markdown/legacy_rewrite.go
Normal file
158
backend/markdown/legacy_rewrite.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RewriteLegacyDirectives 将旧式 :::hide / :::timeline / 裸 ::: 改写为行级 BBCode。
|
||||
// 幂等:已是 BBCode 的内容原样返回(若无旧标记)。代码围栏内不改写。
|
||||
func RewriteLegacyDirectives(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
}
|
||||
if !strings.Contains(content, ":::") {
|
||||
return content
|
||||
}
|
||||
lines := splitLines(content)
|
||||
var out []string
|
||||
inCode := false
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
inCode = !inCode
|
||||
out = append(out, lines[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if inCode {
|
||||
out = append(out, lines[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if kind, pts, pwd, locked, ok := parseLegacyHideOpen(trimmed); ok {
|
||||
bodyLines := make([]string, 0)
|
||||
j := i + 1
|
||||
innerCode := false
|
||||
found := false
|
||||
for j < len(lines) {
|
||||
inner := strings.TrimSpace(lines[j])
|
||||
if strings.HasPrefix(inner, "```") {
|
||||
innerCode = !innerCode
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if innerCode {
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if inner == ":::" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
}
|
||||
out = append(out, openMarkerLine(kind, pts, pwd, locked))
|
||||
out = append(out, bodyLines...)
|
||||
if found {
|
||||
out = append(out, "[/hide]")
|
||||
i = j + 1
|
||||
} else {
|
||||
// 未闭合:仍写出已转换开标记与正文,避免丢内容
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == ":::timeline" || strings.HasPrefix(trimmed, ":::timeline ") {
|
||||
bodyLines := make([]string, 0)
|
||||
j := i + 1
|
||||
innerCode := false
|
||||
found := false
|
||||
for j < len(lines) {
|
||||
inner := strings.TrimSpace(lines[j])
|
||||
if strings.HasPrefix(inner, "```") {
|
||||
innerCode = !innerCode
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if innerCode {
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if inner == ":::" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
bodyLines = append(bodyLines, lines[j])
|
||||
j++
|
||||
}
|
||||
out = append(out, "[timeline]")
|
||||
out = append(out, bodyLines...)
|
||||
if found {
|
||||
out = append(out, "[/timeline]")
|
||||
i = j + 1
|
||||
} else {
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, lines[i])
|
||||
i++
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// parseLegacyHideOpen 解析旧式 :::hide <kind> [arg] [locked]
|
||||
func parseLegacyHideOpen(trimmed string) (kind string, points int, password string, locked bool, ok bool) {
|
||||
if !strings.HasPrefix(trimmed, ":::hide") {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[len(":::hide"):])
|
||||
if rest == "" {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) == 0 {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
kind = parts[0]
|
||||
switch kind {
|
||||
case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword:
|
||||
default:
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
idx := 1
|
||||
if kind == HideKindPoints {
|
||||
if idx >= len(parts) {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
n, err := strconv.Atoi(parts[idx])
|
||||
if err != nil {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
points = n
|
||||
idx++
|
||||
} else if kind == HideKindPassword {
|
||||
if idx < len(parts) && parts[idx] != "locked" {
|
||||
password = parts[idx]
|
||||
idx++
|
||||
}
|
||||
}
|
||||
for ; idx < len(parts); idx++ {
|
||||
if parts[idx] == "locked" {
|
||||
locked = true
|
||||
} else {
|
||||
return "", 0, "", false, false
|
||||
}
|
||||
}
|
||||
return kind, points, password, locked, true
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -36,9 +37,14 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("清理遗留 FK 约束失败: %w", err)
|
||||
}
|
||||
|
||||
// 公告 pinned:已有行不能直接 ADD NOT NULL,先加列并回填 false
|
||||
if err := prepareAnnouncementPinnedColumn(db); err != nil {
|
||||
return fmt.Errorf("公告置顶列迁移失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{},
|
||||
@@ -46,10 +52,14 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 旧帖整帖可见性 → 正文 :::hide 块(幂等)
|
||||
// 旧帖整帖可见性 → 正文 [hide] 块(幂等)
|
||||
if err := migratePostContentAccessToHideBlocks(db); err != nil {
|
||||
return fmt.Errorf("正文隐藏块迁移失败: %w", err)
|
||||
}
|
||||
// 旧式 :::hide / :::timeline → 行级 BBCode(幂等)
|
||||
if err := migrateLegacyDirectiveSyntax(db); err != nil {
|
||||
return fmt.Errorf("短代码语法迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
||||
if err := backfillPointsFromCheckin(db); err != nil {
|
||||
@@ -120,6 +130,28 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareAnnouncementPinnedColumn 存量公告加 pinned NOT NULL:先 DEFAULT false 再回填
|
||||
func prepareAnnouncementPinnedColumn(db *gorm.DB) error {
|
||||
var tableCount int64
|
||||
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'announcements'`).
|
||||
Scan(&tableCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if tableCount == 0 {
|
||||
return nil
|
||||
}
|
||||
var hasCol int64
|
||||
if err := db.Raw(`SELECT count(1) FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'pinned'`).
|
||||
Scan(&hasCol).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if hasCol > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
|
||||
}
|
||||
|
||||
// prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列,
|
||||
// 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前:
|
||||
// 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败)
|
||||
@@ -372,7 +404,7 @@ func ensureDefaultChatMemberships(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 :::hide 块(幂等)。
|
||||
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。
|
||||
func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
|
||||
var posts []Post
|
||||
if err := db.Unscoped().
|
||||
@@ -407,3 +439,98 @@ func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateLegacyDirectiveSyntax 将正文中的 :::hide / :::timeline 改写为 BBCode(幂等)。
|
||||
func migrateLegacyDirectiveSyntax(db *gorm.DB) error {
|
||||
type row struct {
|
||||
ID uint
|
||||
Content string
|
||||
}
|
||||
rewriteTable := func(table string, rows []row) (int, error) {
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
if !strings.Contains(r.Content, ":::") {
|
||||
continue
|
||||
}
|
||||
next := markdown.RewriteLegacyDirectives(r.Content)
|
||||
if next == r.Content {
|
||||
continue
|
||||
}
|
||||
if err := db.Table(table).Where("id = ?", r.ID).
|
||||
Update("content", next).Error; err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var posts []row
|
||||
if err := db.Model(&Post{}).Unscoped().Select("id", "content").
|
||||
Where("content LIKE ?", "%:::%").Find(&posts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := rewriteTable("posts", posts); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
log.Printf("[model] 帖子短代码改写:%d 篇", n)
|
||||
}
|
||||
|
||||
var comments []row
|
||||
if err := db.Model(&Comment{}).Unscoped().Select("id", "content").
|
||||
Where("content LIKE ?", "%:::%").Find(&comments).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := rewriteTable("comments", comments); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
log.Printf("[model] 评论短代码改写:%d 条", n)
|
||||
}
|
||||
|
||||
var anns []row
|
||||
if err := db.Model(&Announcement{}).Unscoped().Select("id", "content").
|
||||
Where("content LIKE ?", "%:::%").Find(&anns).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := rewriteTable("announcements", anns); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
log.Printf("[model] 公告短代码改写:%d 条", n)
|
||||
}
|
||||
|
||||
var pages []row
|
||||
if err := db.Model(&SitePage{}).Unscoped().Select("id", "content").
|
||||
Where("content LIKE ?", "%:::%").Find(&pages).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := rewriteTable("site_pages", pages); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
log.Printf("[model] 站点页短代码改写:%d 篇", n)
|
||||
}
|
||||
|
||||
var hist []struct {
|
||||
ID uint
|
||||
OldContent string
|
||||
}
|
||||
if err := db.Model(&CommentEditHistory{}).Select("id", "old_content").
|
||||
Where("old_content LIKE ?", "%:::%").Find(&hist).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nHist := 0
|
||||
for _, r := range hist {
|
||||
next := markdown.RewriteLegacyDirectives(r.OldContent)
|
||||
if next == r.OldContent {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&CommentEditHistory{}).Where("id = ?", r.ID).
|
||||
Update("old_content", next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nHist++
|
||||
}
|
||||
if nHist > 0 {
|
||||
log.Printf("[model] 评论历史短代码改写:%d 条", nHist)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func NormalizePostType(t string) string {
|
||||
return PostTypeDiscussion
|
||||
}
|
||||
|
||||
// 正文可见性(由正文 :::hide 块派生;mixed = 多种隐藏类型并存)
|
||||
// 正文可见性(由正文 [hide] 块派生;mixed = 多种隐藏类型并存)
|
||||
const (
|
||||
ContentAccessPublic = "public" // 公开
|
||||
ContentAccessLogin = "login" // 仅登录可见块
|
||||
@@ -388,8 +388,25 @@ type Announcement struct {
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
|
||||
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray
|
||||
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray/teal/cyan/pink/amber/indigo/rose
|
||||
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
|
||||
Pinned bool `gorm:"not null;index" json:"pinned"` // 默认 false;写入须 Select,禁止 default:true
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// SitePage 站点单页(功能介绍等;侧栏入口 + /p/:slug)
|
||||
type SitePage 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"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Excerpt string `gorm:"size:300" json:"excerpt"`
|
||||
Published bool `gorm:"not null;index" json:"published"`
|
||||
ShowInSidebar bool `gorm:"not null;index" json:"show_in_sidebar"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
AllowComments bool `gorm:"not null" json:"allow_comments"` // 默认 false;本轮无评论 UI
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
@@ -51,6 +51,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
overviewSvc := service.NewOverviewService(model.DB)
|
||||
checkinSvc := service.NewCheckinService(model.DB)
|
||||
announcementSvc := service.NewAnnouncementService(model.DB)
|
||||
sitePageSvc := service.NewSitePageService(model.DB)
|
||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
@@ -79,6 +80,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
OverviewSvc: overviewSvc,
|
||||
Checkin: checkinSvc,
|
||||
Announcement: announcementSvc,
|
||||
SitePage: sitePageSvc,
|
||||
Upload: uploadSvc,
|
||||
PostFile: postFileSvc,
|
||||
Points: pointsSvc,
|
||||
@@ -129,6 +131,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||
pubAPI.GET("/announcements", h.AnnouncementsList)
|
||||
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
||||
pubAPI.GET("/pages/:slug", h.SitePageDetail)
|
||||
pubAPI.GET("/settings", h.PublicSettings)
|
||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
@@ -183,6 +186,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.GET("/my/media", h.MyMedia)
|
||||
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
|
||||
|
||||
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
||||
api.POST("/timeline/from-git", middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit), h.TimelineFromGit)
|
||||
|
||||
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
||||
{
|
||||
@@ -247,9 +253,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
||||
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
||||
announceAPI.GET("/pages", h.AdminListSitePages)
|
||||
announceAPI.POST("/pages", h.AdminCreateSitePage)
|
||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||
|
||||
// 站点外观设置(超级管理员/站长)
|
||||
// 站点外观设置(超级管理员/站长);含 timeline_git_import
|
||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
||||
|
||||
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
var allowedTagColors = map[string]bool{
|
||||
"blue": true, "green": true, "orange": true,
|
||||
"red": true, "purple": true, "gray": true,
|
||||
"teal": true, "cyan": true, "pink": true,
|
||||
"amber": true, "indigo": true, "rose": true,
|
||||
}
|
||||
|
||||
// AnnouncementService 站点公告服务
|
||||
@@ -30,6 +33,7 @@ type AnnouncementInput struct {
|
||||
Tag string `json:"tag"`
|
||||
TagColor string `json:"tag_color"`
|
||||
Published *bool `json:"published"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// normalize 清洗并校验入参,返回可直接落库的字段
|
||||
@@ -45,6 +49,12 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
|
||||
if in.Content == "" {
|
||||
return errors.New("内容不能为空")
|
||||
}
|
||||
if err := markdown.ValidateHideContent(in.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectReplyHideBlocks(in.Content, ErrAnnouncementReplyHideNotAllowed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in.Tag = strings.TrimSpace(in.Tag)
|
||||
if in.Tag == "" {
|
||||
@@ -68,7 +78,7 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyTo 把入参写入模型(Published 为指针以区分"未传"与"显式 false")
|
||||
// applyTo 把入参写入模型(Published/Pinned 为指针以区分"未传"与"显式 false")
|
||||
func (in *AnnouncementInput) applyTo(a *model.Announcement) {
|
||||
a.Title = in.Title
|
||||
a.Content = in.Content
|
||||
@@ -77,19 +87,46 @@ func (in *AnnouncementInput) applyTo(a *model.Announcement) {
|
||||
if in.Published != nil {
|
||||
a.Published = *in.Published
|
||||
}
|
||||
if in.Pinned != nil {
|
||||
a.Pinned = *in.Pinned
|
||||
}
|
||||
}
|
||||
|
||||
// ListPublished 已发布公告(首页右栏 / 公开列表),按发布时间倒序
|
||||
// 写入字段须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名
|
||||
var announcementWriteFields = []string{
|
||||
"Title", "Content", "Tag", "TagColor", "Published", "Pinned",
|
||||
}
|
||||
|
||||
// ListPublished 已发布公告(首页右栏),置顶优先再按发布时间倒序
|
||||
func (s *AnnouncementService) ListPublished(limit int) ([]model.Announcement, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
var list []model.Announcement
|
||||
err := s.db.Where("published = ?", true).
|
||||
Order("created_at DESC, id DESC").Limit(limit).Find(&list).Error
|
||||
Order("pinned DESC, created_at DESC, id DESC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ListPublishedPage 已发布公告分页(全部公告页)
|
||||
func (s *AnnouncementService) ListPublishedPage(page, size int) ([]model.Announcement, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
q := s.db.Model(&model.Announcement{}).Where("published = ?", true)
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []model.Announcement
|
||||
err := q.Order("pinned DESC, created_at DESC, id DESC").
|
||||
Offset((page - 1) * size).Limit(size).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// GetPublished 取单条已发布公告(详情页);草稿/不存在返回 gorm.ErrRecordNotFound
|
||||
func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error) {
|
||||
var a model.Announcement
|
||||
@@ -100,10 +137,10 @@ func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ListAll 管理后台:含草稿,按更新时间倒序
|
||||
// ListAll 管理后台:含草稿,置顶优先
|
||||
func (s *AnnouncementService) ListAll() ([]model.Announcement, error) {
|
||||
var list []model.Announcement
|
||||
err := s.db.Order("created_at DESC, id DESC").Limit(100).Find(&list).Error
|
||||
err := s.db.Order("pinned DESC, created_at DESC, id DESC").Limit(100).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
@@ -112,9 +149,9 @@ func (s *AnnouncementService) Create(in *AnnouncementInput) (*model.Announcement
|
||||
if err := in.normalize(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &model.Announcement{Published: true}
|
||||
a := &model.Announcement{Published: true, Pinned: false}
|
||||
in.applyTo(a)
|
||||
if err := s.db.Create(a).Error; err != nil {
|
||||
if err := s.db.Select(announcementWriteFields).Create(a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
@@ -130,7 +167,27 @@ func (s *AnnouncementService) Update(id uint, in *AnnouncementInput) (*model.Ann
|
||||
return nil, err
|
||||
}
|
||||
in.applyTo(&a)
|
||||
if err := s.db.Save(&a).Error; err != nil {
|
||||
if err := s.db.Model(&model.Announcement{}).Where("id = ?", a.ID).Updates(map[string]interface{}{
|
||||
"title": a.Title,
|
||||
"content": a.Content,
|
||||
"tag": a.Tag,
|
||||
"tag_color": a.TagColor,
|
||||
"published": a.Published,
|
||||
"pinned": a.Pinned,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// TogglePin 切换置顶状态
|
||||
func (s *AnnouncementService) TogglePin(id uint) (*model.Announcement, error) {
|
||||
var a model.Announcement
|
||||
if err := s.db.First(&a, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Pinned = !a.Pinned
|
||||
if err := s.db.Model(&a).Select("Pinned").Update("pinned", a.Pinned).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
|
||||
37
backend/service/content_hide.go
Normal file
37
backend/service/content_hide.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAnnouncementReplyHideNotAllowed 公告不可评论,禁止回复可见块
|
||||
ErrAnnouncementReplyHideNotAllowed = errors.New("公告不可评论,不能使用回复可见")
|
||||
// ErrSitePageHideNotAllowed 站点单页正文禁止 [hide](本轮单页仍无评论/解锁)
|
||||
ErrSitePageHideNotAllowed = errors.New("页面不支持隐藏内容")
|
||||
)
|
||||
|
||||
// rejectHideBlocks 若正文含 [hide] 开标记(忽略代码围栏内伪语法)则返回指定错误。
|
||||
func rejectHideBlocks(content string, deny error) error {
|
||||
if markdown.HasHideBlocks(content) {
|
||||
return deny
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectReplyHideBlocks 若正文含回复可见隐藏块则返回指定错误。
|
||||
func rejectReplyHideBlocks(content string, deny error) error {
|
||||
blocks, err := markdown.ParseHideBlocks(content)
|
||||
if err != nil {
|
||||
// 语法错误交给 ValidateHideContent;此处不阻断「无有效块」的脏文本
|
||||
return nil
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.Kind == markdown.HideKindReply {
|
||||
return deny
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type AnnouncementItem struct {
|
||||
Title string `json:"title"`
|
||||
Tag string `json:"tag"`
|
||||
TagColor string `json:"tag_color"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -70,6 +71,8 @@ type OverviewData struct {
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||
NewUsers []NewUserItem `json:"new_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
}
|
||||
@@ -98,6 +101,7 @@ func (s *OverviewService) Get() (*OverviewData, error) {
|
||||
ActiveUsers: []ActiveUser{},
|
||||
Boards: []BoardCount{},
|
||||
Announcements: []AnnouncementItem{},
|
||||
SidebarPages: []SidebarPageItem{},
|
||||
NewUsers: []NewUserItem{},
|
||||
}
|
||||
|
||||
@@ -230,10 +234,14 @@ LIMIT 5`
|
||||
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
|
||||
}
|
||||
|
||||
// 站点公告(已发布,最新 5 条)
|
||||
// 站点公告(已发布,置顶优先,最新 5 条)+ 总数
|
||||
if err := s.db.Model(&model.Announcement{}).Where("published = ?", true).
|
||||
Count(&data.AnnouncementsTotal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var anns []model.Announcement
|
||||
if err := s.db.Where("published = ?", true).
|
||||
Order("created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
|
||||
Order("pinned DESC, created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range anns {
|
||||
@@ -242,10 +250,25 @@ LIMIT 5`
|
||||
Title: a.Title,
|
||||
Tag: a.Tag,
|
||||
TagColor: a.TagColor,
|
||||
Pinned: a.Pinned,
|
||||
CreatedAt: a.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 侧栏单页入口(已发布且勾选侧栏)
|
||||
var pages []model.SitePage
|
||||
if err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Select("id", "slug", "title", "excerpt").
|
||||
Find(&pages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range pages {
|
||||
data.SidebarPages = append(data.SidebarPages, SidebarPageItem{
|
||||
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
|
||||
})
|
||||
}
|
||||
|
||||
// 最新注册成员
|
||||
var newUsers []model.User
|
||||
if err := s.db.Select("id, username, nickname, avatar, created_at").
|
||||
|
||||
@@ -403,7 +403,7 @@ type PostDetail struct {
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostInput 发帖入参(content_access / access_points 由正文 :::hide 派生)
|
||||
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
|
||||
type CreatePostInput struct {
|
||||
UserID uint
|
||||
BoardID uint
|
||||
@@ -517,7 +517,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizePostContent 按读者能力对正文 :::hide 块脱敏;fullyLocked 表示无可见正文。
|
||||
// sanitizePostContent 按读者能力对正文 [hide] 块脱敏;fullyLocked 表示无可见正文。
|
||||
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (content string, fullyLocked bool, hint string) {
|
||||
caps := s.buildHideViewerCaps(post, viewerID, loadActor, pwdUnlocked)
|
||||
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)
|
||||
|
||||
@@ -70,6 +70,7 @@ const (
|
||||
RateUpload = "upload" // 帖子插图等上传
|
||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||
RateHidePassword = "hide_password" // 密码隐藏块尝试
|
||||
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
@@ -83,5 +84,6 @@ func DefaultRateLimiter() *RateLimiter {
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||
return rl
|
||||
}
|
||||
|
||||
231
backend/service/site_page.go
Normal file
231
backend/service/site_page.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
||||
var sitePageWriteFields = []string{
|
||||
"Slug", "Title", "Content", "Excerpt",
|
||||
"Published", "ShowInSidebar", "SortOrder", "AllowComments",
|
||||
}
|
||||
|
||||
// SitePageService 站点单页服务
|
||||
type SitePageService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSitePageService(db *gorm.DB) *SitePageService {
|
||||
return &SitePageService{db: db}
|
||||
}
|
||||
|
||||
// SitePageInput 创建/更新单页入参
|
||||
type SitePageInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Published *bool `json:"published"`
|
||||
ShowInSidebar *bool `json:"show_in_sidebar"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
}
|
||||
|
||||
// SidebarPageItem 首页右栏「关于本站」入口
|
||||
type SidebarPageItem struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
}
|
||||
|
||||
func (in *SitePageInput) normalize(existing *model.SitePage) 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.Content = strings.TrimSpace(in.Content)
|
||||
if in.Content == "" {
|
||||
return errors.New("内容不能为空")
|
||||
}
|
||||
if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in.Excerpt = strings.TrimSpace(in.Excerpt)
|
||||
if utf8.RuneCountInString(in.Excerpt) > 300 {
|
||||
return errors.New("摘要不能超过 300 字")
|
||||
}
|
||||
_ = existing
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *SitePageInput) applyTo(p *model.SitePage) {
|
||||
p.Slug = in.Slug
|
||||
p.Title = in.Title
|
||||
p.Content = in.Content
|
||||
p.Excerpt = in.Excerpt
|
||||
if in.Published != nil {
|
||||
p.Published = *in.Published
|
||||
}
|
||||
if in.ShowInSidebar != nil {
|
||||
p.ShowInSidebar = *in.ShowInSidebar
|
||||
}
|
||||
if in.SortOrder != nil {
|
||||
p.SortOrder = *in.SortOrder
|
||||
}
|
||||
if in.AllowComments != nil {
|
||||
p.AllowComments = *in.AllowComments
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
|
||||
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
|
||||
if excludeID > 0 {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
if err := q.Count(&n).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
||||
func (s *SitePageService) ListSidebarPublished() ([]SidebarPageItem, error) {
|
||||
var pages []model.SitePage
|
||||
err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Select("id", "slug", "title", "excerpt").
|
||||
Find(&pages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SidebarPageItem, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
out = append(out, SidebarPageItem{
|
||||
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPublishedBySlug 公开详情(仅已发布)
|
||||
func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, error) {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
var p model.SitePage
|
||||
err := s.db.Where("slug = ? AND published = ?", slug, true).First(&p).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListAll 管理后台全部单页
|
||||
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
|
||||
var list []model.SitePage
|
||||
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Get 管理后台按 ID 取单页
|
||||
func (s *SitePageService) Get(id uint) (*model.SitePage, error) {
|
||||
var p model.SitePage
|
||||
if err := s.db.First(&p, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Create 新建单页
|
||||
func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
|
||||
if err := in.normalize(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := s.slugTaken(in.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
p := &model.SitePage{
|
||||
Published: false,
|
||||
ShowInSidebar: false,
|
||||
AllowComments: false,
|
||||
SortOrder: 0,
|
||||
}
|
||||
in.applyTo(p)
|
||||
if err := s.db.Select(sitePageWriteFields).Create(p).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Update 更新单页
|
||||
func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, error) {
|
||||
var p model.SitePage
|
||||
if err := s.db.First(&p, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := in.normalize(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := s.slugTaken(in.Slug, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
in.applyTo(&p)
|
||||
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
||||
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||
"slug": p.Slug,
|
||||
"title": p.Title,
|
||||
"content": p.Content,
|
||||
"excerpt": p.Excerpt,
|
||||
"published": p.Published,
|
||||
"show_in_sidebar": p.ShowInSidebar,
|
||||
"sort_order": p.SortOrder,
|
||||
"allow_comments": p.AllowComments,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete 软删除单页
|
||||
func (s *SitePageService) Delete(id uint) error {
|
||||
result := s.db.Delete(&model.SitePage{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
721
backend/service/timeline_git.go
Normal file
721
backend/service/timeline_git.go
Normal file
@@ -0,0 +1,721 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
timelineGitHTTPTimeout = 8 * time.Second
|
||||
timelineGitMaxBody = 1 << 20 // 1MB
|
||||
timelineTitleMax = 200
|
||||
timelineBodyMax = 2000
|
||||
)
|
||||
|
||||
// TimelineGitItem 导入结果单项(已消毒)
|
||||
type TimelineGitItem struct {
|
||||
Date string `json:"date"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
SHA string `json:"sha,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineGitImportResult 导入响应
|
||||
type TimelineGitImportResult struct {
|
||||
Items []TimelineGitItem `json:"items"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
repoNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`)
|
||||
shaHexRe = regexp.MustCompile(`^[0-9a-fA-F]{7,64}$`)
|
||||
)
|
||||
|
||||
// ImportTimelineFromGit 按适配器解析 commits 页 URL 并拉取提交
|
||||
func (s *SettingService) ImportTimelineFromGit(urls []string, followPages bool) (*TimelineGitImportResult, error) {
|
||||
cfg, err := s.loadTimelineGitAdapter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
seenSHA := map[string]bool{}
|
||||
var items []TimelineGitItem
|
||||
var failMsgs []string
|
||||
truncated := false
|
||||
|
||||
for _, rawURL := range cleanURLs {
|
||||
part, partTrunc, err := s.importOneGitURL(client, cfg, rawURL, followPages, seenSHA, cfg.MaxCommits-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) >= cfg.MaxCommits {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := &TimelineGitImportResult{Items: items}
|
||||
if truncated {
|
||||
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", cfg.MaxCommits)
|
||||
}
|
||||
if len(failMsgs) > 0 {
|
||||
out.Error = strings.Join(failMsgs, ";")
|
||||
}
|
||||
if len(items) == 0 && out.Error == "" {
|
||||
out.Error = "未能解析出提交"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) importOneGitURL(
|
||||
client *http.Client,
|
||||
cfg *TimelineGitAdapter,
|
||||
rawURL string,
|
||||
followPages bool,
|
||||
seenSHA 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, singleSHA, err := matchGitSource(cfg, host, path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
|
||||
return nil, false, errors.New("仓库名非法")
|
||||
}
|
||||
if ref, ok := caps["ref"]; ok && ref != "" {
|
||||
if strings.ContainsAny(ref, " \t\n\r") || utf8.RuneCountInString(ref) > 200 {
|
||||
return nil, false, errors.New("分支名非法")
|
||||
}
|
||||
}
|
||||
|
||||
startPage := 1
|
||||
if p := u.Query().Get("page"); p != "" {
|
||||
if n, e := strconv.Atoi(p); e == nil && n >= 1 {
|
||||
startPage = n
|
||||
}
|
||||
}
|
||||
|
||||
if singleSHA != "" {
|
||||
item, err := fetchSingleCommit(client, src, host, caps, singleSHA)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if item.SHA != "" && seenSHA[item.SHA] {
|
||||
return nil, false, nil
|
||||
}
|
||||
if item.SHA != "" {
|
||||
seenSHA[item.SHA] = true
|
||||
}
|
||||
return []TimelineGitItem{*item}, false, nil
|
||||
}
|
||||
|
||||
var out []TimelineGitItem
|
||||
truncated := false
|
||||
page := startPage
|
||||
maxPages := 1
|
||||
if followPages {
|
||||
maxPages = cfg.MaxPages
|
||||
}
|
||||
pagesDone := 0
|
||||
nextURL := ""
|
||||
|
||||
for pagesDone < maxPages && len(out) < remain {
|
||||
var apiURL string
|
||||
if nextURL != "" {
|
||||
apiURL = nextURL
|
||||
nextURL = ""
|
||||
} else {
|
||||
apiURL, err = expandAPITemplate(src.APIURL, host, caps, page)
|
||||
if err != nil {
|
||||
return out, truncated, err
|
||||
}
|
||||
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 := parseCommitListJSON(body, src, host)
|
||||
if err != nil {
|
||||
return out, truncated, err
|
||||
}
|
||||
if len(pageItems) == 0 {
|
||||
break
|
||||
}
|
||||
for _, it := range pageItems {
|
||||
if it.SHA != "" && seenSHA[it.SHA] {
|
||||
continue
|
||||
}
|
||||
if it.SHA != "" {
|
||||
seenSHA[it.SHA] = true
|
||||
}
|
||||
out = append(out, it)
|
||||
if len(out) >= remain {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
pagesDone++
|
||||
if !followPages {
|
||||
break
|
||||
}
|
||||
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 && followPages {
|
||||
truncated = true
|
||||
}
|
||||
return out, truncated, nil
|
||||
}
|
||||
|
||||
func matchGitSource(cfg *TimelineGitAdapter, host, path string) (*TimelineGitSourceCfg, map[string]string, string, error) {
|
||||
for i := range cfg.Sources {
|
||||
src := &cfg.Sources[i]
|
||||
if src.Host != "*" && !strings.EqualFold(src.Host, host) {
|
||||
continue
|
||||
}
|
||||
if src.Host == "*" && strings.EqualFold(host, "github.com") {
|
||||
continue
|
||||
}
|
||||
if src.CommitPath != "" {
|
||||
re, err := regexp.Compile(src.CommitPath)
|
||||
if err == nil {
|
||||
if m := re.FindStringSubmatch(path); m != nil {
|
||||
caps := subexpMap(re, m)
|
||||
sha := caps["sha"]
|
||||
if !shaHexRe.MatchString(sha) {
|
||||
return nil, nil, "", errors.New("提交哈希非法")
|
||||
}
|
||||
return src, caps, sha, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
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("地址不符或主机未配置")
|
||||
}
|
||||
|
||||
func subexpMap(re *regexp.Regexp, m []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for i, name := range re.SubexpNames() {
|
||||
if i == 0 || name == "" || i >= len(m) {
|
||||
continue
|
||||
}
|
||||
out[name] = m[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandTemplate(tpl, host string, caps map[string]string, page int) string {
|
||||
r := strings.NewReplacer(
|
||||
"{host}", host,
|
||||
"{owner}", caps["owner"],
|
||||
"{repo}", caps["repo"],
|
||||
"{ref}", caps["ref"],
|
||||
"{sha}", caps["sha"],
|
||||
"{page}", strconv.Itoa(page),
|
||||
)
|
||||
return r.Replace(tpl)
|
||||
}
|
||||
|
||||
func expandAPITemplate(tpl, host string, caps map[string]string, page int) (string, error) {
|
||||
s := expandTemplate(tpl, host, caps, page)
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", errors.New("API URL 无效")
|
||||
}
|
||||
if err := assertSafeHTTPSURL(u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func fetchSingleCommit(client *http.Client, src *TimelineGitSourceCfg, host string, caps map[string]string, sha string) (*TimelineGitItem, error) {
|
||||
caps = copyCaps(caps)
|
||||
caps["sha"] = sha
|
||||
apiTpl := src.CommitAPI
|
||||
if apiTpl == "" {
|
||||
apiTpl = strings.TrimSuffix(src.APIURL, "/") + "/{sha}"
|
||||
}
|
||||
apiURL, err := expandAPITemplate(apiTpl, host, caps, 1)
|
||||
if err != nil {
|
||||
return nil, 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, _, status, err := httpGetLimited(client, apiURL, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("远端返回 %d", status)
|
||||
}
|
||||
var obj any
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
return nil, errors.New("响应非 JSON")
|
||||
}
|
||||
item, ok := mapCommitObject(obj, src, host)
|
||||
if !ok {
|
||||
return nil, errors.New("无法解析提交")
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func copyCaps(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseCommitListJSON(body []byte, src *TimelineGitSourceCfg, 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 {
|
||||
if m, isMap := root.(map[string]any); isMap {
|
||||
if c, ok := m["commits"].([]any); ok {
|
||||
arr = c
|
||||
}
|
||||
}
|
||||
}
|
||||
if arr == nil {
|
||||
return nil, errors.New("响应不是提交列表")
|
||||
}
|
||||
var out []TimelineGitItem
|
||||
for _, el := range arr {
|
||||
item, ok := mapCommitObject(el, src, host)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapCommitObject(el any, src *TimelineGitSourceCfg, host string) (TimelineGitItem, bool) {
|
||||
sha := jsonPathString(el, src.Item.SHA)
|
||||
dateRaw := jsonPathString(el, src.Item.Date)
|
||||
msg := jsonPathString(el, src.Item.Message)
|
||||
srcURL := jsonPathString(el, src.Item.SourceURL)
|
||||
if msg == "" && sha == "" {
|
||||
return TimelineGitItem{}, false
|
||||
}
|
||||
sha = sanitizeSHA(sha)
|
||||
title, body := splitCommitMessage(msg)
|
||||
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,
|
||||
SHA: sha,
|
||||
}, true
|
||||
}
|
||||
|
||||
func jsonPathString(root any, path string) string {
|
||||
if path == "" || root == nil {
|
||||
return ""
|
||||
}
|
||||
cur := root
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur, ok = m[part]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
switch v := cur.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func splitCommitMessage(msg string) (title, body string) {
|
||||
msg = strings.ReplaceAll(msg, "\r\n", "\n")
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return "提交", ""
|
||||
}
|
||||
parts := strings.SplitN(msg, "\n", 2)
|
||||
title = strings.TrimSpace(parts[0])
|
||||
if title == "" {
|
||||
title = "提交"
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
body = stripCommitTrailers(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
return title, body
|
||||
}
|
||||
|
||||
// stripCommitTrailers 去掉 Co-authored-by 等尾部 trailer 块(及前导空行)。
|
||||
func stripCommitTrailers(body string) string {
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
// 从末尾向前:连续 trailer / 空行;遇到非 trailer 正文则停
|
||||
end := len(lines)
|
||||
for end > 0 {
|
||||
t := strings.TrimSpace(lines[end-1])
|
||||
if t == "" || isCommitTrailerLine(t) {
|
||||
end--
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// 若尾部有 trailer,再去掉其前的空行分隔
|
||||
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
|
||||
end--
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines[:end], "\n"))
|
||||
}
|
||||
|
||||
func isCommitTrailerLine(t string) bool {
|
||||
lower := strings.ToLower(t)
|
||||
prefixes := []string{
|
||||
"co-authored-by:",
|
||||
"signed-off-by:",
|
||||
"reviewed-by:",
|
||||
"acked-by:",
|
||||
"tested-by:",
|
||||
"reported-by:",
|
||||
"suggested-by:",
|
||||
"helped-by:",
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeSHA(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !shaHexRe.MatchString(s) {
|
||||
return ""
|
||||
}
|
||||
if len(s) > 64 {
|
||||
return s[:64]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sanitizeTimelinePlain(s string, max int) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == 0 || (r < 0x20 && r != '\n' && r != '\t') || r == 0x7f {
|
||||
continue
|
||||
}
|
||||
if r >= 0x202A && r <= 0x202E {
|
||||
continue
|
||||
}
|
||||
if r >= 0x2066 && r <= 0x2069 {
|
||||
continue
|
||||
}
|
||||
if unicode.Is(unicode.Cs, r) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
out := b.String()
|
||||
if utf8.RuneCountInString(out) > max {
|
||||
runes := []rune(out)
|
||||
out = string(runes[:max])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func neutralizeDirectivePlain(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
lower := strings.ToLower(t)
|
||||
if lower == "[/hide]" || lower == "[/timeline]" || lower == "[timeline]" ||
|
||||
strings.HasPrefix(lower, "[hide") || strings.HasPrefix(t, "<!-- git:") {
|
||||
lines[i] = " " + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func parseCommitDate(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func sanitizeSourceURL(raw, allowedHost string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, "https") {
|
||||
return ""
|
||||
}
|
||||
h := strings.ToLower(u.Hostname())
|
||||
ah := strings.ToLower(allowedHost)
|
||||
if h == ah || (ah == "github.com" && (h == "github.com" || h == "www.github.com")) {
|
||||
return u.String()
|
||||
}
|
||||
if ah == "api.github.com" && (h == "github.com" || h == "www.github.com") {
|
||||
return u.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSafeHTTPSURL(u *url.URL) error {
|
||||
if u == nil {
|
||||
return errors.New("URL 无效")
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, "https") {
|
||||
return errors.New("仅允许 https")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return errors.New("主机为空")
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
|
||||
return errors.New("禁止访问内网地址")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return errors.New("无法解析主机")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return errors.New("无法解析主机")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicIP(ip) {
|
||||
return errors.New("禁止访问内网地址")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
if ip4[0] == 169 && ip4[1] == 254 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func httpGetLimited(client *http.Client, rawURL string, headers http.Header) ([]byte, string, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timelineGitHTTPTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
for k, vs := range headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", 0, errors.New("请求失败")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
limited := io.LimitReader(resp.Body, timelineGitMaxBody+1)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, "", resp.StatusCode, errors.New("读取响应失败")
|
||||
}
|
||||
if len(body) > timelineGitMaxBody {
|
||||
return nil, "", resp.StatusCode, errors.New("响应过大")
|
||||
}
|
||||
linkNext := parseLinkNext(resp.Header.Get("Link"))
|
||||
return body, linkNext, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func parseLinkNext(link string) string {
|
||||
parts := strings.Split(link, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if !strings.Contains(p, `rel="next"`) && !strings.Contains(p, `rel=next`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(p, "<")
|
||||
end := strings.Index(p, ">")
|
||||
if start >= 0 && end > start {
|
||||
return p[start+1 : end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateTimelineStr(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n]) + "…"
|
||||
}
|
||||
230
backend/service/timeline_git_adapter.go
Normal file
230
backend/service/timeline_git_adapter.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SettingKeyTimelineGitImport 提交导入适配器 JSON(不进公开 settings)
|
||||
const SettingKeyTimelineGitImport = "timeline_git_import"
|
||||
|
||||
const (
|
||||
maxAdapterJSONBytes = 64 * 1024
|
||||
maxAdapterRegexLen = 512
|
||||
maxAdapterSources = 20
|
||||
)
|
||||
|
||||
// TimelineGitAdapter 声明式提交导入配置
|
||||
type TimelineGitAdapter struct {
|
||||
MaxPages int `json:"max_pages"`
|
||||
MaxCommits int `json:"max_commits"`
|
||||
Sources []TimelineGitSourceCfg `json:"sources"`
|
||||
}
|
||||
|
||||
// TimelineGitSourceCfg 单个 Git 源
|
||||
type TimelineGitSourceCfg struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"` // 精确主机或 "*"
|
||||
ListPath string `json:"list_path"`
|
||||
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
|
||||
APIURL string `json:"api_url"`
|
||||
CommitAPI string `json:"commit_api,omitempty"`
|
||||
Query map[string]string `json:"query"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Pagination string `json:"pagination"` // link_header | query_page
|
||||
Item TimelineGitItemMap `json:"item"`
|
||||
}
|
||||
|
||||
// TimelineGitItemMap JSON 点路径映射
|
||||
type TimelineGitItemMap struct {
|
||||
SHA string `json:"sha"`
|
||||
Date string `json:"date"`
|
||||
Message string `json:"message"`
|
||||
SourceURL string `json:"source_url"`
|
||||
}
|
||||
|
||||
var allowedAdapterHeaders = map[string]bool{
|
||||
"user-agent": true,
|
||||
"accept": true,
|
||||
"accept-language": true,
|
||||
}
|
||||
|
||||
// DefaultTimelineGitAdapterJSON 内置 GitHub + Gitea 模板
|
||||
func DefaultTimelineGitAdapterJSON() string {
|
||||
return `{
|
||||
"max_pages": 10,
|
||||
"max_commits": 100,
|
||||
"sources": [
|
||||
{
|
||||
"id": "github",
|
||||
"host": "github.com",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
|
||||
"pagination": "link_header",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "commit.committer.date",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"host": "*",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
|
||||
"pagination": "query_page",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "created",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
// ValidateTimelineGitAdapterJSON 校验并规范化适配器 JSON
|
||||
func ValidateTimelineGitAdapterJSON(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("适配器不能为空")
|
||||
}
|
||||
if len(raw) > maxAdapterJSONBytes {
|
||||
return "", errors.New("适配器过大")
|
||||
}
|
||||
var cfg TimelineGitAdapter
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return "", fmt.Errorf("JSON 无效: %w", err)
|
||||
}
|
||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
||||
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
|
||||
return errors.New("max_pages 须在 1–50")
|
||||
}
|
||||
if cfg.MaxCommits < 1 || cfg.MaxCommits > 500 {
|
||||
return errors.New("max_commits 须在 1–500")
|
||||
}
|
||||
if len(cfg.Sources) == 0 {
|
||||
return errors.New("至少配置一个 source")
|
||||
}
|
||||
if len(cfg.Sources) > maxAdapterSources {
|
||||
return errors.New("sources 过多")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := range cfg.Sources {
|
||||
src := &cfg.Sources[i]
|
||||
src.ID = strings.TrimSpace(src.ID)
|
||||
if src.ID == "" {
|
||||
return fmt.Errorf("sources[%d].id 不能为空", i)
|
||||
}
|
||||
if seen[src.ID] {
|
||||
return fmt.Errorf("重复的 source id: %s", src.ID)
|
||||
}
|
||||
seen[src.ID] = true
|
||||
src.Host = strings.ToLower(strings.TrimSpace(src.Host))
|
||||
if src.Host == "" {
|
||||
return fmt.Errorf("%s.host 不能为空", src.ID)
|
||||
}
|
||||
if utf8.RuneCountInString(src.ListPath) > maxAdapterRegexLen {
|
||||
return fmt.Errorf("%s.list_path 过长", src.ID)
|
||||
}
|
||||
if _, err := regexp.Compile(src.ListPath); err != nil {
|
||||
return fmt.Errorf("%s.list_path 正则无效: %w", src.ID, err)
|
||||
}
|
||||
if src.CommitPath != "" {
|
||||
if _, err := regexp.Compile(src.CommitPath); err != nil {
|
||||
return fmt.Errorf("%s.commit_path 正则无效: %w", src.ID, err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(src.APIURL) == "" {
|
||||
return fmt.Errorf("%s.api_url 不能为空", src.ID)
|
||||
}
|
||||
if src.Pagination != "link_header" && src.Pagination != "query_page" {
|
||||
return fmt.Errorf("%s.pagination 须为 link_header 或 query_page", src.ID)
|
||||
}
|
||||
for k := range src.Headers {
|
||||
if !allowedAdapterHeaders[strings.ToLower(k)] {
|
||||
return fmt.Errorf("%s.headers 不允许字段 %s", src.ID, k)
|
||||
}
|
||||
}
|
||||
if src.Item.SHA == "" || src.Item.Date == "" || src.Item.Message == "" || src.Item.SourceURL == "" {
|
||||
return fmt.Errorf("%s.item 须包含 sha/date/message/source_url", src.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimelineGitAdapterJSON 读取站点适配器;缺行返回默认
|
||||
func (s *SettingService) TimelineGitAdapterJSON() (string, error) {
|
||||
v, found, err := s.getValue(SettingKeyTimelineGitImport)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || strings.TrimSpace(v) == "" {
|
||||
return DefaultTimelineGitAdapterJSON(), nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetTimelineGitAdapterJSON 保存适配器(已校验)
|
||||
func (s *SettingService) SetTimelineGitAdapterJSON(raw string) (string, error) {
|
||||
normalized, err := ValidateTimelineGitAdapterJSON(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// ResetTimelineGitAdapter 恢复默认
|
||||
func (s *SettingService) ResetTimelineGitAdapter() (string, error) {
|
||||
def := DefaultTimelineGitAdapterJSON()
|
||||
normalized, err := ValidateTimelineGitAdapterJSON(def)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
|
||||
raw, err := s.TimelineGitAdapterJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg TimelineGitAdapter
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
||||
}
|
||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -1,59 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Save,
|
||||
X,
|
||||
ExternalLink,
|
||||
Megaphone,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListAnnouncements,
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
apiAdminDeleteAnnouncement,
|
||||
apiAdminToggleAnnouncementPin,
|
||||
type Announcement,
|
||||
type AnnouncementColor,
|
||||
type AnnouncementInput,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminField,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
{ value: "blue", label: "蓝色" },
|
||||
{ value: "green", label: "绿色" },
|
||||
{ value: "orange", label: "橙色" },
|
||||
{ value: "red", label: "红色" },
|
||||
{ value: "purple", label: "紫色" },
|
||||
{ value: "gray", label: "灰色" },
|
||||
];
|
||||
|
||||
type FormState = AnnouncementInput & { id: number | null };
|
||||
type PublishKey = "published" | "draft";
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
id: null,
|
||||
title: "",
|
||||
content: "",
|
||||
tag: "公告",
|
||||
tag_color: "blue",
|
||||
published: true,
|
||||
};
|
||||
|
||||
/** 粗略去掉 Markdown,取一行摘要 */
|
||||
function plainSummary(md: string, max = 96): string {
|
||||
const text = md
|
||||
@@ -66,144 +42,6 @@ function plainSummary(md: string, max = 96): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
function AnnouncementForm({
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||
{form.id == null ? "新建公告" : "编辑公告"}
|
||||
</p>
|
||||
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
className="mb-4"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(title) => setForm({ ...form, title })}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||
<div className="w-36">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => setForm({ ...form, tag })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="field-label">标签颜色</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
aria-pressed={form.tag_color === c.value}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
onClick={() => setForm({ ...form, tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="field-label">发布状态</p>
|
||||
<div className="mb-4">
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => setForm({ ...form, published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField
|
||||
id="anno-content"
|
||||
label="正文(支持 Markdown)"
|
||||
as="textarea"
|
||||
minHeight="260px"
|
||||
value={form.content}
|
||||
onChange={(content) => setForm({ ...form, content })}
|
||||
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-4">
|
||||
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="btn btn-line"
|
||||
aria-label="取消编辑"
|
||||
>
|
||||
<X size={15} /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnouncementPreview({ a }: { a: Announcement }) {
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11.5px] tabular-nums">{formatRelative(a.updated_at || a.created_at)}</span>
|
||||
</div>
|
||||
<h2 className="text-[17px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
|
||||
{a.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-[13.5px] leading-relaxed whitespace-pre-wrap" style={{ color: "var(--ink-2)" }}>
|
||||
{a.content}
|
||||
</p>
|
||||
{a.published ? (
|
||||
<a
|
||||
href={`/announcement/${a.id}`}
|
||||
className="btn btn-line btn-sm mt-4 inline-flex"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} /> 查看前台页
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnnouncementAdmin({
|
||||
initial,
|
||||
initError,
|
||||
@@ -213,10 +51,8 @@ export default function AnnouncementAdmin({
|
||||
}) {
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(initial[0]?.id ?? null);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pinningId, setPinningId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
||||
|
||||
@@ -229,11 +65,6 @@ export default function AnnouncementAdmin({
|
||||
return list;
|
||||
}, [list, statusFilter]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null),
|
||||
[list, selectedId]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiAdminListAnnouncements();
|
||||
@@ -244,59 +75,19 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const startEdit = (a: Announcement) => {
|
||||
setSelectedId(a.id);
|
||||
setForm({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
content: a.content,
|
||||
tag: a.tag,
|
||||
tag_color: a.tag_color,
|
||||
published: a.published,
|
||||
});
|
||||
};
|
||||
|
||||
const selectRow = (a: Announcement) => {
|
||||
setSelectedId(a.id);
|
||||
if (form && form.id !== a.id) setForm(null);
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (!form) return;
|
||||
const payload: AnnouncementInput = {
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
tag: form.tag.trim() || "公告",
|
||||
tag_color: form.tag_color,
|
||||
published: form.published,
|
||||
};
|
||||
if (!payload.title || !payload.content) {
|
||||
toast("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
async function togglePin(a: Announcement) {
|
||||
if (pinningId != null) return;
|
||||
setPinningId(a.id);
|
||||
try {
|
||||
if (form.id == null) {
|
||||
const res = await apiAdminCreateAnnouncement(payload);
|
||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||
setForm(null);
|
||||
setSelectedId(res.announcement?.id ?? null);
|
||||
} else {
|
||||
await apiAdminUpdateAnnouncement(form.id, payload);
|
||||
toast("公告已更新", "ok");
|
||||
setForm(null);
|
||||
setSelectedId(form.id);
|
||||
}
|
||||
const res = await apiAdminToggleAnnouncementPin(a.id);
|
||||
setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x)));
|
||||
toast(res.announcement.pinned ? "已置顶" : "已取消置顶", "ok");
|
||||
// 置顶后列表顺序可能变化,重新拉一次对齐后端排序
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
toast(e instanceof Error ? e.message : "置顶失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setPinningId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +95,7 @@ export default function AnnouncementAdmin({
|
||||
setDeletingId(a.id);
|
||||
try {
|
||||
await apiAdminDeleteAnnouncement(a.id);
|
||||
setList((l) => {
|
||||
const next = l.filter((x) => x.id !== a.id);
|
||||
if (selectedId === a.id) setSelectedId(next[0]?.id ?? null);
|
||||
return next;
|
||||
});
|
||||
if (form?.id === a.id) setForm(null);
|
||||
setList((l) => l.filter((x) => x.id !== a.id));
|
||||
toast("公告已删除", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
@@ -324,13 +110,11 @@ export default function AnnouncementAdmin({
|
||||
<AdminPageHeader
|
||||
icon={Megaphone}
|
||||
title="站点公告管理"
|
||||
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
|
||||
description="发布、编辑与下线右栏站点公告,支持置顶与自定义标签色"
|
||||
actions={
|
||||
form?.id == null && form != null ? undefined : (
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</button>
|
||||
)
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -348,8 +132,6 @@ export default function AnnouncementAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* 左侧列表 */}
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
@@ -367,33 +149,32 @@ export default function AnnouncementAdmin({
|
||||
<AdminEmpty
|
||||
icon={<Megaphone size={22} />}
|
||||
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
|
||||
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
|
||||
description={
|
||||
list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((a, idx) => {
|
||||
const active = selectedId === a.id || form?.id === a.id;
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => selectRow(a)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectRow(a);
|
||||
}
|
||||
}}
|
||||
className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
className="px-4 sm:px-5 py-3.5"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
||||
background: active ? "var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<span
|
||||
className={`j13-anno-dot is-${a.tag_color} mt-1.5 shrink-0`}
|
||||
aria-hidden
|
||||
data-tip={a.tag}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{a.pinned ? (
|
||||
<span className="badge-pin shrink-0">置顶</span>
|
||||
) : null}
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
@@ -401,27 +182,45 @@ export default function AnnouncementAdmin({
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
||||
{formatRelative(a.updated_at || a.created_at)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
</div>
|
||||
{summary ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
||||
<AdminIconButton
|
||||
tip={a.pinned ? "取消置顶" : "置顶"}
|
||||
ariaLabel={a.pinned ? "取消置顶" : "置顶公告"}
|
||||
disabled={pinningId === a.id}
|
||||
onClick={() => void togglePin(a)}
|
||||
>
|
||||
{pinningId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Pin size={14} style={{ color: a.pinned ? "var(--accent)" : undefined }} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
{a.published ? (
|
||||
<AdminIconButton
|
||||
href={`/announcement/${a.id}`}
|
||||
tip="查看"
|
||||
tip="查看前台"
|
||||
ariaLabel="查看公告"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
|
||||
<AdminIconButton
|
||||
href={`/admin/announcements/${a.id}/edit`}
|
||||
tip="编辑"
|
||||
ariaLabel="编辑公告"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
@@ -439,55 +238,20 @@ export default function AnnouncementAdmin({
|
||||
</AdminIconButton>
|
||||
</span>
|
||||
</div>
|
||||
{summary ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{list.length === 0 && !loadError ? (
|
||||
<div className="px-4 sm:px-5 pb-5 -mt-4 flex justify-center">
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建第一条公告
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 右侧:表单 / 预览 / 占位 */}
|
||||
<div className="min-w-0 lg:sticky lg:top-4">
|
||||
{form ? (
|
||||
<AnnouncementForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
onSave={() => void save()}
|
||||
onCancel={() => setForm(null)}
|
||||
/>
|
||||
) : selected ? (
|
||||
<AnnouncementPreview a={selected} />
|
||||
) : (
|
||||
<div className="panel p-8">
|
||||
<AdminEmpty
|
||||
icon={<Megaphone size={22} />}
|
||||
title="选择或新建公告"
|
||||
description="在左侧点选一条查看预览,或新建公告"
|
||||
/>
|
||||
<div className="flex justify-center -mt-4 pb-2">
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除公告"
|
||||
|
||||
235
frontend/app/admin/announcements/AnnouncementComposeClient.tsx
Normal file
235
frontend/app/admin/announcements/AnnouncementComposeClient.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
type Announcement,
|
||||
type AnnouncementColor,
|
||||
type AnnouncementInput,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminSegmented, AdminSwitch } from "@/components/admin";
|
||||
import { ANNOUNCEMENT_COLORS } from "./announcementColors";
|
||||
|
||||
type PublishKey = "published" | "draft";
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
content: string;
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
function toForm(a?: Announcement | null): FormState {
|
||||
return {
|
||||
title: a?.title ?? "",
|
||||
content: a?.content ?? "",
|
||||
tag: a?.tag ?? "公告",
|
||||
tag_color: a?.tag_color ?? "blue",
|
||||
published: a?.published ?? true,
|
||||
pinned: a?.pinned ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AnnouncementComposeClient({
|
||||
mode,
|
||||
initial,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
initial?: Announcement | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<FormState>(() => toForm(initial));
|
||||
const formRef = useRef(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isEdit = mode === "edit" && initial != null;
|
||||
|
||||
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||
formRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
const current = formRef.current;
|
||||
const payload: AnnouncementInput = {
|
||||
title: current.title.trim(),
|
||||
content: current.content.trim(),
|
||||
tag: current.tag.trim() || "公告",
|
||||
tag_color: current.tag_color,
|
||||
published: current.published,
|
||||
pinned: current.pinned,
|
||||
};
|
||||
if (!payload.title || !payload.content) {
|
||||
toast("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiAdminUpdateAnnouncement(initial.id, payload);
|
||||
toast("公告已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateAnnouncement(payload);
|
||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||
}
|
||||
router.push("/admin/announcements");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page w-full">
|
||||
<div className="j13-compose-stage">
|
||||
<header className="j13-compose-toolbar">
|
||||
<h1 className="j13-compose-toolbar-title">
|
||||
{isEdit ? "编辑公告" : "新建公告"}
|
||||
</h1>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
<Link href="/admin/announcements" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm min-w-[4.5rem]"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="j13-compose-body">
|
||||
<section className="j13-compose-section" aria-label="发布设置">
|
||||
<h2 className="j13-compose-section-title">发布设置</h2>
|
||||
<div className="j13-compose-typepanel space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="anno-tag">
|
||||
标签
|
||||
</label>
|
||||
<input
|
||||
id="anno-tag"
|
||||
className="j13-compose-field w-full max-w-[12rem]"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(e) => patchForm({ tag: e.target.value })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="field-label">标签颜色</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ANNOUNCEMENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
aria-pressed={form.tag_color === c.value}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
onClick={() => patchForm({ tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="field-label">发布状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
置顶公告
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5">置顶后优先显示在首页右栏</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={form.pinned}
|
||||
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
|
||||
label="置顶公告"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<h2 className="j13-compose-section-title !mb-0">标题与正文</h2>
|
||||
</div>
|
||||
<input
|
||||
className="j13-compose-title"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(e) => patchForm({ title: e.target.value })}
|
||||
placeholder="请输入公告标题"
|
||||
aria-label="公告标题"
|
||||
/>
|
||||
<div className="j13-compose-canvas">
|
||||
<MarkdownEditor
|
||||
value={form.content}
|
||||
onChange={(content) => patchForm({ content })}
|
||||
allowHide
|
||||
allowReplyHide={false}
|
||||
placeholder="请输入公告正文,支持 Markdown…"
|
||||
rows={18}
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
<div className="j13-compose-actions-end">
|
||||
<Link href="/admin/announcements" className="btn btn-line">
|
||||
取消
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiAdminListAnnouncements, type Announcement } from "@/lib/api";
|
||||
import AnnouncementComposeClient from "../../AnnouncementComposeClient";
|
||||
|
||||
/** 无详情 API:从管理列表按 id 拉取后进入编辑器 */
|
||||
export default function AnnouncementEditLoader({ id }: { id: number }) {
|
||||
const [item, setItem] = useState<Announcement | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiAdminListAnnouncements()
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
const found = res.announcements.find((a) => a.id === id) ?? null;
|
||||
if (!found) {
|
||||
setError("公告不存在或已删除");
|
||||
setItem(null);
|
||||
} else {
|
||||
setItem(found);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="panel p-10 flex items-center justify-center gap-2 meta">
|
||||
<Loader2 size={18} className="animate-spin" /> 加载公告…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return <p className="alert-error">{error || "公告不存在"}</p>;
|
||||
}
|
||||
|
||||
return <AnnouncementComposeClient mode="edit" initial={item} />;
|
||||
}
|
||||
31
frontend/app/admin/announcements/[id]/edit/page.tsx
Normal file
31
frontend/app/admin/announcements/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑公告",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
const numId = parseInt(id, 10);
|
||||
if (!Number.isFinite(numId) || numId <= 0) {
|
||||
return <Forbidden text="无效的公告 ID。" />;
|
||||
}
|
||||
|
||||
return <AnnouncementEditLoader id={numId} />;
|
||||
}
|
||||
17
frontend/app/admin/announcements/announcementColors.ts
Normal file
17
frontend/app/admin/announcements/announcementColors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AnnouncementColor } from "@/lib/api";
|
||||
|
||||
/** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */
|
||||
export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
{ value: "blue", label: "蓝色" },
|
||||
{ value: "green", label: "绿色" },
|
||||
{ value: "orange", label: "橙色" },
|
||||
{ value: "red", label: "红色" },
|
||||
{ value: "purple", label: "紫色" },
|
||||
{ value: "gray", label: "灰色" },
|
||||
{ value: "teal", label: "青绿" },
|
||||
{ value: "cyan", label: "青色" },
|
||||
{ value: "pink", label: "粉色" },
|
||||
{ value: "amber", label: "琥珀" },
|
||||
{ value: "indigo", label: "靛蓝" },
|
||||
{ value: "rose", label: "玫红" },
|
||||
];
|
||||
21
frontend/app/admin/announcements/new/page.tsx
Normal file
21
frontend/app/admin/announcements/new/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建公告",
|
||||
};
|
||||
|
||||
export default async function AdminAnnouncementNewPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
return <AnnouncementComposeClient mode="create" />;
|
||||
}
|
||||
263
frontend/app/admin/pages/PageComposeClient.tsx
Normal file
263
frontend/app/admin/pages/PageComposeClient.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSitePage,
|
||||
apiAdminUpdateSitePage,
|
||||
type SitePage,
|
||||
type SitePageInput,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminSegmented, AdminSwitch } from "@/components/admin";
|
||||
|
||||
type PublishKey = "published" | "draft";
|
||||
|
||||
type FormState = {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
published: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
function toForm(p?: SitePage | null): FormState {
|
||||
return {
|
||||
slug: p?.slug ?? "",
|
||||
title: p?.title ?? "",
|
||||
content: p?.content ?? "",
|
||||
excerpt: p?.excerpt ?? "",
|
||||
published: p?.published ?? true,
|
||||
show_in_sidebar: p?.show_in_sidebar ?? false,
|
||||
sort_order: p?.sort_order ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** slug 仅允许小写字母数字与连字符 */
|
||||
function normalizeSlug(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export default function PageComposeClient({
|
||||
mode,
|
||||
initial,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
initial?: SitePage | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<FormState>(() => toForm(initial));
|
||||
const formRef = useRef(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isEdit = mode === "edit" && initial != null;
|
||||
|
||||
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||
formRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
const current = formRef.current;
|
||||
const slug = normalizeSlug(current.slug);
|
||||
const payload: SitePageInput = {
|
||||
slug,
|
||||
title: current.title.trim(),
|
||||
content: current.content.trim(),
|
||||
excerpt: current.excerpt.trim(),
|
||||
published: current.published,
|
||||
show_in_sidebar: current.show_in_sidebar,
|
||||
sort_order: current.sort_order,
|
||||
};
|
||||
if (!payload.slug || !payload.title || !payload.content) {
|
||||
toast("slug、标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiAdminUpdateSitePage(initial.id, payload);
|
||||
toast("页面已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateSitePage(payload);
|
||||
toast(payload.published ? "页面已发布" : "草稿已保存", "ok");
|
||||
}
|
||||
router.push("/admin/pages");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page w-full">
|
||||
<div className="j13-compose-stage">
|
||||
<header className="j13-compose-toolbar">
|
||||
<h1 className="j13-compose-toolbar-title">{isEdit ? "编辑单页" : "新建单页"}</h1>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm min-w-[4.5rem]"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="j13-compose-body">
|
||||
<section className="j13-compose-section" aria-label="页面设置">
|
||||
<h2 className="j13-compose-section-title">页面设置</h2>
|
||||
<div className="j13-compose-typepanel space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-slug">
|
||||
URL slug
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="meta text-[13px]">/p/</span>
|
||||
<input
|
||||
id="page-slug"
|
||||
className="j13-compose-field flex-1 font-mono text-[13px]"
|
||||
maxLength={64}
|
||||
value={form.slug}
|
||||
onChange={(e) => patchForm({ slug: e.target.value })}
|
||||
onBlur={() =>
|
||||
patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))
|
||||
}
|
||||
placeholder="about"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-excerpt">
|
||||
摘要(侧栏展示)
|
||||
</label>
|
||||
<textarea
|
||||
id="page-excerpt"
|
||||
className="j13-compose-field w-full"
|
||||
rows={3}
|
||||
maxLength={300}
|
||||
value={form.excerpt}
|
||||
onChange={(e) => patchForm({ excerpt: e.target.value })}
|
||||
placeholder="一句话介绍本页…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-sort">
|
||||
排序(越小越靠前)
|
||||
</label>
|
||||
<input
|
||||
id="page-sort"
|
||||
type="number"
|
||||
className="j13-compose-field w-28"
|
||||
value={form.sort_order}
|
||||
onChange={(e) =>
|
||||
patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="field-label">发布状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
显示在首页侧栏
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5">开启后出现在「关于本站」模块</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={form.show_in_sidebar}
|
||||
onChange={() =>
|
||||
patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))
|
||||
}
|
||||
label="显示在首页侧栏"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
|
||||
<h2 className="j13-compose-section-title">标题与正文</h2>
|
||||
<input
|
||||
className="j13-compose-title"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(e) => patchForm({ title: e.target.value })}
|
||||
placeholder="页面标题"
|
||||
aria-label="页面标题"
|
||||
/>
|
||||
<div className="j13-compose-canvas">
|
||||
<MarkdownEditor
|
||||
value={form.content}
|
||||
onChange={(content) => patchForm({ content })}
|
||||
allowHide={false}
|
||||
placeholder="请输入页面正文,支持 Markdown…"
|
||||
rows={18}
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
<div className="j13-compose-actions-end">
|
||||
<Link href="/admin/pages" className="btn btn-line">
|
||||
取消
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
frontend/app/admin/pages/PagesAdmin.tsx
Normal file
205
frontend/app/admin/pages/PagesAdmin.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminDeleteSitePage,
|
||||
type SitePage,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
export default function PagesAdmin({
|
||||
initial,
|
||||
initError,
|
||||
}: {
|
||||
initial: SitePage[];
|
||||
initError: string;
|
||||
}) {
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
|
||||
|
||||
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
||||
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (statusFilter === "published") return list.filter((p) => p.published);
|
||||
if (statusFilter === "draft") return list.filter((p) => !p.published);
|
||||
return list;
|
||||
}, [list, statusFilter]);
|
||||
|
||||
async function remove(p: SitePage) {
|
||||
setDeletingId(p.id);
|
||||
try {
|
||||
await apiAdminDeleteSitePage(p.id);
|
||||
setList((l) => l.filter((x) => x.id !== p.id));
|
||||
toast("页面已删除", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={FileText}
|
||||
title="单页管理"
|
||||
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
|
||||
actions={
|
||||
<Link href="/admin/pages/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建单页
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<AdminSegmented<StatusFilter>
|
||||
ariaLabel="单页状态筛选"
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ key: "all", label: "全部", count: list.length },
|
||||
{ key: "published", label: "已发布", count: publishedCount },
|
||||
{ key: "draft", label: "草稿", count: draftCount },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
单页列表
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="alert-error m-4">{loadError}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<FileText size={22} />}
|
||||
title={list.length === 0 ? "还没有单页" : "该状态下暂无单页"}
|
||||
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((p, idx) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="px-4 sm:px-5 py-3.5"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={p.title}
|
||||
>
|
||||
{p.title}
|
||||
</p>
|
||||
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
|
||||
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
||||
{p.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
{p.show_in_sidebar ? (
|
||||
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
|
||||
) : null}
|
||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
||||
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{p.excerpt ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
||||
{p.published ? (
|
||||
<AdminIconButton
|
||||
href={`/p/${p.slug}`}
|
||||
tip="查看前台"
|
||||
ariaLabel="查看单页"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton
|
||||
href={`/admin/pages/${p.id}/edit`}
|
||||
tip="编辑"
|
||||
ariaLabel="编辑单页"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除单页"
|
||||
tone="danger"
|
||||
disabled={deletingId === p.id}
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
>
|
||||
{deletingId === p.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{list.length === 0 && !loadError ? (
|
||||
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
||||
<Link href="/admin/pages/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建第一页
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除单页"
|
||||
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
||||
danger
|
||||
busy={deletingId != null}
|
||||
confirmLabel="删除"
|
||||
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/app/admin/pages/[id]/edit/PageEditLoader.tsx
Normal file
54
frontend/app/admin/pages/[id]/edit/PageEditLoader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiAdminListSitePages, type SitePage } from "@/lib/api";
|
||||
import PageComposeClient from "../../PageComposeClient";
|
||||
|
||||
/** 无详情 API:从管理列表按 id 拉取后进入编辑器 */
|
||||
export default function PageEditLoader({ id }: { id: number }) {
|
||||
const [item, setItem] = useState<SitePage | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiAdminListSitePages()
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
const found = res.pages.find((p) => p.id === id) ?? null;
|
||||
if (!found) {
|
||||
setError("页面不存在或已删除");
|
||||
setItem(null);
|
||||
} else {
|
||||
setItem(found);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="panel p-10 flex items-center justify-center gap-2 meta">
|
||||
<Loader2 size={18} className="animate-spin" /> 加载页面…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return <p className="alert-error">{error || "页面不存在"}</p>;
|
||||
}
|
||||
|
||||
return <PageComposeClient mode="edit" initial={item} />;
|
||||
}
|
||||
31
frontend/app/admin/pages/[id]/edit/page.tsx
Normal file
31
frontend/app/admin/pages/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import PageEditLoader from "./PageEditLoader";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑单页",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminPageEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
const numId = parseInt(id, 10);
|
||||
if (!Number.isFinite(numId) || numId <= 0) {
|
||||
return <Forbidden text="无效的页面 ID。" />;
|
||||
}
|
||||
|
||||
return <PageEditLoader id={numId} />;
|
||||
}
|
||||
21
frontend/app/admin/pages/new/page.tsx
Normal file
21
frontend/app/admin/pages/new/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import PageComposeClient from "../PageComposeClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建单页",
|
||||
};
|
||||
|
||||
export default async function AdminPageNewPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
return <PageComposeClient mode="create" />;
|
||||
}
|
||||
31
frontend/app/admin/pages/page.tsx
Normal file
31
frontend/app/admin/pages/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import PagesAdmin from "./PagesAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "单页管理",
|
||||
};
|
||||
|
||||
export default async function AdminPagesPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
let initial: SitePage[] = [];
|
||||
let initError = "";
|
||||
try {
|
||||
const res = await fetchAdminSitePages(cookie || undefined);
|
||||
initial = res.pages ?? [];
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "加载单页失败";
|
||||
}
|
||||
|
||||
return <PagesAdmin initial={initial} initError={initError} />;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
FileText,
|
||||
Paperclip,
|
||||
Image as ImageIcon,
|
||||
GitBranch,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
@@ -27,17 +30,61 @@ import {
|
||||
AdminStepper,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
function extsEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
/** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */
|
||||
const DEFAULT_TIMELINE_GIT_IMPORT = `{
|
||||
"max_pages": 10,
|
||||
"max_commits": 100,
|
||||
"sources": [
|
||||
{
|
||||
"id": "github",
|
||||
"host": "github.com",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
|
||||
"pagination": "link_header",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "commit.committer.date",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"host": "*",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
|
||||
"pagination": "query_page",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "created",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
type SectionId =
|
||||
| "basic"
|
||||
| "content"
|
||||
| "gitimport"
|
||||
| "brand"
|
||||
| "footer"
|
||||
| "security"
|
||||
@@ -49,6 +96,7 @@ type SectionId =
|
||||
const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
|
||||
{ id: "basic", label: "基本信息", ready: true },
|
||||
{ id: "content", label: "内容与互动", ready: true },
|
||||
{ id: "gitimport", label: "提交导入", ready: true },
|
||||
{ id: "brand", label: "品牌与 SEO" },
|
||||
{ id: "footer", label: "页脚与合规" },
|
||||
{ id: "security", label: "安全与限流" },
|
||||
@@ -138,8 +186,46 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
const [savingLimits, setSavingLimits] = useState(false);
|
||||
const [savingExts, setSavingExts] = useState(false);
|
||||
|
||||
const [gitImportJSON, setGitImportJSON] = useState("");
|
||||
const [savedGitImport, setSavedGitImport] = useState("");
|
||||
const [gitImportLoading, setGitImportLoading] = useState(false);
|
||||
const [gitImportLoaded, setGitImportLoaded] = useState(false);
|
||||
const [savingGitImport, setSavingGitImport] = useState(false);
|
||||
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
|
||||
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
||||
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
|
||||
const gitImportDirty = gitImportJSON !== savedGitImport;
|
||||
|
||||
const loadGitImport = async () => {
|
||||
if (gitImportLoading) return;
|
||||
setGitImportLoading(true);
|
||||
try {
|
||||
const res = await apiGetAdminSettings();
|
||||
const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT;
|
||||
let pretty = raw;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
/* 保持原文 */
|
||||
}
|
||||
setGitImportJSON(pretty);
|
||||
setSavedGitImport(pretty);
|
||||
setGitImportLoaded(true);
|
||||
setGitValidateMsg(null);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "加载适配器失败");
|
||||
} finally {
|
||||
setGitImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadGitImport();
|
||||
// 仅挂载时拉取一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ids = NAV.map((n) => n.id);
|
||||
@@ -165,6 +251,73 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
const scrollTo = (id: SectionId) => {
|
||||
setActive(id);
|
||||
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
||||
void loadGitImport();
|
||||
}
|
||||
};
|
||||
|
||||
const validateGitImportClient = (): boolean => {
|
||||
try {
|
||||
JSON.parse(gitImportJSON);
|
||||
setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" });
|
||||
return true;
|
||||
} catch (e) {
|
||||
setGitValidateMsg({
|
||||
ok: false,
|
||||
text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitImport = async () => {
|
||||
if (savingGitImport) return;
|
||||
if (!validateGitImportClient()) {
|
||||
toast("请先修正 JSON 语法");
|
||||
return;
|
||||
}
|
||||
setSavingGitImport(true);
|
||||
try {
|
||||
await apiUpdateSiteSettings({ timeline_git_import: gitImportJSON });
|
||||
// 保存后以服务端规范化结果为准
|
||||
const res = await apiGetAdminSettings();
|
||||
const raw = res.timeline_git_import?.trim() || gitImportJSON;
|
||||
let pretty = raw;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
/* 保持 */
|
||||
}
|
||||
setGitImportJSON(pretty);
|
||||
setSavedGitImport(pretty);
|
||||
toast("提交导入适配器已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingGitImport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreGitDefault = async () => {
|
||||
setSavingGitImport(true);
|
||||
try {
|
||||
let pretty = DEFAULT_TIMELINE_GIT_IMPORT;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2);
|
||||
} catch {
|
||||
/* 保持 */
|
||||
}
|
||||
await apiUpdateSiteSettings({ timeline_git_import: pretty });
|
||||
setGitImportJSON(pretty);
|
||||
setSavedGitImport(pretty);
|
||||
setGitValidateMsg(null);
|
||||
setRestoreGitOpen(false);
|
||||
toast("已恢复默认适配器", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "恢复失败");
|
||||
} finally {
|
||||
setSavingGitImport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveIdentity = async () => {
|
||||
@@ -602,6 +755,92 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
<div id="settings-gitimport" className="scroll-mt-4">
|
||||
<AdminSection
|
||||
title="提交导入"
|
||||
description="声明式匹配规则,非可执行脚本。用于将 GitHub / Gitea 等提交历史导入时间线。"
|
||||
>
|
||||
<p className="meta text-[12.5px] mb-3 leading-relaxed">
|
||||
通过 JSON 描述 host、路径正则与 API 字段映射;服务端只按规则拉取,不会执行任意代码。
|
||||
</p>
|
||||
|
||||
{gitImportLoading && !gitImportLoaded ? (
|
||||
<div className="flex items-center gap-2 meta py-8 justify-center">
|
||||
<Loader2 size={16} className="animate-spin" /> 加载适配器…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="field-label" htmlFor="git-import-json">
|
||||
适配器 JSON
|
||||
</label>
|
||||
<textarea
|
||||
id="git-import-json"
|
||||
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
|
||||
style={{ minHeight: 320 }}
|
||||
spellCheck={false}
|
||||
value={gitImportJSON}
|
||||
onChange={(e) => {
|
||||
setGitImportJSON(e.target.value);
|
||||
setGitValidateMsg(null);
|
||||
}}
|
||||
placeholder="粘贴或编辑适配器 JSON…"
|
||||
/>
|
||||
|
||||
{gitValidateMsg ? (
|
||||
<p
|
||||
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
||||
gitValidateMsg.ok ? "" : "alert-error !mt-2"
|
||||
}`}
|
||||
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
|
||||
role="status"
|
||||
>
|
||||
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
|
||||
{gitValidateMsg.text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={gitImportLoading || savingGitImport}
|
||||
onClick={() => {
|
||||
if (validateGitImportClient()) toast("语法校验通过", "ok");
|
||||
else toast("JSON 语法无效");
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={14} /> 校验
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!gitImportDirty || savingGitImport}
|
||||
onClick={() => void saveGitImport()}
|
||||
>
|
||||
{savingGitImport ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingGitImport}
|
||||
onClick={() => setRestoreGitOpen(true)}
|
||||
>
|
||||
<RotateCcw size={14} /> 恢复默认
|
||||
</button>
|
||||
<span className="meta text-[11.5px] inline-flex items-center gap-1 ml-auto">
|
||||
<GitBranch size={12} /> GitHub + Gitea 模板
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
<div id="settings-brand" className="scroll-mt-4">
|
||||
<ComingSoonCard
|
||||
title="品牌与 SEO"
|
||||
@@ -645,6 +884,16 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreGitOpen}
|
||||
title="恢复默认适配器"
|
||||
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
|
||||
busy={savingGitImport}
|
||||
confirmLabel="恢复并保存"
|
||||
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
|
||||
onConfirm={() => void restoreGitDefault()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight, Megaphone } from "lucide-react";
|
||||
import { fetchAnnouncementDetail } from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
|
||||
interface PageProps {
|
||||
@@ -28,10 +31,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
|
||||
export default async function AnnouncementPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const data = await fetchAnnouncementDetail(id).catch(() => null);
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const [data, me] = await Promise.all([
|
||||
fetchAnnouncementDetail(id, cookie || undefined).catch(() => null),
|
||||
getMeCached(cookie || undefined),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
|
||||
const a = data.announcement;
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
@@ -55,12 +63,21 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
首页
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--accent)" }}>站点公告</span>
|
||||
<Link href="/announcements" className="hover:text-[var(--accent)] transition-colors">
|
||||
站点公告
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span className="truncate" style={{ color: "var(--accent)" }}>
|
||||
{a.title}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
</div>
|
||||
<time className="meta text-[12px] tabular-nums" dateTime={a.created_at}>
|
||||
{formatDate(a.created_at)}
|
||||
</time>
|
||||
@@ -77,7 +94,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
className="mt-5 pt-5"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<MarkdownBody content={a.content} />
|
||||
<MarkdownBody
|
||||
content={a.content}
|
||||
loggedIn={!!me.user}
|
||||
loginHref={loginHref}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
109
frontend/app/announcements/page.tsx
Normal file
109
frontend/app/announcements/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { fetchAnnouncementsPage, type Announcement } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import Pagination from "@/components/Pagination";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "站点公告",
|
||||
description: "站点公告与通知列表",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
/** 粗略去掉 Markdown,取摘要 */
|
||||
function plainSummary(md: string, max = 120): string {
|
||||
const text = md
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_`~\-|=]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
function AnnouncementCard({ a }: { a: Announcement }) {
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
className="panel block p-5 sm:p-6 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||
{formatDate(a.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
<h2
|
||||
className="mt-2.5 text-[17px] sm:text-[18px] font-semibold leading-snug tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{a.title}
|
||||
</h2>
|
||||
{summary ? (
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AnnouncementsListPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||
const size = 20;
|
||||
|
||||
let announcements: Announcement[] = [];
|
||||
let total = 0;
|
||||
let error = "";
|
||||
try {
|
||||
const res = await fetchAnnouncementsPage(page, size);
|
||||
announcements = res.announcements ?? [];
|
||||
total = res.total ?? 0;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "加载公告失败";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-6">
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
站点公告
|
||||
</h1>
|
||||
<p className="meta mt-2">站点通知与运营公告</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="alert-error">{error}</p>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className="panel p-10 text-center">
|
||||
<p className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
暂无公告
|
||||
</p>
|
||||
<p className="meta mt-1.5">有新消息时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{announcements.map((a) => (
|
||||
<AnnouncementCard key={a.id} a={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error ? (
|
||||
<Pagination total={total} page={page} size={size} basePath="/announcements" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1629,6 +1629,23 @@ body {
|
||||
background: var(--panel-2) !important;
|
||||
color: var(--ink) !important;
|
||||
}
|
||||
.j13-md-menu-item:disabled,
|
||||
.j13-md-menu-item[aria-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
color: var(--ink-3) !important;
|
||||
}
|
||||
.j13-md-menu-item:disabled:hover,
|
||||
.j13-md-menu-item[aria-disabled="true"]:hover {
|
||||
background: transparent !important;
|
||||
color: var(--ink-3) !important;
|
||||
}
|
||||
.j13-md-menu-item-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-md-popover button[role="menuitem"]:focus-visible,
|
||||
.j13-md-menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -3773,12 +3790,24 @@ a.j13-mention-token:hover {
|
||||
.j13-anno-tag.is-red { background: rgba(200, 40, 30, 0.1); color: #c8281e; }
|
||||
.j13-anno-tag.is-purple { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
|
||||
.j13-anno-tag.is-gray { background: rgba(82, 96, 122, 0.13); color: #52607a; }
|
||||
.j13-anno-tag.is-teal { background: rgba(15, 118, 110, 0.12); color: #0f766e; }
|
||||
.j13-anno-tag.is-cyan { background: rgba(14, 116, 144, 0.12); color: #0e7490; }
|
||||
.j13-anno-tag.is-pink { background: rgba(190, 24, 93, 0.11); color: #be185d; }
|
||||
.j13-anno-tag.is-amber { background: rgba(180, 83, 9, 0.12); color: #b45309; }
|
||||
.j13-anno-tag.is-indigo { background: rgba(67, 56, 202, 0.12); color: #4338ca; }
|
||||
.j13-anno-tag.is-rose { background: rgba(190, 18, 60, 0.11); color: #be123c; }
|
||||
.dark .j13-anno-tag.is-blue { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
|
||||
.dark .j13-anno-tag.is-green { background: rgba(88, 209, 138, 0.16); color: #58d18a; }
|
||||
.dark .j13-anno-tag.is-orange { background: rgba(240, 135, 90, 0.17); color: #f0875a; }
|
||||
.dark .j13-anno-tag.is-red { background: rgba(255, 107, 96, 0.16); color: #ff6b60; }
|
||||
.dark .j13-anno-tag.is-purple { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
|
||||
.dark .j13-anno-tag.is-gray { background: rgba(170, 182, 207, 0.17); color: #aab6cf; }
|
||||
.dark .j13-anno-tag.is-teal { background: rgba(94, 234, 212, 0.16); color: #5eead4; }
|
||||
.dark .j13-anno-tag.is-cyan { background: rgba(103, 232, 249, 0.16); color: #67e8f9; }
|
||||
.dark .j13-anno-tag.is-pink { background: rgba(251, 113, 133, 0.16); color: #fb7185; }
|
||||
.dark .j13-anno-tag.is-amber { background: rgba(252, 211, 77, 0.16); color: #fcd34d; }
|
||||
.dark .j13-anno-tag.is-indigo { background: rgba(165, 180, 252, 0.18); color: #a5b4fc; }
|
||||
.dark .j13-anno-tag.is-rose { background: rgba(251, 113, 133, 0.16); color: #fb7185; }
|
||||
|
||||
/* 后台颜色选择器圆点 */
|
||||
.j13-anno-dot {
|
||||
@@ -3801,12 +3830,134 @@ a.j13-mention-token:hover {
|
||||
.j13-anno-dot.is-red { background: #c8281e; }
|
||||
.j13-anno-dot.is-purple { background: #6d47c0; }
|
||||
.j13-anno-dot.is-gray { background: #52607a; }
|
||||
.j13-anno-dot.is-teal { background: #0f766e; }
|
||||
.j13-anno-dot.is-cyan { background: #0e7490; }
|
||||
.j13-anno-dot.is-pink { background: #be185d; }
|
||||
.j13-anno-dot.is-amber { background: #b45309; }
|
||||
.j13-anno-dot.is-indigo { background: #4338ca; }
|
||||
.j13-anno-dot.is-rose { background: #be123c; }
|
||||
.dark .j13-anno-dot.is-blue { background: #a9b8ff; }
|
||||
.dark .j13-anno-dot.is-green { background: #58d18a; }
|
||||
.dark .j13-anno-dot.is-orange { background: #f0875a; }
|
||||
.dark .j13-anno-dot.is-red { background: #ff6b60; }
|
||||
.dark .j13-anno-dot.is-purple { background: #c0a8f5; }
|
||||
.dark .j13-anno-dot.is-gray { background: #aab6cf; }
|
||||
.dark .j13-anno-dot.is-teal { background: #5eead4; }
|
||||
.dark .j13-anno-dot.is-cyan { background: #67e8f9; }
|
||||
.dark .j13-anno-dot.is-pink { background: #fb7185; }
|
||||
.dark .j13-anno-dot.is-amber { background: #fcd34d; }
|
||||
.dark .j13-anno-dot.is-indigo { background: #a5b4fc; }
|
||||
.dark .j13-anno-dot.is-rose { background: #fb7185; }
|
||||
|
||||
/* ----- 正文时间线(更新树) ----- */
|
||||
.j13-timeline {
|
||||
margin: 1.1rem 0;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.j13-timeline-day {
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
.j13-timeline-day:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.j13-timeline-day-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-3);
|
||||
margin-bottom: 0.55rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.j13-timeline-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 0.85rem;
|
||||
border-left: 2px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.j13-timeline-item {
|
||||
position: relative;
|
||||
padding-left: 0.95rem;
|
||||
}
|
||||
.j13-timeline-dot {
|
||||
position: absolute;
|
||||
left: -0.85rem;
|
||||
top: 0.45rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--panel);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.j13-timeline-card {
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-timeline-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.j13-timeline-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-timeline-src {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-timeline-src:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-timeline-body {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.j13-timeline-body > :first-child { margin-top: 0; }
|
||||
.j13-timeline-body > :last-child { margin-bottom: 0; }
|
||||
|
||||
/* 时间线编辑器 */
|
||||
.j13-tl-import {
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 10px;
|
||||
background: var(--tint);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.j13-tl-urls,
|
||||
.j13-tl-field,
|
||||
.j13-tl-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
.j13-tl-urls {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
.j13-tl-select {
|
||||
width: auto;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
.j13-tl-edit-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* ----- 分页 ----- */
|
||||
.pager-btn {
|
||||
|
||||
84
frontend/app/p/[slug]/page.tsx
Normal file
84
frontend/app/p/[slug]/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { fetchSitePageBySlug } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const data = await fetchSitePageBySlug(slug).catch(() => null);
|
||||
if (!data) return { title: "页面不存在" };
|
||||
const p = data.page;
|
||||
return {
|
||||
title: p.title,
|
||||
description: p.excerpt || p.title,
|
||||
openGraph: {
|
||||
title: p.title,
|
||||
description: p.excerpt || p.title,
|
||||
type: "article",
|
||||
publishedTime: p.created_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SitePagePublic({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const data = await fetchSitePageBySlug(slug).catch(() => null);
|
||||
if (!data) notFound();
|
||||
|
||||
const p = data.page;
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: p.title,
|
||||
description: p.excerpt || undefined,
|
||||
datePublished: p.created_at,
|
||||
dateModified: p.updated_at,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
||||
首页
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span className="truncate" style={{ color: "var(--accent)" }}>
|
||||
{p.title}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<time className="meta text-[12px] tabular-nums" dateTime={p.updated_at || p.created_at}>
|
||||
{formatDate(p.updated_at || p.created_at)}
|
||||
</time>
|
||||
<h1
|
||||
className="mt-2 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{p.title}
|
||||
</h1>
|
||||
{p.excerpt ? (
|
||||
<p className="mt-3 text-[14.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
{p.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 pt-5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<MarkdownBody content={p.content} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import PostRow from "@/components/PostRow";
|
||||
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
|
||||
@@ -110,6 +111,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
]);
|
||||
const user = me.user;
|
||||
const boards = overview?.boards ?? [];
|
||||
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
|
||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
|
||||
const showBoardPanels = boardId > 0 && !isSearch;
|
||||
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
||||
@@ -235,6 +238,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
newUsers={overview.new_users}
|
||||
checkin={overview.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
canManageAnnouncements={canManageAnnouncements}
|
||||
sidebarPages={sidebarPages}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -266,6 +271,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
newUsers={overview.new_users}
|
||||
checkin={overview.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
canManageAnnouncements={canManageAnnouncements}
|
||||
sidebarPages={sidebarPages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { fetchPosts } from "@/lib/api";
|
||||
import { fetchPosts, fetchAnnouncementsPage, fetchOverview } from "@/lib/api";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = "http://localhost:3000";
|
||||
const entries: MetadataRoute.Sitemap = [
|
||||
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
|
||||
{
|
||||
url: `${baseUrl}/announcements`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -21,5 +27,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
/* 忽略 API 错误 */
|
||||
}
|
||||
|
||||
try {
|
||||
const { announcements } = await fetchAnnouncementsPage(1, 100);
|
||||
for (const a of announcements) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/announcement/${a.id}`,
|
||||
lastModified: new Date(a.updated_at || a.created_at),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.5,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
|
||||
// 公开单页列表 API 尚未提供:用 overview 侧栏页近似收录已发布且展示中的页面
|
||||
try {
|
||||
const overview = await fetchOverview();
|
||||
for (const p of overview.sidebar_pages ?? []) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/p/${p.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.4,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
|
||||
import { splitHideSegments, type HideKind } from "@/lib/hideBlocks";
|
||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||
import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
|
||||
type MarkdownBodyProps = {
|
||||
content: string;
|
||||
@@ -9,20 +12,23 @@ type MarkdownBodyProps = {
|
||||
components?: Components;
|
||||
/** 详情页解锁门禁需要 */
|
||||
postId?: number;
|
||||
/** 登录跳转(公告等无 postId 时) */
|
||||
loginHref?: string;
|
||||
loggedIn?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 :::hide 内嵌块。
|
||||
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline]。
|
||||
*/
|
||||
export default async function MarkdownBody({
|
||||
content,
|
||||
className = "prose-content",
|
||||
components,
|
||||
postId,
|
||||
loginHref,
|
||||
loggedIn = false,
|
||||
}: MarkdownBodyProps) {
|
||||
const segs = splitHideSegments(content || "");
|
||||
const segs = splitContentSegments(content || "");
|
||||
const nodes = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
@@ -33,6 +39,25 @@ export default async function MarkdownBody({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "timeline") {
|
||||
const bodies: ReactNode[] = [];
|
||||
for (let j = 0; j < seg.items.length; j++) {
|
||||
const it = seg.items[j];
|
||||
if (!it.body.trim()) {
|
||||
bodies.push(null);
|
||||
continue;
|
||||
}
|
||||
bodies.push(await renderMarkdownToReact(it.body, components));
|
||||
}
|
||||
nodes.push(
|
||||
<MdTimeline
|
||||
key={`tl-${i}`}
|
||||
items={seg.items}
|
||||
renderBody={(_it, idx) => bodies[idx]}
|
||||
/>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
nodes.push(
|
||||
@@ -42,6 +67,7 @@ export default async function MarkdownBody({
|
||||
points={seg.points}
|
||||
locked
|
||||
postId={postId}
|
||||
loginHref={loginHref}
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,28 +3,32 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
|
||||
import { splitHideSegments, type HideKind } from "@/lib/hideBlocks";
|
||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||
import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
|
||||
type MarkdownBodyClientProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
components?: Components;
|
||||
postId?: number;
|
||||
loginHref?: string;
|
||||
loggedIn?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块徽章预览。
|
||||
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线预览。
|
||||
*/
|
||||
export default function MarkdownBodyClient({
|
||||
content,
|
||||
className = "prose-content",
|
||||
components,
|
||||
postId,
|
||||
loginHref,
|
||||
loggedIn = false,
|
||||
}: MarkdownBodyClientProps) {
|
||||
const segs = splitHideSegments(content || "");
|
||||
const segs = splitContentSegments(content || "");
|
||||
const mdComponents = mergeMarkdownComponents(components);
|
||||
|
||||
return (
|
||||
@@ -38,6 +42,21 @@ export default function MarkdownBodyClient({
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
if (seg.type === "timeline") {
|
||||
return (
|
||||
<MdTimeline
|
||||
key={`tl-${i}`}
|
||||
items={seg.items}
|
||||
renderBody={(it) =>
|
||||
it.body.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{it.body}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
return (
|
||||
@@ -47,6 +66,7 @@ export default function MarkdownBodyClient({
|
||||
points={seg.points}
|
||||
locked
|
||||
postId={postId}
|
||||
loginHref={loginHref}
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -33,10 +33,13 @@ import {
|
||||
ChevronDown,
|
||||
Upload,
|
||||
Link2,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import { apiUploadPostImage } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
@@ -49,6 +52,13 @@ type MarkdownEditorProps = {
|
||||
id?: string;
|
||||
/** 正文插图体积上限(MB),缺省 5 */
|
||||
imageMaxMB?: number;
|
||||
/** 是否提供隐藏可见([hide]) */
|
||||
allowHide?: boolean;
|
||||
/**
|
||||
* 是否允许「回复可见」。
|
||||
* 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。
|
||||
*/
|
||||
allowReplyHide?: boolean;
|
||||
};
|
||||
|
||||
type EditorMode = "split" | "write" | "preview";
|
||||
@@ -110,6 +120,8 @@ export default function MarkdownEditor({
|
||||
onSubmitShortcut,
|
||||
id,
|
||||
imageMaxMB = 5,
|
||||
allowHide = false,
|
||||
allowReplyHide = true,
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -128,6 +140,11 @@ export default function MarkdownEditor({
|
||||
const [hidePoints, setHidePoints] = useState(10);
|
||||
const [hidePassword, setHidePassword] = useState("");
|
||||
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
|
||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
const [timelineBody, setTimelineBody] = useState("");
|
||||
const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>(
|
||||
null
|
||||
);
|
||||
const savedSel = useRef<{ start: number; end: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -336,19 +353,20 @@ export default function MarkdownEditor({
|
||||
points?: number,
|
||||
password?: string
|
||||
) => {
|
||||
if (!allowHide) return;
|
||||
restoreSel();
|
||||
const el = taRef.current;
|
||||
const start = el?.selectionStart ?? 0;
|
||||
const end = el?.selectionEnd ?? 0;
|
||||
const selected = value.slice(start, end);
|
||||
const body = selected || "在此填写隐藏内容";
|
||||
let open = `:::hide ${kind}`;
|
||||
let open = `[hide ${kind}]`;
|
||||
if (kind === "points") {
|
||||
open = `:::hide points ${points || 10}`;
|
||||
open = `[hide points=${points || 10}]`;
|
||||
} else if (kind === "password") {
|
||||
open = `:::hide password ${password || "password"}`;
|
||||
open = `[hide password=${password || "password"}]`;
|
||||
}
|
||||
const block = `${open}\n${body}\n:::`;
|
||||
const block = `${open}\n${body}\n[/hide]`;
|
||||
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||
const inserted = padBefore + block + padAfter;
|
||||
@@ -363,6 +381,51 @@ export default function MarkdownEditor({
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
const openTimelineEditor = () => {
|
||||
rememberSel();
|
||||
const el = taRef.current;
|
||||
const cursor = el?.selectionStart ?? savedSel.current?.start ?? 0;
|
||||
const found = resolveTimelineBlockForEdit(value, cursor);
|
||||
if (found) {
|
||||
setTimelineBody(found.body);
|
||||
setTimelineRange({ start: found.start, end: found.end });
|
||||
} else {
|
||||
setTimelineBody("");
|
||||
setTimelineRange(null);
|
||||
}
|
||||
setPopover(null);
|
||||
setTimelineOpen(true);
|
||||
};
|
||||
|
||||
const applyTimelineBlock = (block: string) => {
|
||||
const el = taRef.current;
|
||||
if (timelineRange) {
|
||||
const next = value.slice(0, timelineRange.start) + block + value.slice(timelineRange.end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const pos = timelineRange.start + block.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
return;
|
||||
}
|
||||
restoreSel();
|
||||
const start = el?.selectionStart ?? 0;
|
||||
const end = el?.selectionEnd ?? start;
|
||||
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||
const inserted = padBefore + block + padAfter;
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const pos = start + inserted.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
|
||||
const insertOrderedList = () => {
|
||||
const el = taRef.current;
|
||||
if (!el || disabled) return;
|
||||
@@ -717,6 +780,12 @@ export default function MarkdownEditor({
|
||||
)}
|
||||
</div>
|
||||
<Sep />
|
||||
<ToolBtn
|
||||
label="时间线"
|
||||
icon={<History size={15} />}
|
||||
onClick={openTimelineEditor}
|
||||
/>
|
||||
{allowHide ? (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="隐藏内容"
|
||||
@@ -731,8 +800,21 @@ export default function MarkdownEditor({
|
||||
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
|
||||
登录可见
|
||||
</button>
|
||||
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("reply")}>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
disabled={!allowReplyHide}
|
||||
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
|
||||
aria-disabled={!allowReplyHide}
|
||||
onClick={() => {
|
||||
if (!allowReplyHide) return;
|
||||
insertHide("reply");
|
||||
}}
|
||||
>
|
||||
回复可见
|
||||
{!allowReplyHide ? (
|
||||
<span className="j13-md-menu-item-hint">不可用</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -845,6 +927,7 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
|
||||
@@ -950,6 +1033,17 @@ export default function MarkdownEditor({
|
||||
<span>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span>
|
||||
<span className="tabular-nums">{value.length} 字</span>
|
||||
</div>
|
||||
|
||||
<TimelineEditor
|
||||
open={timelineOpen}
|
||||
initialBody={timelineBody}
|
||||
onClose={() => {
|
||||
setTimelineOpen(false);
|
||||
setTimelineRange(null);
|
||||
setTimelineBody("");
|
||||
}}
|
||||
onInsert={applyTimelineBlock}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1227,6 +1227,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
id="compose-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
allowHide
|
||||
disabled={loading}
|
||||
rows={18}
|
||||
imageMaxMB={attachLimits.imageMaxMB}
|
||||
|
||||
364
frontend/components/TimelineEditor.tsx
Normal file
364
frontend/components/TimelineEditor.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
isLikelyMergeCommit,
|
||||
parseTimelineBody,
|
||||
sanitizeTimelineText,
|
||||
sanitizeTimelineTitle,
|
||||
serializeTimelineBlock,
|
||||
type TimelineItem,
|
||||
} from "@/lib/timelineBlocks";
|
||||
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type ImportMode = "replace" | "append";
|
||||
|
||||
type TimelineEditorProps = {
|
||||
open: boolean;
|
||||
/** 编辑已有块时传入 body;新建为空 */
|
||||
initialBody?: string;
|
||||
onInsert: (block: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function todayISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function fromGitItem(it: TimelineGitItem): TimelineItem {
|
||||
const title = sanitizeTimelineTitle(it.title || "提交");
|
||||
return {
|
||||
date: /^\d{4}-\d{2}-\d{2}$/.test(it.date) ? it.date : todayISO(),
|
||||
title,
|
||||
body: sanitizeTimelineText(it.body || "", 2000),
|
||||
sourceUrl: it.source_url,
|
||||
selected: !isLikelyMergeCommit(title),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyItem(): TimelineItem {
|
||||
return {
|
||||
date: todayISO(),
|
||||
title: "",
|
||||
body: "",
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
|
||||
function seedItems(initialBody: string): TimelineItem[] {
|
||||
const parsed = parseTimelineBody(initialBody);
|
||||
return parsed.length > 0 ? parsed : [emptyItem()];
|
||||
}
|
||||
|
||||
export default function TimelineEditor({
|
||||
open,
|
||||
initialBody = "",
|
||||
onInsert,
|
||||
onClose,
|
||||
}: TimelineEditorProps) {
|
||||
const [items, setItems] = useState<TimelineItem[]>(() => seedItems(initialBody));
|
||||
const [urlsText, setUrlsText] = useState("");
|
||||
const [followPages, setFollowPages] = useState(true);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("replace");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [deleteIdx, setDeleteIdx] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems(seedItems(initialBody));
|
||||
setUrlsText("");
|
||||
setImportError(null);
|
||||
setFollowPages(true);
|
||||
setImportMode(initialBody.trim() ? "append" : "replace");
|
||||
}, [open, initialBody]);
|
||||
|
||||
const updateItem = useCallback((idx: number, patch: Partial<TimelineItem>) => {
|
||||
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
|
||||
}, []);
|
||||
|
||||
const moveItem = (idx: number, dir: -1 | 1) => {
|
||||
setItems((prev) => {
|
||||
const j = idx + dir;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[idx], next[j]] = [next[j], next[idx]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sortByDate = () => {
|
||||
setItems((prev) =>
|
||||
[...prev].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
);
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
const urls = urlsText
|
||||
.split(/\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
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 (mapped.length === 0) {
|
||||
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
|
||||
if (res.warning) toast(res.warning);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => {
|
||||
if (importMode === "append") {
|
||||
const seen = new Set(
|
||||
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
|
||||
);
|
||||
const extra = mapped.filter(
|
||||
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
|
||||
);
|
||||
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
if (res.warning) toast(res.warning, "ok");
|
||||
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
|
||||
if (res.error) setImportError(res.error);
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsert = () => {
|
||||
const kept = items.filter((it) => it.selected !== false);
|
||||
if (kept.length === 0) {
|
||||
toast("请至少保留一条时间线项目", "error");
|
||||
return;
|
||||
}
|
||||
for (const it of kept) {
|
||||
if (!it.title.trim()) {
|
||||
toast("每条时间线需要标题", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
onInsert(serializeTimelineBlock(kept));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
title="编辑时间线"
|
||||
onClose={onClose}
|
||||
zIndexClass="z-[1200]"
|
||||
maxWidthClass="max-w-2xl"
|
||||
panelClassName="p-0 overflow-hidden"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
|
||||
{initialBody.trim() ? "更新时间线" : "插入时间线"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* 导入区 */}
|
||||
<section className="j13-tl-import" aria-label="从提交记录导入">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitBranch size={15} style={{ color: "var(--accent)" }} />
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
从提交记录导入
|
||||
</p>
|
||||
</div>
|
||||
<p className="meta text-[12px] mb-2 leading-relaxed">
|
||||
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
|
||||
</p>
|
||||
<textarea
|
||||
value={urlsText}
|
||||
onChange={(e) => setUrlsText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
|
||||
className="j13-tl-urls"
|
||||
disabled={importing}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={followPages}
|
||||
onChange={(e) => setFollowPages(e.target.checked)}
|
||||
disabled={importing}
|
||||
/>
|
||||
自动拉取后续页
|
||||
</label>
|
||||
<div className="flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
<span>导入方式</span>
|
||||
<select
|
||||
className="j13-tl-select"
|
||||
value={importMode}
|
||||
onChange={(e) => setImportMode(e.target.value as ImportMode)}
|
||||
disabled={importing}
|
||||
>
|
||||
<option value="replace">替换当前</option>
|
||||
<option value="append">追加</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm ml-auto"
|
||||
disabled={importing}
|
||||
onClick={() => void runImport()}
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> 导入中…
|
||||
</>
|
||||
) : (
|
||||
"导入"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{importError ? (
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
{importError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
时间线条目
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
|
||||
按日期整理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setItems((prev) => [...prev, emptyItem()])}
|
||||
>
|
||||
<Plus size={14} /> 添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((it, idx) => (
|
||||
<li key={idx} className="j13-tl-edit-card">
|
||||
<div className="flex items-start gap-2">
|
||||
<label className="mt-2 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={it.selected !== false}
|
||||
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
|
||||
aria-label="保留此条"
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={it.date}
|
||||
onChange={(e) => updateItem(idx, { date: e.target.value })}
|
||||
className="j13-tl-field w-[9.5rem]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={it.title}
|
||||
maxLength={200}
|
||||
placeholder="标题"
|
||||
onChange={(e) => updateItem(idx, { title: e.target.value })}
|
||||
className="j13-tl-field flex-1 min-w-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={it.body}
|
||||
rows={2}
|
||||
placeholder="说明(可选,支持 Markdown)"
|
||||
onChange={(e) => updateItem(idx, { body: e.target.value })}
|
||||
className="j13-tl-field w-full"
|
||||
/>
|
||||
{it.sourceUrl ? (
|
||||
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
|
||||
来自提交 · {it.sourceUrl}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="上移"
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveItem(idx, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="下移"
|
||||
disabled={idx === items.length - 1}
|
||||
onClick={() => moveItem(idx, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="删除"
|
||||
onClick={() => setDeleteIdx(idx)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteIdx != null}
|
||||
title="删除条目"
|
||||
message="确定删除这条时间线项目?"
|
||||
danger
|
||||
confirmLabel="删除"
|
||||
onCancel={() => setDeleteIdx(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteIdx != null) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== deleteIdx));
|
||||
}
|
||||
setDeleteIdx(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
User as UserIcon,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -83,6 +84,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Megaphone,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/pages",
|
||||
label: "单页",
|
||||
icon: FileText,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
PenLine,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Info,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
PostListItem,
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
AnnouncementSummary,
|
||||
NewUser,
|
||||
CheckinStatus,
|
||||
SidebarPageItem,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
@@ -117,14 +120,82 @@ function PulseCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 关于本站(侧栏单页) ---------- */
|
||||
|
||||
function AboutSite({ pages }: { pages: SidebarPageItem[] }) {
|
||||
if (pages.length === 0) return null;
|
||||
|
||||
if (pages.length === 1) {
|
||||
const p = pages[0];
|
||||
return (
|
||||
<section className="j13-module" aria-label="关于本站">
|
||||
<p className="j13-module-title">
|
||||
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
||||
</p>
|
||||
<Link href={`/p/${p.slug}`} className="block mt-2 rounded-lg px-0.5 py-1 transition-colors hover:bg-[var(--accent-soft)]">
|
||||
<p className="text-[14px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</p>
|
||||
{p.excerpt ? (
|
||||
<p className="meta text-[12.5px] mt-1.5 leading-relaxed line-clamp-3">{p.excerpt}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="j13-module" aria-label="关于本站">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{pages.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/p/${p.slug}`} className="j13-raillink" data-tip={p.title}>
|
||||
<span className="flex-1 min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 站点公告 ---------- */
|
||||
|
||||
function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
function Announcements({
|
||||
items,
|
||||
canManageAnnouncements,
|
||||
}: {
|
||||
items: AnnouncementSummary[];
|
||||
canManageAnnouncements?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="站点公告">
|
||||
<p className="j13-module-title">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="j13-module-title !mb-0">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
全部
|
||||
</Link>
|
||||
{canManageAnnouncements ? (
|
||||
<Link
|
||||
href="/admin/announcements"
|
||||
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
管理
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="meta mt-2 px-0.5">暂无公告</p>
|
||||
) : (
|
||||
@@ -137,7 +208,17 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
data-tip={a.title}
|
||||
>
|
||||
<span className="j13-anno-item-body">
|
||||
<span className="j13-anno-item-title truncate">{a.title}</span>
|
||||
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
|
||||
{a.pinned ? (
|
||||
<Pin
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label="置顶"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{a.title}</span>
|
||||
</span>
|
||||
<span className="j13-anno-item-meta">
|
||||
{a.tag ? (
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
@@ -338,6 +419,8 @@ type PanelsProps = {
|
||||
newUsers: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
allowComments: boolean;
|
||||
canManageAnnouncements?: boolean;
|
||||
sidebarPages?: SidebarPageItem[];
|
||||
};
|
||||
|
||||
function CommunityPanelsBody({
|
||||
@@ -348,11 +431,14 @@ function CommunityPanelsBody({
|
||||
newUsers,
|
||||
checkin,
|
||||
allowComments,
|
||||
canManageAnnouncements,
|
||||
sidebarPages = [],
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||
<Announcements items={announcements} />
|
||||
<AboutSite pages={sidebarPages} />
|
||||
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
|
||||
@@ -27,6 +27,8 @@ type MdHideBlockProps = {
|
||||
points?: number;
|
||||
locked?: boolean;
|
||||
postId?: number;
|
||||
/** 登录跳转路径(公告等无 postId 时使用) */
|
||||
loginHref?: string;
|
||||
loggedIn?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
@@ -37,6 +39,7 @@ export default function MdHideBlock({
|
||||
points = 0,
|
||||
locked = false,
|
||||
postId,
|
||||
loginHref,
|
||||
loggedIn = false,
|
||||
children,
|
||||
}: MdHideBlockProps) {
|
||||
@@ -46,6 +49,9 @@ export default function MdHideBlock({
|
||||
const [pwd, setPwd] = useState("");
|
||||
const [pwdError, setPwdError] = useState("");
|
||||
const label = hideKindLabel(kind);
|
||||
const loginTo =
|
||||
loginHref ||
|
||||
(postId ? `/login?redirect=${encodeURIComponent(`/post/${postId}`)}` : "/login");
|
||||
|
||||
if (locked) {
|
||||
const unlockPoints = async () => {
|
||||
@@ -115,11 +121,8 @@ export default function MdHideBlock({
|
||||
: "登录后即可阅读此段"}
|
||||
</p>
|
||||
<div className="j13-hide-gate-actions">
|
||||
{kind === "login" && !loggedIn && postId && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{kind === "login" && !loggedIn && (
|
||||
<Link href={loginTo} className="btn btn-primary">
|
||||
去登录
|
||||
</Link>
|
||||
)}
|
||||
@@ -133,23 +136,24 @@ export default function MdHideBlock({
|
||||
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
|
||||
</button>
|
||||
)}
|
||||
{kind === "points" && !loggedIn && postId && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{kind === "points" && !loggedIn && (
|
||||
<Link href={loginTo} className="btn btn-primary">
|
||||
登录后解锁
|
||||
</Link>
|
||||
)}
|
||||
{kind === "reply" && postId && !loggedIn && (
|
||||
{kind === "reply" && !loggedIn && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`}
|
||||
href={
|
||||
postId
|
||||
? `/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`
|
||||
: loginTo
|
||||
}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
登录后去回复
|
||||
</Link>
|
||||
)}
|
||||
{kind === "reply" && loggedIn && (
|
||||
{kind === "reply" && loggedIn && postId && (
|
||||
<a
|
||||
href="#comment-input"
|
||||
className="btn btn-primary"
|
||||
|
||||
67
frontend/components/markdown/MdTimeline.tsx
Normal file
67
frontend/components/markdown/MdTimeline.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
groupTimelineByDate,
|
||||
type TimelineItem,
|
||||
} from "@/lib/timelineBlocks";
|
||||
|
||||
type MdTimelineProps = {
|
||||
items: TimelineItem[];
|
||||
/** 每项正文已渲染的 React 节点(与 items 等长);缺省则只显示纯文本 */
|
||||
renderBody?: (item: TimelineItem, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按日分组的垂直时间线(更新树)。颜色走 CSS 令牌。
|
||||
*/
|
||||
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||
if (!items.length) {
|
||||
return <p className="meta px-1 py-2">暂无时间线条目</p>;
|
||||
}
|
||||
const groups = groupTimelineByDate(items);
|
||||
let flat = 0;
|
||||
|
||||
return (
|
||||
<div className="j13-timeline" role="list" aria-label="时间线">
|
||||
{groups.map((g) => (
|
||||
<div key={g.date} className="j13-timeline-day">
|
||||
<div className="j13-timeline-day-label">
|
||||
<time dateTime={g.date}>{g.date}</time>
|
||||
</div>
|
||||
<ul className="j13-timeline-items">
|
||||
{g.items.map((it) => {
|
||||
const idx = flat++;
|
||||
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">
|
||||
<h3 className="j13-timeline-title">{it.title}</h3>
|
||||
{it.sourceUrl ? (
|
||||
<a
|
||||
href={it.sourceUrl}
|
||||
className="j13-timeline-src"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-tip="查看提交"
|
||||
aria-label="查看提交"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{renderBody ? (
|
||||
<div className="j13-timeline-body">{renderBody(it, idx)}</div>
|
||||
) : it.body.trim() ? (
|
||||
<div className="j13-timeline-body whitespace-pre-wrap">{it.body}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,13 @@ export type AnnouncementColor =
|
||||
| "orange"
|
||||
| "red"
|
||||
| "purple"
|
||||
| "gray";
|
||||
| "gray"
|
||||
| "teal"
|
||||
| "cyan"
|
||||
| "pink"
|
||||
| "amber"
|
||||
| "indigo"
|
||||
| "rose";
|
||||
|
||||
// 右栏站点公告摘要(标签/日期/标题)
|
||||
export interface AnnouncementSummary {
|
||||
@@ -95,6 +101,7 @@ export interface AnnouncementSummary {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
created_at: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
// 公告详情/后台管理完整对象
|
||||
@@ -105,10 +112,42 @@ export interface Announcement {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SidebarPageItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface SitePage {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
published: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
sort_order: number;
|
||||
allow_comments: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SitePageInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
published: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface NewUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -130,6 +169,8 @@ export interface OverviewResponse {
|
||||
active_users: ActiveUser[];
|
||||
boards: BoardWithCount[];
|
||||
announcements: AnnouncementSummary[];
|
||||
announcements_total?: number;
|
||||
sidebar_pages?: SidebarPageItem[];
|
||||
new_users: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
}
|
||||
@@ -1509,11 +1550,15 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
|
||||
|
||||
// ===== 站点公告 =====
|
||||
|
||||
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
|
||||
// SSR 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide)
|
||||
export async function fetchAnnouncementDetail(
|
||||
id: string
|
||||
id: string,
|
||||
cookieHeader?: string
|
||||
): Promise<{ announcement: Announcement } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/announcements/${id}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取公告失败");
|
||||
return res.json();
|
||||
@@ -1550,6 +1595,7 @@ export interface AnnouncementInput {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
async function saveAnnouncement(
|
||||
@@ -1586,6 +1632,126 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminToggleAnnouncementPin(
|
||||
id: number
|
||||
): Promise<{ announcement: Announcement }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "置顶失败");
|
||||
return data as { announcement: Announcement };
|
||||
}
|
||||
|
||||
/** SSR / 公开:分页公告列表 */
|
||||
export async function fetchAnnouncementsPage(
|
||||
page = 1,
|
||||
size = 20
|
||||
): Promise<{ announcements: Announcement[]; total: number; page: number }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/announcements?page=${page}&size=${size}`,
|
||||
ssrInit()
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取公告失败");
|
||||
return data as { announcements: Announcement[]; total: number; page: number };
|
||||
}
|
||||
|
||||
export type TimelineGitItem = {
|
||||
date: string;
|
||||
title: string;
|
||||
body: string;
|
||||
source_url?: string;
|
||||
sha?: string;
|
||||
};
|
||||
|
||||
export async function apiTimelineFromGit(input: {
|
||||
urls: string[];
|
||||
follow_pages?: boolean;
|
||||
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
|
||||
const res = await fetchWithRefresh("/api/timeline/from-git", {
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function fetchSitePageBySlug(
|
||||
slug: string
|
||||
): Promise<{ page: SitePage } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取页面失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> {
|
||||
const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||||
return data as { pages: SitePage[] };
|
||||
}
|
||||
|
||||
export async function fetchAdminSitePages(
|
||||
cookieHeader?: string
|
||||
): Promise<{ pages: SitePage[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||||
return data as { pages: SitePage[] };
|
||||
}
|
||||
|
||||
async function saveSitePage(
|
||||
method: "POST" | "PUT",
|
||||
url: string,
|
||||
input: SitePageInput
|
||||
): Promise<{ page: SitePage }> {
|
||||
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 { page: SitePage };
|
||||
}
|
||||
|
||||
export function apiAdminCreateSitePage(input: SitePageInput) {
|
||||
return saveSitePage("POST", "/api/admin/pages", input);
|
||||
}
|
||||
|
||||
export function apiAdminUpdateSitePage(id: number, input: SitePageInput) {
|
||||
return saveSitePage("PUT", `/api/admin/pages/${id}`, input);
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/pages/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除页面失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGetAdminSettings(): Promise<
|
||||
PublicSettings & { timeline_git_import?: string }
|
||||
> {
|
||||
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取设置失败");
|
||||
return {
|
||||
...normalizePublicSettings(data as Partial<PublicSettings>),
|
||||
timeline_git_import:
|
||||
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 用户管理(管理员) =====
|
||||
|
||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||
@@ -1901,6 +2067,7 @@ export type UpdateSiteSettingsBody = {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
timeline_git_import?: string;
|
||||
};
|
||||
|
||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||
|
||||
205
frontend/lib/directiveBlocks.ts
Normal file
205
frontend/lib/directiveBlocks.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
|
||||
|
||||
import {
|
||||
splitHideSegments,
|
||||
type HideKind,
|
||||
type HideSegment,
|
||||
} from "@/lib/hideBlocks";
|
||||
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "md"; text: string }
|
||||
| {
|
||||
type: "hide";
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
}
|
||||
|
||||
function isTimelineOpen(trimmed: string): boolean {
|
||||
return trimmed.toLowerCase() === "[timeline]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
*/
|
||||
export function splitContentSegments(content: string): ContentSegment[] {
|
||||
if (!content) return [{ type: "md", text: "" }];
|
||||
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 }> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
|
||||
const flushMd = () => {
|
||||
if (buf.length === 0) return;
|
||||
raw.push({ type: "md", text: buf.join("\n") });
|
||||
buf = [];
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTimelineOpen(trimmed)) {
|
||||
flushMd();
|
||||
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;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isTimelineOpen(inner)) {
|
||||
// 嵌套视为失败:整段当 md
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (isTimelineClose(inner)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
raw.push({ type: "timeline", body: bodyLines.join("\n") });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
flushMd();
|
||||
|
||||
const out: ContentSegment[] = [];
|
||||
for (const part of raw) {
|
||||
if (part.type === "timeline") {
|
||||
out.push({
|
||||
type: "timeline",
|
||||
items: parseTimelineBody(part.body),
|
||||
rawBody: part.body,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
out.push({ type: "md", text: s.text });
|
||||
} else {
|
||||
out.push({
|
||||
type: "hide",
|
||||
kind: s.kind,
|
||||
points: s.points,
|
||||
locked: s.locked,
|
||||
body: s.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [{ type: "md", text: content }];
|
||||
}
|
||||
|
||||
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
|
||||
export function findTimelineBlockAt(
|
||||
content: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; body: string } | null {
|
||||
const blocks = listTimelineBlocks(content);
|
||||
for (const b of blocks) {
|
||||
if (cursor >= b.start && cursor <= b.end) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器打开时间线面板时的目标块:
|
||||
* 1) 光标落在某块内 → 该块
|
||||
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
|
||||
* 3) 否则 null(新建插入)
|
||||
*/
|
||||
export function resolveTimelineBlockForEdit(
|
||||
content: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; body: string } | null {
|
||||
const at = findTimelineBlockAt(content, cursor);
|
||||
if (at) return at;
|
||||
const blocks = listTimelineBlocks(content);
|
||||
if (blocks.length === 1) return blocks[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function listTimelineBlocks(
|
||||
content: string
|
||||
): Array<{ start: number; end: number; body: string }> {
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const out: Array<{ start: number; end: number; body: string }> = [];
|
||||
let offset = 0;
|
||||
let inCode = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineStart = offset;
|
||||
const lineEnd = offset + line.length;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) inCode = !inCode;
|
||||
if (!inCode && isTimelineOpen(trimmed)) {
|
||||
const openStart = lineStart;
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let closeEnd = -1;
|
||||
const bodyLines: string[] = [];
|
||||
let pos = lineEnd + 1;
|
||||
while (j < lines.length) {
|
||||
const l = lines[j];
|
||||
const t = l.trim();
|
||||
if (t.startsWith("```")) innerCode = !innerCode;
|
||||
if (!innerCode && isTimelineClose(t)) {
|
||||
closeEnd = pos + l.length;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(l);
|
||||
pos += l.length + 1;
|
||||
j++;
|
||||
}
|
||||
if (closeEnd >= 0) {
|
||||
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
|
||||
}
|
||||
}
|
||||
offset = lineEnd + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
|
||||
/** 正文 [hide]…[/hide] 块解析(与 backend/markdown/hide.go 契约一致) */
|
||||
|
||||
export type HideKind = "login" | "reply" | "points" | "password";
|
||||
|
||||
@@ -13,25 +13,34 @@ export type HideSegment =
|
||||
};
|
||||
|
||||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||||
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
|
||||
|
||||
function isClose(line: string): boolean {
|
||||
return line.trim() === ":::";
|
||||
return line.trim().toLowerCase() === "[/hide]";
|
||||
}
|
||||
|
||||
/** 与 Go parseOpenMarker 对齐::::hide <kind> [points|password] [locked] */
|
||||
/** 与 Go parseOpenMarker 对齐:[hide <kind>[=arg] [locked]] */
|
||||
function parseOpen(trimmed: string): {
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
} | null {
|
||||
if (!trimmed.startsWith(":::hide")) return null;
|
||||
const parts = trimmed
|
||||
.slice(":::hide".length)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const m = trimmed.match(HIDE_OPEN_RE);
|
||||
if (!m) return null;
|
||||
const rest = (m[1] || "").trim();
|
||||
if (!rest) return null;
|
||||
const parts = rest.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
const kind = parts[0] as HideKind;
|
||||
|
||||
let head = parts[0];
|
||||
let kind = head;
|
||||
let arg = "";
|
||||
const eq = head.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
kind = head.slice(0, eq);
|
||||
arg = head.slice(eq + 1);
|
||||
}
|
||||
kind = kind.toLowerCase() as HideKind;
|
||||
if (!HIDE_KINDS.has(kind)) return null;
|
||||
|
||||
let idx = 1;
|
||||
@@ -39,20 +48,21 @@ function parseOpen(trimmed: string): {
|
||||
let locked = false;
|
||||
|
||||
if (kind === "points") {
|
||||
if (idx >= parts.length) return null;
|
||||
const n = Number(parts[idx]);
|
||||
if (!arg) return null;
|
||||
const n = Number(arg);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
|
||||
points = n;
|
||||
idx++;
|
||||
} else if (kind === "password") {
|
||||
// 脱敏::::hide password locked;原文::::hide password <pwd> [locked]
|
||||
if (idx < parts.length && parts[idx] !== "locked") {
|
||||
idx++;
|
||||
// 脱敏:[hide password locked];原文密码不在前端解析保留
|
||||
if (arg) {
|
||||
/* skip plaintext */
|
||||
}
|
||||
} else if (arg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (; idx < parts.length; idx++) {
|
||||
if (parts[idx] === "locked") locked = true;
|
||||
if (parts[idx].toLowerCase() === "locked") locked = true;
|
||||
else return null;
|
||||
}
|
||||
return { kind, points, locked };
|
||||
|
||||
140
frontend/lib/timelineBlocks.ts
Normal file
140
frontend/lib/timelineBlocks.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** 正文 [timeline]…[/timeline] 块解析与序列化 */
|
||||
|
||||
export type TimelineItem = {
|
||||
date: string;
|
||||
title: string;
|
||||
body: string;
|
||||
sourceUrl?: string;
|
||||
/** 导入勾选;手填项默认 true */
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type TimelineDayGroup = {
|
||||
date: string;
|
||||
items: TimelineItem[];
|
||||
};
|
||||
|
||||
const GIT_COMMENT_RE = /^<!--\s*git:\s*(https?:\/\/\S+)\s*-->\s*$/i;
|
||||
const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
|
||||
|
||||
/** 去掉控制字符与 RTL 覆盖,截断长度 */
|
||||
export function sanitizeTimelineText(raw: string, max: number): string {
|
||||
let s = raw
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
||||
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
|
||||
if ([...s].length > max) {
|
||||
s = [...s].slice(0, max).join("");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function isDirectiveLine(t: string): boolean {
|
||||
const lower = t.toLowerCase();
|
||||
return (
|
||||
lower === "[/hide]" ||
|
||||
lower === "[/timeline]" ||
|
||||
lower === "[timeline]" ||
|
||||
lower.startsWith("[hide") ||
|
||||
t.startsWith("<!-- git:")
|
||||
);
|
||||
}
|
||||
|
||||
/** 避免导入文本拆坏指令块:危险独立行加前缀空格 */
|
||||
export function neutralizeDirectiveLines(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const t = line.trim();
|
||||
if (isDirectiveLine(t)) {
|
||||
return " " + line;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** 标题行:去掉换行,防止拆项 */
|
||||
export function sanitizeTimelineTitle(title: string): string {
|
||||
return sanitizeTimelineText(title.replace(/[\r\n]+/g, " ").trim(), 200);
|
||||
}
|
||||
|
||||
export function parseTimelineBody(body: string): TimelineItem[] {
|
||||
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const items: TimelineItem[] = [];
|
||||
let cur: TimelineItem | null = null;
|
||||
let bodyBuf: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!cur) return;
|
||||
let sourceUrl: string | undefined;
|
||||
const kept: string[] = [];
|
||||
for (const line of bodyBuf) {
|
||||
const m = line.trim().match(GIT_COMMENT_RE);
|
||||
if (m) {
|
||||
sourceUrl = m[1];
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
cur.body = kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
||||
if (sourceUrl) cur.sourceUrl = sourceUrl;
|
||||
items.push(cur);
|
||||
cur = null;
|
||||
bodyBuf = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(HEADING_RE);
|
||||
if (m) {
|
||||
flush();
|
||||
cur = {
|
||||
date: m[1],
|
||||
title: sanitizeTimelineTitle(m[2]),
|
||||
body: "",
|
||||
selected: true,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (cur) bodyBuf.push(line);
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
export function serializeTimelineBlock(items: TimelineItem[]): string {
|
||||
const parts = items
|
||||
.filter((it) => it.selected !== false)
|
||||
.map((it) => {
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/.test(it.date)
|
||||
? it.date
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const title = sanitizeTimelineTitle(it.title) || "未命名";
|
||||
const body = neutralizeDirectiveLines(sanitizeTimelineText(it.body || "", 2000));
|
||||
const lines = [`## ${date} ${title}`];
|
||||
if (body.trim()) lines.push(body.trimEnd());
|
||||
if (it.sourceUrl && /^https:\/\//i.test(it.sourceUrl)) {
|
||||
lines.push(`<!-- git: ${it.sourceUrl} -->`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`;
|
||||
}
|
||||
|
||||
export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] {
|
||||
const map = new Map<string, TimelineItem[]>();
|
||||
const order: string[] = [];
|
||||
for (const it of items) {
|
||||
const d = it.date || "未知日期";
|
||||
if (!map.has(d)) {
|
||||
map.set(d, []);
|
||||
order.push(d);
|
||||
}
|
||||
map.get(d)!.push(it);
|
||||
}
|
||||
order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
return order.map((date) => ({ date, items: map.get(date)! }));
|
||||
}
|
||||
|
||||
export function isLikelyMergeCommit(title: string): boolean {
|
||||
return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim());
|
||||
}
|
||||
Reference in New Issue
Block a user