feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user