feat: 定时发布系统 — Phase 5 完成
- Post ScheduledAt 字段 - Scheduler goroutine 每分钟扫描到期文章 - Create/Update 校验最短 30min 定时 - Approve 审核晚于定时则即时发布 - public 列表+空间页过滤未到期文章 - 写文章页 datetime-local 选择器(min=now+30min) - AutoMigrate 追加新表
This commit is contained in:
@ -2,11 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
"metazone.cc/mce/internal/repository"
|
||||||
"metazone.cc/mce/internal/router"
|
"metazone.cc/mce/internal/router"
|
||||||
|
"metazone.cc/mce/internal/scheduler"
|
||||||
"metazone.cc/mce/internal/theme"
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -27,7 +30,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}, &model.Announcement{}, &model.Category{}, &model.Tag{}, &model.PostTag{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +101,9 @@ func main() {
|
|||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg, siteSettings, redisClient)
|
router.Setup(r, db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 定时发布调度器
|
||||||
|
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
||||||
|
|
||||||
// 启动
|
// 启动
|
||||||
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
||||||
r.Run("0.0.0.0:" + cfg.Server.Port)
|
r.Run("0.0.0.0:" + cfg.Server.Port)
|
||||||
|
|||||||
3
docs/migrations/005_scheduled_posts.sql
Normal file
3
docs/migrations/005_scheduled_posts.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 定时发布
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_scheduled_at ON posts(scheduled_at);
|
||||||
@ -40,6 +40,7 @@ var (
|
|||||||
// 帖子相关
|
// 帖子相关
|
||||||
ErrPostNotFound = errors.New("帖子不存在")
|
ErrPostNotFound = errors.New("帖子不存在")
|
||||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||||
|
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
|
||||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||||
|
|||||||
@ -42,9 +42,9 @@ type postUseCase interface {
|
|||||||
|
|
||||||
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
type studioUseCase interface {
|
type studioUseCase interface {
|
||||||
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error)
|
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error
|
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
|||||||
@ -30,8 +30,12 @@ func (ctrl *StudioController) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID)
|
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == common.ErrScheduledTooSoon {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Error(c, http.StatusInternalServerError, "发布失败")
|
common.Error(c, http.StatusInternalServerError, "发布失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -74,7 +78,7 @@ func (ctrl *StudioController) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID); err != nil {
|
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt); err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ type PostCreateRequest struct {
|
|||||||
ReprintProhibited bool `json:"reprint_prohibited"`
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
CategoryID string `json:"category_id"`
|
CategoryID string `json:"category_id"`
|
||||||
Tags string `json:"tags"`
|
Tags string `json:"tags"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostUpdateRequest 编辑请求
|
// PostUpdateRequest 编辑请求
|
||||||
@ -54,6 +55,7 @@ type PostUpdateRequest struct {
|
|||||||
ReprintProhibited bool `json:"reprint_prohibited"`
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
CategoryID string `json:"category_id"`
|
CategoryID string `json:"category_id"`
|
||||||
Tags string `json:"tags"`
|
Tags string `json:"tags"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostRejectRequest 退回请求
|
// PostRejectRequest 退回请求
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type Post struct {
|
|||||||
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
|
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
|
||||||
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
|
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
|
||||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
|
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
|
||||||
|
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
|
||||||
|
|
||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||||
|
|||||||
@ -62,27 +62,37 @@ func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
|
|||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题,过滤私密)
|
// FindPageable 分页查询帖子列表(仅 approved + 公开 + 已到期,定时发布未到期的不可见)
|
||||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
query := r.buildQuery(keyword, model.PostStatusApproved).
|
query := r.buildQuery(keyword, model.PostStatusApproved).
|
||||||
Where("posts.visibility = ?", model.VisibilityPublic)
|
Where("posts.visibility = ?", model.VisibilityPublic).
|
||||||
|
Where("posts.scheduled_at IS NULL OR posts.scheduled_at <= NOW()")
|
||||||
return r.pageResults(query, offset, limit)
|
return r.pageResults(query, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密)
|
// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密和定时待发)
|
||||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
query := r.buildQuery(keyword, status)
|
query := r.buildQuery(keyword, status)
|
||||||
return r.pageResultsAdmin(query, offset, limit)
|
return r.pageResultsAdmin(query, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开,含作者用户名)
|
// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开 + 已到期,含作者用户名)
|
||||||
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
|
||||||
query := r.buildQuery("", model.PostStatusApproved).
|
query := r.buildQuery("", model.PostStatusApproved).
|
||||||
Where("posts.user_id = ?", userID).
|
Where("posts.user_id = ?", userID).
|
||||||
Where("posts.visibility = ?", model.VisibilityPublic)
|
Where("posts.visibility = ?", model.VisibilityPublic).
|
||||||
|
Where("posts.scheduled_at IS NULL OR posts.scheduled_at <= NOW()")
|
||||||
return r.pageResults(query, offset, limit)
|
return r.pageResults(query, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindScheduledDue 查询到期未发布的定时文章
|
||||||
|
func (r *PostRepo) FindScheduledDue() ([]model.Post, error) {
|
||||||
|
var posts []model.Post
|
||||||
|
err := r.db.Where("status = ? AND scheduled_at IS NOT NULL AND scheduled_at <= NOW()", model.PostStatusApproved).
|
||||||
|
Find(&posts).Error
|
||||||
|
return posts, err
|
||||||
|
}
|
||||||
|
|
||||||
// FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态)
|
// FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态)
|
||||||
func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
query := r.buildQuery("", status).
|
query := r.buildQuery("", status).
|
||||||
|
|||||||
45
internal/scheduler/scheduler.go
Normal file
45
internal/scheduler/scheduler.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostStore Scheduler 所需的最小帖子仓储接口(ISP)
|
||||||
|
type PostStore interface {
|
||||||
|
FindScheduledDue() ([]model.Post, error)
|
||||||
|
Update(post *model.Post) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPublishScheduler 启动定时发布调度器(goroutine,每分钟扫描到期文章)
|
||||||
|
func StartPublishScheduler(repo PostStore, interval time.Duration) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
log.Printf("[Scheduler] 定时发布调度器已启动,间隔=%v", interval)
|
||||||
|
for range ticker.C {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[Scheduler] panic recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
posts, err := repo.FindScheduledDue()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Scheduler] 扫描失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range posts {
|
||||||
|
p.ScheduledAt = nil
|
||||||
|
if err := repo.Update(&p); err != nil {
|
||||||
|
log.Printf("[Scheduler] 发布 postID=%d 失败: %v", p.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[Scheduler] 定时发布 postID=%d title=%q", p.ID, p.Title)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -2,13 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update 编辑帖子(权限在 controller 层检查)
|
// Update 编辑帖子(权限在 controller 层检查)
|
||||||
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error {
|
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error {
|
||||||
post, err := s.findPost(postID)
|
post, err := s.findPost(postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -31,6 +32,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy
|
|||||||
} else {
|
} else {
|
||||||
post.CategoryID = nil
|
post.CategoryID = nil
|
||||||
}
|
}
|
||||||
|
// 定时发布
|
||||||
|
if t, err := parseScheduledAt(scheduledAt); err == nil {
|
||||||
|
if time.Until(t) < 30*time.Minute {
|
||||||
|
return common.ErrScheduledTooSoon
|
||||||
|
}
|
||||||
|
post.ScheduledAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
||||||
if post.Status == model.PostStatusApproved {
|
if post.Status == model.PostStatusApproved {
|
||||||
@ -121,6 +129,10 @@ func (s *PostService) Approve(postID uint) error {
|
|||||||
}
|
}
|
||||||
post.Status = model.PostStatusApproved
|
post.Status = model.PostStatusApproved
|
||||||
post.RejectReason = ""
|
post.RejectReason = ""
|
||||||
|
// 审核晚于定时时间 → 即时发布
|
||||||
|
if post.ScheduledAt != nil && !post.ScheduledAt.After(time.Now()) {
|
||||||
|
post.ScheduledAt = nil
|
||||||
|
}
|
||||||
if err := s.repo.Update(post); err != nil {
|
if err := s.repo.Update(post); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
|
|||||||
// Create 创建帖子
|
// Create 创建帖子
|
||||||
// body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒
|
// body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒
|
||||||
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
|
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
|
||||||
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) {
|
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error) {
|
||||||
status := model.PostStatusApproved
|
status := model.PostStatusApproved
|
||||||
if s.auditEnabled() {
|
if s.auditEnabled() {
|
||||||
status = model.PostStatusDraft
|
status = model.PostStatusDraft
|
||||||
@ -93,6 +93,13 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy
|
|||||||
id := uint(cid)
|
id := uint(cid)
|
||||||
post.CategoryID = &id
|
post.CategoryID = &id
|
||||||
}
|
}
|
||||||
|
// 定时发布
|
||||||
|
if t, err := parseScheduledAt(scheduledAt); err == nil {
|
||||||
|
if time.Until(t) < 30*time.Minute {
|
||||||
|
return nil, common.ErrScheduledTooSoon
|
||||||
|
}
|
||||||
|
post.ScheduledAt = &t
|
||||||
|
}
|
||||||
if err := s.repo.Create(post); err != nil {
|
if err := s.repo.Create(post); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -224,3 +231,15 @@ func (s *PostService) Unpin(postID uint) error {
|
|||||||
post.PinnedAt = nil
|
post.PinnedAt = nil
|
||||||
return s.repo.Update(post)
|
return s.repo.Update(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseScheduledAt 解析 RFC3339 时间,非法格式返回零值+error
|
||||||
|
func parseScheduledAt(s string) (time.Time, error) {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ type postStore interface {
|
|||||||
RecordRead(userID, postID uint) (bool, error)
|
RecordRead(userID, postID uint) (bool, error)
|
||||||
RecordGuestRead(visitorID string, postID uint) (bool, error)
|
RecordGuestRead(visitorID string, postID uint) (bool, error)
|
||||||
IncrementViewsCount(postID uint) error
|
IncrementViewsCount(postID uint) error
|
||||||
|
FindScheduledDue() ([]model.Post, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spaceUserStore SpaceService 所需的最小用户仓储接口
|
// spaceUserStore SpaceService 所需的最小用户仓储接口
|
||||||
|
|||||||
@ -77,6 +77,10 @@
|
|||||||
<span class="meta-label">标签</span>
|
<span class="meta-label">标签</span>
|
||||||
<input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px">
|
<input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">定时</span>
|
||||||
|
<input type="datetime-local" id="scheduledAt" style="width:auto" min="">
|
||||||
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">声明</span>
|
<span class="meta-label">声明</span>
|
||||||
<select id="declaration" style="width:auto">
|
<select id="declaration" style="width:auto">
|
||||||
|
|||||||
@ -98,6 +98,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
||||||
|
|
||||||
|
// 定时发布时间选择器 min 设为 now+30min
|
||||||
|
const scheduledAt = document.getElementById('scheduledAt');
|
||||||
|
if (scheduledAt) {
|
||||||
|
const min = new Date(Date.now() + 30 * 60 * 1000)
|
||||||
|
scheduledAt.setAttribute('min', min.toISOString().slice(0, 16))
|
||||||
|
}
|
||||||
|
|
||||||
// 类型切换联动:转载→显示来源,原创→显示禁止转载
|
// 类型切换联动:转载→显示来源,原创→显示禁止转载
|
||||||
const typeRadios = document.querySelectorAll('input[name="post_type"]')
|
const typeRadios = document.querySelectorAll('input[name="post_type"]')
|
||||||
const reprintSourceArea = document.getElementById('reprintSourceArea')
|
const reprintSourceArea = document.getElementById('reprintSourceArea')
|
||||||
@ -192,6 +199,7 @@ async function handleSubmit(e) {
|
|||||||
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
|
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
|
||||||
const categoryId = document.getElementById('categoryId')?.value || ''
|
const categoryId = document.getElementById('categoryId')?.value || ''
|
||||||
const tags = document.getElementById('tagInput')?.value.trim() || ''
|
const tags = document.getElementById('tagInput')?.value.trim() || ''
|
||||||
|
const scheduledAt = document.getElementById('scheduledAt')?.value || ''
|
||||||
|
|
||||||
const isEdit = !!postId
|
const isEdit = !!postId
|
||||||
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
||||||
@ -211,6 +219,7 @@ async function handleSubmit(e) {
|
|||||||
reprint_prohibited: reprintProhibited,
|
reprint_prohibited: reprintProhibited,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
tags,
|
tags,
|
||||||
|
scheduled_at: scheduledAt,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user