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 @@