feat: 已发布帖子编辑后进修订审核,通过前保持展示旧内容
- Post 模型新增 PendingTitle/PendingBody 字段,分离已发布内容与待审修订 - Update: approved 帖子编辑写入 Pending 字段,不覆盖正文,不改变发布状态 - Approve: 有 PendingBody 时复制到正式字段后清空,无 PendingBody 走首次审核 - Reject: 有 PendingBody 时仅清空待审修订(保持已发布),无 PendingBody 走普通退回 - 新增 ListPendingRevisions 查询,管理后台独立展示"修订待审核"区域 - 详情页展示"修订审核中"标识,编辑页加载待审内容 - 通过修订后直接覆盖,不保留历史版本
This commit is contained in:
@ -47,6 +47,9 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
|||||||
nextPage = totalPages
|
nextPage = totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询待审修订(approved 帖子有 PendingBody 的)
|
||||||
|
revisions, _, _ := ctrl.postService.ListPendingRevisions(keyword, 1, 100)
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
"Title": "内容管理",
|
"Title": "内容管理",
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
@ -58,6 +61,7 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
|||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"StatusNames": common.PostStatusDisplayNames,
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
|
"Revisions": revisions,
|
||||||
"ExtraCSS": "/admin/static/css/posts.css",
|
"ExtraCSS": "/admin/static/css/posts.css",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,9 +33,10 @@ type auditStatusProvider interface {
|
|||||||
FindByID(id uint) (*model.User, error)
|
FindByID(id uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:6 个方法)
|
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||||
type adminPostUseCase interface {
|
type adminPostUseCase interface {
|
||||||
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
Approve(postID uint) error
|
Approve(postID uint) error
|
||||||
Reject(postID uint, reason string) error
|
Reject(postID uint, reason string) error
|
||||||
Lock(postID uint) error
|
Lock(postID uint) error
|
||||||
|
|||||||
@ -154,6 +154,12 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已通过帖子有待审修订时,编辑页展示待审内容
|
||||||
|
if post.PendingBody != "" {
|
||||||
|
post.Title = post.PendingTitle
|
||||||
|
post.Body = post.PendingBody
|
||||||
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "编辑帖子",
|
"Title": "编辑帖子",
|
||||||
"Post": post,
|
"Post": post,
|
||||||
|
|||||||
@ -15,6 +15,8 @@ type Post struct {
|
|||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
||||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||||
|
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||||
|
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
@ -88,6 +88,13 @@ func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, lim
|
|||||||
return r.pageResults(query, offset, limit)
|
return r.pageResults(query, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindPendingRevisions 查询有待审修订的帖子(approved 且 pending_body 不为空)
|
||||||
|
func (r *PostRepo) FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.buildQuery(keyword, model.PostStatusApproved).
|
||||||
|
Where("posts.pending_body != ''")
|
||||||
|
return r.pageResults(query, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// pageResults 执行 Count + Offset/Limit + ORDER BY
|
// pageResults 执行 Count + Offset/Limit + ORDER BY
|
||||||
func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
|
func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@ -217,11 +217,19 @@ func (s *PostService) Update(postID uint, title, body string) error {
|
|||||||
return common.ErrPostCannotEdit
|
return common.ErrPostCannotEdit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
||||||
|
if post.Status == model.PostStatusApproved {
|
||||||
|
post.PendingTitle = title
|
||||||
|
post.PendingBody = body
|
||||||
|
post.RejectReason = "" // 清空旧退回理由
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 草稿/已退回:直接修改原文(现有逻辑)
|
||||||
post.Title = title
|
post.Title = title
|
||||||
post.Body = body
|
post.Body = body
|
||||||
post.Excerpt = generateExcerpt(body)
|
post.Excerpt = generateExcerpt(body)
|
||||||
|
|
||||||
// rejected 状态编辑后自动重置为 draft
|
|
||||||
if post.Status == model.PostStatusRejected {
|
if post.Status == model.PostStatusRejected {
|
||||||
post.Status = model.PostStatusDraft
|
post.Status = model.PostStatusDraft
|
||||||
post.RejectReason = ""
|
post.RejectReason = ""
|
||||||
@ -248,12 +256,26 @@ func (s *PostService) SubmitForAudit(postID uint) error {
|
|||||||
return s.repo.Update(post)
|
return s.repo.Update(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve 审核通过:pending → approved
|
// Approve 审核通过:pending → approved;或修订审核通过复制 Pending 到正式字段
|
||||||
func (s *PostService) Approve(postID uint) error {
|
func (s *PostService) Approve(postID uint) error {
|
||||||
post, err := s.findPost(postID)
|
post, err := s.findPost(postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修订审核:将待审内容覆盖到正式字段
|
||||||
|
if post.PendingBody != "" {
|
||||||
|
post.Title = post.PendingTitle
|
||||||
|
post.Body = post.PendingBody
|
||||||
|
post.Excerpt = generateExcerpt(post.PendingBody)
|
||||||
|
post.PendingTitle = ""
|
||||||
|
post.PendingBody = ""
|
||||||
|
post.Status = model.PostStatusApproved
|
||||||
|
post.RejectReason = ""
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次审核通过
|
||||||
if post.Status != model.PostStatusPending {
|
if post.Status != model.PostStatusPending {
|
||||||
return common.ErrPostCannotApprove
|
return common.ErrPostCannotApprove
|
||||||
}
|
}
|
||||||
@ -262,8 +284,7 @@ func (s *PostService) Approve(postID uint) error {
|
|||||||
return s.repo.Update(post)
|
return s.repo.Update(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject 退回:pending/approved → rejected
|
// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved
|
||||||
// 独立于锁定:可单独退回(允许作者修改后重审),也可锁定+退回(禁止再投稿)
|
|
||||||
func (s *PostService) Reject(postID uint, reason string) error {
|
func (s *PostService) Reject(postID uint, reason string) error {
|
||||||
post, err := s.findPost(postID)
|
post, err := s.findPost(postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -272,6 +293,16 @@ func (s *PostService) Reject(postID uint, reason string) error {
|
|||||||
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
|
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
|
||||||
return common.ErrPostCannotReject
|
return common.ErrPostCannotReject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修订退回:清空待审修订,保持已发布内容不变
|
||||||
|
if post.PendingBody != "" {
|
||||||
|
post.PendingTitle = ""
|
||||||
|
post.PendingBody = ""
|
||||||
|
post.RejectReason = reason
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通退回(首次审核)
|
||||||
post.Status = model.PostStatusRejected
|
post.Status = model.PostStatusRejected
|
||||||
post.RejectReason = reason
|
post.RejectReason = reason
|
||||||
return s.repo.Update(post)
|
return s.repo.Update(post)
|
||||||
@ -316,6 +347,13 @@ func (s *PostService) Restore(postID uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPendingRevisions 查询有待审修订的帖子(approved 且 PendingBody 不为空)
|
||||||
|
func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
return s.repo.FindPendingRevisions(keyword, offset, limit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
||||||
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||||||
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
||||||
|
|||||||
@ -31,7 +31,7 @@ type userAdminStore interface {
|
|||||||
IncrementTokenVersion(userID uint) error
|
IncrementTokenVersion(userID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// postStore PostService 所需的最小仓储接口(ISP:9 个方法)
|
// postStore PostService 所需的最小仓储接口(ISP:10 个方法)
|
||||||
type postStore interface {
|
type postStore interface {
|
||||||
Create(post *model.Post) error
|
Create(post *model.Post) error
|
||||||
FindByID(id uint) (*model.Post, error)
|
FindByID(id uint) (*model.Post, error)
|
||||||
@ -39,6 +39,7 @@ type postStore interface {
|
|||||||
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
|
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
|
||||||
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
|
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
|
||||||
FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error)
|
FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error)
|
||||||
|
FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error)
|
||||||
Update(post *model.Post) error
|
Update(post *model.Post) error
|
||||||
SoftDelete(id uint) error
|
SoftDelete(id uint) error
|
||||||
Restore(id uint) error
|
Restore(id uint) error
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
{{if .Post.IsLocked}}
|
{{if .Post.IsLocked}}
|
||||||
<span class="post-status post-status-locked">已锁定</span>
|
<span class="post-status post-status-locked">已锁定</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Post.PendingBody}}
|
||||||
|
<span class="post-status post-status-revision">修订审核中</span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -179,6 +179,7 @@
|
|||||||
.post-status-approved { background: #d1fae5; color: #059669; }
|
.post-status-approved { background: #d1fae5; color: #059669; }
|
||||||
.post-status-rejected { background: #fef2f2; color: #dc2626; }
|
.post-status-rejected { background: #fef2f2; color: #dc2626; }
|
||||||
.post-status-locked { background: #fee2e2; color: #b91c1c; }
|
.post-status-locked { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.post-status-revision { background: #e0e7ff; color: #4338ca; }
|
||||||
|
|
||||||
.post-reject-reason {
|
.post-reject-reason {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|||||||
@ -22,6 +22,47 @@
|
|||||||
<div class="error-message">{{.Error}}</div>
|
<div class="error-message">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Revisions}}
|
||||||
|
<div class="revision-section">
|
||||||
|
<h2>修订待审核 <span class="revision-count">{{len .Revisions}}</span></h2>
|
||||||
|
<table class="admin-table revision-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>作者</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Revisions}}
|
||||||
|
<tr class="post-row revision-row">
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a> <span class="revision-badge">修订中</span></td>
|
||||||
|
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
|
||||||
|
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>{{if .IsLocked}} <span class="status-badge status-locked">已锁定</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<div class="action-btns">
|
||||||
|
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过修订</button>
|
||||||
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button>
|
||||||
|
{{if and (not .IsLocked)}}
|
||||||
|
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsLocked}}
|
||||||
|
<button class="btn btn-sm btn-success post-unlock" data-id="{{.ID}}">解锁</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -34,13 +75,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{if eq (len .Posts) 0}}
|
{{if and (eq (len .Posts) 0) (eq (len .Revisions) 0)}}
|
||||||
<tr><td colspan="6" class="empty">暂无帖子</td></tr>
|
<tr><td colspan="6" class="empty">暂无帖子</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{range .Posts}}
|
{{range .Posts}}
|
||||||
<tr class="post-row">
|
<tr class="post-row">
|
||||||
<td>{{.ID}}</td>
|
<td>{{.ID}}</td>
|
||||||
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a></td>
|
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a>{{if .PendingBody}} <span class="revision-badge">修订中</span>{{end}}</td>
|
||||||
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
|
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
|
||||||
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>{{if .IsLocked}} <span class="status-badge status-locked">已锁定</span>{{end}}</td>
|
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>{{if .IsLocked}} <span class="status-badge status-locked">已锁定</span>{{end}}</td>
|
||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
@ -50,9 +91,13 @@
|
|||||||
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过</button>
|
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过</button>
|
||||||
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Status "approved"}}
|
{{if and (eq .Status "approved") (not .PendingBody)}}
|
||||||
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if and (eq .Status "approved") .PendingBody}}
|
||||||
|
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过修订</button>
|
||||||
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button>
|
||||||
|
{{end}}
|
||||||
{{if and (not .IsLocked) (ne .Status "pending")}}
|
{{if and (not .IsLocked) (ne .Status "pending")}}
|
||||||
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
|
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -81,6 +81,13 @@
|
|||||||
.status-rejected { background: #fee2e2; color: #dc2626; }
|
.status-rejected { background: #fee2e2; color: #dc2626; }
|
||||||
.status-locked { background: #fee2e2; color: #b91c1c; }
|
.status-locked { background: #fee2e2; color: #b91c1c; }
|
||||||
|
|
||||||
|
.revision-section { margin-bottom: 24px; }
|
||||||
|
.revision-section h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #4338ca; }
|
||||||
|
.revision-count { background: #4338ca; color: #fff; border-radius: 10px; padding: 1px 8px; font-size: 13px; }
|
||||||
|
.revision-table { border: 2px solid #c7d2fe; }
|
||||||
|
.revision-row { background: #eef2ff; }
|
||||||
|
.revision-badge { background: #e0e7ff; color: #4338ca; font-size: 11px; border-radius: 4px; padding: 1px 6px; margin-left: 6px; }
|
||||||
|
|
||||||
.actions-cell { min-width: 180px; }
|
.actions-cell { min-width: 180px; }
|
||||||
|
|
||||||
.action-btns {
|
.action-btns {
|
||||||
|
|||||||
Reference in New Issue
Block a user