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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user