diff --git a/backend/handler/post.go b/backend/handler/post.go index 46b984d..414f870 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -96,8 +96,6 @@ type CreatePostRequest struct { Content string `json:"content" binding:"required,min=1"` Tags string `json:"tags"` PostType string `json:"post_type"` - ContentAccess string `json:"content_access"` - AccessPoints int `json:"access_points"` TypeMeta string `json:"type_meta"` AttachmentIDs []uint `json:"attachment_ids"` } @@ -118,8 +116,6 @@ func (h *Handlers) CreatePost(c *gin.Context) { Content: req.Content, Tags: req.Tags, PostType: req.PostType, - ContentAccess: req.ContentAccess, - AccessPoints: req.AccessPoints, TypeMeta: req.TypeMeta, Status: status, AttachmentIDs: req.AttachmentIDs, @@ -141,8 +137,6 @@ type UpdatePostRequest struct { Title string `json:"title" binding:"omitempty,min=1,max=256"` Content string `json:"content" binding:"omitempty,min=1"` Tags string `json:"tags"` - ContentAccess *string `json:"content_access"` - AccessPoints *int `json:"access_points"` TypeMeta *string `json:"type_meta"` AttachmentIDs *[]uint `json:"attachment_ids"` } @@ -165,8 +159,6 @@ func (h *Handlers) UpdatePost(c *gin.Context) { Title: req.Title, Content: req.Content, Tags: req.Tags, - ContentAccess: req.ContentAccess, - AccessPoints: req.AccessPoints, TypeMeta: req.TypeMeta, AttachmentIDs: req.AttachmentIDs, }) diff --git a/backend/markdown/hide.go b/backend/markdown/hide.go new file mode 100644 index 0000000..b7140e4 --- /dev/null +++ b/backend/markdown/hide.go @@ -0,0 +1,324 @@ +// Package markdown 提供帖子正文隐藏块(:::hide)的解析、校验与脱敏。 +package markdown + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + HideKindLogin = "login" + HideKindReply = "reply" + HideKindPoints = "points" +) + +// HideBlock 正文中的一段隐藏内容 +type HideBlock struct { + Kind string // login | reply | points + Points int // 仅 points 有效 + Body string // 块内 Markdown(不含开闭标记行) + Start int // 开标记行在 lines 中的下标 + End int // 闭标记行在 lines 中的下标(含) + Locked bool // 开标记是否已带 locked(脱敏输出) +} + +// DerivedAccess 由正文隐藏块派生的帖级可见性 +type DerivedAccess struct { + Access string // public | login | reply | points | mixed + Points int // 所有积分块价格之和 +} + +// ViewerCaps 读者对隐藏块的能力(由 service 填充) +type ViewerCaps struct { + Bypass bool // 作者 / 版主 + LoggedIn bool + HasReplied bool + PointsPaid bool // 已支付本帖积分解锁 +} + +var ( + ErrHideNested = errors.New("隐藏块不可嵌套") + ErrHideUnclosed = errors.New("隐藏块未正确闭合") + ErrHideInvalid = errors.New("隐藏块语法无效") + ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分") +) + +// ParseHideBlocks 扫描正文中的 :::hide 块(忽略代码围栏内伪语法)。 +// 校验失败返回 error(用于 Create/Update)。 +func ParseHideBlocks(content string) ([]HideBlock, error) { + lines := splitLines(content) + var blocks []HideBlock + inCode := false + i := 0 + for i < len(lines) { + trimmed := strings.TrimSpace(lines[i]) + + if strings.HasPrefix(trimmed, "```") { + inCode = !inCode + i++ + continue + } + if inCode { + i++ + continue + } + + if kind, pts, locked, ok := parseOpenMarker(trimmed); ok { + j := i + 1 + bodyLines := make([]string, 0) + foundClose := false + innerCode := 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 _, _, _, isOpen := parseOpenMarker(inner); isOpen { + return nil, ErrHideNested + } + if isCloseMarker(inner) { + foundClose = true + break + } + bodyLines = append(bodyLines, lines[j]) + j++ + } + if !foundClose { + return nil, ErrHideUnclosed + } + if kind == HideKindPoints { + if pts < 1 || pts > 100000 { + return nil, ErrHidePointsNeed + } + } + blocks = append(blocks, HideBlock{ + Kind: kind, + Points: pts, + Body: strings.Join(bodyLines, "\n"), + Start: i, + End: j, + Locked: locked, + }) + i = j + 1 + continue + } + + if isCloseMarker(trimmed) { + return nil, ErrHideInvalid + } + i++ + } + return blocks, nil +} + +// DeriveAccess 由隐藏块列表派生帖级 content_access / access_points +func DeriveAccess(blocks []HideBlock) DerivedAccess { + if len(blocks) == 0 { + return DerivedAccess{Access: "public", Points: 0} + } + kinds := map[string]struct{}{} + totalPts := 0 + for _, b := range blocks { + kinds[b.Kind] = struct{}{} + if b.Kind == HideKindPoints { + totalPts += b.Points + } + } + if len(kinds) > 1 { + return DerivedAccess{Access: "mixed", Points: totalPts} + } + for k := range kinds { + return DerivedAccess{Access: k, Points: totalPts} + } + return DerivedAccess{Access: "public", Points: 0} +} + +// DeriveAccessFromContent 解析并派生;校验失败返回 error +func DeriveAccessFromContent(content string) (DerivedAccess, error) { + blocks, err := ParseHideBlocks(content) + if err != nil { + return DerivedAccess{}, err + } + return DeriveAccess(blocks), nil +} + +// SanitizeForViewer 按读者能力脱敏:未满足条件的块清空正文并加 locked。 +// fullyLocked 表示读者当前看不到任何可见正文(整篇都在锁块里或公开区为空)。 +func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fullyLocked bool) { + blocks, err := ParseHideBlocks(content) + if err != nil { + return "", true + } + if len(blocks) == 0 { + return content, strings.TrimSpace(content) == "" + } + if caps.Bypass { + return content, false + } + + lines := splitLines(content) + var out []string + cursor := 0 + anyVisible := false + + for _, b := range blocks { + for i := cursor; i < b.Start; i++ { + out = append(out, lines[i]) + if strings.TrimSpace(lines[i]) != "" { + anyVisible = true + } + } + + if blockUnlocked(b, caps) { + out = append(out, openMarkerLine(b.Kind, b.Points, false)) + if b.Body != "" { + out = append(out, splitLines(b.Body)...) + if strings.TrimSpace(b.Body) != "" { + anyVisible = true + } + } + out = append(out, ":::") + } else { + out = append(out, openMarkerLine(b.Kind, b.Points, true)) + out = append(out, ":::") + } + cursor = b.End + 1 + } + for i := cursor; i < len(lines); i++ { + out = append(out, lines[i]) + if strings.TrimSpace(lines[i]) != "" { + anyVisible = true + } + } + + return strings.Join(out, "\n"), !anyVisible +} + +// WrapContentAsHide 将整篇正文包进单一隐藏块(旧帖迁移用) +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" + body + "\n:::\n" +} + +// HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等) +func HasHideBlocks(content string) bool { + blocks, err := ParseHideBlocks(content) + if err != nil { + return strings.Contains(content, ":::hide") + } + return len(blocks) > 0 +} + +func blockUnlocked(b HideBlock, caps ViewerCaps) bool { + switch b.Kind { + case HideKindLogin: + return caps.LoggedIn + case HideKindReply: + return caps.HasReplied + case HideKindPoints: + return caps.PointsPaid + default: + return true + } +} + +func openMarkerLine(kind string, points int, locked bool) string { + var b strings.Builder + b.WriteString(":::hide ") + b.WriteString(kind) + if kind == HideKindPoints && points > 0 { + b.WriteByte(' ') + b.WriteString(strconv.Itoa(points)) + } + if locked { + b.WriteString(" locked") + } + return b.String() +} + +func parseOpenMarker(trimmed string) (kind string, points int, 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: + 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++ + } + for ; idx < len(parts); idx++ { + if parts[idx] == "locked" { + locked = true + } else { + return "", 0, false, false + } + } + return kind, points, locked, true +} + +func isCloseMarker(trimmed string) bool { + return trimmed == ":::" +} + +func splitLines(s string) []string { + if s == "" { + return []string{} + } + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return strings.Split(s, "\n") +} + +// ValidateHideContent 校验正文隐藏块;失败返回用户可读错误 +func ValidateHideContent(content string) error { + _, err := ParseHideBlocks(content) + if err == nil { + return nil + } + switch { + case errors.Is(err, ErrHideNested): + return fmt.Errorf("隐藏块不可嵌套") + case errors.Is(err, ErrHideUnclosed): + return fmt.Errorf("隐藏块未正确闭合,请检查 ::: 标记") + case errors.Is(err, ErrHidePointsNeed): + return fmt.Errorf("积分可见块须指定 1–100000 的积分") + case errors.Is(err, ErrHideInvalid): + return fmt.Errorf("隐藏块语法无效") + default: + return err + } +} diff --git a/backend/markdown/hide_test.go b/backend/markdown/hide_test.go new file mode 100644 index 0000000..84e9103 --- /dev/null +++ b/backend/markdown/hide_test.go @@ -0,0 +1,122 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestParseIgnoreCodeFence(t *testing.T) { + src := "公开\n\n```\n:::hide login\n假的\n:::\n```\n\n结尾\n" + blocks, err := ParseHideBlocks(src) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 0 { + t.Fatalf("expected 0 blocks, got %d", len(blocks)) + } +} + +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" + blocks, err := ParseHideBlocks(src) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks, got %d", len(blocks)) + } + d := DeriveAccess(blocks) + if d.Access != "mixed" { + t.Fatalf("access=%s", d.Access) + } + if d.Points != 15 { + t.Fatalf("points=%d", d.Points) + } +} + +func TestParseNestedRejected(t *testing.T) { + src := ":::hide login\n外\n:::hide reply\n内\n:::\n:::\n" + _, err := ParseHideBlocks(src) + if err != ErrHideNested { + t.Fatalf("want ErrHideNested, got %v", err) + } +} + +func TestSanitizeLocksBody(t *testing.T) { + src := "公开\n\n:::hide login\n秘密内容\n:::\n\n尾\n" + out, fully := SanitizeForViewer(src, ViewerCaps{}) + if fully { + t.Fatal("should not be fully locked") + } + if strings.Contains(out, "秘密内容") { + t.Fatalf("leaked: %q", out) + } + if !strings.Contains(out, ":::hide login locked") { + t.Fatalf("missing locked marker: %q", out) + } + if !strings.Contains(out, "公开") || !strings.Contains(out, "尾") { + t.Fatalf("public parts lost: %q", out) + } +} + +func TestSanitizeUnlockLogin(t *testing.T) { + src := ":::hide login\n秘密\n:::\n" + out, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true}) + if fully { + t.Fatal("should see content") + } + if !strings.Contains(out, "秘密") { + t.Fatalf("missing body: %q", out) + } + if strings.Contains(out, "locked") { + t.Fatalf("should not lock: %q", out) + } +} + +func TestSanitizeBypass(t *testing.T) { + src := ":::hide points 9\n付费\n:::\n" + out, _ := SanitizeForViewer(src, ViewerCaps{Bypass: true}) + if !strings.Contains(out, "付费") { + t.Fatalf("bypass failed: %q", out) + } +} + +func TestSanitizePointsPaid(t *testing.T) { + src := ":::hide points 3\n付费文\n:::\n" + out, _ := SanitizeForViewer(src, ViewerCaps{LoggedIn: true, PointsPaid: true}) + if !strings.Contains(out, "付费文") { + t.Fatalf("paid unlock failed: %q", out) + } +} + +func TestFullyLocked(t *testing.T) { + src := ":::hide reply\n仅回复\n:::\n" + _, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true}) + if !fully { + t.Fatal("expected fully locked") + } +} + +func TestCodeInsideHide(t *testing.T) { + src := ":::hide login\n```\n:::hide reply\n伪\n:::\n```\n真\n:::\n" + blocks, err := ParseHideBlocks(src) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 { + t.Fatalf("got %d", len(blocks)) + } + if !strings.Contains(blocks[0].Body, "真") { + t.Fatalf("body=%q", blocks[0].Body) + } +} + +func TestWrapContentAsHide(t *testing.T) { + got := WrapContentAsHide("points", 20, "旧正文") + if !strings.HasPrefix(got, ":::hide points 20\n") { + t.Fatalf("%q", got) + } + if !strings.Contains(got, "旧正文") { + t.Fatal(got) + } +} diff --git a/backend/model/db.go b/backend/model/db.go index 40a0efc..275ce84 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -7,6 +7,7 @@ import ( "fmt" "log" + "github.com/freefire/jiang13-bbs/markdown" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -45,6 +46,11 @@ func InitDB(dsn string) error { return fmt.Errorf("自动迁移失败: %w", err) } + // 旧帖整帖可见性 → 正文 :::hide 块(幂等) + if err := migratePostContentAccessToHideBlocks(db); err != nil { + return fmt.Errorf("正文隐藏块迁移失败: %w", err) + } + // 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户) if err := backfillPointsFromCheckin(db); err != nil { return fmt.Errorf("积分余额回填失败: %w", err) @@ -365,3 +371,39 @@ func ensureDefaultChatMemberships(db *gorm.DB) error { } return nil } + +// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 :::hide 块(幂等)。 +func migratePostContentAccessToHideBlocks(db *gorm.DB) error { + var posts []Post + if err := db.Unscoped(). + Where("content_access IN ? AND deleted_at IS NULL", + []string{ContentAccessLogin, ContentAccessReply, ContentAccessPoints}). + Find(&posts).Error; err != nil { + return err + } + n := 0 + for i := range posts { + p := &posts[i] + if markdown.HasHideBlocks(p.Content) { + continue + } + kind := NormalizeContentAccess(p.ContentAccess) + pts := 0 + if kind == ContentAccessPoints { + pts = p.AccessPoints + if pts <= 0 { + pts = 1 + } + } + wrapped := markdown.WrapContentAsHide(kind, pts, p.Content) + if err := db.Model(&Post{}).Where("id = ?", p.ID). + Update("content", wrapped).Error; err != nil { + return err + } + n++ + } + if n > 0 { + log.Printf("[model] 旧帖可见性已迁入隐藏块:%d 篇", n) + } + return nil +} diff --git a/backend/model/models.go b/backend/model/models.go index eb7e1dd..ca21fea 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -80,18 +80,19 @@ func NormalizePostType(t string) string { return PostTypeDiscussion } -// 正文可见性 +// 正文可见性(由正文 :::hide 块派生;mixed = 多种隐藏类型并存) const ( ContentAccessPublic = "public" // 公开 - ContentAccessLogin = "login" // 登录可见 - ContentAccessReply = "reply" // 回复可见 - ContentAccessPoints = "points" // 积分购买可见 + ContentAccessLogin = "login" // 仅登录可见块 + ContentAccessReply = "reply" // 仅回复可见块 + ContentAccessPoints = "points" // 仅积分可见块 + ContentAccessMixed = "mixed" // 多种隐藏块混合 ) // ValidContentAccess 可见性白名单 func ValidContentAccess(a string) bool { switch a { - case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints: + case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints, ContentAccessMixed: return true } return false @@ -120,8 +121,8 @@ type User struct { Role Role `gorm:"size:16;default:user" json:"role"` Banned bool `gorm:"default:false" json:"banned"` Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 - TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 - LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) + TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 + LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` @@ -160,30 +161,30 @@ type Board struct { // Post 帖子 type Post struct { - ID uint `gorm:"primaryKey" json:"id"` - BoardID uint `gorm:"index;not null" json:"board_id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Title string `gorm:"size:256;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Tags string `gorm:"size:256" json:"tags"` - PostType string `gorm:"size:16;default:discussion;index" json:"post_type"` - ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points - AccessPoints int `gorm:"not null;default:0" json:"access_points"` // points 可见时所需积分 - TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) - Pinned int `gorm:"default:0" json:"pinned"` - Recommended bool `gorm:"default:false;index" json:"recommended"` - Status string `gorm:"size:16;default:published;index" json:"status"` - LikeCount int `gorm:"default:0" json:"like_count"` - ViewCount int `gorm:"default:0" json:"view_count"` - CommentCount int `gorm:"default:0" json:"comment_count"` - Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库) + ID uint `gorm:"primaryKey" json:"id"` + BoardID uint `gorm:"index;not null" json:"board_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Title string `gorm:"size:256;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Tags string `gorm:"size:256" json:"tags"` + PostType string `gorm:"size:16;default:discussion;index" json:"post_type"` + ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points|mixed(由正文隐藏块派生) + AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 积分隐藏块价格之和 + TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) + Pinned int `gorm:"default:0" json:"pinned"` + Recommended bool `gorm:"default:false;index" json:"recommended"` + Status string `gorm:"size:16;default:published;index" json:"status"` + LikeCount int `gorm:"default:0" json:"like_count"` + ViewCount int `gorm:"default:0" json:"view_count"` + CommentCount int `gorm:"default:0" json:"comment_count"` + Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库) // 软删元数据:列表仍排除;详情 Unscoped 可读作 tombstone 页 - DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` - DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` - DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` // 执行删除者;与 UserID 不同即为管理删除 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` + DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` + DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` // 执行删除者;与 UserID 不同即为管理删除 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` @@ -241,13 +242,13 @@ type PointLedger struct { // 积分流水原因 const ( - PointReasonCheckin = "checkin" - PointReasonUnlockPost = "unlock_post" - PointReasonDownloadFile = "download_file" - PointReasonMigrateCheckin = "migrate_checkin" - PointReasonBountyEscrow = "bounty_escrow" - PointReasonBountyRefund = "bounty_refund" - PointReasonBountyAward = "bounty_award" + PointReasonCheckin = "checkin" + PointReasonUnlockPost = "unlock_post" + PointReasonDownloadFile = "download_file" + PointReasonMigrateCheckin = "migrate_checkin" + PointReasonBountyEscrow = "bounty_escrow" + PointReasonBountyRefund = "bounty_refund" + PointReasonBountyAward = "bounty_award" ) // PostPollVote 投票记录(多选时同一用户多行) @@ -280,17 +281,17 @@ type PostContentUnlock struct { // PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载) type PostAttachment struct { - ID uint `gorm:"primaryKey" json:"id"` - PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定 - UserID uint `gorm:"index;not null" json:"user_id"` - Name string `gorm:"size:256;not null" json:"name"` // 原始文件名 - StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名 - MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"` - Size int `gorm:"not null;default:0" json:"size"` - PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费 - DownloadCount int `gorm:"not null;default:0" json:"download_count"` - CreatedAt time.Time `json:"created_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定 + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:256;not null" json:"name"` // 原始文件名 + StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名 + MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"` + Size int `gorm:"not null;default:0" json:"size"` + PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费 + DownloadCount int `gorm:"not null;default:0" json:"download_count"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // PostAttachmentUnlock 积分附件下载解锁(按附件计费一次) @@ -304,18 +305,18 @@ type PostAttachmentUnlock struct { // Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下) type Comment struct { - ID uint `gorm:"primaryKey" json:"id"` - PostID uint `gorm:"index;not null" json:"post_id"` - UserID uint `gorm:"index;not null" json:"user_id,omitempty"` - ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层) - RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填 - Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1 - Content string `gorm:"type:text;not null" json:"content"` - Status string `gorm:"size:16;default:published;index" json:"status"` + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"index;not null" json:"post_id"` + UserID uint `gorm:"index;not null" json:"user_id,omitempty"` + ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层) + RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填 + Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1 + Content string `gorm:"type:text;not null" json:"content"` + Status string `gorm:"size:16;default:published;index" json:"status"` // 软删元数据:列表 Unscoped 作 tombstone;DeletedBy≠UserID 为管理删除 - DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` - DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` - DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` + DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` + DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` + DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` @@ -469,7 +470,7 @@ type ChatRoom struct { Description string `gorm:"size:256;not null;default:''" json:"description"` OwnerID uint `gorm:"index;not null" json:"owner_id"` RoomType string `gorm:"size:16;not null;default:group;index" json:"room_type"` // group | direct - DirectKey string `gorm:"size:64;not null;default:'';index" json:"-"` // 私聊双方 ID 排序键 "min:max" + DirectKey string `gorm:"size:64;not null;default:'';index" json:"-"` // 私聊双方 ID 排序键 "min:max" IsPrivate bool `gorm:"not null;default:false;index" json:"is_private"` IsDefault bool `gorm:"not null;default:false;index" json:"is_default"` MemberCount int `gorm:"not null;default:0" json:"member_count"` @@ -495,7 +496,7 @@ type ChatRoomMember struct { Role string `gorm:"size:16;not null;default:member" json:"role"` LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"` Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言 - PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段 + PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -508,15 +509,15 @@ type ChatMessage struct { RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"` SenderID uint `gorm:"index;not null" json:"sender_id"` Content string `gorm:"type:varchar(2000);not null" json:"content"` - ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用 + ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用 ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示 - MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用) + MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用) RecalledAt *time.Time `json:"recalled_at,omitempty"` RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"` CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` + Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` // RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称 Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"` } diff --git a/backend/service/post.go b/backend/service/post.go index ec3870d..fed4b97 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/freefire/jiang13-bbs/markdown" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) @@ -402,7 +403,7 @@ type PostDetail struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } -// CreatePostInput 发帖入参 +// CreatePostInput 发帖入参(content_access / access_points 由正文 :::hide 派生) type CreatePostInput struct { UserID uint BoardID uint @@ -410,8 +411,6 @@ type CreatePostInput struct { Content string Tags string PostType string - ContentAccess string - AccessPoints int TypeMeta string Status string AttachmentIDs []uint @@ -422,8 +421,6 @@ type UpdatePostInput struct { Title string Content string Tags string - ContentAccess *string - AccessPoints *int TypeMeta *string AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表 } @@ -464,12 +461,10 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto post.ViewCount++ detail := buildPostDetail(&post) - locked, hint := s.evalContentAccess(&post, viewerID, loadActor) - detail.ContentLocked = locked + sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor) + detail.Content = sanitized + detail.ContentLocked = fullyLocked detail.AccessHint = hint - if locked { - detail.Content = "" - } atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID) detail.Attachments = atts @@ -521,56 +516,81 @@ func buildPostDetail(post *model.Post) *PostDetail { } } -func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) { - access := model.NormalizeContentAccess(post.ContentAccess) - if access == model.ContentAccessPublic { - return false, "" +// sanitizePostContent 按读者能力对正文 :::hide 块脱敏;fullyLocked 表示无可见正文。 +func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor) (content string, fullyLocked bool, hint string) { + caps := s.buildHideViewerCaps(post, viewerID, loadActor) + sanitized, fully := markdown.SanitizeForViewer(post.Content, caps) + if !fully { + return sanitized, false, "" } - // 作者与版主始终可见 + access := model.NormalizeContentAccess(post.ContentAccess) + switch access { + case model.ContentAccessPoints: + return sanitized, true, "支付积分后可见隐藏内容" + case model.ContentAccessMixed: + if post.AccessPoints > 0 { + return sanitized, true, "满足条件后可见隐藏内容" + } + return sanitized, true, "满足条件后可见隐藏内容" + case model.ContentAccessReply: + return sanitized, true, "回复本帖后可见隐藏内容" + case model.ContentAccessLogin: + return sanitized, true, "登录后可见隐藏内容" + default: + return sanitized, true, "正文暂不可见" + } +} + +func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor) markdown.ViewerCaps { + caps := markdown.ViewerCaps{} if viewerID > 0 && post.UserID == viewerID { - return false, "" + caps.Bypass = true + caps.LoggedIn = true + return caps } if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) { - return false, "" + caps.Bypass = true + caps.LoggedIn = true + return caps + } + if viewerID == 0 { + return caps + } + caps.LoggedIn = true + + needReply := false + needPoints := false + blocks, err := markdown.ParseHideBlocks(post.Content) + if err == nil { + for _, b := range blocks { + if b.Kind == markdown.HideKindReply { + needReply = true + } + if b.Kind == markdown.HideKindPoints { + needPoints = true + } + } + } else { + access := model.NormalizeContentAccess(post.ContentAccess) + needReply = access == model.ContentAccessReply || access == model.ContentAccessMixed + needPoints = post.AccessPoints > 0 || access == model.ContentAccessPoints || access == model.ContentAccessMixed } - switch access { - case model.ContentAccessLogin: - if viewerID == 0 { - return true, "登录后可见全文" - } - return false, "" - case model.ContentAccessReply: - if viewerID == 0 { - return true, "回复本帖后可见全文" - } + if needReply { var n int64 s.db.Model(&model.Comment{}). Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL", post.ID, viewerID, model.ContentStatusPublished). Count(&n) - if n == 0 { - return true, "回复本帖后可见全文" - } - return false, "" - case model.ContentAccessPoints: - need := post.AccessPoints - if need <= 0 { - need = 1 - } - if viewerID == 0 { - return true, "支付积分后可见全文" - } + caps.HasReplied = n > 0 + } + if needPoints { var n int64 s.db.Model(&model.PostContentUnlock{}). Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n) - if n > 0 { - return false, "" - } - return true, "支付积分后可见全文" - default: - return false, "" + caps.PointsPaid = n > 0 } + return caps } func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) { @@ -605,21 +625,25 @@ func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]Pos return out, nil } -// UnlockContent 积分解锁正文 +// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块) func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) { var post model.Post if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { return nil, ErrPostNotFound } - if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints { + derived, derr := markdown.DeriveAccessFromContent(post.Content) + if derr != nil { + return nil, derr + } + need := derived.Points + if need <= 0 { + need = post.AccessPoints + } + if need <= 0 { return nil, errors.New("本文无需积分解锁") } if post.UserID == userID { - return buildPostDetail(&post), nil - } - need := post.AccessPoints - if need <= 0 { - need = 1 + return s.detailAfterInteract(&post, userID) } err := s.db.Transaction(func(tx *gorm.DB) error { var n int64 @@ -645,11 +669,7 @@ func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) { if err != nil { return nil, err } - detail := buildPostDetail(&post) - atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID) - detail.Attachments = atts - s.fillInteractState(detail, &post, userID, nil) - return detail, nil + return s.detailAfterInteract(&post, userID) } // Create 创建帖子。status 由 handler 按角色计算 @@ -673,18 +693,15 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) { if !model.ValidPostType(postType) { return nil, errors.New("无效的帖子类型") } - access := model.NormalizeContentAccess(in.ContentAccess) - accessPts := in.AccessPoints - if access == model.ContentAccessPoints { - if accessPts <= 0 { - return nil, errors.New("请设置解锁所需积分") - } - if accessPts > 100000 { - return nil, errors.New("解锁积分过高") - } - } else { - accessPts = 0 + if err := markdown.ValidateHideContent(content); err != nil { + return nil, err } + derived, err := markdown.DeriveAccessFromContent(content) + if err != nil { + return nil, err + } + access := model.NormalizeContentAccess(derived.Access) + accessPts := derived.Points typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta) if err != nil { return nil, err @@ -840,31 +857,19 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp if c == "" { return nil, errors.New("内容不能为空") } + if err := markdown.ValidateHideContent(c); err != nil { + return nil, err + } + derived, err := markdown.DeriveAccessFromContent(c) + if err != nil { + return nil, err + } updates["content"] = c + updates["content_access"] = model.NormalizeContentAccess(derived.Access) + updates["access_points"] = derived.Points } updates["tags"] = in.Tags - if in.ContentAccess != nil { - access := model.NormalizeContentAccess(*in.ContentAccess) - updates["content_access"] = access - if access == model.ContentAccessPoints { - pts := post.AccessPoints - if in.AccessPoints != nil { - pts = *in.AccessPoints - } - if pts <= 0 { - return nil, errors.New("请设置解锁所需积分") - } - updates["access_points"] = pts - } else { - updates["access_points"] = 0 - } - } else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints { - if *in.AccessPoints <= 0 { - return nil, errors.New("请设置解锁所需积分") - } - updates["access_points"] = *in.AccessPoints - } if in.TypeMeta != nil { pt := model.NormalizePostType(post.PostType) meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8470558..d4f2933 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1320,9 +1320,137 @@ body { color: var(--accent); } .j13-md-tool:disabled { opacity: 0.4; } -.j13-md-tool:focus-visible { +.j13-md-tool.is-active { + background: var(--accent-soft); + color: var(--accent); +} +.j13-md-sep { + width: 1px; + height: 18px; + margin: 0 4px; + background: var(--line); + flex-shrink: 0; +} +.j13-md-popover-anchor { + position: relative; +} +.j13-md-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 40; + min-width: 168px; + padding: 6px; + border-radius: 12px; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 2px; +} +.j13-md-popover-wide { + min-width: 200px; + max-height: 280px; +} +.j13-md-popover-form { + min-width: 260px; + padding: 10px; + gap: 8px; +} +.j13-md-popover button[role="menuitem"], +.j13-md-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + padding: 8px 10px; + border-radius: 8px; + font-size: 13px; + color: var(--ink); + background: transparent; +} +.j13-md-popover button[role="menuitem"]:hover, +.j13-md-menu-item:hover { + background: var(--accent-soft); + color: var(--accent); +} +.j13-md-popover-input { + width: 100%; + height: 34px; + padding: 0 10px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--bg); + color: var(--ink); + font-size: 13px; +} +.j13-md-popover-input:focus { outline: 2px solid var(--accent); - outline-offset: 1px; + outline-offset: 0; +} +.j13-md-popover-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1px; +} +.j13-md-popover-list button { + text-align: left; + padding: 7px 10px; + border-radius: 8px; + font-size: 12.5px; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--ink-2); +} +.j13-md-popover-list button:hover { + background: var(--accent-soft); + color: var(--accent); +} +.j13-md-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--ink-3); +} +.j13-md-popover-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 2px; +} +.btn-sm { + height: 30px; + padding: 0 12px; + font-size: 12.5px; +} +.j13-md-write-wrap { + position: relative; + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} +.j13-md-write-wrap.is-dragover .j13-md-textarea { + opacity: 0.35; +} +.j13-md-drop-mask { + position: absolute; + inset: 8px 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 2px dashed var(--accent); + background: color-mix(in srgb, var(--accent-soft) 80%, transparent); + color: var(--accent); + font-weight: 600; + font-size: 14px; + pointer-events: none; } .j13-md-mode { display: inline-flex; @@ -1394,6 +1522,66 @@ body { background: transparent; } +/* 正文内嵌隐藏块 */ +.j13-hide-block { + margin: 1.1rem 0; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel)); + overflow: hidden; +} +.j13-hide-block-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--accent) 14%, var(--line)); +} +.j13-hide-badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); +} +.j13-hide-block-body { + padding: 12px 16px 14px; +} +.j13-hide-block-body > :first-child { margin-top: 0; } +.j13-hide-block-body > :last-child { margin-bottom: 0; } +.j13-hide-gate { + margin: 1.1rem 0; + border-radius: 14px; + padding: 1.35rem 1.25rem; + text-align: center; + background: var(--accent-soft); + border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent); +} +.j13-hide-gate-icon { + margin: 0 auto 0.55rem; + color: var(--accent); +} +.j13-hide-gate-title { + font-weight: 700; + font-size: 15px; + color: var(--ink); + margin-bottom: 0.25rem; +} +.j13-hide-gate-desc { + margin-bottom: 0.9rem; + font-size: 13px; +} +.j13-hide-gate-actions { + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + .j13-compose-shell { background: var(--panel); border: 1px solid var(--line); diff --git a/frontend/app/post/[id]/edit/EditPostClient.tsx b/frontend/app/post/[id]/edit/EditPostClient.tsx index 942d506..ff74147 100644 --- a/frontend/app/post/[id]/edit/EditPostClient.tsx +++ b/frontend/app/post/[id]/edit/EditPostClient.tsx @@ -9,8 +9,6 @@ type Props = { initialContent: string; initialTags: string; initialPostType?: string; - initialContentAccess?: string; - initialAccessPoints?: number; initialTypeMeta?: string; initialAttachments?: PostAttachment[]; canEditPollOptions?: boolean; @@ -26,8 +24,6 @@ export default function EditPostClient({ initialContent, initialTags, initialPostType, - initialContentAccess, - initialAccessPoints, initialTypeMeta, initialAttachments, canEditPollOptions, @@ -44,8 +40,6 @@ export default function EditPostClient({ initialContent={initialContent} initialTags={initialTags} initialPostType={initialPostType} - initialContentAccess={initialContentAccess} - initialAccessPoints={initialAccessPoints} initialTypeMeta={initialTypeMeta} initialAttachments={initialAttachments} canEditPollOptions={canEditPollOptions} diff --git a/frontend/app/post/[id]/edit/page.tsx b/frontend/app/post/[id]/edit/page.tsx index 07d7e50..5c82049 100644 --- a/frontend/app/post/[id]/edit/page.tsx +++ b/frontend/app/post/[id]/edit/page.tsx @@ -60,8 +60,6 @@ export default async function EditPostPage({ params }: PageProps) { initialContent={post.content} initialTags={post.tags || ""} initialPostType={post.post_type} - initialContentAccess={post.content_access} - initialAccessPoints={post.access_points} initialTypeMeta={post.type_meta} initialAttachments={post.attachments || []} canEditPollOptions={post.poll?.can_edit_options !== false} diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index d0745d5..6c5cfcf 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -34,7 +34,7 @@ import MobilePostBar from "@/components/MobilePostBar"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; import MarkdownBody from "@/components/MarkdownBody"; -import ContentLockGate from "@/components/ContentLockGate"; +import { publicMarkdownForSeo } from "@/lib/hideBlocks"; import PostAttachments from "@/components/PostAttachments"; import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels"; import AcceptedAnswerCard from "@/components/AcceptedAnswerCard"; @@ -60,9 +60,10 @@ export async function generateMetadata({ params }: PageProps): Promise robots: { index: false, follow: false }, }; } - const desc = post.content_locked - ? post.access_hint || "部分内容需满足条件后可见" - : (post.content || "").slice(0, 160); + const publicBody = publicMarkdownForSeo(post.content || ""); + const desc = publicBody + ? publicBody.slice(0, 160) + : post.access_hint || "部分内容需满足条件后可见"; return { title: post.title, description: desc, @@ -481,10 +482,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5); const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5); - const toc = post.content_locked ? [] : extractToc(post.content); + const publicBody = publicMarkdownForSeo(post.content || ""); + const toc = extractToc(publicBody); const readingMinutes = Math.max( 1, - Math.ceil((post.content_locked ? post.title.length : post.content.length) / 400) + Math.ceil((publicBody.length > 0 ? publicBody.length : post.title.length) / 400) ); const edited = new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000; @@ -527,8 +529,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps "@context": "https://schema.org", "@type": "DiscussionForumPosting", headline: post.title, - // 锁定正文不进 JSON-LD,避免 SEO 泄露付费/回复可见内容 - articleBody: post.content_locked ? undefined : post.content, + // 仅公开片段进 JSON-LD,避免 SEO 泄露隐藏块原文 + articleBody: publicBody || undefined, datePublished: post.created_at, dateModified: post.updated_at, author: { "@type": "Person", name: post.user.nickname }, @@ -541,7 +543,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps ...(allowComments && acceptedAnswer && !acceptedAnswer.deleted && - !post.content_locked && { + !!publicBody && { acceptedAnswer: { "@type": "Comment", text: acceptedAnswer.content, @@ -718,24 +720,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps )} - {post.content_locked ? ( - - ) : ( - - )} + - {!post.content_locked && ( - - )} + {/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */} - {!post.content_locked && allowComments && acceptedAnswer ? ( + {allowComments && acceptedAnswer ? ( { - setBusy(true); - try { - const res = await apiUnlockPost(String(postId)); - if (res.post) { - toast("已解锁全文", "ok"); - router.refresh(); - } else { - toast(res.error || "解锁失败", "error"); - } - } catch { - toast("解锁失败", "error"); - } finally { - setBusy(false); - } - }; - - return ( -
- -

