feat: SSR 私信与通知(/messages)

会话列表、读写、未读角标与全部已读;系统会话只读;用户主页可发起私信。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:59:54 +08:00
parent 126e3bae98
commit 40ec20b3df
14 changed files with 455 additions and 11 deletions

View File

@@ -114,12 +114,12 @@
## G. 私信与通知 ## G. 私信与通知
- [ ] 会话列表(含系统会话 peer=0 - [x] 会话列表(含系统会话 peer=0— SSR `/messages`
- [ ] 会话消息(分页 / before 游标) - [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
- [ ] 发送私信 - [x] 发送私信 — 表单 PRG用户主页「发私信」入口
- [ ] 未读数(可分私信 / 通知) - [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
- [ ] 标记会话已读 / 通知已读 / 全部已读 - [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
- [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` - [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation`(写入已有;筛选 UI 未迁)
--- ---

View File

@@ -34,7 +34,8 @@
| `/favorites` | 收藏 | 已迁 | | `/favorites` | 收藏 | 已迁 |
| `/projects` | Gitea 码桶 | 后置 | | `/projects` | Gitea 码桶 | 后置 |
| `/links` | 友链 | pending | | `/links` | 友链 | pending |
| `/messages` | 私信 | pending | | `/messages` | 私信/通知会话列表 | 已迁 |
| `/messages/with/:peerId` | 会话详情peer=0 系统通知) | 已迁 |
| `/page/:slug` | 站点单页 | 未注册 | | `/page/:slug` | 站点单页 | 未注册 |
| `*` | 404 / pending | 已迁 | | `*` | 404 / pending | 已迁 |

View File

@@ -45,7 +45,7 @@ modules/
templates/ templates/
install.tmpl install.tmpl
post-install.tmpl post-install.tmpl
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ messages/
services/ services/
web_src/ → public/assets/ web_src/ → public/assets/
``` ```
@@ -85,4 +85,6 @@ web_src/ → public/assets/
个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id``/favorites` 个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id``/favorites`
私信:`/messages``/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/敏感词)。 Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/敏感词)。

View File

@@ -69,6 +69,7 @@ var parseGlobs = []string{
"profile/*.tmpl", "profile/*.tmpl",
"user/*.tmpl", "user/*.tmpl",
"favorites/*.tmpl", "favorites/*.tmpl",
"messages/*.tmpl",
} }
// Load 解析全部模板(进程内一次) // Load 解析全部模板(进程内一次)

View File

@@ -378,5 +378,59 @@ body.j13-body {
.j13-user-hero h1 { margin: 0 0 0.35rem; font-size: 1.35rem; } .j13-user-hero h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
.j13-user-sig { margin: 0.5rem 0 0; white-space: pre-wrap; } .j13-user-sig { margin: 0.5rem 0 0; white-space: pre-wrap; }
.j13-badge {
display: inline-block;
min-width: 1.1rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--j13-accent);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
line-height: 1.2;
text-align: center;
vertical-align: middle;
}
.j13-messages { max-width: 720px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
.j13-messages h1 { margin: 0 0 0.75rem; font-size: 1.35rem; }
.j13-msg-list { list-style: none; margin: 0; padding: 0; }
.j13-msg-item {
border-bottom: 1px solid var(--j13-border);
}
.j13-msg-item a {
display: grid;
gap: 0.2rem;
padding: 0.85rem 0;
color: inherit;
text-decoration: none;
}
.j13-msg-item.is-unread a strong { color: var(--j13-accent); }
.j13-msg-item__preview {
color: var(--j13-muted);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.j13-msg-thread { list-style: none; margin: 1rem 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.j13-msg-bubble {
max-width: 92%;
padding: 0.65rem 0.85rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
background: var(--j13-surface);
align-self: flex-start;
}
.j13-msg-bubble.is-self {
align-self: flex-end;
background: #eff6ff;
border-color: #bfdbfe;
}
.j13-msg-bubble.is-system { max-width: 100%; }
.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; }

View File

@@ -47,6 +47,7 @@ type PageChrome struct {
CSRF string CSRF string
Flash string Flash string
Error string Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
} }
func (d Deps) ctx(c *gin.Context) *webctx.Context { func (d Deps) ctx(c *gin.Context) *webctx.Context {
@@ -73,6 +74,10 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
for _, b := range boards { for _, b := range boards {
bv = append(bv, BoardView{ID: b.ID, Name: b.Name}) bv = append(bv, BoardView{ID: b.ID, Name: b.Name})
} }
var unread int64
if ctx.IsSigned() && d.Message != nil {
unread, _ = d.Message.UnreadCount(ctx.UserID())
}
return PageChrome{ return PageChrome{
Title: title, Title: title,
Description: desc, Description: desc,
@@ -86,6 +91,7 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
Inner: inner, Inner: inner,
CSRF: ctx.EnsureCSRF(), CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(), Flash: ctx.TakeFlash(),
UnreadCount: unread,
} }
} }

View File

@@ -59,7 +59,10 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost) g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost) g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet) g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
g.GET("/messages", deps.PendingPage) g.GET("/messages", authMW.RequireAuth(), deps.MessagesList)
g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)
g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend)
g.POST("/messages/read-all", authMW.RequireAuth(), deps.MessagesReadAll)
g.GET("/projects", deps.PendingPage) g.GET("/projects", deps.PendingPage)
g.GET("/links", deps.PendingPage) g.GET("/links", deps.PendingPage)
g.GET("/boards", deps.PendingPage) g.GET("/boards", deps.PendingPage)

242
routers/web/messages.go Normal file
View File

@@ -0,0 +1,242 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type msgConvRow struct {
PeerID uint
PeerName string
IsSystem bool
Preview string
Unread int64
UpdatedLabel string
}
type messagesListData struct {
PageChrome
Conversations []msgConvRow
UnreadTotal int64
UnreadDM int64
UnreadNotify int64
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
type msgBubble struct {
ID uint
FromSelf bool
IsSystem bool
Subject string
Content string
CreatedLabel string
Kind string
}
type messagesThreadData struct {
PageChrome
PeerID uint
PeerName string
IsSystem bool
CanReply bool
Messages []msgBubble
OlderBefore uint
HasOlder bool
Draft string
}
// MessagesList GET /messages
func (d Deps) MessagesList(c *gin.Context) {
ctx := d.ctx(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := 30
list, total, err := d.Message.ListConversations(services.ConversationListQuery{
UserID: ctx.UserID(), Page: page, Size: size,
})
if err != nil {
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = err.Error()
ctx.HTML(http.StatusOK, "messages/list", messagesListData{PageChrome: chrome})
return
}
rows := make([]msgConvRow, 0, len(list))
for _, cv := range list {
name := "系统通知"
if !cv.IsSystem {
name = "用户"
if cv.PeerUser != nil {
name = displayName(cv.PeerUser)
}
}
preview := ""
if cv.LastMessage != nil {
preview = strings.TrimSpace(cv.LastMessage.Subject)
if preview == "" {
preview = truncateRunes(cv.LastMessage.Content, 48)
}
}
rows = append(rows, msgConvRow{
PeerID: cv.PeerUserID, PeerName: name, IsSystem: cv.IsSystem,
Preview: preview, Unread: cv.UnreadCount,
UpdatedLabel: formatTime(cv.UpdatedAt),
})
}
totalUnread, dm, notify, _ := d.Message.UnreadCounts(ctx.UserID())
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
hasMore := int64(page*size) < total
ctx.HTML(http.StatusOK, "messages/list", messagesListData{
PageChrome: chrome, Conversations: rows,
UnreadTotal: totalUnread, UnreadDM: dm, UnreadNotify: notify,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore,
})
}
// MessagesThread GET /messages/with/:peerId
func (d Deps) MessagesThread(c *gin.Context) {
peerID, err := parsePeerID(c.Param("peerId"))
if err != nil {
ctx := d.ctx(c)
ctx.SetFlash("无效会话")
ctx.Redirect("/messages")
return
}
d.renderMessagesThread(c, peerID, "", c.Query("before"), "")
}
func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRaw, draft string) {
ctx := d.ctx(c)
uid := ctx.UserID()
before, _ := strconv.ParseUint(beforeRaw, 10, 64)
size := 40
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")
return
}
if before == 0 {
_ = d.Message.MarkConversationRead(uid, peerID)
}
peerName := "系统通知"
canReply := peerID > 0
if peerID > 0 {
if u, e := d.User.GetByID(peerID); e == nil {
peerName = displayName(u)
} else {
peerName = fmt.Sprintf("用户 #%d", peerID)
}
}
bubbles := make([]msgBubble, 0, len(list))
var oldest uint
for _, m := range list {
if oldest == 0 || m.ID < oldest {
oldest = m.ID
}
bubbles = append(bubbles, msgBubble{
ID: m.ID, FromSelf: m.FromUserID == uid && m.FromUserID > 0,
IsSystem: m.FromUserID == 0, Subject: m.Subject, Content: m.Content,
CreatedLabel: formatTime(m.CreatedAt), Kind: m.Kind,
})
}
hasOlder := false
if oldest > 0 {
if before == 0 {
hasOlder = total > int64(len(list))
} else {
hasOlder = len(list) >= size
}
}
chrome := d.chrome(ctx, peerName+" · 私信 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "messages/thread", messagesThreadData{
PageChrome: chrome, PeerID: peerID, PeerName: peerName,
IsSystem: peerID == 0, CanReply: canReply, Messages: bubbles,
OlderBefore: oldest, HasOlder: hasOlder, Draft: draft,
})
}
// MessagesSend POST /messages/with/:peerId
func (d Deps) MessagesSend(c *gin.Context) {
ctx := d.ctx(c)
peerID, err := parsePeerID(c.Param("peerId"))
if err != nil || peerID == 0 {
ctx.SetFlash("无法向系统通知会话发送私信")
ctx.Redirect("/messages")
return
}
content := strings.TrimSpace(c.PostForm("content"))
if !ctx.CheckCSRF() {
d.renderMessagesThread(c, peerID, "无效请求,请重试", "", content)
return
}
if d.Limiter != nil && !d.Limiter.Allow("message", fmt.Sprintf("%d", ctx.UserID())) {
d.renderMessagesThread(c, peerID, "发送过于频繁,请稍后再试", "", content)
return
}
if ctx.Doer != nil && ctx.Doer.Banned {
d.renderMessagesThread(c, peerID, "账号已被禁言,无法发私信", "", content)
return
}
_, err = d.Message.Send(services.MessageSendInput{
FromUserID: ctx.UserID(),
ToUserID: peerID,
Content: content,
})
if err != nil {
d.renderMessagesThread(c, peerID, err.Error(), "", content)
return
}
ctx.SetFlash("已发送")
ctx.Redirect(fmt.Sprintf("/messages/with/%d", peerID))
}
// MessagesReadAll POST /messages/read-all
func (d Deps) MessagesReadAll(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/messages")
return
}
_ = d.Message.MarkAllRead(ctx.UserID())
ctx.SetFlash("已全部标为已读")
ctx.Redirect("/messages")
}
func parsePeerID(raw string) (uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "system" {
return 0, nil
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func truncateRunes(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "…"
}

View File

@@ -11,6 +11,7 @@
<nav class="j13-nav"> <nav class="j13-nav">
{{if .LoggedIn}} {{if .LoggedIn}}
<a href="/compose">发帖</a> <a href="/compose">发帖</a>
<a href="/messages">私信{{if gt .UnreadCount 0}} <span class="j13-badge">{{.UnreadCount}}</span>{{end}}</a>
<a href="/favorites">收藏</a> <a href="/favorites">收藏</a>
<a href="/profile">{{.ViewerName}}</a> <a href="/profile">{{.ViewerName}}</a>
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}} {{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}

View File

@@ -2,5 +2,5 @@ package templates
import "embed" import "embed"
//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl //go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl messages/*.tmpl
var FS embed.FS var FS embed.FS

View File

@@ -0,0 +1,40 @@
{{define "messages/list"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-messages">
<h1>私信与通知</h1>
{{template "base/alert" .}}
<p class="j13-muted">
未读 {{.UnreadTotal}}
(私信 {{.UnreadDM}} · 通知 {{.UnreadNotify}}
{{if gt .UnreadTotal 0}}
<form method="post" action="/messages/read-all" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit" class="j13-linkbtn">全部标已读</button>
</form>
{{end}}
</p>
{{if .Conversations}}
<ul class="j13-msg-list">
{{range .Conversations}}
<li class="j13-msg-item{{if gt .Unread 0}} is-unread{{end}}">
<a href="/messages/with/{{.PeerID}}">
<strong>{{.PeerName}}</strong>
{{if gt .Unread 0}}<span class="j13-badge">{{.Unread}}</span>{{end}}
<span class="j13-msg-item__preview">{{.Preview}}</span>
<span class="j13-muted">{{.UpdatedLabel}}</span>
</a>
</li>
{{end}}
</ul>
<nav class="j13-pager">
{{if .HasPrev}}<a href="/messages?page={{.PrevPage}}">上一页</a>{{end}}
{{if .HasMore}}<a href="/messages?page={{.NextPage}}">下一页</a>{{end}}
</nav>
{{else}}
<p class="j13-empty">暂无会话。可在用户主页点击「发私信」开始对话。</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,39 @@
{{define "messages/thread"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<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}}
{{template "base/alert" .}}
{{if .HasOlder}}
<p><a href="/messages/with/{{.PeerID}}?before={{.OlderBefore}}">加载更早消息</a></p>
{{end}}
{{if .Messages}}
<ul class="j13-msg-thread">
{{range .Messages}}
<li class="j13-msg-bubble{{if .FromSelf}} is-self{{end}}{{if .IsSystem}} is-system{{end}}">
{{if .Subject}}<div class="j13-msg-bubble__subj">{{.Subject}}</div>{{end}}
<div class="j13-msg-bubble__body">{{.Content}}</div>
<div class="j13-muted">{{.CreatedLabel}}{{if .Kind}} · {{.Kind}}{{end}}</div>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-empty">还没有消息。{{if .CanReply}}发送第一条私信开始对话。{{end}}</p>
{{end}}
{{if .CanReply}}
<form method="post" action="/messages/with/{{.PeerID}}" class="j13-form j13-msg-compose">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>回复
<textarea name="content" rows="4" required maxlength="4000">{{.Draft}}</textarea>
</label>
<button type="submit">发送</button>
</form>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -12,7 +12,8 @@
<h1>{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}</h1> <h1>{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}</h1>
<p class="j13-muted">@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}</p> <p class="j13-muted">@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}</p>
{{if .Signature}}<p class="j13-user-sig">{{.Signature}}</p>{{end}} {{if .Signature}}<p class="j13-user-sig">{{.Signature}}</p>{{end}}
{{if .IsSelf}}<p><a href="/profile">编辑资料</a></p>{{end}} {{if .IsSelf}}<p><a href="/profile">编辑资料</a></p>
{{else if .LoggedIn}}<p><a href="/messages/with/{{.UserID}}">发私信</a></p>{{end}}
</div> </div>
</header> </header>
{{template "base/alert" .}} {{template "base/alert" .}}

View File

@@ -378,5 +378,59 @@ body.j13-body {
.j13-user-hero h1 { margin: 0 0 0.35rem; font-size: 1.35rem; } .j13-user-hero h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
.j13-user-sig { margin: 0.5rem 0 0; white-space: pre-wrap; } .j13-user-sig { margin: 0.5rem 0 0; white-space: pre-wrap; }
.j13-badge {
display: inline-block;
min-width: 1.1rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--j13-accent);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
line-height: 1.2;
text-align: center;
vertical-align: middle;
}
.j13-messages { max-width: 720px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
.j13-messages h1 { margin: 0 0 0.75rem; font-size: 1.35rem; }
.j13-msg-list { list-style: none; margin: 0; padding: 0; }
.j13-msg-item {
border-bottom: 1px solid var(--j13-border);
}
.j13-msg-item a {
display: grid;
gap: 0.2rem;
padding: 0.85rem 0;
color: inherit;
text-decoration: none;
}
.j13-msg-item.is-unread a strong { color: var(--j13-accent); }
.j13-msg-item__preview {
color: var(--j13-muted);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.j13-msg-thread { list-style: none; margin: 1rem 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.j13-msg-bubble {
max-width: 92%;
padding: 0.65rem 0.85rem;
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
background: var(--j13-surface);
align-self: flex-start;
}
.j13-msg-bubble.is-self {
align-self: flex-end;
background: #eff6ff;
border-color: #bfdbfe;
}
.j13-msg-bubble.is-system { max-width: 100%; }
.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; }