feat: 对接稿件审核通知系统 + 消息页面系统消息区域
- 新增通知类型 post_approved / post_rejected
- PostService.Approve/Reject 完成后通知作者(通知失败不影响主流程)
- NotificationService 新增 NotifyPostApproved/NotifyPostRejected 方法
- 消息页面顶部增加系统消息标识和分隔线,之下为全部消息列表
- 稿件审核通过通知卡片点击跳转到 /posts/{id} 页面
- 稿件审核拒绝通知卡片点击跳转到 /studio/posts 创作中心
- 新增 postNotifier 接口遵循 ISP 原则
This commit is contained in:
@ -2,8 +2,10 @@ package model
|
|||||||
|
|
||||||
// 消息/通知类型常量
|
// 消息/通知类型常量
|
||||||
const (
|
const (
|
||||||
NotifyAuditApproved = "audit_approved"
|
NotifyAuditApproved = "audit_approved"
|
||||||
NotifyAuditRejected = "audit_rejected"
|
NotifyAuditRejected = "audit_rejected"
|
||||||
|
NotifyPostApproved = "post_approved"
|
||||||
|
NotifyPostRejected = "post_rejected"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -20,8 +22,10 @@ type Notification struct {
|
|||||||
|
|
||||||
// NotifyTypeNames 通知类型 → 中文名
|
// NotifyTypeNames 通知类型 → 中文名
|
||||||
var NotifyTypeNames = map[string]string{
|
var NotifyTypeNames = map[string]string{
|
||||||
NotifyAuditApproved: "系统通知",
|
NotifyAuditApproved: "资料审核",
|
||||||
NotifyAuditRejected: "系统通知",
|
NotifyAuditRejected: "资料审核",
|
||||||
|
NotifyPostApproved: "稿件审核",
|
||||||
|
NotifyPostRejected: "稿件审核",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationListResult 消息列表查询结果
|
// NotificationListResult 消息列表查询结果
|
||||||
|
|||||||
@ -32,7 +32,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
)
|
)
|
||||||
|
|
||||||
postRepo := repository.NewPostRepo(db)
|
postRepo := repository.NewPostRepo(db)
|
||||||
postService := service.NewPostService(postRepo, siteSettings)
|
postService := service.NewPostService(postRepo, siteSettings, notificationService)
|
||||||
shortcodeSvc := service.NewShortcodeService()
|
shortcodeSvc := service.NewShortcodeService()
|
||||||
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
||||||
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||||
|
|||||||
@ -110,3 +110,20 @@ func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason
|
|||||||
content,
|
content,
|
||||||
&relatedID)
|
&relatedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyPostApproved 稿件审核通过通知
|
||||||
|
func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) {
|
||||||
|
title := "稿件审核通过"
|
||||||
|
content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布"
|
||||||
|
_ = s.Create(userID, model.NotifyPostApproved, title, content, &postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyPostRejected 稿件审核拒绝通知
|
||||||
|
func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) {
|
||||||
|
title := "稿件审核未通过"
|
||||||
|
content := "你的稿件《" + postTitle + "》未通过审核"
|
||||||
|
if reason != "" {
|
||||||
|
content += ",理由:" + reason
|
||||||
|
}
|
||||||
|
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID)
|
||||||
|
}
|
||||||
|
|||||||
@ -75,15 +75,17 @@ func generateExcerpt(md string) string {
|
|||||||
|
|
||||||
// PostService 帖子业务逻辑
|
// PostService 帖子业务逻辑
|
||||||
type PostService struct {
|
type PostService struct {
|
||||||
repo postStore
|
repo postStore
|
||||||
ss *config.SiteSettings
|
ss *config.SiteSettings
|
||||||
|
notifier postNotifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostService 构造函数
|
// NewPostService 构造函数
|
||||||
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
|
func NewPostService(repo postStore, ss *config.SiteSettings, notifier postNotifier) *PostService {
|
||||||
return &PostService{
|
return &PostService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
ss: ss,
|
ss: ss,
|
||||||
|
notifier: notifier,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,8 +265,10 @@ func (s *PostService) Approve(postID uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wasRevision := post.PendingBody != ""
|
||||||
|
|
||||||
// 修订审核:将待审内容覆盖到正式字段
|
// 修订审核:将待审内容覆盖到正式字段
|
||||||
if post.PendingBody != "" {
|
if wasRevision {
|
||||||
post.Title = post.PendingTitle
|
post.Title = post.PendingTitle
|
||||||
post.Body = post.PendingBody
|
post.Body = post.PendingBody
|
||||||
post.Excerpt = generateExcerpt(post.PendingBody)
|
post.Excerpt = generateExcerpt(post.PendingBody)
|
||||||
@ -272,7 +276,14 @@ func (s *PostService) Approve(postID uint) error {
|
|||||||
post.PendingBody = ""
|
post.PendingBody = ""
|
||||||
post.Status = model.PostStatusApproved
|
post.Status = model.PostStatusApproved
|
||||||
post.RejectReason = ""
|
post.RejectReason = ""
|
||||||
return s.repo.Update(post)
|
if err := s.repo.Update(post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通知作者修订审核通过
|
||||||
|
if s.notifier != nil {
|
||||||
|
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次审核通过
|
// 首次审核通过
|
||||||
@ -281,7 +292,14 @@ func (s *PostService) Approve(postID uint) error {
|
|||||||
}
|
}
|
||||||
post.Status = model.PostStatusApproved
|
post.Status = model.PostStatusApproved
|
||||||
post.RejectReason = ""
|
post.RejectReason = ""
|
||||||
return s.repo.Update(post)
|
if err := s.repo.Update(post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通知作者审核通过
|
||||||
|
if s.notifier != nil {
|
||||||
|
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved
|
// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved
|
||||||
@ -299,13 +317,27 @@ func (s *PostService) Reject(postID uint, reason string) error {
|
|||||||
post.PendingTitle = ""
|
post.PendingTitle = ""
|
||||||
post.PendingBody = ""
|
post.PendingBody = ""
|
||||||
post.RejectReason = reason
|
post.RejectReason = reason
|
||||||
return s.repo.Update(post)
|
if err := s.repo.Update(post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通知作者修订被退回
|
||||||
|
if s.notifier != nil {
|
||||||
|
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通退回(首次审核)
|
// 普通退回(首次审核)
|
||||||
post.Status = model.PostStatusRejected
|
post.Status = model.PostStatusRejected
|
||||||
post.RejectReason = reason
|
post.RejectReason = reason
|
||||||
return s.repo.Update(post)
|
if err := s.repo.Update(post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通知作者审核被退回
|
||||||
|
if s.notifier != nil {
|
||||||
|
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||||||
|
|||||||
@ -53,4 +53,10 @@ type spaceUserStore interface {
|
|||||||
// spacePostStore SpaceService 所需的最小帖子仓储接口(ISP:1 个方法)
|
// spacePostStore SpaceService 所需的最小帖子仓储接口(ISP:1 个方法)
|
||||||
type spacePostStore interface {
|
type spacePostStore interface {
|
||||||
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
|
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postNotifier PostService 所需的通知服务接口(ISP:2 个方法)
|
||||||
|
type postNotifier interface {
|
||||||
|
NotifyPostApproved(userID uint, postTitle string, postID uint)
|
||||||
|
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
|
||||||
}
|
}
|
||||||
@ -21,6 +21,15 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="msg-main">
|
<div class="msg-main">
|
||||||
|
<!-- 系统消息区域 -->
|
||||||
|
<div class="msg-system-section">
|
||||||
|
<div class="msg-system-header">
|
||||||
|
<svg class="msg-system-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
系统消息
|
||||||
|
</div>
|
||||||
|
<div class="msg-system-divider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .Messages}}
|
{{if .Messages}}
|
||||||
<div class="msg-toolbar">
|
<div class="msg-toolbar">
|
||||||
{{if .UnreadCount}}
|
{{if .UnreadCount}}
|
||||||
@ -29,7 +38,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="msg-list">
|
<div class="msg-list">
|
||||||
{{range $i, $m := .Messages}}
|
{{range $i, $m := .Messages}}
|
||||||
<div class="msg-card{{if not $m.IsRead}} unread{{end}}" data-id="{{$m.ID}}">
|
{{$link := ""}}
|
||||||
|
{{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}}
|
||||||
|
{{if eq $m.NotifyType "post_approved"}}
|
||||||
|
{{$link = printf "/posts/%d" $m.RelatedID}}
|
||||||
|
{{else}}
|
||||||
|
{{$link = "/studio/posts"}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if $link}}
|
||||||
|
<a href="{{$link}}" class="msg-card-link">
|
||||||
|
{{end}}
|
||||||
|
<div class="msg-card{{if not $m.IsRead}} unread{{end}}" data-id="{{$m.ID}}" data-type="{{$m.NotifyType}}" data-link="{{$link}}">
|
||||||
<div class="msg-icon">
|
<div class="msg-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
</div>
|
</div>
|
||||||
@ -45,6 +65,9 @@
|
|||||||
<span class="msg-badge"></span>
|
<span class="msg-badge"></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{if $link}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -82,7 +105,10 @@
|
|||||||
var cards = document.querySelectorAll('.msg-card.unread');
|
var cards = document.querySelectorAll('.msg-card.unread');
|
||||||
for (var i = 0; i < cards.length; i++) {
|
for (var i = 0; i < cards.length; i++) {
|
||||||
(function (card) {
|
(function (card) {
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function (e) {
|
||||||
|
var link = card.getAttribute('data-link');
|
||||||
|
// 如果是可跳转的通知(post 审核),不阻止默认行为,只标记已读
|
||||||
|
// 标记已读通过 fetch 发送,不阻止链接跳转
|
||||||
var id = card.getAttribute('data-id');
|
var id = card.getAttribute('data-id');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
@ -96,6 +122,15 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.send();
|
xhr.send();
|
||||||
|
|
||||||
|
// 如果有链接,在标记已读后跳转(非 post 通知直接标记已读)
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = link;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})(cards[i]);
|
})(cards[i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,43 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- System Message Section ---------- */
|
||||||
|
.msg-system-section {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-system-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0 0 .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-system-icon {
|
||||||
|
color: var(--color-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-system-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, var(--color-border), transparent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Message Card Link ---------- */
|
||||||
|
.msg-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-card-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Toolbar ---------- */
|
/* ---------- Toolbar ---------- */
|
||||||
.msg-toolbar {
|
.msg-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user