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

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