feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View File

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