feat: SSR 评论回复与 @ 提及通知

发评/审通接入 NotifyService,系统通知会话支持 kind 筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:51:52 +08:00
parent 145c7a3e1f
commit c32c81d1ca
11 changed files with 143 additions and 19 deletions

View File

@@ -105,8 +105,8 @@
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
- [x] 评论举报 — SSR
- [ ] @ 提及 → 通知
- [ ] 回复提醒(站内信 + 可选邮件)
- [x] @ 提及 → 通知 — SSR 发评/审通调用 `NotifyCommentMentions`
- [x] 回复提醒(站内信 + 可选邮件)— SSR 发评/审通调用 `NotifyCommentPublished`
- [ ] 审核中 / 被拒评论可见性规则
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
@@ -119,7 +119,7 @@
- [x] 发送私信 — 表单 PRG用户主页「发私信」入口
- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
- [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;筛选 UI 未迁
- [x] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;`/messages/with/0?kind=` 筛选)
---

View File

@@ -147,7 +147,7 @@
| 投票卡 | 选选项提交;显示百分比;作者可结束 |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除 — SSR@ |
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR嵌套树 UI |
| 修订 | 面板列出历史,可选对比 |
| 图片 | Lightbox 查看 |
@@ -155,10 +155,10 @@
## 5. 消息页
- 左:会话列表(系统会话单独)
- 右:消息时间线;发送框
- 左:会话列表(系统会话单独)— SSR `/messages`;快捷链到回复/提及筛选
- 右:消息时间线;发送框 — SSR `/messages/with/:peerId`
- 顶:未读角标(全局导航也可显示)
- 通知筛选kind
- 通知筛选kind— 系统会话 `?kind=reply|mention|…`
---

View File

@@ -698,6 +698,18 @@ body.j13-body {
.j13-msg-bubble__subj { font-weight: 600; margin-bottom: 0.25rem; }
.j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; }
.j13-msg-compose { margin-top: 1.25rem; }
.j13-msg-kinds {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
margin: 0.5rem 0 0;
font-size: 0.9rem;
}
.j13-msg-kinds a { color: var(--j13-muted); }
.j13-msg-kinds a.is-active {
color: var(--j13-accent);
font-weight: 600;
}
/* 内容门控锁定壳 */
members-only,

View File

@@ -108,6 +108,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
SitePage: sitePageSvc,
Report: reportSvc,
Captcha: captchaSvc,
Notify: notifySvc,
}, authMW)
r.GET("/media/thumb/*filepath", h.ServeImageThumb)

View File

@@ -357,6 +357,13 @@ func (d Deps) AdminCommentApprove(c *gin.Context) {
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
ctx.SetFlash(err.Error())
} else {
if d.Notify != nil {
if comment, err := d.Comment.GetByID(uint(id)); err == nil {
comment.Status = models.ContentStatusPublished
d.Notify.AsyncNotifyCommentPublished(comment)
d.Notify.AsyncNotifyCommentMentions(comment)
}
}
ctx.SetFlash("评论已通过")
}
ctx.Redirect("/admin/moderation")

View File

