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