feat: SSR 私信与通知(/messages)
会话列表、读写、未读角标与全部已读;系统会话只读;用户主页可发起私信。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 未迁)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 已迁 |
|
||||||
|
|
||||||
|
|||||||
@@ -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`(品牌/限流/敏感词)。
|
||||||
@@ -69,6 +69,7 @@ var parseGlobs = []string{
|
|||||||
"profile/*.tmpl",
|
"profile/*.tmpl",
|
||||||
"user/*.tmpl",
|
"user/*.tmpl",
|
||||||
"favorites/*.tmpl",
|
"favorites/*.tmpl",
|
||||||
|
"messages/*.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 解析全部模板(进程内一次)
|
// Load 解析全部模板(进程内一次)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
242
routers/web/messages.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
40
templates/messages/list.tmpl
Normal file
40
templates/messages/list.tmpl
Normal 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}}
|
||||||
39
templates/messages/thread.tmpl
Normal file
39
templates/messages/thread.tmpl
Normal 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}}
|
||||||
@@ -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" .}}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user