From 40ec20b3df1754f17c1bc8a25835d27d939c5b5f Mon Sep 17 00:00:00 2001
From: freefire
Date: Sat, 29 Aug 2026 05:59:54 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E7=A7=81=E4=BF=A1=E4=B8=8E?=
=?UTF-8?q?=E9=80=9A=E7=9F=A5=EF=BC=88/messages=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
会话列表、读写、未读角标与全部已读;系统会话只读;用户主页可发起私信。
Co-authored-by: Cursor
---
docs/rebuild-spec/02-features.md | 12 +-
docs/rebuild-spec/06-pages-ux.md | 3 +-
.../rebuild-spec/08-gitea-ssr-architecture.md | 4 +-
modules/webrender/render.go | 1 +
public/assets/site.css | 54 ++++
routers/web/chrome.go | 6 +
routers/web/home.go | 5 +-
routers/web/messages.go | 242 ++++++++++++++++++
templates/base/navbar.tmpl | 1 +
templates/embed.go | 2 +-
templates/messages/list.tmpl | 40 +++
templates/messages/thread.tmpl | 39 +++
templates/user/view.tmpl | 3 +-
web_src/css/site.css | 54 ++++
14 files changed, 455 insertions(+), 11 deletions(-)
create mode 100644 routers/web/messages.go
create mode 100644 templates/messages/list.tmpl
create mode 100644 templates/messages/thread.tmpl
diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md
index f5f7c84..d7772ce 100644
--- a/docs/rebuild-spec/02-features.md
+++ b/docs/rebuild-spec/02-features.md
@@ -114,12 +114,12 @@
## G. 私信与通知
-- [ ] 会话列表(含系统会话 peer=0)
-- [ ] 会话消息(分页 / before 游标)
-- [ ] 发送私信
-- [ ] 未读数(可分私信 / 通知)
-- [ ] 标记会话已读 / 通知已读 / 全部已读
-- [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等
+- [x] 会话列表(含系统会话 peer=0)— SSR `/messages`
+- [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
+- [x] 发送私信 — 表单 PRG;用户主页「发私信」入口
+- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
+- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
+- [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;筛选 UI 未迁)
---
diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md
index e9ecb41..ff32713 100644
--- a/docs/rebuild-spec/06-pages-ux.md
+++ b/docs/rebuild-spec/06-pages-ux.md
@@ -34,7 +34,8 @@
| `/favorites` | 收藏 | 已迁 |
| `/projects` | Gitea 码桶 | 后置 |
| `/links` | 友链 | pending |
-| `/messages` | 私信 | pending |
+| `/messages` | 私信/通知会话列表 | 已迁 |
+| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
| `/page/:slug` | 站点单页 | 未注册 |
| `*` | 404 / pending | 已迁 |
diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md
index 82f814f..a41fdab 100644
--- a/docs/rebuild-spec/08-gitea-ssr-architecture.md
+++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md
@@ -45,7 +45,7 @@ modules/
templates/
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/
web_src/ → public/assets/
```
@@ -85,4 +85,6 @@ web_src/ → public/assets/
个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id`、`/favorites`。
+私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
+
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
\ No newline at end of file
diff --git a/modules/webrender/render.go b/modules/webrender/render.go
index e22dc9c..a07a31e 100644
--- a/modules/webrender/render.go
+++ b/modules/webrender/render.go
@@ -69,6 +69,7 @@ var parseGlobs = []string{
"profile/*.tmpl",
"user/*.tmpl",
"favorites/*.tmpl",
+ "messages/*.tmpl",
}
// Load 解析全部模板(进程内一次)
diff --git a/public/assets/site.css b/public/assets/site.css
index 1dd9433..d0acadf 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -378,5 +378,59 @@ body.j13-body {
.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-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; }
+
+
diff --git a/routers/web/chrome.go b/routers/web/chrome.go
index 74223af..65ce6f1 100644
--- a/routers/web/chrome.go
+++ b/routers/web/chrome.go
@@ -47,6 +47,7 @@ type PageChrome struct {
CSRF string
Flash string
Error string
+ UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
}
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 {
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{
Title: title,
Description: desc,
@@ -86,6 +91,7 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
Inner: inner,
CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(),
+ UnreadCount: unread,
}
}
diff --git a/routers/web/home.go b/routers/web/home.go
index 4d7c235..56f6037 100644
--- a/routers/web/home.go
+++ b/routers/web/home.go
@@ -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/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
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("/links", deps.PendingPage)
g.GET("/boards", deps.PendingPage)
diff --git a/routers/web/messages.go b/routers/web/messages.go
new file mode 100644
index 0000000..fefa57d
--- /dev/null
+++ b/routers/web/messages.go
@@ -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]) + "…"
+}
diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl
index bf4e40e..72bed1a 100644
--- a/templates/base/navbar.tmpl
+++ b/templates/base/navbar.tmpl
@@ -11,6 +11,7 @@
+
+ {{if .Conversations}}
+
+
+ {{else}}
+ 暂无会话。可在用户主页点击「发私信」开始对话。
+ {{end}}
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/messages/thread.tmpl b/templates/messages/thread.tmpl
new file mode 100644
index 0000000..d2b3460
--- /dev/null
+++ b/templates/messages/thread.tmpl
@@ -0,0 +1,39 @@
+{{define "messages/thread"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ ← 返回会话列表
+ {{.PeerName}}
+ {{if .IsSystem}}系统通知(只读)
{{else}}查看主页
{{end}}
+ {{template "base/alert" .}}
+
+ {{if .HasOlder}}
+ 加载更早消息
+ {{end}}
+
+ {{if .Messages}}
+
+ {{else}}
+ 还没有消息。{{if .CanReply}}发送第一条私信开始对话。{{end}}
+ {{end}}
+
+ {{if .CanReply}}
+
+ {{end}}
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/user/view.tmpl b/templates/user/view.tmpl
index 98de5ab..0a9ccaa 100644
--- a/templates/user/view.tmpl
+++ b/templates/user/view.tmpl
@@ -12,7 +12,8 @@
{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}
@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}
{{if .Signature}}{{.Signature}}
{{end}}
- {{if .IsSelf}}编辑资料
{{end}}
+ {{if .IsSelf}}编辑资料
+ {{else if .LoggedIn}}发私信
{{end}}
{{template "base/alert" .}}
diff --git a/web_src/css/site.css b/web_src/css/site.css
index 1dd9433..d0acadf 100644
--- a/web_src/css/site.css
+++ b/web_src/css/site.css
@@ -378,5 +378,59 @@ body.j13-body {
.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-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; }
+
+