fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -102,7 +102,7 @@ func (h *Handlers) AdminUserLoginLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取登录历史失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"logs": nonNilSlice(logs), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminGetUserAudit 站长:用户内容档案摘要
|
||||
@@ -131,7 +131,7 @@ func (h *Handlers) AdminUserAuditPosts(c *gin.Context) {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminUserAuditComments 站长:用户评论(含驳回/软删)
|
||||
@@ -146,7 +146,7 @@ func (h *Handlers) AdminUserAuditComments(c *gin.Context) {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminUserAuditMessages 站长:用户聊天消息(含撤回/软删)
|
||||
@@ -161,7 +161,7 @@ func (h *Handlers) AdminUserAuditMessages(c *gin.Context) {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// parseAdminUserID 解析路径中的用户 ID,失败时直接写出 400
|
||||
|
||||
@@ -19,7 +19,7 @@ func (h *Handlers) AnnouncementsList(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"announcements": list})
|
||||
c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// AnnouncementDetail 已发布公告详情
|
||||
@@ -50,7 +50,7 @@ func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"announcements": list})
|
||||
c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// AdminCreateAnnouncement 新建公告
|
||||
|
||||
@@ -13,5 +13,5 @@ func (h *Handlers) Boards(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"boards": boards})
|
||||
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (h *Handlers) ListChatRooms(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rooms": rooms, "total": total, "page": page, "size": size})
|
||||
c.JSON(http.StatusOK, gin.H{"rooms": nonNilSlice(rooms), "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// GetChatRoom 群详情(公开非成员可预览;私密非成员 404)
|
||||
@@ -273,7 +273,7 @@ func (h *Handlers) ListChatMembers(c *gin.Context) {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": list})
|
||||
c.JSON(http.StatusOK, gin.H{"members": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// KickChatMember 群主踢人
|
||||
@@ -315,7 +315,7 @@ func (h *Handlers) ListChatMessages(c *gin.Context) {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": msgs, "has_more": hasMore})
|
||||
c.JSON(http.StatusOK, gin.H{"messages": nonNilSlice(msgs), "has_more": hasMore})
|
||||
}
|
||||
|
||||
// SendChatMessage 发消息
|
||||
@@ -387,16 +387,30 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data := gin.H{
|
||||
"id": msg.ID,
|
||||
"room_id": roomID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recalled_by": msg.RecalledBy,
|
||||
"recalled_at": msg.RecalledAt,
|
||||
}
|
||||
if msg.Recaller != nil && msg.Recaller.ID > 0 {
|
||||
data["recaller"] = gin.H{
|
||||
"id": msg.Recaller.ID,
|
||||
"username": msg.Recaller.Username,
|
||||
"nickname": msg.Recaller.Nickname,
|
||||
"avatar": msg.Recaller.Avatar,
|
||||
"role": msg.Recaller.Role,
|
||||
}
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatRecalled,
|
||||
Data: gin.H{
|
||||
"id": msg.ID,
|
||||
"room_id": roomID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recalled_by": msg.RecalledBy,
|
||||
"recalled_at": msg.RecalledAt,
|
||||
},
|
||||
Data: data,
|
||||
})
|
||||
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
|
||||
if !oversee && msg.SenderID != claims.ID {
|
||||
msg.Content = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
@@ -444,7 +458,7 @@ func (h *Handlers) SearchChatUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
c.JSON(http.StatusOK, gin.H{"users": nonNilSlice(users)})
|
||||
}
|
||||
|
||||
// ChatUnreadSummary 未读汇总
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"comments": nonNilSlice(comments),
|
||||
"total": floors, // 楼层数(主评论数)→ 前端分页与楼层号计算
|
||||
"total_comments": totalComments, // 全部评论数(含回复)→ 展示徽标
|
||||
"page": page,
|
||||
|
||||
9
backend/handler/jsonutil.go
Normal file
9
backend/handler/jsonutil.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package handler
|
||||
|
||||
// nonNilSlice 保证 JSON 序列化为 [] 而非 null(Go nil slice 会编成 null)。
|
||||
func nonNilSlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func (h *Handlers) AdminPendingPosts(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取待审帖子失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(posts), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminPendingComments 待审核评论分页
|
||||
@@ -32,7 +32,7 @@ func (h *Handlers) AdminPendingComments(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取待审评论失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": total, "page": page})
|
||||
c.JSON(http.StatusOK, gin.H{"comments": nonNilSlice(comments), "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminPendingCounts 当前操作者可见的待审数量(导航角标)
|
||||
|
||||
@@ -20,7 +20,7 @@ func (h *Handlers) Notifications(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": list,
|
||||
"notifications": nonNilSlice(list),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
|
||||
@@ -77,7 +77,7 @@ func (h *Handlers) MyMedia(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": list})
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// DeleteAttachment 彻底删除本人附件(DB + 物理文件)
|
||||
|
||||
@@ -104,6 +104,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
if user.Role == model.RoleBoardAdmin {
|
||||
boardIDs, _ = h.Auth.GetUserBoardIDs(user.ID)
|
||||
}
|
||||
if boardIDs == nil {
|
||||
boardIDs = []uint{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
@@ -122,7 +125,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
"points": pointsTotal,
|
||||
"streak": streak,
|
||||
},
|
||||
"posts": posts,
|
||||
"posts": nonNilSlice(posts),
|
||||
"posts_total": postsTotal,
|
||||
"page": page,
|
||||
"size": size,
|
||||
@@ -152,7 +155,7 @@ func (h *Handlers) UserComments(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"comments": nonNilSlice(comments),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
|
||||
@@ -318,5 +318,7 @@ type ChatMessage struct {
|
||||
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
|
||||
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
|
||||
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
||||
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
||||
}
|
||||
|
||||
@@ -652,8 +652,9 @@ func (s *AdminUserService) ListAuditMessages(actor *Actor, userID uint, page, si
|
||||
}
|
||||
content := m.Content
|
||||
recalled := m.RecalledAt != nil
|
||||
// 历史数据:早期撤回曾清空正文,无法还原
|
||||
if recalled && content == "" {
|
||||
content = "(已撤回的消息)"
|
||||
content = "(撤回时未保留正文)"
|
||||
}
|
||||
it := AdminAuditMessageItem{
|
||||
ID: m.ID,
|
||||
|
||||
@@ -153,6 +153,10 @@ func (s *ChatService) toViews(rooms []model.ChatRoom, userID uint) []RoomView {
|
||||
unread := s.unreadCounts(userID, ids)
|
||||
views := make([]RoomView, 0, len(rooms))
|
||||
for _, r := range rooms {
|
||||
// 会话列表预览不向前台下发已撤回正文
|
||||
if r.LastMessage != nil && r.LastMessage.RecalledAt != nil {
|
||||
r.LastMessage.Content = ""
|
||||
}
|
||||
v := RoomView{ChatRoom: r, UnreadCount: unread[r.ID]}
|
||||
if r.IsDefault {
|
||||
v.Pinned = true
|
||||
@@ -887,7 +891,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
q = q.Where("id < ?", beforeID)
|
||||
}
|
||||
var msgs []model.ChatMessage
|
||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Find(&msgs).Error; err != nil {
|
||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(msgs) > size
|
||||
@@ -897,7 +901,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
// 对非监管者清空已撤回正文
|
||||
// 已撤回正文:仅监管者(站长/超管)保留,供聊天界面审计展示;普通成员清空
|
||||
if !oversee {
|
||||
for i := range msgs {
|
||||
if msgs[i].RecalledAt != nil {
|
||||
@@ -908,7 +912,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
return msgs, hasMore, nil
|
||||
}
|
||||
|
||||
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt 不清行
|
||||
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计
|
||||
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) {
|
||||
if _, err := s.getRoom(roomID); err != nil {
|
||||
return nil, err
|
||||
@@ -935,17 +939,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
// 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content
|
||||
if err := s.db.Model(&msg).Updates(map[string]interface{}{
|
||||
"recalled_at": now,
|
||||
"recalled_by": operatorID,
|
||||
"content": "",
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.RecalledAt = &now
|
||||
msg.RecalledBy = operatorID
|
||||
msg.Content = ""
|
||||
_ = s.db.Preload("Sender").First(&msg, msg.ID)
|
||||
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,9 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
||||
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []UserCommentItem{}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type RoleUserLike,
|
||||
} from "@/lib/roles";
|
||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
|
||||
@@ -85,14 +86,11 @@ function usePendingCounts(enabled: boolean) {
|
||||
};
|
||||
load();
|
||||
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") load();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
const offVisible = onDocumentVisible(load);
|
||||
return () => {
|
||||
alive = false;
|
||||
off();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
offVisible();
|
||||
};
|
||||
}, [enabled]);
|
||||
return counts.posts + counts.comments;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type Board,
|
||||
type LoginLog,
|
||||
} from "@/lib/api";
|
||||
import { isOwner, ROLES } from "@/lib/roles";
|
||||
import { isOwner, ROLES, ROLE_META } from "@/lib/roles";
|
||||
import { formatDate, formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime } from "@/lib/realtime";
|
||||
@@ -60,7 +60,6 @@ interface RoleOption {
|
||||
value: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -131,9 +130,7 @@ export default function UsersAdmin({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
// 封禁二次确认弹窗;null 关闭
|
||||
const [banTarget, setBanTarget] = useState<AdminUser | null>(null);
|
||||
// 角色授权弹窗 / 登录历史弹窗
|
||||
// 角色与账号弹窗 / 登录历史弹窗
|
||||
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
||||
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
@@ -412,42 +409,33 @@ export default function UsersAdmin({
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="设置管理角色"
|
||||
aria-label={`设置 ${u.username} 的管理角色`}
|
||||
title="角色与账号"
|
||||
aria-label={`设置 ${u.username} 的角色与账号状态`}
|
||||
disabled={busy}
|
||||
onClick={() => setRoleTarget(u)}
|
||||
className={ghostBtn}
|
||||
style={u.role === ROLES.USER ? { color: "var(--ink-3)" } : { color: "var(--accent)" }}
|
||||
style={
|
||||
u.banned
|
||||
? { color: "var(--danger)" }
|
||||
: u.role === ROLES.USER
|
||||
? { color: "var(--ink-3)" }
|
||||
: { color: "var(--accent)" }
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--accent-soft)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
|
||||
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
e.currentTarget.style.color = u.role === ROLES.USER ? "var(--ink-3)" : "var(--accent)";
|
||||
e.currentTarget.style.color = u.banned
|
||||
? "var(--danger)"
|
||||
: u.role === ROLES.USER
|
||||
? "var(--ink-3)"
|
||||
: "var(--accent)";
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={u.banned ? "解封" : "封禁"}
|
||||
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
|
||||
disabled={busy}
|
||||
onClick={() => setBanTarget(u)}
|
||||
className={ghostBtn}
|
||||
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--danger-soft)";
|
||||
e.currentTarget.style.color = "var(--danger)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
|
||||
}}
|
||||
>
|
||||
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -475,7 +463,7 @@ export default function UsersAdmin({
|
||||
用户管理
|
||||
</h1>
|
||||
<p className="meta mt-1.5 text-[12.5px]">
|
||||
检索全部成员,配置管理角色与板块授权;站长账号受保护,操作即时生效
|
||||
检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -738,24 +726,16 @@ export default function UsersAdmin({
|
||||
disabled={actingId === u.id}
|
||||
onClick={() => setRoleTarget(u)}
|
||||
className="btn btn-line btn-sm"
|
||||
style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
|
||||
>
|
||||
<ShieldCheck size={13} />
|
||||
角色权限
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingId === u.id}
|
||||
onClick={() => setBanTarget(u)}
|
||||
className="btn btn-sm"
|
||||
style={
|
||||
u.banned
|
||||
? { background: "var(--ok-soft)", color: "var(--ok)" }
|
||||
: { background: "var(--danger-soft)", color: "var(--danger)" }
|
||||
? { color: "var(--danger)", borderColor: "var(--danger)" }
|
||||
: u.role !== ROLES.USER
|
||||
? { color: "var(--accent)", borderColor: "var(--accent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
|
||||
{u.banned ? "解封" : "封禁"}
|
||||
<ShieldCheck size={13} />
|
||||
角色与账号
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -811,33 +791,12 @@ export default function UsersAdmin({
|
||||
setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)}
|
||||
/>
|
||||
|
||||
<BanConfirmDialog
|
||||
target={banTarget}
|
||||
busy={actingId === banTarget?.id}
|
||||
onCancel={() => setBanTarget(null)}
|
||||
onConfirm={async () => {
|
||||
const u = banTarget;
|
||||
if (!u) return;
|
||||
setActingId(u.id);
|
||||
try {
|
||||
await apiAdminSetUserBan(u.id, !u.banned);
|
||||
toast(u.banned ? "已解封" : "已封禁", "ok");
|
||||
setBanTarget(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<LoginLogsDialog target={logsTarget} onClose={() => setLogsTarget(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 角色授权弹窗 =====
|
||||
// ===== 角色与账号弹窗(角色授权 + 封禁状态) =====
|
||||
|
||||
function RoleAssignDialog({
|
||||
target,
|
||||
@@ -856,47 +815,35 @@ function RoleAssignDialog({
|
||||
onSaved: () => void;
|
||||
setActing: (v: boolean) => void;
|
||||
}) {
|
||||
// 可授予角色(按等级从高到低);超管选项仅站长可见
|
||||
const options: RoleOption[] = [
|
||||
{
|
||||
value: ROLES.SUPER_ADMIN,
|
||||
title: "超级管理员",
|
||||
desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外",
|
||||
icon: <ShieldCheck size={18} />,
|
||||
token: "--accent",
|
||||
},
|
||||
{
|
||||
value: ROLES.ADMIN,
|
||||
title: "管理员",
|
||||
desc: "可管理公告,审核全站帖子与评论,可置顶/加精",
|
||||
icon: <ShieldCheck size={18} />,
|
||||
token: "--accent",
|
||||
},
|
||||
{
|
||||
value: ROLES.BOARD_ADMIN,
|
||||
title: "板块管理员",
|
||||
desc: "仅可审核被授权板块的帖子与评论",
|
||||
icon: <ShieldCheck size={18} />,
|
||||
token: "--accent",
|
||||
},
|
||||
{
|
||||
value: ROLES.USER,
|
||||
title: "普通用户",
|
||||
desc: "无后台权限;发帖与评论需通过审核",
|
||||
icon: <UsersIcon size={18} />,
|
||||
token: "--ink-3",
|
||||
},
|
||||
].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN);
|
||||
const grantKeys = [
|
||||
ROLES.SUPER_ADMIN,
|
||||
ROLES.ADMIN,
|
||||
ROLES.BOARD_ADMIN,
|
||||
ROLES.USER,
|
||||
] as const;
|
||||
|
||||
const options: RoleOption[] = grantKeys
|
||||
.filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN)
|
||||
.map((k) => {
|
||||
const meta = ROLE_META[k];
|
||||
return {
|
||||
value: k,
|
||||
title: meta.label,
|
||||
desc: meta.grantDesc || "",
|
||||
token: meta.colorToken,
|
||||
};
|
||||
});
|
||||
|
||||
const [role, setRole] = useState<string>(ROLES.USER);
|
||||
const [boardIds, setBoardIds] = useState<number[]>([]);
|
||||
const [banned, setBanned] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// 每次打开按目标当前角色/授权初始化
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
|
||||
setBoardIds(target.board_ids ?? []);
|
||||
setBanned(!!target.banned);
|
||||
setErr("");
|
||||
}
|
||||
}, [target]);
|
||||
@@ -916,15 +863,36 @@ function RoleAssignDialog({
|
||||
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const sameBoards =
|
||||
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
|
||||
JSON.stringify([...boardIds].sort((a, b) => a - b));
|
||||
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
|
||||
const banChanged = banned !== !!target.banned;
|
||||
const willBan = banChanged && banned;
|
||||
const willUnban = banChanged && !banned;
|
||||
|
||||
const submit = async () => {
|
||||
if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) {
|
||||
setErr("请至少选择一个授权板块");
|
||||
return;
|
||||
}
|
||||
if (!roleChanged && !banChanged) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
try {
|
||||
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
|
||||
toast("角色权限已更新", "ok");
|
||||
if (roleChanged) {
|
||||
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
|
||||
}
|
||||
if (banChanged) {
|
||||
await apiAdminSetUserBan(target.id, banned);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (roleChanged) parts.push("角色已更新");
|
||||
if (willBan) parts.push("已封禁");
|
||||
if (willUnban) parts.push("已解封");
|
||||
toast(parts.join(" · ") || "已保存", "ok");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
@@ -933,6 +901,8 @@ function RoleAssignDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
|
||||
@@ -940,17 +910,17 @@ function RoleAssignDialog({
|
||||
onClick={() => !acting && onClose()}
|
||||
>
|
||||
<div
|
||||
className="j13-toast-in panel w-full max-w-[460px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
|
||||
className="j13-toast-in panel w-full max-w-[500px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
|
||||
style={{ boxShadow: "var(--shadow-lg)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置管理角色"
|
||||
aria-label="角色与账号"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
设置管理角色
|
||||
角色与账号
|
||||
</h3>
|
||||
<p className="meta mt-1 text-[12.5px] truncate">
|
||||
@{target.username}({target.nickname})
|
||||
@@ -968,7 +938,14 @@ function RoleAssignDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
|
||||
<p className="mt-3 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。
|
||||
</p>
|
||||
|
||||
<p className="mt-4 mb-2 text-[12.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
管理角色
|
||||
</p>
|
||||
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
|
||||
{options.map((o) => {
|
||||
const active = role === o.value;
|
||||
return (
|
||||
@@ -981,7 +958,7 @@ function RoleAssignDialog({
|
||||
setRole(o.value);
|
||||
setErr("");
|
||||
}}
|
||||
className="text-left rounded-xl p-3 flex items-start gap-3 transition-colors"
|
||||
className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
@@ -995,13 +972,13 @@ function RoleAssignDialog({
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{o.icon}
|
||||
{o.value === ROLES.USER ? <UsersIcon size={18} /> : <ShieldCheck size={18} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
{o.title}
|
||||
</span>
|
||||
<span className="block mt-0.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="block mt-1 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{o.desc}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1017,7 +994,6 @@ function RoleAssignDialog({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 板块授权(仅板块管理员) */}
|
||||
{role === ROLES.BOARD_ADMIN && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
|
||||
@@ -1060,111 +1036,89 @@ function RoleAssignDialog({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>{err}</p>}
|
||||
{err && (
|
||||
<p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
|
||||
账号状态
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!banned}
|
||||
onClick={() => setBanned(false)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
|
||||
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-bold"
|
||||
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
|
||||
>
|
||||
<RotateCcw size={14} /> 正常
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
可登录与互动
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={banned}
|
||||
onClick={() => setBanned(true)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
|
||||
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-bold"
|
||||
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
|
||||
>
|
||||
<Ban size={14} /> 封禁
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
强制下线,禁止登录
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{willBan && (
|
||||
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={submit} disabled={acting}>
|
||||
{acting && <Loader2 size={15} className="animate-spin" />}
|
||||
保存角色
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 封禁确认弹窗 =====
|
||||
|
||||
function BanConfirmDialog({
|
||||
target,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: AdminUser | null;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !busy) onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [target, busy, onCancel]);
|
||||
|
||||
if (!target) return null;
|
||||
const banning = !target.banned;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
|
||||
onClick={() => !busy && onCancel()}
|
||||
>
|
||||
<div
|
||||
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
|
||||
style={{ boxShadow: "var(--shadow-lg)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: banning ? "var(--danger-soft)" : "var(--ok-soft)",
|
||||
color: banning ? "var(--danger)" : "var(--ok)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
<button
|
||||
type="button"
|
||||
className={willBan ? "btn btn-danger" : "btn"}
|
||||
onClick={submit}
|
||||
disabled={acting}
|
||||
>
|
||||
{banning ? <Ban size={23} /> : <RotateCcw size={21} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
{banning ? "封禁账号" : "解除封禁"}
|
||||
</h3>
|
||||
<p className="meta mt-1.5 text-[13px] leading-relaxed">
|
||||
{banning
|
||||
? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`
|
||||
: `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button type="button" className="btn btn-line" onClick={onCancel} disabled={busy} autoFocus>
|
||||
取消
|
||||
{acting && <Loader2 size={15} className="animate-spin" />}
|
||||
{saveLabel}
|
||||
</button>
|
||||
{banning ? (
|
||||
<button type="button" className="btn btn-danger" onClick={onConfirm} disabled={busy}>
|
||||
{busy && <Loader2 size={15} className="animate-spin" />}
|
||||
确认封禁
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={15} className="animate-spin" />}
|
||||
解除封禁
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ===== 登录历史弹窗 =====
|
||||
|
||||
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
@@ -114,28 +115,35 @@ export default function ChatShell({
|
||||
setRooms(sortRooms(initialRooms));
|
||||
}, [initialRooms]);
|
||||
|
||||
const refreshRooms = useCallback(async () => {
|
||||
const roomsInFlight = useRef(false);
|
||||
const lastRoomsFetchAt = useRef(0);
|
||||
|
||||
const refreshRooms = useCallback(async (opts?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
if (!opts?.force) {
|
||||
if (roomsInFlight.current) return;
|
||||
// 切房/回前台可合并;建群、被邀请等走 force
|
||||
if (now - lastRoomsFetchAt.current < 2_000) return;
|
||||
}
|
||||
roomsInFlight.current = true;
|
||||
lastRoomsFetchAt.current = now;
|
||||
try {
|
||||
const res = await apiListChatRooms(1, 50, "", "joined");
|
||||
setRooms(sortRooms(res.rooms || []));
|
||||
} catch {
|
||||
// 静默
|
||||
} finally {
|
||||
roomsInFlight.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 每次进入消息壳都用 HTTP 拉最新排序(避免首页 Link 预取/路由缓存导致新消息会话不置顶)
|
||||
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存)
|
||||
useEffect(() => {
|
||||
refreshRooms();
|
||||
void refreshRooms();
|
||||
}, [refreshRooms, activeRoomId]);
|
||||
|
||||
// 从其他标签页切回时也对账一次
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") refreshRooms();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, [refreshRooms]);
|
||||
// 从其他标签页切回时对账(10s 限频 + 上方 2s 合并)
|
||||
useEffect(() => onDocumentVisible(() => void refreshRooms()), [refreshRooms]);
|
||||
|
||||
// 实时未读:更新侧栏角标与预览,按置顶规则重排(不盲目置顶到最前)
|
||||
useEffect(() => {
|
||||
@@ -145,7 +153,7 @@ export default function ChatShell({
|
||||
setRooms((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === data.room_id);
|
||||
if (idx < 0) {
|
||||
refreshRooms();
|
||||
void refreshRooms({ force: true });
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
@@ -179,7 +187,7 @@ export default function ChatShell({
|
||||
const off = realtime.on<RtChatMembershipData>(RT_CHAT_MEMBERSHIP, (data) => {
|
||||
if (!data) return;
|
||||
if (data.status === "invited" || data.status === "dissolved" || data.status === "kicked") {
|
||||
refreshRooms();
|
||||
void refreshRooms({ force: true });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
@@ -230,7 +238,7 @@ export default function ChatShell({
|
||||
setQuery("");
|
||||
setUserHits([]);
|
||||
router.push(`/chat/${room.id}`);
|
||||
refreshRooms();
|
||||
void refreshRooms({ force: true });
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "无法发起私聊");
|
||||
}
|
||||
@@ -524,7 +532,7 @@ export default function ChatShell({
|
||||
setShowCreate(false);
|
||||
toast("群已创建", "ok");
|
||||
router.push(`/chat/${id}`);
|
||||
refreshRooms();
|
||||
void refreshRooms({ force: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
apiRecallChatMessage,
|
||||
type ChatMessage,
|
||||
type ChatRoomMember,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
realtime,
|
||||
@@ -49,6 +50,30 @@ import Avatar from "@/components/Avatar";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
|
||||
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
|
||||
const MARK_READ_GAP_MS = 2_000;
|
||||
const markReadInflight = new Set<number>();
|
||||
const markReadLastAt = new Map<number, number>();
|
||||
|
||||
function requestMarkChatRead(roomId: number): void {
|
||||
const now = Date.now();
|
||||
const last = markReadLastAt.get(roomId) || 0;
|
||||
if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return;
|
||||
markReadInflight.add(roomId);
|
||||
markReadLastAt.set(roomId, now);
|
||||
apiMarkChatRead(roomId, 0)
|
||||
.then(() => {
|
||||
window.dispatchEvent(new Event("j13-chat-unread-sync"));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
markReadInflight.delete(roomId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 成员列表:并发去重(进房 effect 与面板刷新) */
|
||||
const membersInflight = new Set<number>();
|
||||
|
||||
type RightPanel = null | "members" | "settings" | "invite";
|
||||
|
||||
type ConfirmState = {
|
||||
@@ -59,18 +84,84 @@ type ConfirmState = {
|
||||
action: () => Promise<void> | void;
|
||||
} | null;
|
||||
|
||||
function recallLabel(m: ChatMessage, meId: number): string {
|
||||
if (!m.recalled_at) return "";
|
||||
// 操作者不是发送者 → 管理员/监管撤回
|
||||
if (m.recalled_by && m.recalled_by !== m.sender_id) {
|
||||
return "管理员撤回了此消息";
|
||||
/** 撤回提示:本人 / 他人撤回 / 管理员代撤 */
|
||||
function RecallNotice({
|
||||
message,
|
||||
meId,
|
||||
members,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
meId: number;
|
||||
members: ChatRoomMember[];
|
||||
}) {
|
||||
if (!message.recalled_at) return null;
|
||||
const by =
|
||||
message.recalled_by ||
|
||||
message.recaller?.id ||
|
||||
message.sender_id;
|
||||
// 操作者不是原发送者 → 监管代撤
|
||||
const adminRecall = !!(by && by !== message.sender_id);
|
||||
|
||||
if (by === meId) {
|
||||
return (
|
||||
<span>
|
||||
{adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (m.sender_id === meId || m.recalled_by === meId) {
|
||||
return "你撤回了一条消息";
|
||||
|
||||
let recaller: User | null | undefined =
|
||||
message.recaller && message.recaller.id
|
||||
? message.recaller
|
||||
: by === message.sender_id
|
||||
? message.sender
|
||||
: null;
|
||||
if ((!recaller || !recaller.id) && by > 0) {
|
||||
const hit = members.find((x) => x.user_id === by);
|
||||
if (hit) recaller = hit.user;
|
||||
}
|
||||
return "此消息已撤回";
|
||||
const name =
|
||||
(recaller?.nickname || recaller?.username || "").trim() || "有人";
|
||||
const uid = recaller?.id || by;
|
||||
const nameStyle = {
|
||||
color: "color-mix(in srgb, var(--accent) 72%, var(--ink))",
|
||||
} as const;
|
||||
const nameNode =
|
||||
uid > 0 ? (
|
||||
<Link
|
||||
href={`/u/${uid}`}
|
||||
className="font-semibold transition-opacity hover:opacity-80"
|
||||
style={nameStyle}
|
||||
title={`查看 ${name} 的主页`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold" style={nameStyle}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (adminRecall) {
|
||||
return (
|
||||
<span>
|
||||
管理员
|
||||
{nameNode}
|
||||
撤回了一条成员消息
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{nameNode}
|
||||
撤回了一条消息
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 撤回后「重新编辑」有效窗口(接近微信体验) */
|
||||
const RE_EDIT_MS = 2 * 60 * 1000;
|
||||
|
||||
interface Props {
|
||||
roomId: number;
|
||||
roomName: string;
|
||||
@@ -236,6 +327,10 @@ export default function ChatRoomClient({
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||||
const [reEditDrafts, setReEditDrafts] = useState<
|
||||
Record<number, { text: string; until: number }>
|
||||
>({});
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -250,6 +345,40 @@ export default function ChatRoomClient({
|
||||
const oversee = canOverseeChat(meRole);
|
||||
const canManageInvite = !isDirect && (isOwner || oversee);
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
setReEditDrafts((prev) => ({
|
||||
...prev,
|
||||
[id]: { text: t, until: Date.now() + RE_EDIT_MS },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const takeReEditDraft = useCallback((id: number): string | null => {
|
||||
const d = reEditDrafts[id];
|
||||
if (!d || Date.now() > d.until) return null;
|
||||
return d.text;
|
||||
}, [reEditDrafts]);
|
||||
|
||||
// 到期后清掉「重新编辑」,避免按钮残留
|
||||
useEffect(() => {
|
||||
const ids = Object.keys(reEditDrafts);
|
||||
if (ids.length === 0) return;
|
||||
const nearest = Math.min(...Object.values(reEditDrafts).map((d) => d.until));
|
||||
const wait = Math.max(0, nearest - Date.now()) + 50;
|
||||
const t = setTimeout(() => {
|
||||
setReEditDrafts((prev) => {
|
||||
const now = Date.now();
|
||||
const next: typeof prev = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (v.until > now) next[Number(k)] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, wait);
|
||||
return () => clearTimeout(t);
|
||||
}, [reEditDrafts]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
@@ -261,34 +390,36 @@ export default function ChatRoomClient({
|
||||
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!canView) return;
|
||||
if (membersInflight.has(roomId)) return;
|
||||
membersInflight.add(roomId);
|
||||
try {
|
||||
const res = await apiListChatMembers(roomId);
|
||||
setMembers(res.members || []);
|
||||
if (res.members) setMemberCount(res.members.length);
|
||||
} catch {
|
||||
// 静默:@ 功能降级为纯文本
|
||||
} finally {
|
||||
membersInflight.delete(roomId);
|
||||
}
|
||||
}, [roomId, canView]);
|
||||
|
||||
const markReadToLatest = useCallback(() => {
|
||||
if (!isRealMember) return;
|
||||
apiMarkChatRead(roomId, 0)
|
||||
.then(() => {
|
||||
window.dispatchEvent(new Event("j13-chat-unread-sync"));
|
||||
})
|
||||
.catch(() => {});
|
||||
requestMarkChatRead(roomId);
|
||||
}, [roomId, isRealMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
realtime.joinRoom(roomId);
|
||||
refreshMembers();
|
||||
void refreshMembers();
|
||||
requestAnimationFrame(() => scrollToBottom(false));
|
||||
markReadToLatest();
|
||||
return () => {
|
||||
realtime.leaveRoom(roomId);
|
||||
};
|
||||
}, [roomId, canView, refreshMembers, scrollToBottom, markReadToLatest]);
|
||||
// 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId, canView]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMessageData>(RT_CHAT_MESSAGE, (data) => {
|
||||
@@ -315,21 +446,34 @@ export default function ChatRoomClient({
|
||||
const off = realtime.on<RtChatRecalledData>(RT_CHAT_RECALLED, (data) => {
|
||||
if (!data || data.room_id !== roomId) return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === data.id
|
||||
? {
|
||||
...m,
|
||||
content: "",
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender_id: data.sender_id || m.sender_id,
|
||||
}
|
||||
: m
|
||||
)
|
||||
prev.map((m) => {
|
||||
if (m.id !== data.id) return m;
|
||||
// 本人撤回:草稿留给「重新编辑」,气泡不展示正文
|
||||
if (m.sender_id === meId && m.content) {
|
||||
rememberReEdit(m.id, m.content);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
|
||||
content: oversee ? m.content : "",
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender_id: data.sender_id || m.sender_id,
|
||||
recaller: data.recaller
|
||||
? {
|
||||
id: data.recaller.id,
|
||||
username: data.recaller.username,
|
||||
nickname: data.recaller.nickname,
|
||||
avatar: data.recaller.avatar,
|
||||
role: data.recaller.role || "user",
|
||||
}
|
||||
: m.recaller,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
return off;
|
||||
}, [roomId]);
|
||||
}, [roomId, oversee, meId, rememberReEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMembershipData>(
|
||||
@@ -359,7 +503,7 @@ export default function ChatRoomClient({
|
||||
try {
|
||||
const before = messages[0].id;
|
||||
const res = await apiListChatMessages(roomId, before, 30);
|
||||
if (res.messages.length > 0) {
|
||||
if ((res.messages || []).length > 0) {
|
||||
setMessages((prev) => [...res.messages, ...prev]);
|
||||
setHasMore(res.has_more);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -672,6 +816,7 @@ export default function ChatRoomClient({
|
||||
};
|
||||
|
||||
const handleRecall = (mid: number) => {
|
||||
const snapshot = messages.find((m) => m.id === mid);
|
||||
setCtxMenu(null);
|
||||
setConfirm({
|
||||
title: "撤回消息",
|
||||
@@ -680,14 +825,18 @@ export default function ChatRoomClient({
|
||||
danger: true,
|
||||
action: async () => {
|
||||
const { message } = await apiRecallChatMessage(roomId, mid);
|
||||
const body = (message.content || snapshot?.content || "").trim();
|
||||
const mine = (snapshot?.sender_id ?? message.sender_id) === meId;
|
||||
if (mine && body) rememberReEdit(mid, body);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === mid
|
||||
? {
|
||||
...m,
|
||||
content: "",
|
||||
content: oversee ? body || m.content : "",
|
||||
recalled_at: message.recalled_at || new Date().toISOString(),
|
||||
recalled_by: message.recalled_by,
|
||||
recaller: message.recaller || m.recaller,
|
||||
}
|
||||
: m
|
||||
)
|
||||
@@ -697,6 +846,24 @@ export default function ChatRoomClient({
|
||||
});
|
||||
};
|
||||
|
||||
const handleReEdit = (mid: number) => {
|
||||
const text = takeReEditDraft(mid);
|
||||
if (!text) {
|
||||
toast("重新编辑已过期");
|
||||
return;
|
||||
}
|
||||
setReplyTo(null);
|
||||
setContent(text);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToMsg = (id: number) => {
|
||||
const el = msgRefs.current.get(id);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -925,6 +1092,114 @@ export default function ChatRoomClient({
|
||||
new Date(m.created_at).getTime() -
|
||||
new Date(prev.created_at).getTime() <
|
||||
5 * 60_000;
|
||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||
|
||||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||||
if (recalled && !(oversee && m.content)) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(node) => {
|
||||
if (node) msgRefs.current.set(m.id, node);
|
||||
else msgRefs.current.delete(m.id);
|
||||
}}
|
||||
className="flex justify-center px-2 py-0.5"
|
||||
>
|
||||
<p
|
||||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<RecallNotice
|
||||
message={m}
|
||||
meId={meId}
|
||||
members={members}
|
||||
/>
|
||||
{reEditText ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReEdit(m.id)}
|
||||
className="font-semibold transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
color:
|
||||
"color-mix(in srgb, var(--accent) 72%, var(--ink))",
|
||||
}}
|
||||
>
|
||||
重新编辑
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
||||
if (recalled && oversee && m.content) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(node) => {
|
||||
if (node) msgRefs.current.set(m.id, node);
|
||||
else msgRefs.current.delete(m.id);
|
||||
}}
|
||||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||||
mine ? "items-end" : "items-start"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{!isDirect && m.sender_id === ownerId && (
|
||||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||||
)}
|
||||
<span
|
||||
className="text-[12px] font-semibold truncate"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: mine ? 4 : undefined,
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
opacity: 0.92,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="inline-flex items-center gap-1 mb-1.5 text-[11px] font-semibold"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<Undo2 size={11} strokeWidth={2.4} />
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -964,68 +1239,56 @@ export default function ChatRoomClient({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{recalled ? (
|
||||
<div
|
||||
className="px-3 py-2 rounded-xl text-[13px] italic"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{recallLabel(m, meId)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMsgMenu(m, e.clientX, e.clientY);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
clearLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
openMsgMenu(m, t.clientX, t.clientY);
|
||||
}, 480);
|
||||
}}
|
||||
onTouchEnd={clearLongPress}
|
||||
onTouchMove={clearLongPress}
|
||||
style={
|
||||
mine
|
||||
? {
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 14%, var(--panel))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--tint)",
|
||||
color: "var(--ink)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{!!m.reply_to_id && m.reply_snap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||||
style={{
|
||||
background: mine
|
||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||||
: "var(--bg)",
|
||||
borderLeft: "3px solid var(--accent)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMsgMenu(m, e.clientX, e.clientY);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
clearLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
openMsgMenu(m, t.clientX, t.clientY);
|
||||
}, 480);
|
||||
}}
|
||||
onTouchEnd={clearLongPress}
|
||||
onTouchMove={clearLongPress}
|
||||
style={
|
||||
mine
|
||||
? {
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 14%, var(--panel))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--tint)",
|
||||
color: "var(--ink)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{!!m.reply_to_id && m.reply_snap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||||
style={{
|
||||
background: mine
|
||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||||
: "var(--bg)",
|
||||
borderLeft: "3px solid var(--accent)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { apiUpdateProfile, type User } from "@/lib/api";
|
||||
@@ -23,6 +23,34 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [avatarDragOver, setAvatarDragOver] = useState(false);
|
||||
const [incomingAvatarFile, setIncomingAvatarFile] = useState<File | null>(null);
|
||||
|
||||
const clearIncomingAvatar = useCallback(() => setIncomingAvatarFile(null), []);
|
||||
|
||||
const handleAvatarDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes("Files")) setAvatarDragOver(true);
|
||||
};
|
||||
|
||||
const handleAvatarDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 仅在离开整块卡片时取消高亮(避免子元素冒泡闪烁)
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && e.currentTarget.contains(related)) return;
|
||||
setAvatarDragOver(false);
|
||||
};
|
||||
|
||||
const handleAvatarDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAvatarDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
setIncomingAvatarFile(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -72,20 +100,32 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
)}
|
||||
{error && <div className="alert-error" role="alert">{error}</div>}
|
||||
|
||||
{/* 头像:点击上传/裁剪,或从历史图片中选择 */}
|
||||
{/* 头像:点击上传/裁剪,或拖放图片到本区域 */}
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 rounded-xl"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
className="flex items-center gap-4 p-4 rounded-xl transition-colors"
|
||||
style={{
|
||||
background: avatarDragOver
|
||||
? "color-mix(in srgb, var(--accent) 12%, var(--panel-2))"
|
||||
: "var(--panel-2)",
|
||||
border: `1px solid ${avatarDragOver ? "var(--accent)" : "var(--line)"}`,
|
||||
}}
|
||||
onDragOver={handleAvatarDragOver}
|
||||
onDragLeave={handleAvatarDragLeave}
|
||||
onDrop={handleAvatarDrop}
|
||||
>
|
||||
<AvatarPicker
|
||||
name={nickname || user.username}
|
||||
src={user.avatar || ""}
|
||||
size={56}
|
||||
incomingFile={incomingAvatarFile}
|
||||
onIncomingConsumed={clearIncomingAvatar}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>头像</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--ink-3)" }}>
|
||||
点击头像上传新图片,支持拖动裁剪;统一转为 WebP,裁剪后不超过 2MB
|
||||
{avatarDragOver
|
||||
? "松开即可上传并裁剪"
|
||||
: "点击头像或拖放图片到此处;支持拖动裁剪,统一转为 WebP,裁剪后不超过 2MB"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function UserProfileContent({
|
||||
<div>
|
||||
{tab === "posts" ? (
|
||||
<div>
|
||||
{profile.posts.length > 0 ? (
|
||||
{Array.isArray(profile.posts) && profile.posts.length > 0 ? (
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{profile.posts.map((post, i) => (
|
||||
<Link
|
||||
@@ -83,10 +83,10 @@ export default function UserProfileContent({
|
||||
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
|
||||
<Link href={`/u/${userId}?tab=comments`} className="btn btn-line btn-sm">重新加载</Link>
|
||||
</div>
|
||||
) : commentData && commentData.comments.length > 0 ? (
|
||||
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
|
||||
<div>
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{commentData.comments.map((c, i) => (
|
||||
{(commentData.comments ?? []).map((c, i) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/post/${c.post_id}`}
|
||||
|
||||
@@ -18,13 +18,17 @@ interface AvatarCropModalProps {
|
||||
currentAvatar: string;
|
||||
/** 头像变更(新 URL)或被删除(空串)时触发 */
|
||||
onChanged: (avatar: string) => void;
|
||||
/** 外部拖入的图片:打开后直接进入裁剪 */
|
||||
seedFile?: File | null;
|
||||
/** 已消费 seedFile(成功或校验失败后均调用,便于父级清空) */
|
||||
onSeedConsumed?: () => void;
|
||||
}
|
||||
|
||||
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||||
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export default function AvatarCropModal({
|
||||
open, onClose, currentAvatar, onChanged,
|
||||
open, onClose, currentAvatar, onChanged, seedFile, onSeedConsumed,
|
||||
}: AvatarCropModalProps) {
|
||||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null); // blob: 或同源 /uploads URL
|
||||
@@ -37,6 +41,7 @@ export default function AvatarCropModal({
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
// 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use)
|
||||
const [usingId, setUsingId] = useState<number | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -64,6 +69,7 @@ export default function AvatarCropModal({
|
||||
setBusy(false);
|
||||
setConfirmDeleteId(null);
|
||||
setUsingId(null);
|
||||
setDragOver(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -85,13 +91,36 @@ export default function AvatarCropModal({
|
||||
setPixelCrop(areaPixels);
|
||||
}, []);
|
||||
|
||||
const enterCrop = (src: string, isBlob: boolean) => {
|
||||
const enterCrop = useCallback((src: string, isBlob: boolean) => {
|
||||
setError("");
|
||||
setImageSrc(src);
|
||||
setImageSrc((prev) => {
|
||||
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||
return src;
|
||||
});
|
||||
setSrcIsBlob(isBlob);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 校验并进入裁剪;返回错误文案,成功返回空串 */
|
||||
const acceptImageFile = useCallback((file: File): string => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return "请选择图片文件(JPG / PNG / GIF / WebP)";
|
||||
}
|
||||
if (file.size > SOURCE_MAX_BYTES) {
|
||||
return "原图不能超过 15MB,裁剪后的头像需在 2MB 以内";
|
||||
}
|
||||
enterCrop(URL.createObjectURL(file), true);
|
||||
return "";
|
||||
}, [enterCrop]);
|
||||
|
||||
// 外部拖入:弹窗打开后立刻进入裁剪
|
||||
useEffect(() => {
|
||||
if (!open || !seedFile) return;
|
||||
const errMsg = acceptImageFile(seedFile);
|
||||
if (errMsg) setError(errMsg);
|
||||
onSeedConsumed?.();
|
||||
}, [open, seedFile, acceptImageFile, onSeedConsumed]);
|
||||
|
||||
/**
|
||||
* 点击媒体库图片:
|
||||
@@ -128,15 +157,31 @@ export default function AvatarCropModal({
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("请选择图片文件(JPG / PNG / GIF / WebP)");
|
||||
return;
|
||||
}
|
||||
if (file.size > SOURCE_MAX_BYTES) {
|
||||
setError("原图不能超过 15MB,裁剪后的头像需在 2MB 以内");
|
||||
return;
|
||||
}
|
||||
enterCrop(URL.createObjectURL(file), true);
|
||||
const errMsg = acceptImageFile(file);
|
||||
if (errMsg) setError(errMsg);
|
||||
};
|
||||
|
||||
const onDropZoneDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!busy) setDragOver(true);
|
||||
};
|
||||
|
||||
const onDropZoneDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const onDropZoneDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
if (busy) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
const errMsg = acceptImageFile(file);
|
||||
if (errMsg) setError(errMsg);
|
||||
};
|
||||
|
||||
const backToLibrary = () => {
|
||||
@@ -233,14 +278,19 @@ export default function AvatarCropModal({
|
||||
|
||||
{!imageSrc ? (
|
||||
<>
|
||||
{/* 选择新图片 */}
|
||||
{/* 选择新图片:点击或拖放 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={onDropZoneDragOver}
|
||||
onDragLeave={onDropZoneDragLeave}
|
||||
onDrop={onDropZoneDrop}
|
||||
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
|
||||
style={{
|
||||
border: "1.5px dashed var(--line-2)",
|
||||
background: "var(--panel-2)",
|
||||
border: `1.5px dashed ${dragOver ? "var(--accent)" : "var(--line-2)"}`,
|
||||
background: dragOver
|
||||
? "color-mix(in srgb, var(--accent) 10%, var(--panel-2))"
|
||||
: "var(--panel-2)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
@@ -251,7 +301,7 @@ export default function AvatarCropModal({
|
||||
<UploadCloud size={20} />
|
||||
</span>
|
||||
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
点击选择图片
|
||||
{dragOver ? "松开即可开始裁剪" : "点击选择或拖放图片"}
|
||||
</span>
|
||||
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
支持 JPG / PNG / GIF / WebP,裁剪后统一转为 WebP,大小不超过 2MB
|
||||
|
||||
@@ -11,6 +11,9 @@ interface AvatarPickerProps {
|
||||
name: string;
|
||||
src: string;
|
||||
size?: number;
|
||||
/** 外部拖入的文件:非空时打开裁剪弹窗并进入裁剪 */
|
||||
incomingFile?: File | null;
|
||||
onIncomingConsumed?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,10 +26,13 @@ interface AvatarPickerProps {
|
||||
* 另外监听广播,使页面上若存在多个 picker 实例也保持一致;
|
||||
* 并随 src prop(SSR 校正)同步,避免 useState 初始值造成数据漂移。
|
||||
*/
|
||||
export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps) {
|
||||
export default function AvatarPicker({
|
||||
name, src, size = 56, incomingFile, onIncomingConsumed,
|
||||
}: AvatarPickerProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState(src);
|
||||
const [seedFile, setSeedFile] = useState<File | null>(null);
|
||||
|
||||
// SSR props 校正(router.refresh 后服务端数据回流)
|
||||
useEffect(() => setCurrent(src), [src]);
|
||||
@@ -36,13 +42,26 @@ export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps
|
||||
if (typeof patch.avatar === "string") setCurrent(patch.avatar);
|
||||
}), []);
|
||||
|
||||
// 外层拖入:打开弹窗并交给裁剪流程
|
||||
useEffect(() => {
|
||||
if (!incomingFile) return;
|
||||
setSeedFile(incomingFile);
|
||||
setOpen(true);
|
||||
}, [incomingFile]);
|
||||
|
||||
const handleChanged = (avatar: string) => {
|
||||
setCurrent(avatar);
|
||||
setOpen(false);
|
||||
setSeedFile(null);
|
||||
emitUserUpdate({ avatar });
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSeedConsumed = () => {
|
||||
setSeedFile(null);
|
||||
onIncomingConsumed?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -66,9 +85,14 @@ export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps
|
||||
|
||||
<AvatarCropModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setSeedFile(null);
|
||||
}}
|
||||
currentAvatar={current}
|
||||
onChanged={handleChanged}
|
||||
seedFile={seedFile}
|
||||
onSeedConsumed={handleSeedConsumed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { isStaff } from "@/lib/roles";
|
||||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
@@ -112,29 +113,26 @@ export default function Header({
|
||||
return off;
|
||||
}, [user, pathname]);
|
||||
|
||||
// 会话内已读后主动对账(ChatRoomClient 派发)
|
||||
// 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const onSync = () => {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pullSummary = () => {
|
||||
apiChatUnreadSummary()
|
||||
.then((res) => setChatUnread(res.total || 0))
|
||||
.catch(() => {});
|
||||
};
|
||||
const onSync = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(pullSummary, 250);
|
||||
};
|
||||
window.addEventListener("j13-chat-unread-sync", onSync);
|
||||
return () => window.removeEventListener("j13-chat-unread-sync", onSync);
|
||||
}, [user]);
|
||||
|
||||
// 切回标签页时重新对账未读
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
apiChatUnreadSummary()
|
||||
.then((res) => setChatUnread(res.total || 0))
|
||||
.catch(() => {});
|
||||
const offVisible = onDocumentVisible(pullSummary);
|
||||
return () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
window.removeEventListener("j13-chat-unread-sync", onSync);
|
||||
offVisible();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, [user]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
|
||||
@@ -12,17 +12,21 @@ import {
|
||||
type NotificationItem,
|
||||
} from "@/lib/api";
|
||||
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
|
||||
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
// SSR 直出未读数,红点不再挂载后才弹出;挂载后轮询自然校正
|
||||
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const loadNotificationsRef = useRef<() => void>(() => {});
|
||||
|
||||
// 未读数:WS 推送后拉取校准;回前台再对账(无定时轮询)
|
||||
// 未读数:首屏用 SSR;WS 推送 / 回前台校准(无定时轮询;open 变化不重订)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -40,22 +44,18 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
debounceTimer = setTimeout(fetchCount, 200);
|
||||
};
|
||||
|
||||
fetchCount();
|
||||
const off = realtime.on(RT_NOTIFICATION_NEW, () => {
|
||||
fetchCountDebounced();
|
||||
if (open) loadNotifications();
|
||||
if (openRef.current) loadNotificationsRef.current();
|
||||
});
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") fetchCount();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
const offVisible = onDocumentVisible(fetchCount);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
off();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
offVisible();
|
||||
};
|
||||
}, [open]);
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
@@ -78,6 +78,9 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
loadNotificationsRef.current = () => {
|
||||
void loadNotifications();
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !open;
|
||||
|
||||
@@ -5,13 +5,17 @@ import { usePathname } from "next/navigation";
|
||||
import { apiGetSettings } from "@/lib/api";
|
||||
import { applyAccentTheme } from "@/lib/theme";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
// 站点主题色同步器:
|
||||
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换
|
||||
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
|
||||
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
|
||||
const MIN_FETCH_GAP_MS = 10_000;
|
||||
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
|
||||
|
||||
export default function ThemeSync({ initialAccent }: { initialAccent: string }) {
|
||||
const pathname = usePathname();
|
||||
@@ -56,19 +60,15 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
|
||||
if (data) apply((data.accent || "").trim().toLowerCase());
|
||||
});
|
||||
|
||||
const onActive = () => {
|
||||
if (document.visibilityState === "visible") void sync();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onActive);
|
||||
window.addEventListener("focus", onActive);
|
||||
// 仅 visibility:window focus 与 visibility 常连发,统一走限频即可
|
||||
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
|
||||
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
|
||||
void sync();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offPush();
|
||||
document.removeEventListener("visibilitychange", onActive);
|
||||
window.removeEventListener("focus", onActive);
|
||||
offVisible();
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
|
||||
@@ -460,7 +460,9 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
|
||||
if (res.status === 404) throw new Error("用户不存在");
|
||||
if (!res.ok) throw new Error("获取评论失败");
|
||||
return res.json();
|
||||
const data = (await res.json()) as UserCommentsResponse;
|
||||
// Go 空切片曾序列化为 null;统一归一成 [],避免 SSR 读 .length 崩
|
||||
return { ...data, comments: data.comments ?? [] };
|
||||
}
|
||||
|
||||
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
|
||||
@@ -1225,6 +1227,8 @@ export interface ChatMessage {
|
||||
recalled_at?: string | null;
|
||||
recalled_by?: number;
|
||||
sender: User;
|
||||
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
|
||||
recaller?: User | null;
|
||||
}
|
||||
|
||||
// 单群未读明细
|
||||
|
||||
@@ -72,6 +72,13 @@ export interface RtChatRecalledData {
|
||||
sender_id?: number;
|
||||
recalled_by: number;
|
||||
recalled_at?: string;
|
||||
recaller?: {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标)
|
||||
|
||||
@@ -86,6 +86,11 @@ export interface RoleMeta {
|
||||
/** 主色使用的 CSS token 名(RoleBadge 取 var(--token)) */
|
||||
colorToken: string;
|
||||
softToken: string;
|
||||
/**
|
||||
* 授权弹窗用的职责说明(站长不出现在可授权列表)。
|
||||
* 只描述该角色「有什么」,最终权限以后端为准。
|
||||
*/
|
||||
grantDesc?: string;
|
||||
}
|
||||
|
||||
// 各管理角色的图标与配色集中配置;站长固定使用皇冠站长标识
|
||||
@@ -96,6 +101,8 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
icon: "crown",
|
||||
colorToken: "--gold",
|
||||
softToken: "--gold-soft",
|
||||
grantDesc:
|
||||
"站点所有者:全部后台能力、群聊监管、用户内容档案;身份不可授予或降级。",
|
||||
},
|
||||
[ROLES.SUPER_ADMIN]: {
|
||||
key: ROLES.SUPER_ADMIN,
|
||||
@@ -103,6 +110,8 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
icon: "shield-star",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"用户与角色管理、封禁、外观主题、公告;全站内容审核与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可动站长账号,亦无站长专属的用户内容档案。",
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
key: ROLES.ADMIN,
|
||||
@@ -110,6 +119,8 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
icon: "shield",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"公告管理;全站帖子/评论审核;前台置顶与加精。不含用户权限、外观设置与群聊监管旁路。",
|
||||
},
|
||||
[ROLES.BOARD_ADMIN]: {
|
||||
key: ROLES.BOARD_ADMIN,
|
||||
@@ -117,6 +128,8 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
icon: "shield-board",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"仅审核并管理被授权板块内的帖子与评论(含删除违规内容)。无公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||
},
|
||||
[ROLES.USER]: {
|
||||
key: ROLES.USER,
|
||||
@@ -124,6 +137,8 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
icon: "user",
|
||||
colorToken: "--ink-3",
|
||||
softToken: "--panel-2",
|
||||
grantDesc:
|
||||
"无任何后台入口。可发帖、评论、私信等;新帖与评论按站点规则进入审核。降级为普通用户时会清空板块授权。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
31
frontend/lib/visibilityReconcile.ts
Normal file
31
frontend/lib/visibilityReconcile.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 回前台 HTTP 对账限频。
|
||||
*
|
||||
* 多组件(Header / 通知铃 / 聊天侧栏 / ThemeSync / 管理后台)都会在
|
||||
* visibilitychange 时拉一次状态;短时间切标签会叠加请求。
|
||||
* 本工具保证「同一订阅在 minGapMs 内最多执行一次」。
|
||||
*/
|
||||
|
||||
/** 与 ThemeSync 对齐:10s 内不重复对账 */
|
||||
export const VISIBLE_RECONCILE_GAP_MS = 10_000;
|
||||
|
||||
/**
|
||||
* 监听 document 重新可见;限频后调用 run。
|
||||
* 返回取消订阅函数。
|
||||
*/
|
||||
export function onDocumentVisible(
|
||||
run: () => void,
|
||||
minGapMs: number = VISIBLE_RECONCILE_GAP_MS
|
||||
): () => void {
|
||||
if (typeof document === "undefined") return () => {};
|
||||
let lastAt = 0;
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (now - lastAt < minGapMs) return;
|
||||
lastAt = now;
|
||||
run();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}
|
||||
Reference in New Issue
Block a user