feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,8 @@ export interface User {
|
||||
export interface MeResponse {
|
||||
user: User | null;
|
||||
unread_count: number;
|
||||
// 群聊未读消息总数(二期)
|
||||
chat_unread_count?: number;
|
||||
// 后端 OptionalAuth 识别到凭据所属账号已封禁时携带(HTTP 仍 200,不阻断页面)
|
||||
banned?: boolean;
|
||||
code?: string;
|
||||
@@ -144,6 +146,7 @@ export interface PostListItem {
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
status?: string;
|
||||
liked: boolean;
|
||||
created_at: string;
|
||||
last_reply?: {
|
||||
@@ -251,14 +254,17 @@ export interface NotificationItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
actor_id: number;
|
||||
type: "comment" | "reply" | "like" | "approved" | "rejected";
|
||||
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review";
|
||||
post_id: number;
|
||||
comment_id: number;
|
||||
room_id?: number;
|
||||
message_id?: number;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
actor: User;
|
||||
post: { id: number; title: string };
|
||||
post?: { id: number; title: string };
|
||||
room?: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export interface NotificationsResponse {
|
||||
@@ -890,6 +896,18 @@ export async function fetchAdminPendingPosts(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */
|
||||
export async function fetchAdminPendingCounts(
|
||||
cookieHeader?: string
|
||||
): Promise<PendingCounts> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/moderation/counts`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (!res.ok) return { posts: 0, comments: 0 };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 客户端后台用户列表(走 rewrite,携带 cookie + CSRF)
|
||||
export async function apiAdminListUsers(query: AdminUsersQuery = {}): Promise<AdminUsersResponse> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users?${adminUsersParams(query)}`, {
|
||||
@@ -946,6 +964,115 @@ export async function apiAdminSetUserBan(
|
||||
return data as { user: AdminUser };
|
||||
}
|
||||
|
||||
// ===== 站长用户内容档案 =====
|
||||
|
||||
export interface AdminUserAuditProfile {
|
||||
user: AdminUser;
|
||||
posts_total: number;
|
||||
comments_total: number;
|
||||
messages_total: number;
|
||||
published_posts: number;
|
||||
published_comments: number;
|
||||
}
|
||||
|
||||
export interface AdminAuditPostItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
deleted: boolean;
|
||||
board_id: number;
|
||||
board_name: string;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminAuditCommentItem {
|
||||
id: number;
|
||||
post_id: number;
|
||||
content: string;
|
||||
status: string;
|
||||
deleted: boolean;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminAuditMessageItem {
|
||||
id: number;
|
||||
room_id: number;
|
||||
room_name: string;
|
||||
room_type: string;
|
||||
content: string;
|
||||
recalled: boolean;
|
||||
deleted: boolean;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminAuditListResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export async function fetchAdminUserAudit(
|
||||
id: number,
|
||||
cookieHeader?: string
|
||||
): Promise<AdminUserAuditProfile> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/users/${id}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
|
||||
return data as AdminUserAuditProfile;
|
||||
}
|
||||
|
||||
export async function apiAdminUserAudit(id: number): Promise<AdminUserAuditProfile> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
|
||||
return data as AdminUserAuditProfile;
|
||||
}
|
||||
|
||||
export async function apiAdminUserAuditPosts(
|
||||
id: number,
|
||||
page = 1
|
||||
): Promise<AdminAuditListResponse<AdminAuditPostItem>> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/posts?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取帖子失败");
|
||||
return data as AdminAuditListResponse<AdminAuditPostItem>;
|
||||
}
|
||||
|
||||
export async function apiAdminUserAuditComments(
|
||||
id: number,
|
||||
page = 1
|
||||
): Promise<AdminAuditListResponse<AdminAuditCommentItem>> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/comments?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取评论失败");
|
||||
return data as AdminAuditListResponse<AdminAuditCommentItem>;
|
||||
}
|
||||
|
||||
export async function apiAdminUserAuditMessages(
|
||||
id: number,
|
||||
page = 1
|
||||
): Promise<AdminAuditListResponse<AdminAuditMessageItem>> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/messages?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取消息失败");
|
||||
return data as AdminAuditListResponse<AdminAuditMessageItem>;
|
||||
}
|
||||
|
||||
// ===== 站点外观设置 =====
|
||||
|
||||
export async function apiGetSettings(): Promise<PublicSettings> {
|
||||
@@ -1043,3 +1170,400 @@ export const apiAdminApproveComment = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/comments/${id}/approve`);
|
||||
export const apiAdminRejectComment = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
|
||||
|
||||
// ===== 群聊(二期) =====
|
||||
|
||||
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
||||
export interface ChatRoom {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
owner_id: number;
|
||||
room_type: "group" | "direct";
|
||||
member_count: number;
|
||||
last_message_id: number;
|
||||
is_private: boolean;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
owner: User;
|
||||
last_message?: ChatMessage | null;
|
||||
}
|
||||
|
||||
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
|
||||
export interface ChatRoomView extends ChatRoom {
|
||||
joined: boolean;
|
||||
my_role: "" | "owner" | "member" | "overseer";
|
||||
unread_count: number;
|
||||
pinned?: boolean;
|
||||
pin_forced?: boolean;
|
||||
peer?: User | null;
|
||||
}
|
||||
|
||||
// 群成员行(名单/踢人用)
|
||||
export interface ChatRoomMember {
|
||||
id: number;
|
||||
room_id: number;
|
||||
user_id: number;
|
||||
role: "owner" | "member";
|
||||
last_read_message_id: number;
|
||||
muted: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// 群消息(发送者已 Preload)
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
room_id: number;
|
||||
sender_id: number;
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
reply_snap?: string;
|
||||
created_at: string;
|
||||
recalled_at?: string | null;
|
||||
recalled_by?: number;
|
||||
sender: User;
|
||||
}
|
||||
|
||||
// 单群未读明细
|
||||
export interface UnreadRoom {
|
||||
room_id: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ChatRoomsResponse {
|
||||
rooms: ChatRoomView[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ChatMembersResponse {
|
||||
members: ChatRoomMember[];
|
||||
}
|
||||
|
||||
export interface ChatMessagesResponse {
|
||||
messages: ChatMessage[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface ChatUnreadSummaryResponse {
|
||||
total: number;
|
||||
rooms: UnreadRoom[];
|
||||
}
|
||||
|
||||
// SSR 消息会话列表(默认仅已加入)
|
||||
export async function fetchChatRooms(
|
||||
page = 1,
|
||||
size = 40,
|
||||
q = "",
|
||||
cookieHeader?: string,
|
||||
scope: "joined" | "all" = "joined"
|
||||
): Promise<ChatRoomsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
scope,
|
||||
});
|
||||
if (q) params.set("q", q);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/chat/rooms?${params}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (res.status === 401) throw new Error("未登录");
|
||||
if (!res.ok) throw new Error("获取会话列表失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// SSR 群详情:公开非成员 200(joined=false);私密非成员/不存在 404
|
||||
export async function fetchChatRoom(
|
||||
id: string,
|
||||
cookieHeader?: string
|
||||
): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/chat/rooms/${id}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (res.status === 401) throw new Error("未登录");
|
||||
if (res.status === 404) throw new Error("群不存在");
|
||||
if (!res.ok) throw new Error("获取群详情失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// SSR 历史消息游标分页(成员可见;非成员抛错由页面层处理)
|
||||
export async function fetchChatMessages(
|
||||
id: string,
|
||||
before = 0,
|
||||
size = 30,
|
||||
cookieHeader?: string
|
||||
): Promise<ChatMessagesResponse> {
|
||||
const params = new URLSearchParams({ size: String(size) });
|
||||
if (before) params.set("before_id", String(before));
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/chat/rooms/${id}/messages?${params}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (res.status === 401) throw new Error("未登录");
|
||||
if (res.status === 403) throw new Error("无权查看消息");
|
||||
if (res.status === 404) throw new Error("群不存在");
|
||||
if (!res.ok) throw new Error("获取消息失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 客户端:会话列表
|
||||
export async function apiListChatRooms(
|
||||
page = 1,
|
||||
size = 40,
|
||||
q = "",
|
||||
scope: "joined" | "all" = "joined"
|
||||
): Promise<ChatRoomsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
scope,
|
||||
});
|
||||
if (q) params.set("q", q);
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms?${params}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取会话列表失败");
|
||||
return data as ChatRoomsResponse;
|
||||
}
|
||||
|
||||
// 客户端:打开/创建私聊
|
||||
export async function apiOpenDirectChat(
|
||||
userId: number
|
||||
): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetchWithRefresh("/api/chat/direct", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "无法发起私聊");
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:搜索用户以发起私聊
|
||||
export async function apiSearchChatUsers(
|
||||
q: string
|
||||
): Promise<{ users: User[] }> {
|
||||
const params = new URLSearchParams({ q });
|
||||
const res = await fetchWithRefresh(`/api/chat/users?${params}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "搜索失败");
|
||||
return data as { users: User[] };
|
||||
}
|
||||
|
||||
// 客户端:建群(可公开/私密,私密可带初始成员用户名)
|
||||
export async function apiCreateChatRoom(
|
||||
name: string,
|
||||
description = "",
|
||||
opts?: { is_private?: boolean; member_usernames?: string[] }
|
||||
): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetchWithRefresh("/api/chat/rooms", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
is_private: opts?.is_private ?? false,
|
||||
member_usernames: opts?.member_usernames ?? [],
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "建群失败");
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:群详情
|
||||
export async function apiGetChatRoom(id: number): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取群详情失败");
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:群主改群名/简介
|
||||
export async function apiUpdateChatRoom(
|
||||
id: number,
|
||||
name: string,
|
||||
description = ""
|
||||
): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "修改群信息失败");
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:置顶/取消置顶(大厅强制置顶不可取消)
|
||||
export async function apiPinChatRoom(
|
||||
id: number,
|
||||
pinned: boolean
|
||||
): Promise<{ room: ChatRoomView }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/pin`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ pinned }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "置顶失败");
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:群主解散群
|
||||
export async function apiDissolveChatRoom(id: number): Promise<{ ok: boolean }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "解散群失败");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
// 客户端:加入群聊
|
||||
export async function apiJoinChatRoom(
|
||||
id: number
|
||||
): Promise<{ ok: boolean; joined: boolean }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/join`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "加入群失败");
|
||||
return data as { ok: boolean; joined: boolean };
|
||||
}
|
||||
|
||||
// 客户端:退出群聊
|
||||
export async function apiLeaveChatRoom(id: number): Promise<{ ok: boolean }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/leave`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "退出群失败");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
// 客户端:成员名单
|
||||
export async function apiListChatMembers(
|
||||
id: number
|
||||
): Promise<ChatMembersResponse> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/members`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取成员名单失败");
|
||||
return data as ChatMembersResponse;
|
||||
}
|
||||
|
||||
// 客户端:群主踢人
|
||||
export async function apiKickChatMember(
|
||||
roomId: number,
|
||||
userId: number
|
||||
): Promise<{ ok: boolean }> {
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/chat/rooms/${roomId}/members/${userId}`,
|
||||
{ method: "DELETE", headers: clientHeaders() }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "踢人失败");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
// 客户端:邀请成员(用户名列表,群主/监管)
|
||||
export async function apiInviteChatMembers(
|
||||
roomId: number,
|
||||
usernames: string[]
|
||||
): Promise<{ added: number[] }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${roomId}/members`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ usernames }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "邀请失败");
|
||||
return data as { added: number[] };
|
||||
}
|
||||
|
||||
// 客户端:撤回消息
|
||||
export async function apiRecallChatMessage(
|
||||
roomId: number,
|
||||
messageId: number
|
||||
): Promise<{ message: ChatMessage }> {
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/chat/rooms/${roomId}/messages/${messageId}`,
|
||||
{ method: "DELETE", headers: clientHeaders() }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "撤回失败");
|
||||
return data as { message: ChatMessage };
|
||||
}
|
||||
|
||||
// 客户端:历史消息游标分页
|
||||
export async function apiListChatMessages(
|
||||
id: number,
|
||||
before = 0,
|
||||
size = 30
|
||||
): Promise<ChatMessagesResponse> {
|
||||
const params = new URLSearchParams({ size: String(size) });
|
||||
if (before) params.set("before_id", String(before));
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/messages?${params}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取消息失败");
|
||||
return data as ChatMessagesResponse;
|
||||
}
|
||||
|
||||
// 客户端:发消息(可带引用;@ 提醒由后端解析)
|
||||
export async function apiSendChatMessage(
|
||||
id: number,
|
||||
content: string,
|
||||
replyToId = 0
|
||||
): Promise<{ message: ChatMessage }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/messages`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ content, reply_to_id: replyToId || undefined }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "发送失败");
|
||||
return data as { message: ChatMessage };
|
||||
}
|
||||
|
||||
// 客户端:推进已读水位(messageId=0 或超过最新则取最新)
|
||||
export async function apiMarkChatRead(
|
||||
id: number,
|
||||
messageId = 0
|
||||
): Promise<{ ok: boolean }> {
|
||||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/read`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ message_id: messageId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "标记已读失败");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
// 客户端:全部群未读明细与总数(Header 红点对账)
|
||||
export async function apiChatUnreadSummary(): Promise<ChatUnreadSummaryResponse> {
|
||||
const res = await fetchWithRefresh("/api/chat/unread-summary", {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) return { total: 0, rooms: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
336
frontend/lib/realtime.ts
Normal file
336
frontend/lib/realtime.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// 全站实时总线客户端(一期:在线状态 + 站点设置变更;二期:群聊 + 站内通知;三期:帖子流有新内容)。
|
||||
//
|
||||
// - 单条 WebSocket(浏览器握手自动携带 HttpOnly cookie 鉴权),后续群聊/通知/
|
||||
// 审核角标都复用本连接,按 message type 路由。
|
||||
// - 连接状态机 + 指数退避重连;应用层心跳(协议层 Ping 由浏览器自动应答)。
|
||||
// - 鉴权失效关闭码 4401 不自动重连(封禁/强制下线由 AccountGuard 统一处理)。
|
||||
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
||||
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
||||
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
||||
// - 轮询仅作兜底:回前台 / 进页时 HTTP 校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||
|
||||
export const RT_HELLO = "hello";
|
||||
export const RT_PONG = "pong";
|
||||
export const RT_SETTINGS_CHANGED = "settings:changed";
|
||||
export const RT_PRESENCE_UPDATE = "presence:update";
|
||||
export const RT_CHAT_MESSAGE = "chat:message";
|
||||
export const RT_CHAT_MEMBERSHIP = "chat:membership";
|
||||
export const RT_CHAT_RECALLED = "chat:message_recalled";
|
||||
export const RT_CHAT_UNREAD = "chat:unread";
|
||||
export const RT_NOTIFICATION_NEW = "notification:new";
|
||||
export const RT_FEED_CHANGED = "feed:changed";
|
||||
export const RT_MODERATION_CHANGED = "moderation:changed";
|
||||
|
||||
export interface RtEnvelope<T = unknown> {
|
||||
type: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface RtHelloData {
|
||||
user_id: number;
|
||||
ts: number;
|
||||
// 仅管理团队成员会收到
|
||||
online_user_ids?: number[];
|
||||
}
|
||||
|
||||
export interface RtPresenceData {
|
||||
user_id: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface RtSettingsData {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||
export interface RtChatMessageData {
|
||||
id: number;
|
||||
room_id: number;
|
||||
sender_id: number;
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
reply_snap?: string;
|
||||
created_at: string;
|
||||
sender: {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 群成员关系变更:被踢/群解散/被邀请
|
||||
export interface RtChatMembershipData {
|
||||
room_id: number;
|
||||
status: "kicked" | "dissolved" | "invited";
|
||||
}
|
||||
|
||||
// 消息撤回
|
||||
export interface RtChatRecalledData {
|
||||
id: number;
|
||||
room_id: number;
|
||||
sender_id?: number;
|
||||
recalled_by: number;
|
||||
recalled_at?: string;
|
||||
}
|
||||
|
||||
// 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标)
|
||||
export interface RtChatUnreadData {
|
||||
room_id: number;
|
||||
message_id: number;
|
||||
sender_id: number;
|
||||
preview?: string;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
// 新站内通知(如群聊 @):收到后应刷新通知列表/红点
|
||||
export interface RtNotificationData {
|
||||
room_id?: number;
|
||||
message_id?: number;
|
||||
}
|
||||
|
||||
// 帖子流有公开新内容(三期):首页 tip「有新内容」
|
||||
export interface RtFeedChangedData {
|
||||
kind: "post" | "comment";
|
||||
post_id: number;
|
||||
board_id: number;
|
||||
actor_id: number;
|
||||
}
|
||||
|
||||
type RtHandler<T = unknown> = (data: T) => void;
|
||||
type RtState = "idle" | "connecting" | "open" | "closed";
|
||||
|
||||
const HEARTBEAT_MS = 25_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 15_000;
|
||||
const CLOSE_AUTH_INVALID = 4401;
|
||||
|
||||
// 开发环境 Next dev 的 rewrite 不透传 WebSocket Upgrade,直连后端 3001;
|
||||
// Cookie 不按端口隔离,localhost:3000 的 j13_token 会随握手发到 :3001。
|
||||
// 生产环境前后端同源(反向代理),走同源 /api/ws;可用 NEXT_PUBLIC_WS_URL 覆盖。
|
||||
function wsEndpoint(): string {
|
||||
const explicit = process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (explicit) return explicit;
|
||||
if (typeof window === "undefined") return "";
|
||||
const scheme = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return `${scheme}${window.location.hostname}:3001/api/ws`;
|
||||
}
|
||||
return `${scheme}${window.location.host}/api/ws`;
|
||||
}
|
||||
|
||||
class RealtimeClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: RtState = "idle";
|
||||
// 是否应保持连接(登录态);登出或组件卸载时置 false 停止重连
|
||||
private wanted = false;
|
||||
private retryCount = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly handlers = new Map<string, Set<RtHandler>>();
|
||||
private readonly presenceHandlers = new Set<RtHandler<RtPresenceData>>();
|
||||
private onlineIds = new Set<number>();
|
||||
// 已订阅的群聊房间(roomID 集合):重连后会自动重订,断连期间不丢订阅意图
|
||||
private readonly joinedRooms = new Set<number>();
|
||||
|
||||
/** 建立连接(幂等);登录后调用 */
|
||||
connect(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
this.wanted = true;
|
||||
if (this.state === "open" || this.state === "connecting") return;
|
||||
this.clearReconnectTimer();
|
||||
this.open();
|
||||
window.addEventListener("online", this.handleNetworkBack);
|
||||
document.addEventListener("visibilitychange", this.handleVisible);
|
||||
}
|
||||
|
||||
/** 主动断开(登出/卸载);不再自动重连 */
|
||||
disconnect(): void {
|
||||
this.wanted = false;
|
||||
window.removeEventListener("online", this.handleNetworkBack);
|
||||
document.removeEventListener("visibilitychange", this.handleVisible);
|
||||
this.clearReconnectTimer();
|
||||
this.clearHeartbeat();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close(1000, "client logout");
|
||||
this.ws = null;
|
||||
}
|
||||
this.state = "idle";
|
||||
// 登出时清空房间订阅,避免下次同标签页重新登录后误订旧群
|
||||
this.joinedRooms.clear();
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state === "open";
|
||||
}
|
||||
|
||||
/** 订阅指定事件,返回取消订阅函数 */
|
||||
on<T>(type: string, handler: RtHandler<T>): () => void {
|
||||
let set = this.handlers.get(type);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(type, set);
|
||||
}
|
||||
set.add(handler as RtHandler);
|
||||
return () => set!.delete(handler as RtHandler);
|
||||
}
|
||||
|
||||
/** 订阅在线状态增量(用户 0↔1 连接跳变);快照请在订阅时读 getOnlineIds */
|
||||
onPresence(handler: RtHandler<RtPresenceData>): () => void {
|
||||
this.presenceHandlers.add(handler);
|
||||
return () => this.presenceHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 当前在线用户 ID 快照 */
|
||||
getOnlineIds(): ReadonlySet<number> {
|
||||
return this.onlineIds;
|
||||
}
|
||||
|
||||
/** 订阅群聊房间(进入群详情页调用)。幂等;连接已 open 时立即发 join 帧。
|
||||
* 后端会按 chat:{id} 实时校验成员身份,被踢后再 join 也会被拒绝。 */
|
||||
joinRoom(roomId: number): void {
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) return;
|
||||
this.joinedRooms.add(roomId);
|
||||
this.sendRoomFrame("join", roomId);
|
||||
}
|
||||
|
||||
/** 退订群聊房间(离开群详情页/退出群调用)。幂等。 */
|
||||
leaveRoom(roomId: number): void {
|
||||
if (!this.joinedRooms.delete(roomId)) return;
|
||||
this.sendRoomFrame("leave", roomId);
|
||||
}
|
||||
|
||||
/** 退订全部群聊房间(切账号/被踢后清理) */
|
||||
clearRooms(): void {
|
||||
for (const id of this.joinedRooms) {
|
||||
this.sendRoomFrame("leave", id);
|
||||
}
|
||||
this.joinedRooms.clear();
|
||||
}
|
||||
|
||||
private sendRoomFrame(type: "join" | "leave", roomId: number): void {
|
||||
if (this.state !== "open" || !this.ws) return;
|
||||
this.ws.send(JSON.stringify({ type, room: `chat:${roomId}` }));
|
||||
}
|
||||
|
||||
private handleNetworkBack = (): void => {
|
||||
if (this.wanted && this.state === "closed") this.open();
|
||||
};
|
||||
|
||||
private handleVisible = (): void => {
|
||||
if (!this.wanted || document.visibilityState !== "visible") return;
|
||||
// 后台标签页定时器可能被节流;重新可见时若连接已断则立即重连
|
||||
if (this.state === "closed" || this.state === "idle") this.open();
|
||||
};
|
||||
|
||||
private open(): void {
|
||||
if (typeof WebSocket === "undefined") return;
|
||||
this.state = "connecting";
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsEndpoint());
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = ws;
|
||||
ws.onopen = () => {
|
||||
this.state = "open";
|
||||
this.retryCount = 0;
|
||||
this.clearHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send({ type: "ping" });
|
||||
}, HEARTBEAT_MS);
|
||||
// 重连后自动重订群聊房间(避免断线期间错过消息)
|
||||
// 后端会按 chat:{id} 实时校验成员身份,已退出/被踢的群会被静默拒绝
|
||||
for (const roomId of this.joinedRooms) {
|
||||
this.sendRoomFrame("join", roomId);
|
||||
}
|
||||
};
|
||||
ws.onmessage = (ev) => this.handleMessage(ev.data);
|
||||
ws.onerror = () => {
|
||||
// close 事件会紧随其后,重连逻辑统一在 onclose 处理
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
this.clearHeartbeat();
|
||||
if (this.ws === ws) this.ws = null;
|
||||
this.state = "closed";
|
||||
// 鉴权失效:账号封禁/角色变更导致 token_version 失效,
|
||||
// 不重连(强制下线/重新登录由现有 HTTP 拦截链路处理)
|
||||
if (!this.wanted || ev.code === CLOSE_AUTH_INVALID) {
|
||||
this.wanted = false;
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.wanted || this.reconnectTimer) return;
|
||||
const delay = Math.min(
|
||||
RECONNECT_MAX_MS,
|
||||
RECONNECT_BASE_MS * 2 ** this.retryCount
|
||||
);
|
||||
// ±20% 抖动,避免后端重启时所有客户端同时重连
|
||||
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
||||
this.retryCount += 1;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.wanted) this.open();
|
||||
}, Math.max(500, delay + jitter));
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private send(msg: RtEnvelope): void {
|
||||
if (this.state === "open" && this.ws) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
if (typeof raw !== "string") return;
|
||||
let msg: RtEnvelope;
|
||||
try {
|
||||
msg = JSON.parse(raw) as RtEnvelope;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || typeof msg.type !== "string") return;
|
||||
|
||||
// 内部状态事件先维护在线集合
|
||||
if (msg.type === RT_HELLO) {
|
||||
const data = msg.data as RtHelloData | undefined;
|
||||
const ids = data?.online_user_ids;
|
||||
if (Array.isArray(ids)) {
|
||||
this.onlineIds = new Set(ids.filter((n) => Number.isInteger(n)));
|
||||
}
|
||||
} else if (msg.type === RT_PRESENCE_UPDATE) {
|
||||
const data = msg.data as RtPresenceData | undefined;
|
||||
if (data && typeof data.user_id === "number") {
|
||||
if (data.online) this.onlineIds.add(data.user_id);
|
||||
else this.onlineIds.delete(data.user_id);
|
||||
this.presenceHandlers.forEach((fn) => fn(data));
|
||||
}
|
||||
}
|
||||
|
||||
const set = this.handlers.get(msg.type);
|
||||
if (set) set.forEach((fn) => fn(msg.data));
|
||||
}
|
||||
}
|
||||
|
||||
// 全站单例(模块级,持久 root layout 下与标签页生命周期一致)
|
||||
export const realtime = new RealtimeClient();
|
||||
@@ -39,11 +39,16 @@ export function isSuperOrOwner(role?: string | null): boolean {
|
||||
return roleLevel(role) >= ROLE_LEVEL[ROLES.SUPER_ADMIN];
|
||||
}
|
||||
|
||||
/** 站长(唯一,不可被操作) */
|
||||
/** 站长(站点所有者) */
|
||||
export function isOwner(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER;
|
||||
}
|
||||
|
||||
/** 站长/超管:群聊监管旁路(可见全部群、撤回任意消息) */
|
||||
export function canOverseeChat(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
|
||||
}
|
||||
|
||||
/** 携带板块授权的最小用户形状(/me、帖子详情作者等均满足) */
|
||||
export interface RoleUserLike {
|
||||
role?: string | null;
|
||||
|
||||
@@ -190,10 +190,15 @@ export function deriveAccentPair(input: string): AccentDerivation {
|
||||
}
|
||||
|
||||
// 生成可直接注入的 CSS 文本(覆盖 globals.css 默认 accent 令牌);
|
||||
// 未配置或等于默认色时返回空串,完全使用内置样式
|
||||
// 未配置或等于默认色时返回空串,完全使用内置样式(SSR 场景)
|
||||
export function accentStyleCSS(input: string): string {
|
||||
if (!input || input.toLowerCase() === DEFAULT_ACCENT) return "";
|
||||
const { light, dark } = deriveAccentPair(input);
|
||||
return buildAccentCSS(input);
|
||||
}
|
||||
|
||||
/** 始终输出一套 accent 覆盖(含默认色),供客户端 runtime 盖住 SSR 自定义 style */
|
||||
function buildAccentCSS(input: string): string {
|
||||
const { light, dark } = deriveAccentPair(input || DEFAULT_ACCENT);
|
||||
const hi = mix(light, "#000000", 0.1);
|
||||
const darkHi = mix(dark, "#ffffff", 0.14);
|
||||
return [
|
||||
@@ -222,22 +227,44 @@ export function accentStyleCSS(input: string): string {
|
||||
// 客户端:实时换肤(后台预览 / 保存后即时生效)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const STYLE_ELEMENT_ID = "j13-accent-vars";
|
||||
/** SSR 注入的 style(React 托管,客户端禁止 remove/改写) */
|
||||
const SSR_STYLE_ID = "j13-accent-vars";
|
||||
/** 客户端专用覆盖节点,挂在 body 末尾以压过 SSR */
|
||||
const RUNTIME_STYLE_ID = "j13-accent-runtime";
|
||||
|
||||
// 将主题色应用到当前文档;传 null 移除覆盖、恢复内置默认
|
||||
function ensureRuntimeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (el) return el;
|
||||
el = document.createElement("style");
|
||||
el.id = RUNTIME_STYLE_ID;
|
||||
el.setAttribute("data-accent-source", "runtime");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function removeRuntimeStyle(): void {
|
||||
const el = document.getElementById(RUNTIME_STYLE_ID);
|
||||
// 只移除我们自己创建的节点,绝不碰 React 的 SSR <style>
|
||||
el?.remove();
|
||||
}
|
||||
|
||||
// 将主题色应用到当前文档;传 null/默认色时恢复内置令牌(不拆 SSR 节点)
|
||||
export function applyAccentTheme(input: string | null): void {
|
||||
if (typeof document === "undefined") return;
|
||||
let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
|
||||
const css = input === null ? "" : accentStyleCSS(input);
|
||||
if (!css) {
|
||||
el?.remove();
|
||||
const raw = (input || "").trim().toLowerCase();
|
||||
const isDefault = !raw || raw === DEFAULT_ACCENT.toLowerCase();
|
||||
|
||||
if (isDefault) {
|
||||
const ssr = document.getElementById(SSR_STYLE_ID);
|
||||
const ssrHasCustom = !!(ssr && ssr.textContent && ssr.textContent.trim());
|
||||
if (ssrHasCustom) {
|
||||
// SSR 仍挂着旧自定义色:用 runtime 写回默认覆盖,等 refresh 后再卸
|
||||
ensureRuntimeStyle().textContent = buildAccentCSS(DEFAULT_ACCENT);
|
||||
} else {
|
||||
removeRuntimeStyle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = STYLE_ELEMENT_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute("data-accent-source", "runtime");
|
||||
el.textContent = css;
|
||||
|
||||
ensureRuntimeStyle().textContent = buildAccentCSS(raw);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user