@@ -29,6 +29,7 @@ type Deps struct {
SitePage *services.SitePageService
Report *services.ReportService
Captcha *services.CaptchaService
Notify *services.NotifyService
}
// SitePageLink 导航/页脚站点单页链接

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
@@ -52,6 +53,15 @@ type messagesThreadData struct {
OlderBefore uint
HasOlder bool
Draft string
KindFilter string
KindLinks []msgKindLink
}
type msgKindLink struct {
Kind string
Label string
URL string
Active bool
}
// MessagesList GET /messages
@@ -121,9 +131,22 @@ func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRa
uid := ctx.UserID()
before, _ := strconv.ParseUint(beforeRaw, 10, 64)
size := 40
list, total, err := d.Message.ListConversationMessages(services.ConversationMessagesQuery{
kindFilter := ""
if peerID == 0 {
kindFilter = normalizeNotifyKind(c.Query("kind"))
}
var list []models.PrivateMessage
var total int64
var err error
if peerID == 0 && kindFilter != "" && kindFilter != "all" {
list, total, err = d.Message.ListNotifications(uid, 1, size, kindFilter)
_ = before // kind 筛选暂不分页 before
} else {
list, total, err = d.Message.ListConversationMessages(services.ConversationMessagesQuery{
UserID: uid, PeerID: peerID, Page: 1, Size: size, Before: uint(before),
})
}
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/messages")
@@ -156,6 +179,7 @@ func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRa
})
}
hasOlder := false
if peerID != 0 || kindFilter == "" || kindFilter == "all" {
if oldest > 0 {
if before == 0 {
hasOlder = total > int64(len(list))
@@ -163,15 +187,62 @@ func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRa
hasOlder = len(list) >= size
}
}
} else {
hasOlder = false
}
chrome := d.chrome(ctx, peerName+" · 私信 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "messages/thread", messagesThreadData{
data := messagesThreadData{
PageChrome: chrome, PeerID: peerID, PeerName: peerName,
IsSystem: peerID == 0, CanReply: canReply, Messages: bubbles,
OlderBefore: oldest, HasOlder: hasOlder, Draft: draft,
}
if peerID == 0 {
data.KindFilter = kindFilter
data.KindLinks = notifyKindLinks(kindFilter)
}
ctx.HTML(http.StatusOK, "messages/thread", data)
}
func normalizeNotifyKind(raw string) string {
k := strings.TrimSpace(strings.ToLower(raw))
switch k {
case "", "all":
return "all"
case models.MessageKindReply, models.MessageKindMention, models.MessageKindSystem,
models.MessageKindReject, models.MessageKindReportResult, models.MessageKindModeration:
return k
default:
return "all"
}
}
func notifyKindLinks(active string) []msgKindLink {
if active == "" {
active = "all"
}
items := []struct{ kind, label string }{
{"all", "全部"},
{models.MessageKindReply, "回复"},
{models.MessageKindMention, "提及"},
{models.MessageKindSystem, "系统"},
{models.MessageKindReject, "拒绝"},
{models.MessageKindReportResult, "举报结果"},
{models.MessageKindModeration, "审核"},
}
out := make([]msgKindLink, 0, len(items))
for _, it := range items {
url := "/messages/with/0"
if it.kind != "all" {
url += "?kind=" + it.kind
}
out = append(out, msgKindLink{
Kind: it.kind, Label: it.label, URL: url, Active: active == it.kind,
})
}
return out
}
// MessagesSend POST /messages/with/:peerId
func (d Deps) MessagesSend(c *gin.Context) {

View File

@@ -201,6 +201,16 @@ func (d Deps) PostComment(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
if d.Notify != nil && cm != nil {
switch cm.Status {
case models.ContentStatusPublished:
d.Notify.AsyncNotifyCommentPublished(cm)
d.Notify.AsyncNotifyCommentMentions(cm)
case models.ContentStatusPending:
ctx.SetFlash("评论已提交,审核通过后公开显示")
d.Notify.AsyncNotifyPendingComment(cm)
}
}
anchor := "#comments"
if cm != nil && cm.Floor > 0 {
anchor = fmt.Sprintf("#floor-%d", cm.Floor)

View File

@@ -14,6 +14,7 @@
</form>
{{end}}
</p>
<p class="j13-muted"><a href="/messages/with/0">系统通知</a> · <a href="/messages/with/0?kind=reply">回复</a> · <a href="/messages/with/0?kind=mention">提及</a></p>
{{if .Conversations}}
<ul class="j13-msg-list">

View File

@@ -4,10 +4,19 @@
<main class="j13-main j13-messages">
<p><a href="/messages">← 返回会话列表</a></p>
<h1>{{.PeerName}}</h1>
{{if .IsSystem}}<p class="j13-muted">系统通知(只读)</p>{{else}}<p class="j13-muted"><a href="/user/{{.PeerID}}">查看主页</a></p>{{end}}
{{if .IsSystem}}
<p class="j13-muted">系统通知(只读)</p>
{{if .KindLinks}}
<nav class="j13-msg-kinds" aria-label="通知类型">
{{range .KindLinks}}
<a href="{{.URL}}"{{if .Active}} class="is-active" aria-current="page"{{end}}>{{.Label}}</a>
{{end}}
</nav>
{{end}}
{{else}}<p class="j13-muted"><a href="/user/{{.PeerID}}">查看主页</a></p>{{end}}
{{template "base/alert" .}}
{{if .HasOlder}}
{{if and .HasOlder (or (not .IsSystem) (eq .KindFilter "all") (eq .KindFilter ""))}}
<p><a href="/messages/with/{{.PeerID}}?before={{.OlderBefore}}">加载更早消息</a></p>
{{end}}

View File

@@ -698,6 +698,18 @@ body.j13-body {
.j13-msg-bubble__subj { font-weight: 600; margin-bottom: 0.25rem; }
.j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; }
.j13-msg-compose { margin-top: 1.25rem; }
.j13-msg-kinds {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
margin: 0.5rem 0 0;
font-size: 0.9rem;
}
.j13-msg-kinds a { color: var(--j13-muted); }
.j13-msg-kinds a.is-active {
color: var(--j13-accent);
font-weight: 600;
}
/* 内容门控锁定壳 */
members-only,