import { CSRF_COOKIE } from "./cookies"; import { emitForceLogout } from "./userEvents"; // API 基础配置 // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 // SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置, // localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。 const API_BASE = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; // SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染 const SSR_TIMEOUT_MS = 8000; function ssrInit(headers?: HeadersInit): RequestInit { return { cache: "no-store", headers, signal: AbortSignal.timeout(SSR_TIMEOUT_MS), }; } // 类型定义 export interface User { id: number; username: string; nickname: string; avatar: string; role: string; // 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮) board_ids?: number[]; // 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息) email?: string; signature?: string; } // /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出) export interface MeResponse { user: User | null; unread_count: number; // 群聊未读消息总数(二期) chat_unread_count?: number; // 后端 OptionalAuth 识别到凭据所属账号已封禁时携带(HTTP 仍 200,不阻断页面) banned?: boolean; code?: string; } export interface Board { id: number; name: string; description: string; icon: string; color_index?: number; sort_order: number; } // 首页聚合接口 export interface OverviewStats { posts: number; users: number; comments: number; today_posts: number; today_users: number; today_comments: number; online: number; peak_online: number; } export interface ActiveUser { id: number; username: string; nickname: string; avatar: string; post_count: number; comment_count: number; } export interface BoardWithCount extends Board { post_count: number; } // 公告标签预设色(与后端白名单一致) export type AnnouncementColor = | "blue" | "green" | "orange" | "red" | "purple" | "gray"; // 右栏站点公告摘要(标签/日期/标题) export interface AnnouncementSummary { id: number; title: string; tag: string; tag_color: AnnouncementColor; created_at: string; } // 公告详情/后台管理完整对象 export interface Announcement { id: number; title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; created_at: string; updated_at: string; } export interface NewUser { id: number; username: string; nickname: string; avatar: string; created_at: string; } export interface CheckinStatus { checked_today: boolean; streak: number; total_points: number; today_points: number; } export interface OverviewResponse { stats: OverviewStats; hot: PostListItem[]; active_users: ActiveUser[]; boards: BoardWithCount[]; announcements: AnnouncementSummary[]; new_users: NewUser[]; checkin?: CheckinStatus | null; } /** 板块右栏:管理员公开资料 */ export interface BoardModerator { id: number; username: string; nickname: string; avatar: string; } /** 板块右栏:板级统计 */ export interface BoardSidebarStats { post_count: number; today_posts: number; comment_count: number; today_comments: number; } /** 首页选中板块后的右栏聚合 */ export interface BoardSidebarResponse { board: BoardWithCount; moderators: BoardModerator[]; stats: BoardSidebarStats; hot: PostListItem[]; active_users: ActiveUser[]; checkin?: CheckinStatus | null; } export interface PostListItem { id: number; board_id: number; user_id: number; title: string; tags: string; post_type: string; type_status?: string; content_access?: string; access_points?: number; pinned: number; recommended: boolean; like_count: number; view_count: number; comment_count: number; status?: string; liked: boolean; created_at: string; last_reply?: { user: Pick; created_at: string; } | null; board: Board; user: User; } export interface PostAttachment { id: number; name: string; size: number; mime: string; price_points: number; download_count: number; unlocked: boolean; } export interface Post { id: number; board_id: number; user_id: number; title: string; content: string; tags: string; post_type: string; content_access?: string; access_points?: number; type_meta?: string; type_status?: string; pinned: number; recommended: boolean; status: string; like_count: number; view_count: number; comment_count: number; liked: boolean; created_at: string; updated_at: string; board: Board; user: User; content_locked?: boolean; access_hint?: string; attachments?: PostAttachment[]; question?: QuestionState | null; poll?: PollState | null; bounty?: BountyState | null; lottery?: LotteryState | null; /** 软删专用页标记 */ tombstone?: boolean; delete_type?: string; delete_reason?: string; deleted_by?: number; staff_deleted?: boolean; deleted_at?: string; } export interface AcceptedAnswer { id: number; floor: number; content: string; created_at: string; deleted?: boolean; user: { id: number; username: string; nickname: string; avatar: string; }; } export interface QuestionState { solved: boolean; accepted_comment_id: number; accepted_floor: number; accepted_answer?: AcceptedAnswer | null; can_accept: boolean; can_solve: boolean; can_reopen: boolean; } export interface PollVoter { id: number; nickname: string; username: string; avatar: string; } export interface PollOptionStat { index: number; text: string; votes: number; percent: number; voters?: PollVoter[]; } export interface PollState { options: PollOptionStat[]; multi: boolean; closed: boolean; anonymous: boolean; total_votes: number; voters: number; my_options: number[]; can_vote: boolean; can_close: boolean; can_edit_options: boolean; } export interface BountyState { points: number; accepted_comment_id: number; accepted_floor: number; accepted_answer?: AcceptedAnswer | null; escrowed: boolean; refunded: boolean; expired?: boolean; ends_at?: string; settled: boolean; can_accept: boolean; can_refund: boolean; } export interface LotteryEntrant { id: number; nickname: string; username: string; avatar: string; /** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */ comment_id?: number; floor?: number; } export interface LotteryState { slots: number; drawn: boolean; closed: boolean; ends_at?: string; entry_count: number; eligible?: boolean; can_draw: boolean; can_close: boolean; winners: LotteryEntrant[]; } export interface Comment { id: number; post_id: number; user_id: number; parent_id?: number | null; // 父评论 ID,null = 主评论(楼层) root_id?: number | null; // 所属楼层 ID,子评论必填 depth?: number; // 层级:主评论 0,子评论 = 父 + 1 content: string; status: string; created_at: string; updated_at?: string; /** 相对创建已编辑(服务端判定) */ edited?: boolean; user: User; /** 软删占位 */ deleted?: boolean; /** 管理删除(DeletedBy≠评论者) */ staff_deleted?: boolean; delete_type?: string; delete_reason?: string; deleted_by?: number; /** 管理删执行者(公开,用于主页快捷入口) */ deleted_by_user?: { id: number; username: string; nickname: string; avatar: string; }; } // 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出) export interface CommentNode extends Comment { replies?: CommentNode[]; } // 帖子评论楼层分页响应(时间正序;楼层号 = (page-1)*size + 序号 + 1,仅主评论占楼层) export interface CommentsResponse { comments: CommentNode[]; total: number; // 楼层数(主评论数)→ 分页与楼层号计算 total_comments: number; // 全部评论数(含回复)→ 头部徽标 page: number; size: number; } export interface PostsResponse { posts: PostListItem[]; total: number; page: number; size: number; } export interface UserProfile { user: { id: number; username: string; nickname: string; avatar: string; signature: string; role: string; board_ids?: number[]; created_at: string; /** 仅超管/站长访客可见(公开资料不下发) */ banned?: boolean; }; stats: { post_count: number; comment_count: number; points: number; streak: number; }; posts: PostListItem[]; posts_total: number; page: number; size: number; } export interface UserCommentItem { id: number; post_id: number; post_title: string; content: string; created_at: string; } export interface UserCommentsResponse { comments: UserCommentItem[]; total: number; page: number; size: number; } export interface NotificationItem { id: number; user_id: number; actor_id: number; type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted"; 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 }; room?: { id: number; name: string } | null; } export interface NotificationsResponse { notifications: NotificationItem[]; total: number; page: number; size: number; } // 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀) function getCSRFToken(): string { if (typeof document === "undefined") return ""; const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`)); return match ? decodeURIComponent(match[1]) : ""; } // 构造客户端 fetch 的通用 headers(含 CSRF token) function clientHeaders(extra: Record = {}): HeadersInit { const headers: Record = { ...extra }; const csrf = getCSRFToken(); if (csrf) headers["X-CSRF-Token"] = csrf; return headers; } // ===== Refresh token 自动续期 ===== let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null; // 被封禁通知去重:同一登录生命周期内只强制下线/弹一次, // 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示 let bannedNotified = false; // 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方 function detectBannedBody(res: Response): void { if (res.status !== 403 || bannedNotified) return; res .clone() .json() .then((data: { code?: string }) => { if (data?.code === "account_banned" && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); } }) .catch(() => {}); } // 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带) async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> { try { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", headers: clientHeaders(), }); if (res.ok) return { ok: true, banned: false }; if (res.status === 403) { const data = await res.json().catch(() => ({} as { code?: string })); if (data.code === "account_banned") return { ok: false, banned: true }; } return { ok: false, banned: false }; } catch { return { ok: false, banned: false }; } } // 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求; // 任意环节识别到账号封禁,派发全局强制下线事件(只派一次) async function fetchWithRefresh(url: string, init: RequestInit): Promise { let res = await fetch(url, { ...init, credentials: "include" }); detectBannedBody(res); if (res.status === 401) { // 串行化 refresh:多个并发 401 只触发一次 refresh if (!refreshPromise) { refreshPromise = apiRefresh().finally(() => { refreshPromise = null; }); } const result = await refreshPromise; if (result.ok) { // refresh 成功,重试原请求(CSRF cookie 可能已更新) const newHeaders = clientHeaders(); const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders }; res = await fetch(url, mergedInit); detectBannedBody(res); } else if (result.banned && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); } } return res; } // ===== SSR 端 fetch(公开接口,直接请求后端) ===== // SSR 页面通过 (await cookies()).toString() 传入,后端据此识别登录用户并填充 liked 状态 function cookieHeaders(cookieHeader?: string): HeadersInit | undefined { return cookieHeader ? { Cookie: cookieHeader } : undefined; } export async function fetchPosts( page = 1, size = 20, boardId?: number, sort = "latest", // latest | recommended | new keyword = "", recommended = false, cookieHeader?: string ): Promise { const params = new URLSearchParams({ page: String(page), size: String(size), sort }); if (boardId) params.set("board_id", String(boardId)); if (keyword) params.set("keyword", keyword); if (recommended) params.set("recommended", "true"); const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } // SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块 export async function fetchOverview(cookieHeader?: string): Promise { const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取社区概览失败"); return res.json(); } /** SSR 板块右栏;失败时抛错,由首页降级 */ export async function fetchBoardSidebar( boardId: number, cookieHeader?: string ): Promise { const res = await fetch( `${API_BASE}/api/boards/${boardId}/sidebar`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取板块侧栏失败"); return res.json(); } export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> { const res = await fetch(`${API_BASE}/api/boards`, ssrInit()); if (!res.ok) throw new Error("获取板块失败"); return res.json(); } // 公开站点设置:accent 为站点主题色 hex(空串=内置默认); // trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true) export interface PublicSettings { accent: string; trust_reviewed_publish: boolean; site_name: string; site_description: string; allow_register: boolean; /** 全站是否开放评论/回复;缺省 true */ allow_comments: boolean; post_cooldown_hours: number; /** 超过 N 行的代码块是否默认折叠;缺省 true */ code_block_auto_fold: boolean; /** 折叠阈值行数(超过则折叠;0=无预览);缺省 5 */ code_block_fold_lines: number; /** 界面动画总开关;缺省 true */ ui_animations: boolean; /** 代码折叠动画子开关;缺省 true */ anim_code_fold: boolean; /** 平滑滚动子开关;缺省 true */ anim_smooth_scroll: boolean; /** 装饰入场子开关;缺省 true */ anim_chrome: boolean; } const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { accent: "", trust_reviewed_publish: true, site_name: "姜十三论坛", site_description: "姜十三论坛 - 技术分享与讨论社区", allow_register: true, allow_comments: true, post_cooldown_hours: 24, code_block_auto_fold: true, code_block_fold_lines: 5, ui_animations: true, anim_code_fold: true, anim_smooth_scroll: true, anim_chrome: true, }; function normalizePublicSettings(data: Partial | null | undefined): PublicSettings { return { accent: data?.accent ?? "", trust_reviewed_publish: data?.trust_reviewed_publish !== false, site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name, site_description: data?.site_description !== undefined ? String(data.site_description).trim() : DEFAULT_PUBLIC_SETTINGS.site_description, allow_register: data?.allow_register !== false, allow_comments: data?.allow_comments !== false, post_cooldown_hours: typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0 ? data.post_cooldown_hours : DEFAULT_PUBLIC_SETTINGS.post_cooldown_hours, code_block_auto_fold: data?.code_block_auto_fold !== false, code_block_fold_lines: typeof data?.code_block_fold_lines === "number" && data.code_block_fold_lines >= 0 && data.code_block_fold_lines <= 100 ? Math.round(data.code_block_fold_lines) : DEFAULT_PUBLIC_SETTINGS.code_block_fold_lines, ui_animations: data?.ui_animations !== false, anim_code_fold: data?.anim_code_fold !== false, anim_smooth_scroll: data?.anim_smooth_scroll !== false, anim_chrome: data?.anim_chrome !== false, }; } // SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染 export async function fetchPublicSettings(): Promise { try { const res = await fetch(`${API_BASE}/api/settings`, ssrInit()); if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS }; return normalizePublicSettings((await res.json()) as Partial); } catch { return { ...DEFAULT_PUBLIC_SETTINGS }; } } export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> { const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } export async function fetchComments( postId: string, page = 1, cookieHeader?: string ): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch( `${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取评论失败"); return res.json(); } export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取用户资料失败"); return res.json(); } // SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求), // 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换 export async function fetchMe(cookieHeader?: string): Promise { try { const res = await fetch(`${API_BASE}/api/me`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) return { user: null, unread_count: 0 }; return res.json(); } catch { // 超时/后端不可用:降级游客视图,不阻塞页面渲染 return { user: null, unread_count: 0 }; } } export async function fetchUserComments(id: string, page = 1): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit()); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取评论失败"); const data = (await res.json()) as UserCommentsResponse; // Go 空切片曾序列化为 null;统一归一成 [],避免 SSR 读 .length 崩 return { ...data, comments: data.comments ?? [] }; } // SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导) export async function fetchNotifications(page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/notifications?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 401) throw new Error("未登录"); if (!res.ok) throw new Error("获取通知失败"); return res.json(); } // ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) ===== // 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 }) export async function apiMe(): Promise { const res = await fetch("/api/me", { credentials: "include", cache: "no-store", }); const data: MeResponse = await res.json(); // /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径) if (data.banned === true && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); } return data; } export async function apiLogin(username: string, password: string) { const res = await fetch("/api/login", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username, password }), }); const data = await res.json(); // 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示) if (res.ok && data?.user) bannedNotified = false; return data; } export async function apiRegister(username: string, email: string, password: string) { const res = await fetch("/api/register", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username, email, password }), }); return res.json(); } export async function apiLogout() { const res = await fetchWithRefresh("/api/logout", { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiCreatePost(data: { board_id: number; title: string; content: string; tags?: string; post_type?: string; content_access?: string; access_points?: number; type_meta?: string; attachment_ids?: number[]; }) { const res = await fetchWithRefresh("/api/posts", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); return res.json(); } export async function apiCreateComment(postId: string, content: string, parentId?: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(parentId ? { content, parent_id: parentId } : { content }), }); return res.json(); } export async function apiUpdatePost( postId: string, data: { title?: string; content?: string; tags?: string; content_access?: string; access_points?: number; type_meta?: string; attachment_ids?: number[]; } ) { const res = await fetchWithRefresh(`/api/posts/${postId}`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); return res.json(); } export async function apiUnlockPost(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/unlock`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiGetPoints(): Promise<{ points: number }> { const res = await fetchWithRefresh("/api/points", { method: "GET", headers: clientHeaders(), }); return res.json(); } export interface PointLedgerItem { id: number; delta: number; balance: number; reason: string; ref_type: string; ref_id: number; note: string; created_at: string; } export async function apiGetPointsLedger( page = 1, size = 20 ): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number; error?: string }> { const res = await fetchWithRefresh(`/api/points/ledger?page=${page}&size=${size}`, { method: "GET", headers: clientHeaders(), }); return res.json(); } export interface EconomyStats { total_balance: number; ledger_count: number; checkin_today: number; open_bounties: number; escrowed_points: number; posts_by_type: Record; recent_ledger: PointLedgerItem[]; } export async function apiAdminEconomyStats(): Promise { const res = await fetchWithRefresh("/api/admin/economy", { method: "GET", headers: clientHeaders(), }); return res.json(); } export async function fetchAdminEconomyStats( cookieHeader?: string ): Promise { const res = await fetch(`${API_BASE}/api/admin/economy`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("failed"); return res.json(); } export async function apiUploadPostFile( file: File, pricePoints = 0 ): Promise<{ attachment?: PostAttachment; error?: string }> { const fd = new FormData(); fd.append("file", file); fd.append("price_points", String(pricePoints)); const res = await fetchWithRefresh("/api/upload/file", { method: "POST", headers: clientHeaders(), body: fd, }); return res.json(); } export async function apiDeletePostFile(id: number) { const res = await fetchWithRefresh(`/api/upload/file/${id}`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } export async function apiUpdatePostFilePrice(id: number, pricePoints: number) { const res = await fetchWithRefresh(`/api/upload/file/${id}/price`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ price_points: pricePoints }), }); return res.json(); } export function postAttachmentDownloadURL(postId: number | string, attId: number) { return `/api/posts/${postId}/attachments/${attId}/download`; } export async function apiVotePoll(postId: string, options: number[]) { const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ options }), }); return res.json(); } export async function apiClosePoll(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/poll/close`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiAcceptBounty(postId: string, commentId: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/accept`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ comment_id: commentId }), }); return res.json(); } export async function apiRefundBounty(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiAcceptQuestion(postId: string, commentId: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ comment_id: commentId }), }); return res.json(); } export async function apiSolveQuestion(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/question/solve`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiReopenQuestion(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/question/reopen`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiCloseLottery(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiDrawLottery(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, { method: "POST", headers: clientHeaders(), }); return res.json(); } /** 管理删帖类型(与后端 model.PostDeleteType* 一致) */ export const POST_DELETE_TYPES = [ { value: "spam", label: "垃圾广告" }, { value: "violation", label: "违规内容" }, { value: "off_topic", label: "灌水跑题" }, { value: "duplicate", label: "重复内容" }, { value: "other", label: "其他" }, ] as const; export function postDeleteTypeLabel(t?: string): string { return POST_DELETE_TYPES.find((x) => x.value === t)?.label || t || ""; } export async function apiDeletePost( postId: string, opts?: { delete_type?: string; delete_reason?: string } ) { const res = await fetchWithRefresh(`/api/posts/${postId}`, { method: "DELETE", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ delete_type: opts?.delete_type || "", delete_reason: opts?.delete_reason || "", }), }); return res.json(); } export async function apiTogglePin(postId: string): Promise<{ pinned: number }> { const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiToggleRecommend(postId: string): Promise<{ recommended: boolean }> { const res = await fetchWithRefresh(`/api/posts/${postId}/recommend`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiChangePassword(oldPassword: string, newPassword: string) { const res = await fetchWithRefresh("/api/change-password", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), }); return res.json(); } // 更新当前用户资料(昵称、头像、邮箱、签名) export async function apiUpdateProfile(data: { nickname: string; email?: string; signature?: string; }): Promise<{ user?: User; error?: string }> { const res = await fetchWithRefresh("/api/profile", { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); return res.json(); } // 媒体库附件(avatar = 头像,image = 帖子插图等) export interface MediaAttachment { id: number; url: string; kind: "avatar" | "image"; size: number; width: number; height: number; created_at: string; } // 上传裁剪后的头像(Blob 必须是 WebP;不要手动设置 Content-Type,交给浏览器加 multipart 边界) export async function apiUploadAvatar( file: Blob ): Promise<{ url?: string; error?: string }> { const form = new FormData(); form.append("file", file, "avatar.webp"); const res = await fetchWithRefresh("/api/upload/avatar", { method: "POST", headers: clientHeaders(), body: form, }); return res.json(); } // 上传帖子插图(JPEG / PNG / WebP,≤5MB) export async function apiUploadPostImage( file: File | Blob, filename = "image.jpg" ): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> { const form = new FormData(); const name = file instanceof File && file.name ? file.name : filename; form.append("file", file, name); const res = await fetchWithRefresh("/api/upload/image", { method: "POST", headers: clientHeaders(), body: form, }); return res.json(); } // 选用一张本人历史上传的头像 export async function apiUseAvatar( url: string ): Promise<{ url?: string; error?: string }> { const res = await fetchWithRefresh("/api/avatar/use", { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ url }), }); return res.json(); } // 媒体库:本人上传的全部图片 export async function apiMyMedia(): Promise<{ attachments?: MediaAttachment[]; error?: string; }> { const res = await fetchWithRefresh("/api/my/media", { method: "GET", headers: clientHeaders(), }); return res.json(); } // 彻底删除本人附件 export async function apiDeleteAttachment( id: number ): Promise<{ ok?: boolean; error?: string }> { const res = await fetchWithRefresh(`/api/my/attachments/${id}`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } export async function apiToggleLike(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/like`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiDeleteComment( postId: string, commentId: number, opts: { delete_type?: string; delete_reason: string } ) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, { method: "DELETE", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ delete_type: opts.delete_type || "", delete_reason: opts.delete_reason || "", }), }); return res.json(); } /** 编辑评论(作者或版主) */ export async function apiUpdateComment(postId: string, commentId: number, content: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ content }), }); return res.json(); } /** 恢复软删评论(自删作者或版主) */ export async function apiRestoreComment(postId: string, commentId: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/restore`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } /** 版主硬删评论及子树(不占位) */ export async function apiPurgeComment(postId: string, commentId: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/purge`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } // ===== 通知 ===== export async function apiFetchNotifications(page = 1): Promise { const res = await fetchWithRefresh(`/api/notifications?page=${page}&size=20`, { headers: clientHeaders(), }); return res.json(); } export async function apiUnreadCount(): Promise<{ count: number }> { const res = await fetchWithRefresh("/api/notifications/unread-count", { headers: clientHeaders(), }); return res.json(); } export async function apiMarkRead(id: number) { const res = await fetchWithRefresh(`/api/notifications/${id}/read`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiMarkAllRead() { const res = await fetchWithRefresh("/api/notifications/read-all", { method: "PUT", headers: clientHeaders(), }); return res.json(); } // ===== 每日签到 ===== export async function apiGetCheckin(): Promise { const res = await fetchWithRefresh("/api/checkin", { headers: clientHeaders(), }); if (!res.ok) throw new Error("获取签到状态失败"); return res.json(); } // 执行签到;失败(如今日已签,409)抛出后端消息 export async function apiDoCheckin(): Promise { const res = await fetchWithRefresh("/api/checkin", { method: "POST", headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "签到失败"); return data as CheckinStatus; } // ===== 站点公告 ===== // SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理) export async function fetchAnnouncementDetail( id: string ): Promise<{ announcement: Announcement } | null> { const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit()); if (res.status === 404) return null; if (!res.ok) throw new Error("获取公告失败"); return res.json(); } // 后台:公告列表(含草稿) export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> { const res = await fetchWithRefresh("/api/admin/announcements", { headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "获取公告列表失败"); } return res.json(); } export interface AnnouncementInput { title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; } async function saveAnnouncement( method: "POST" | "PUT", url: string, input: AnnouncementInput ): Promise<{ announcement: Announcement }> { const res = await fetchWithRefresh(url, { method, headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "保存公告失败"); return data as { announcement: Announcement }; } export function apiAdminCreateAnnouncement(input: AnnouncementInput) { return saveAnnouncement("POST", "/api/admin/announcements", input); } export function apiAdminUpdateAnnouncement(id: number, input: AnnouncementInput) { return saveAnnouncement("PUT", `/api/admin/announcements/${id}`, input); } export async function apiAdminDeleteAnnouncement(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/announcements/${id}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除公告失败"); } } // ===== 用户管理(管理员) ===== // 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回 export interface AdminUser { id: number; username: string; nickname: string; email: string; avatar: string; signature: string; role: string; // user | board_admin | admin | super_admin | owner board_ids: number[]; // 板块管理员的授权板块 banned: boolean; post_count: number; comment_count: number; created_at: string; last_seen_at: string | null; online: boolean; // last_seen_at 在 5 分钟内 last_login_ip: string; last_login_at: string | null; } // 登录历史记录 export interface LoginLog { id: number; user_id: number; username: string; ip: string; user_agent: string; success: boolean; created_at: string; } export interface LoginLogsResponse { logs: LoginLog[]; total: number; page: number; } export interface AdminUserSummary { online: number; in_cooldown: number; active_7d: number; failed_logins_24h: number; total: number; admins: number; banned: number; } export interface AdminUsersResponse { users: AdminUser[]; total: number; page: number; size: number; summary: AdminUserSummary; } export interface AdminUsersQuery { page?: number; size?: number; q?: string; role?: string; status?: string; } function adminUsersParams(query: AdminUsersQuery): URLSearchParams { const params = new URLSearchParams(); if (query.page && query.page > 1) params.set("page", String(query.page)); if (query.size) params.set("size", String(query.size)); if (query.q) params.set("q", query.q); if (query.role) params.set("role", query.role); if (query.status) params.set("status", query.status); return params; } // SSR 后台用户列表(转发管理员 cookie 直连后端);权限不足时抛错由页面层处理 export async function fetchAdminUsers( query: AdminUsersQuery, cookieHeader?: string ): Promise { const res = await fetch( `${API_BASE}/api/admin/users?${adminUsersParams(query)}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取用户列表失败"); return res.json(); } // SSR 待审核帖子首页(转发管理团队 cookie 直连后端);权限不足时抛错由页面层处理 export async function fetchAdminPendingPosts( page = 1, cookieHeader?: string ): Promise<{ posts: Post[]; total: number; page: number }> { const res = await fetch( `${API_BASE}/api/admin/moderation/pending-posts?page=${page}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取待审核帖子失败"); return res.json(); } /** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */ export async function fetchAdminPendingCounts( cookieHeader?: string ): Promise { 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 { const res = await fetchWithRefresh(`/api/admin/users?${adminUsersParams(query)}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取用户列表失败"); return data as AdminUsersResponse; } // 变更用户管理角色与板块授权: // role 接受 user/board_admin/admin/super_admin(owner 不可授予); // board_admin 必须携带至少一个 board_ids export async function apiAdminSetUserRole( id: number, role: string, boardIds?: number[] ): Promise<{ user: AdminUser }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/role`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ role, board_ids: boardIds ?? [] }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "角色更新失败"); return data as { user: AdminUser }; } // 某用户的登录历史(IP/UA/成败) export async function apiAdminUserLoginLogs( id: number, page = 1 ): Promise { const res = await fetchWithRefresh(`/api/admin/users/${id}/login-logs?page=${page}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取登录历史失败"); return data as LoginLogsResponse; } // 封禁 / 解封用户 export async function apiAdminSetUserBan( id: number, banned: boolean ): Promise<{ user: AdminUser }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/ban`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ banned }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "操作失败"); 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 { items: T[]; total: number; page: number; } export async function fetchAdminUserAudit( id: number, cookieHeader?: string ): Promise { 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 { 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> { 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; } export async function apiAdminUserAuditComments( id: number, page = 1 ): Promise> { 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; } export async function apiAdminUserAuditMessages( id: number, page = 1 ): Promise> { 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; } // ===== 站点外观设置 ===== export async function apiGetSettings(): Promise { // no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存 const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" }); if (!res.ok) throw new Error("获取站点设置失败"); return normalizePublicSettings(await res.json()); } export type UpdateSiteSettingsBody = { accent?: string; trust_reviewed_publish?: boolean; site_name?: string; site_description?: string; allow_register?: boolean; allow_comments?: boolean; post_cooldown_hours?: number; code_block_auto_fold?: boolean; code_block_fold_lines?: number; ui_animations?: boolean; anim_code_fold?: boolean; anim_smooth_scroll?: boolean; anim_chrome?: boolean; }; // 更新站点设置(字段可选);accent 传空串恢复默认主题色 export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promise { const res = await fetchWithRefresh("/api/admin/settings", { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "保存设置失败"); return normalizePublicSettings(data as Partial); } // ===== 板块(客户端,供角色授权弹窗勾选) ===== export async function apiListBoards(): Promise<{ boards: Board[] }> { const res = await fetchWithRefresh("/api/boards", { headers: clientHeaders() }); if (!res.ok) throw new Error("获取板块失败"); return res.json(); } // ===== 内容审核队列 ===== export interface PendingCommentItem { id: number; post_id: number; post_title: string; board_id: number; board: Board; content: string; created_at: string; user: User; } export interface PendingCounts { posts: number; comments: number; } export async function apiAdminPendingPosts(page = 1): Promise<{ posts: Post[]; total: number; page: number; }> { const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取待审帖子失败"); return data; } export async function apiAdminPendingComments(page = 1): Promise<{ comments: PendingCommentItem[]; total: number; page: number; }> { const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取待审评论失败"); return data; } export async function apiAdminPendingCounts(): Promise { const res = await fetchWithRefresh("/api/admin/moderation/counts", { headers: clientHeaders(), }); if (!res.ok) return { posts: 0, comments: 0 }; return res.json(); } async function moderationAction(url: string): Promise { const res = await fetchWithRefresh(url, { method: "PUT", headers: clientHeaders() }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "操作失败"); } } export const apiAdminApprovePost = (id: number) => moderationAction(`/api/admin/moderation/posts/${id}/approve`); export const apiAdminRejectPost = (id: number) => moderationAction(`/api/admin/moderation/posts/${id}/reject`); export const apiAdminApproveComment = (id: number) => moderationAction(`/api/admin/moderation/comments/${id}/approve`); export const apiAdminRejectComment = (id: number) => moderationAction(`/api/admin/moderation/comments/${id}/reject`); // ===== 内容库(帖子 / 评论 + 回收站) ===== export type AdminContentStatus = "live" | "pending" | "rejected" | "published" | "deleted"; export interface AdminContentCounts { posts_live: number; posts_pending: number; posts_rejected: number; posts_deleted: number; comments_live: number; comments_pending: number; comments_rejected: number; comments_deleted: number; } export interface AdminContentPost { id: number; title: string; status: string; deleted: boolean; deleted_at?: string; created_at: string; comment_count: number; board_id: number; board: Board; user: User; } export interface AdminContentComment { id: number; post_id: number; post_title: string; board_id: number; content: string; status: string; deleted: boolean; deleted_at?: string; created_at: string; board: Board; user: User; } export async function apiAdminContentCounts(): Promise { const res = await fetchWithRefresh("/api/admin/content/counts", { headers: clientHeaders() }); if (!res.ok) throw new Error("获取内容统计失败"); return res.json(); } export async function apiAdminContentPosts( page = 1, status: AdminContentStatus = "live", q = "" ): Promise<{ posts: AdminContentPost[]; total: number; page: number }> { const params = new URLSearchParams({ page: String(page), status }); if (q.trim()) params.set("q", q.trim()); const res = await fetchWithRefresh(`/api/admin/content/posts?${params}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取帖子列表失败"); return data; } export async function apiAdminContentComments( page = 1, status: AdminContentStatus = "live", q = "" ): Promise<{ comments: AdminContentComment[]; total: number; page: number }> { const params = new URLSearchParams({ page: String(page), status }); if (q.trim()) params.set("q", q.trim()); const res = await fetchWithRefresh(`/api/admin/content/comments?${params}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取评论列表失败"); return data; } async function contentAction( url: string, method: string, body?: Record ): Promise { const res = await fetchWithRefresh(url, { method, headers: clientHeaders(body ? { "Content-Type": "application/json" } : undefined), body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "操作失败"); } } export const apiAdminSoftDeletePost = ( id: number, opts: { delete_type: string; delete_reason: string } ) => contentAction(`/api/admin/content/posts/${id}`, "DELETE", opts); export const apiAdminRestorePost = (id: number) => contentAction(`/api/admin/content/posts/${id}/restore`, "PUT"); export const apiAdminPurgePost = (id: number) => contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE"); export const apiAdminSoftDeleteComment = ( id: number, opts: { delete_type: string; delete_reason: string } ) => contentAction(`/api/admin/content/comments/${id}`, "DELETE", opts); export const apiAdminRestoreComment = (id: number) => contentAction(`/api/admin/content/comments/${id}/restore`, "PUT"); export const apiAdminPurgeComment = (id: number) => contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE"); export interface CommentEditHistoryItem { id: number; editor: { id: number; username: string; nickname: string; avatar: string; }; old_content: string; created_at: string; } /** 管理:评论编辑历史(分页;默认每页 10 条) */ export async function apiAdminCommentHistory( commentId: number, page = 1, size = 10 ): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> { const params = new URLSearchParams({ page: String(page), size: String(size) }); const res = await fetchWithRefresh( `/api/admin/content/comments/${commentId}/history?${params}`, { headers: clientHeaders() } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取编辑历史失败"); return data; } export async function fetchAdminContentCounts( cookieHeader?: string ): Promise { const res = await fetch(`${API_BASE}/api/admin/content/counts`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) { return { posts_live: 0, posts_pending: 0, posts_rejected: 0, posts_deleted: 0, comments_live: 0, comments_pending: 0, comments_rejected: 0, comments_deleted: 0, }; } return res.json(); } export async function fetchAdminContentPosts( page = 1, status: AdminContentStatus = "live", cookieHeader?: string ): Promise<{ posts: AdminContentPost[]; total: number; page: number }> { const res = await fetch( `${API_BASE}/api/admin/content/posts?page=${page}&status=${status}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取帖子列表失败"); return res.json(); } // ===== 群聊(二期) ===== // 群聊房间基础模型(与后端 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; /** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */ recaller?: User | null; } // 单群未读明细 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 { 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 { 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 { 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 { 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 { 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 { const res = await fetchWithRefresh("/api/chat/unread-summary", { headers: clientHeaders(), }); if (!res.ok) return { total: 0, rooms: [] }; return res.json(); }