feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -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
View 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();

View File

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

View File

@@ -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);
}