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
|
||||
|
||||
@@ -29,12 +29,14 @@ func (h *Handlers) Overview(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"boards": data.Boards,
|
||||
"announcements": data.Announcements,
|
||||
"new_users": data.NewUsers,
|
||||
"checkin": data.Checkin,
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,26 +21,27 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||
|
||||
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
|
||||
type updateSettingsRequest struct {
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
AllowMessages *bool `json:"allow_messages"`
|
||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||
UIAnimations *bool `json:"ui_animations"`
|
||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome *bool `json:"anim_chrome"`
|
||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
AllowMessages *bool `json:"allow_messages"`
|
||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||
UIAnimations *bool `json:"ui_animations"`
|
||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome *bool `json:"anim_chrome"`
|
||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
|
||||
AttachmentExts *[]string `json:"attachment_exts"`
|
||||
AttachmentMaxMB *int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount *int `json:"attachment_max_count"`
|
||||
ImageMaxMB *int `json:"image_max_mb"`
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user