- {hint || "正文暂不可见"} -

-

- {access === "points" - ? `支付 ${points || 0} 积分后可阅读全文` - : access === "reply" - ? "发表一条回复后即可阅读全文" - : access === "login" - ? "登录后即可阅读全文" - : null} -

- {access === "login" && !loggedIn && ( - - 去登录 - - )} - {access === "points" && loggedIn && ( - - )} - {access === "points" && !loggedIn && ( - - 登录后解锁 - - )} - {access === "reply" && ( - - 去回复 - - )} -
- ); -} diff --git a/frontend/components/MarkdownBody.tsx b/frontend/components/MarkdownBody.tsx index 5d18fb0..bb3855c 100644 --- a/frontend/components/MarkdownBody.tsx +++ b/frontend/components/MarkdownBody.tsx @@ -1,21 +1,61 @@ import type { Components } from "react-markdown"; import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown"; +import { splitHideSegments, type HideKind } from "@/lib/hideBlocks"; +import MdHideBlock from "@/components/markdown/MdHideBlock"; type MarkdownBodyProps = { content: string; className?: string; components?: Components; + /** 详情页解锁门禁需要 */ + postId?: number; + loggedIn?: boolean; }; /** - * 站点统一 Markdown 渲染(服务端):GFM + Shiki 双主题、图箱、代码块复制/折叠。 - * 客户端预览请用 MarkdownBodyClient。 + * 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 :::hide 内嵌块。 */ export default async function MarkdownBody({ content, className = "prose-content", components, + postId, + loggedIn = false, }: MarkdownBodyProps) { - const tree = await renderMarkdownToReact(content, components); - return
{tree}
; + const segs = splitHideSegments(content || ""); + const nodes = []; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (seg.type === "md") { + if (!seg.text.trim()) continue; + nodes.push( +
{await renderMarkdownToReact(seg.text, components)}
+ ); + continue; + } + const kind = seg.kind as HideKind; + if (seg.locked) { + nodes.push( + + ); + continue; + } + const inner = seg.body.trim() + ? await renderMarkdownToReact(seg.body, components) + : null; + nodes.push( + + {inner} + + ); + } + + return
{nodes}
; } diff --git a/frontend/components/MarkdownBodyClient.tsx b/frontend/components/MarkdownBodyClient.tsx index b75d47b..27163fa 100644 --- a/frontend/components/MarkdownBodyClient.tsx +++ b/frontend/components/MarkdownBodyClient.tsx @@ -3,30 +3,64 @@ 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 MdHideBlock from "@/components/markdown/MdHideBlock"; type MarkdownBodyClientProps = { content: string; className?: string; components?: Components; + postId?: number; + loggedIn?: boolean; }; /** - * 客户端 Markdown(发帖预览 / 评论):GFM + 图箱 / 代码折叠复制。 - * 语法高亮在 MdCodeBlock 内按需用 Shiki 补齐。 + * 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块徽章预览。 */ export default function MarkdownBodyClient({ content, className = "prose-content", components, + postId, + loggedIn = false, }: MarkdownBodyClientProps) { + const segs = splitHideSegments(content || ""); + const mdComponents = mergeMarkdownComponents(components); + return (
- - {content || ""} - + {segs.map((seg, i) => { + if (seg.type === "md") { + if (!seg.text.trim()) return null; + return ( + + {seg.text} + + ); + } + const kind = seg.kind as HideKind; + if (seg.locked) { + return ( + + ); + } + return ( + + {seg.body.trim() ? ( + + {seg.body} + + ) : null} + + ); + })}
); } diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 39ecc70..6f61818 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -1,23 +1,41 @@ "use client"; -import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"; +import { + useRef, + useState, + useCallback, + useEffect, + type ReactNode, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { Bold, Italic, + Strikethrough, Code, + Code2, Quote, List, + ListOrdered, Heading2, + Heading3, Link as LinkIcon, Image as ImageIcon, + Table, + Minus, Eye, Pencil, Maximize2, Minimize2, + Lock, + ChevronDown, + Upload, + Link2, } from "lucide-react"; import { apiUploadPostImage } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; +import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; type MarkdownEditorProps = { value: string; @@ -36,6 +54,10 @@ type WrapOpts = { linePrefix?: string; }; +type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null; + +const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; + function applyWrap( el: HTMLTextAreaElement, value: string, @@ -53,11 +75,7 @@ function applyWrap( ); const inserted = lines.join("\n"); const next = value.slice(0, start) + inserted + value.slice(end); - return { - next, - selStart: start, - selEnd: start + inserted.length, - }; + return { next, selStart: start, selEnd: start + inserted.length }; } const inner = selected || opts.placeholder || ""; @@ -68,6 +86,15 @@ function applyWrap( return { next, selStart, selEnd }; } +function isHttpUrl(raw: string): boolean { + try { + const u = new URL(raw.trim()); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + export default function MarkdownEditor({ value, onChange, @@ -79,16 +106,28 @@ export default function MarkdownEditor({ }: MarkdownEditorProps) { const taRef = useRef(null); const fileRef = useRef(null); + const toolbarRef = useRef(null); const [mode, setMode] = useState<"write" | "preview">("write"); const [uploading, setUploading] = useState(false); const [fullscreen, setFullscreen] = useState(false); + const [dragOver, setDragOver] = useState(false); + const [popover, setPopover] = useState(null); + const [linkText, setLinkText] = useState(""); + const [linkUrl, setLinkUrl] = useState("https://"); + const [imageUrl, setImageUrl] = useState("https://"); + const [imageAlt, setImageAlt] = useState("图片"); + const [codeQuery, setCodeQuery] = useState(""); + const [hidePoints, setHidePoints] = useState(10); + const [hideStep, setHideStep] = useState<"menu" | "points">("menu"); + const savedSel = useRef<{ start: number; end: number } | null>(null); useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - setFullscreen(false); + if (popover) setPopover(null); + else setFullscreen(false); } }; const prev = document.body.style.overflow; @@ -98,7 +137,40 @@ export default function MarkdownEditor({ document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; - }, [fullscreen]); + }, [fullscreen, popover]); + + useEffect(() => { + if (!popover) return; + const onDoc = (e: MouseEvent) => { + const t = e.target as Node; + if (toolbarRef.current && !toolbarRef.current.contains(t)) { + setPopover(null); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") setPopover(null); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onEsc); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onEsc); + }; + }, [popover]); + + const rememberSel = () => { + const el = taRef.current; + if (!el) return; + savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; + }; + + const restoreSel = () => { + const el = taRef.current; + const sel = savedSel.current; + if (!el || !sel) return; + el.focus(); + el.setSelectionRange(sel.start, sel.end); + }; const runWrap = useCallback( (opts: WrapOpts) => { @@ -115,29 +187,35 @@ export default function MarkdownEditor({ ); const insertAtCursor = useCallback( - (text: string) => { + (text: string, selectInner?: { offset: number; length: number }) => { const el = taRef.current; if (!el) { onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text); return; } - const start = el.selectionStart; - const end = el.selectionEnd; + const start = savedSel.current?.start ?? el.selectionStart; + const end = savedSel.current?.end ?? el.selectionEnd; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + text + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); - const caret = start + inserted.length; requestAnimationFrame(() => { el.focus(); - el.setSelectionRange(caret, caret); + if (selectInner) { + const a = start + padBefore.length + selectInner.offset; + el.setSelectionRange(a, a + selectInner.length); + } else { + const caret = start + inserted.length; + el.setSelectionRange(caret, caret); + } }); + savedSel.current = null; }, [value, onChange] ); - const handleImagePick = async (file: File | null) => { + const uploadImageFile = async (file: File | null) => { if (!file || disabled) return; if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) { toast("仅支持 JPEG / PNG / WebP", "error"); @@ -166,82 +244,536 @@ export default function MarkdownEditor({ } }; - const tools: { - key: string; + const openPopover = (kind: PopoverKind) => { + rememberSel(); + if (kind === "link") { + const el = taRef.current; + const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : ""; + setLinkText(selected || "链接文字"); + setLinkUrl("https://"); + } + if (kind === "imageUrl") { + setImageUrl("https://"); + setImageAlt("图片"); + } + if (kind === "code") setCodeQuery(""); + if (kind === "hide") { + setHideStep("menu"); + setHidePoints(10); + } + setPopover((prev) => (prev === kind ? null : kind)); + }; + + const applyLink = () => { + const url = linkUrl.trim(); + if (!isHttpUrl(url)) { + toast("请输入有效的 http(s) 链接", "error"); + return; + } + const el = taRef.current; + restoreSel(); + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const selected = value.slice(start, end); + const label = (selected || linkText.trim() || url).trim() || url; + const md = `[${label}](${url})`; + const next = value.slice(0, start) + md + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + el.setSelectionRange(start + md.length, start + md.length); + }); + setPopover(null); + }; + + const applyImageUrl = () => { + const url = imageUrl.trim(); + if (!isHttpUrl(url)) { + toast("请输入有效的图片 URL(http/https)", "error"); + return; + } + const alt = imageAlt.trim() || "图片"; + insertAtCursor(`![${alt}](${url})`); + setPopover(null); + }; + + const insertCodeBlock = (lang: string) => { + const el = taRef.current; + restoreSel(); + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const selected = value.slice(start, end); + const body = selected || "代码"; + const fence = "```" + lang + "\n" + body + "\n```"; + const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; + const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; + const inserted = padBefore + fence + padAfter; + const next = value.slice(0, start) + inserted + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + const bodyStart = start + padBefore.length + 3 + lang.length + 1; + el.setSelectionRange(bodyStart, bodyStart + body.length); + }); + setPopover(null); + }; + + const insertHide = (kind: "login" | "reply" | "points", points?: number) => { + restoreSel(); + const el = taRef.current; + const start = el?.selectionStart ?? 0; + const end = el?.selectionEnd ?? 0; + const selected = value.slice(start, end); + const body = selected || "在此填写隐藏内容"; + const open = + kind === "points" + ? `:::hide points ${points || 10}` + : `:::hide ${kind}`; + const block = `${open}\n${body}\n:::`; + 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 bodyStart = start + padBefore.length + open.length + 1; + el.setSelectionRange(bodyStart, bodyStart + body.length); + }); + setPopover(null); + }; + + const insertOrderedList = () => { + const el = taRef.current; + if (!el || disabled) return; + const start = el.selectionStart; + const end = el.selectionEnd; + const selected = value.slice(start, end) || "列表项"; + const lines = selected.split("\n").map((l, i) => { + if (/^\d+\.\s/.test(l)) return l; + return `${i + 1}. ${l}`; + }); + const inserted = lines.join("\n"); + const next = value.slice(0, start) + inserted + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start, start + inserted.length); + }); + }; + + const insertTable = () => { + insertAtCursor( + "| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n| | | |" + ); + }; + + const onTextareaKeyDown = (e: ReactKeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + onSubmitShortcut?.(); + return; + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "b") { + e.preventDefault(); + runWrap({ before: "**", after: "**", placeholder: "粗体" }); + return; + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "i") { + e.preventDefault(); + runWrap({ before: "*", after: "*", placeholder: "斜体" }); + return; + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "k") { + e.preventDefault(); + openPopover("link"); + return; + } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") { + e.preventDefault(); + openPopover("code"); + return; + } + }; + + const onPaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (items) { + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + await uploadImageFile(file); + return; + } + } + } + const text = e.clipboardData?.getData("text/plain")?.trim() || ""; + if (text && isHttpUrl(text)) { + const el = taRef.current; + if (el && el.selectionStart !== el.selectionEnd) { + e.preventDefault(); + const start = el.selectionStart; + const end = el.selectionEnd; + const selected = value.slice(start, end); + const md = `[${selected}](${text})`; + const next = value.slice(0, start) + md + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start + md.length, start + md.length); + }); + } + } + }; + + const filteredLangs = CODE_LANGS.filter((l) => + l.toLowerCase().includes(codeQuery.trim().toLowerCase()) + ); + + const ToolBtn = ({ + label, + icon, + onClick, + active, + disabled: dis, + }: { label: string; icon: ReactNode; onClick: () => void; - }[] = [ - { - key: "bold", - label: "粗体", - icon: , - onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }), - }, - { - key: "italic", - label: "斜体", - icon: , - onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }), - }, - { - key: "code", - label: "行内代码", - icon: , - onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }), - }, - { - key: "quote", - label: "引用", - icon: , - onClick: () => - runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }), - }, - { - key: "list", - label: "列表", - icon: , - onClick: () => - runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }), - }, - { - key: "h2", - label: "二级标题", - icon: , - onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }), - }, - { - key: "link", - label: "链接", - icon: , - onClick: () => - runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }), - }, - { - key: "image", - label: uploading ? "上传中…" : "插入图片", - icon: , - onClick: () => fileRef.current?.click(), - }, - ]; + active?: boolean; + disabled?: boolean; + }) => ( + + ); + + const Sep = () => ; return (
-
-
- {tools.map((t) => ( - - ))} +
+
+ } + onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })} + /> + } + onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} + /> + } + onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} + /> + +
+ + + + + } + active={popover === "heading"} + onClick={() => openPopover("heading")} + /> + {popover === "heading" && ( +
+ + +
+ )} +
+ + } + onClick={() => + runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) + } + /> + } + onClick={() => + runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) + } + /> + } + onClick={insertOrderedList} + /> + } onClick={insertTable} /> + } + onClick={() => insertAtCursor("---")} + /> + + } + onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} + /> +
+ } + active={popover === "code"} + onClick={() => openPopover("code")} + /> + {popover === "code" && ( +
+ setCodeQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && filteredLangs[0]) { + e.preventDefault(); + insertCodeBlock(filteredLangs[0]); + } + }} + /> +
+ {filteredLangs.map((lang) => ( + + ))} + {filteredLangs.length === 0 && ( +

无匹配语言

+ )} +
+
+ )} +
+ +
+ } + active={popover === "link"} + onClick={() => openPopover("link")} + /> + {popover === "link" && ( +
+ + +
+ + +
+
+ )} +
+
+ } + active={popover === "image" || popover === "imageUrl"} + disabled={uploading} + onClick={() => openPopover("image")} + /> + {popover === "image" && ( +
+ + +
+ )} + {popover === "imageUrl" && ( +
+ + +
+ + +
+
+ )} +
+ +
+ } + active={popover === "hide"} + onClick={() => openPopover("hide")} + /> + {popover === "hide" && ( +
+ {hideStep === "menu" ? ( + <> + + + + + ) : ( + <> + +
+ + +
+ + )} +
+ )} +
@@ -281,26 +813,46 @@ export default function MarkdownEditor({ type="file" accept="image/jpeg,image/png,image/webp" className="hidden" - onChange={(e) => handleImagePick(e.target.files?.[0] ?? null)} + onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)} /> {mode === "write" ? ( -